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/middleware" "go_blog/models" ) // MaxCommentLength 限制单条评论正文的长度(字符数)。 const MaxCommentLength = 4000 // guestCookieName 是用于标识匿名评论者的长期 Cookie, // 使其能看到自己的待审/私密评论。 const guestCookieName = "comment_uid" const guestCookieMaxAge = 365 * 24 * 3600 // 一年 // htmlTagPattern 匹配所有 HTML/XML 标签,以便在存储前从评论 Markdown 中剥除。 // Markdown 语法本身不含会冲突的尖括号形式(唯一类似结构是 // 这样的自动链接,在评论中很少见,可以接受丢失)。 var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) // dangerousSchemePattern 匹配 Markdown 链接目标中的危险 URL 协议, // 这些协议在渲染为 innerHTML 时可能执行脚本。 var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`) // commentForm 是 POST /api/article/:slug/comments 的 JSON 请求体。 // 校验失败时返回 {ok:false,code,error},不再回填模板。 type commentForm struct { Name string `json:"name"` Email string `json:"email"` Website string `json:"website"` Content string `json:"content"` IsPrivate bool `json:"is_private"` ParentID string `json:"parent_id"` } // sanitizeMarkdown 在存储前从评论正文中剥除 HTML 标签与危险 URL 协议。 // Markdown 语法被保留,以便前端渲染。这是两道 XSS 防御中的第一道; // 前端还会将输出经过 marked + DOMPurify 处理。 func sanitizeMarkdown(s string) string { s = htmlTagPattern.ReplaceAllString(s, "") s = dangerousSchemePattern.ReplaceAllString(s, "#") // 折叠超过两个连续换行符的序列。 s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n") return strings.TrimSpace(s) } // newGuestToken 为匿名评论者生成随机十六进制令牌。 func newGuestToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // 极罕见;回退到基于时间戳的令牌。 return fmt.Sprintf("%x", time.Now().UnixNano()) } return hex.EncodeToString(b) } // guestTokenFrom 读取匿名评论者 Cookie,缺失时创建并设置新值。 // 令牌返回给调用方以存储到评论上。 func guestTokenFrom(c *gin.Context) string { token, _ := c.Cookie(guestCookieName) if token == "" { token = newGuestToken() } // (重新)设置 Cookie,使回访访客保持身份一致。HttpOnly 阻止 JS 访问; // SameSite=Lax 加上 HTTPS 下的 Secure 与会话 Cookie 加固措施一致。 c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", middleware.IsHTTPSRequest(c), true) return token } // emailHash 按照 Gravatar 规范返回小写并去除空白后的邮箱的 md5 值。 func emailHash(email string) string { h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))) return hex.EncodeToString(h[:]) } // PostComment 处理在文章上提交新评论(或回复)。 func PostComment(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { slug := c.Param("slug") var article models.Article if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil { APIError(c, http.StatusNotFound, "article_not_found") return } cfg := models.GetCommentConfig() if cfg == nil || !cfg.Enabled { APIError(c, http.StatusForbidden, "comments_disabled") return } isLoggedIn, _ := c.Get("is_logged_in") loggedIn, _ := isLoggedIn.(bool) if !loggedIn && !cfg.AllowGuest { APIError(c, http.StatusForbidden, "comments_guests_disabled") return } var form commentForm if !bindJSON(c, &form) { return } form.Name = strings.TrimSpace(form.Name) form.Email = strings.TrimSpace(form.Email) form.Website = strings.TrimSpace(form.Website) form.Content = strings.TrimSpace(form.Content) form.ParentID = strings.TrimSpace(form.ParentID) // --- 校验 --- if form.Name == "" || len(form.Name) > 64 { APIError(c, http.StatusBadRequest, "comments_required_name") return } if form.Email == "" { APIError(c, http.StatusBadRequest, "comments_required_email") return } if _, err := mail.ParseAddress(form.Email); err != nil { APIError(c, http.StatusBadRequest, "comments_invalid_email") return } if form.Website != "" { u, err := url.Parse(form.Website) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { APIError(c, http.StatusBadRequest, "comments_invalid_url") return } } if form.Content == "" { APIError(c, http.StatusBadRequest, "comments_required_content") return } if len([]rune(form.Content)) > MaxCommentLength { APIErrorf(c, http.StatusBadRequest, "comments_too_long", MaxCommentLength) return } // --- 父评论校验 --- var parentID *uint if form.ParentID != "" { pid, err := strconv.ParseUint(form.ParentID, 10, 64) if err != nil || pid == 0 { APIError(c, http.StatusBadRequest, "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 { APIError(c, http.StatusBadRequest, "comments_required_content") return } id := uint(pid) parentID = &id } // --- 构建评论 --- 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(GetClientIP(c), 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 { APIError(c, http.StatusInternalServerError, "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"]) } APIOK(c, "/article/"+slug+anchor, gin.H{"comment_id": comment.ID}) } } // truncate 将 s 裁剪为最多 n 个 rune。 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 描述当前请求的身份,用于决定评论可见性。 type commentViewer struct { userID *uint isAdmin bool guestToken string } // viewerFromContext 基于请求/会话构建 commentViewer。 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 报告查看者是否被允许查看某条评论。 func (v commentViewer) canSee(c *models.Comment) bool { switch c.Status { case models.CommentApproved: if !c.IsPrivate { return true } // 私密:仅管理员或作者可见。 return v.isAdmin || v.owns(c) case models.CommentPending: return v.isAdmin || v.owns(c) case models.CommentRejected: return v.isAdmin } return false } // owns 报告查看者是否为该评论的作者。 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 是一条评论及其已渲染的子评论,供模板的递归 comment_node 块使用。 // Tr/UseGravatar/AvatarColor 会传播到每个节点,使递归模板能够渲染徽章和头像 // 而无需回溯页面级数据(在 {{template}} 调用内部,$ 绑定到节点而非页面数据)。 type CommentNode struct { Comment models.Comment Children []CommentNode Depth int RelTime string Tr map[string]string UseGravatar bool AvatarColor string } // avatarPalette 是禁用 Gravatar 时用于文本首字母头像的背景色集合。 var avatarPalette = []string{ "#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#14b8a6", "#6366f1", } // avatarColorFor 为评论 ID 返回确定性的调色板颜色。 func avatarColorFor(id uint) string { if len(avatarPalette) == 0 { return "#3b82f6" } return avatarPalette[int(id)%len(avatarPalette)] } // buildCommentTree 按可见性过滤评论,并按创建时间组装为嵌套树。 // tr 与 useGravatar 会传播到每个节点以供模板渲染。 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 为评论时间戳返回粗略的可读年龄,超过一天后回退到绝对日期。 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") } }