Files
go_blog/handlers/comment.go
T
kevinandClaude Fable 5 6139b3ef2c feat: add article comment system with moderation
- Comment model with nested replies (ParentID), dual authorship
  (logged-in UserID / anonymous GuestToken cookie), email hash for
  Gravatar, private flag, and moderation status
- CommentConfig singleton (enabled / allow guest / guest-require-approval
  / use Gravatar) cached like the other platform config
- Markdown comments with built-in emoji picker, preview, and markdown
  help; rendered client-side via marked + DOMPurify, with server-side
  HTML/dangerous-scheme stripping as a first XSS defense
- Private comments visible only to admin and the author; pending
  comments visible only to admin and the author
- Admin moderation list (pending/approved/rejected/all tabs) with
  approve/reject/delete and a pending-count badge
- Comment settings page with the four toggles
- One-time session flash notice (auto-dismissed after 4s) so the
  "comment posted" banner no longer persists across refreshes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 17:21:54 +08:00

367 lines
11 KiB
Go

package handlers
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/mail"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/models"
)
// MaxCommentLength bounds the size of a single comment body, in characters.
const MaxCommentLength = 4000
// guestCookieName is the long-lived cookie used to identify anonymous
// commenters so they can see their own pending/private comments.
const guestCookieName = "comment_uid"
const guestCookieMaxAge = 365 * 24 * 3600 // one year
// htmlTagPattern matches any HTML/XML tag so it can be stripped from comment
// markdown before storage. Markdown syntax itself contains no angle brackets
// in a form that would collide (the only such construct is autolinks like
// <http://…>, which are rare in comments and acceptable to lose).
var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
// dangerousSchemePattern matches dangerous URL schemes inside markdown link
// targets that could execute script when rendered to innerHTML.
var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`)
// commentForm holds the parsed values of a submitted comment form so the
// template can refill the inputs after a validation failure.
type commentForm struct {
Name string
Email string
Website string
Content string
IsPrivate bool
ParentID string
}
// sanitizeMarkdown strips HTML tags and dangerous URL schemes from a comment
// body before storage. Markdown syntax is preserved so the frontend can render
// it. This is the first of two XSS defenses; the frontend also runs the output
// through marked + DOMPurify.
func sanitizeMarkdown(s string) string {
s = htmlTagPattern.ReplaceAllString(s, "")
s = dangerousSchemePattern.ReplaceAllString(s, "#")
// Collapse runs of more than two newlines.
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
// newGuestToken generates a random hex token for an anonymous commenter.
func newGuestToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Extremely unlikely; fall back to a timestamp-based token.
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// guestTokenFrom reads the anonymous commenter cookie, creating and setting a
// new one when absent. The token is returned for storage on the comment.
func guestTokenFrom(c *gin.Context) string {
token, _ := c.Cookie(guestCookieName)
if token == "" {
token = newGuestToken()
}
// (Re)set the cookie so returning visitors keep their identity. HttpOnly
// prevents JS access; SameSite=Lax is the gin default and is appropriate.
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true)
return token
}
// emailHash returns the md5 of a lowercased, trimmed email, per the Gravatar
// spec.
func emailHash(email string) string {
h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(h[:])
}
// PostComment handles submission of a new comment (or reply) on an article.
func PostComment(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
slug := c.Param("slug")
var article models.Article
if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil {
data := DefaultData(c)
data["Title"] = tr["article_not_found"]
c.HTML(http.StatusNotFound, "article_not_found", data)
return
}
cfg := models.GetCommentConfig()
if cfg == nil || !cfg.Enabled {
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "")
return
}
isLoggedIn, _ := c.Get("is_logged_in")
loggedIn, _ := isLoggedIn.(bool)
if !loggedIn && !cfg.AllowGuest {
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_guests_disabled"], "")
return
}
form := commentForm{
Name: strings.TrimSpace(c.PostForm("name")),
Email: strings.TrimSpace(c.PostForm("email")),
Website: strings.TrimSpace(c.PostForm("website")),
Content: strings.TrimSpace(c.PostForm("content")),
IsPrivate: c.PostForm("is_private") == "1",
ParentID: strings.TrimSpace(c.PostForm("parent_id")),
}
// --- Validation ---
if form.Name == "" || len(form.Name) > 64 {
renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "")
return
}
if form.Email == "" {
renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "")
return
}
if _, err := mail.ParseAddress(form.Email); err != nil {
renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "")
return
}
if form.Website != "" {
u, err := url.Parse(form.Website)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "")
return
}
}
if form.Content == "" {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
return
}
if len([]rune(form.Content)) > MaxCommentLength {
renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "")
return
}
// --- Parent validation ---
var parentID *uint
if form.ParentID != "" {
pid, err := strconv.ParseUint(form.ParentID, 10, 64)
if err != nil || pid == 0 {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
return
}
var parent models.Comment
if err := db.Where("id = ? AND article_id = ? AND status = ?", pid, article.ID, models.CommentApproved).First(&parent).Error; err != nil {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
return
}
id := uint(pid)
parentID = &id
}
// --- Build comment ---
comment := models.Comment{
ArticleID: article.ID,
ParentID: parentID,
AuthorName: form.Name,
Email: form.Email,
EmailHash: emailHash(form.Email),
Website: form.Website,
Content: sanitizeMarkdown(form.Content),
IsPrivate: form.IsPrivate,
Status: models.CommentApproved,
IPAddress: truncate(c.ClientIP(), 64),
UserAgent: truncate(c.GetHeader("User-Agent"), 512),
}
if loggedIn {
uid := userIDFromSession(c)
if uid != 0 {
comment.UserID = &uid
}
comment.Status = models.CommentApproved
} else {
comment.GuestToken = guestTokenFrom(c)
if cfg.GuestRequireApproval {
comment.Status = models.CommentPending
}
}
if err := db.Create(&comment).Error; err != nil {
renderArticleDetail(c, db, &article, form, tr["article_error"], "")
return
}
anchor := fmt.Sprintf("#comment-%d", comment.ID)
if comment.Status == models.CommentPending {
setCommentFlash(c, getTr(c)["comments_pending_notice"])
} else {
setCommentFlash(c, getTr(c)["comments_posted"])
}
c.Redirect(http.StatusFound, "/article/"+slug+anchor)
}
}
// truncate clips s to at most n runes.
func truncate(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// commentViewer describes the identity of the current request for the purposes
// of comment visibility.
type commentViewer struct {
userID *uint
isAdmin bool
guestToken string
}
// viewerFromContext builds a commentViewer from the request/session.
func viewerFromContext(c *gin.Context) commentViewer {
v := commentViewer{}
if uid := userIDFromSession(c); uid != 0 {
v.userID = &uid
}
if role, ok := c.Get("role"); ok {
if r, _ := role.(string); r == models.RoleAdmin {
v.isAdmin = true
}
}
v.guestToken, _ = c.Cookie(guestCookieName)
return v
}
// canSee reports whether the viewer is allowed to see one comment.
func (v commentViewer) canSee(c *models.Comment) bool {
switch c.Status {
case models.CommentApproved:
if !c.IsPrivate {
return true
}
// Private: admin or author only.
return v.isAdmin || v.owns(c)
case models.CommentPending:
return v.isAdmin || v.owns(c)
case models.CommentRejected:
return v.isAdmin
}
return false
}
// owns reports whether the viewer is the author of the comment.
func (v commentViewer) owns(c *models.Comment) bool {
if v.userID != nil && c.UserID != nil && *v.userID == *c.UserID {
return true
}
if v.guestToken != "" && c.GuestToken != "" && v.guestToken == c.GuestToken {
return true
}
return false
}
// CommentNode is a comment plus its rendered children, used by the template's
// recursive comment_node block. Tr/UseGravatar/AvatarColor are propagated to
// every node so the recursive template can render badges and avatars without
// reaching back to the page-level data (inside a {{template}} invocation, $
// binds to the node, not the page data).
type CommentNode struct {
Comment models.Comment
Children []CommentNode
Depth int
RelTime string
Tr map[string]string
UseGravatar bool
AvatarColor string
}
// avatarPalette is the set of background colors used for text-initial avatars
// when Gravatar is disabled.
var avatarPalette = []string{
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#6366f1",
}
// avatarColorFor returns a deterministic palette color for a comment ID.
func avatarColorFor(id uint) string {
if len(avatarPalette) == 0 {
return "#3b82f6"
}
return avatarPalette[int(id)%len(avatarPalette)]
}
// buildCommentTree filters comments by visibility and assembles them into a
// nested tree ordered by creation time. tr and useGravatar are propagated to
// every node for template rendering.
func buildCommentTree(comments []models.Comment, viewer commentViewer, tr map[string]string, useGravatar bool) []CommentNode {
visible := make([]models.Comment, 0, len(comments))
for i := range comments {
if viewer.canSee(&comments[i]) {
visible = append(visible, comments[i])
}
}
byParent := make(map[uint][]models.Comment)
var roots []models.Comment
for _, c := range visible {
if c.ParentID == nil {
roots = append(roots, c)
} else {
byParent[*c.ParentID] = append(byParent[*c.ParentID], c)
}
}
nodes := make([]CommentNode, 0, len(roots))
for _, r := range roots {
nodes = append(nodes, buildCommentNode(r, byParent, 1, tr, useGravatar))
}
return nodes
}
func buildCommentNode(c models.Comment, byParent map[uint][]models.Comment, depth int, tr map[string]string, useGravatar bool) CommentNode {
node := CommentNode{
Comment: c,
Depth: depth,
RelTime: relativeTime(c.CreatedAt),
Tr: tr,
UseGravatar: useGravatar,
AvatarColor: avatarColorFor(c.ID),
}
for _, child := range byParent[c.ID] {
node.Children = append(node.Children, buildCommentNode(child, byParent, depth+1, tr, useGravatar))
}
return node
}
// relativeTime returns a coarse human-readable age for a comment timestamp,
// falling back to an absolute date for anything older than a day.
func relativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return t.Format("2006-01-02 15:04")
}
}