Files
go_blog/handlers/comment.go
T
kevin a0061b14e3 feat: 评论接口 JSON 化——/api/article/:slug/comments 与 admin 审核操作
- comment.go:commentForm 加 json tag;PostComment 校验分支改 APIError
  (404 article_not_found、403 comments_disabled/comments_guests_disabled、
  400 校验码、500 article_error),保留 guest 令牌与 flash 机制,成功返回
  {ok,redirect:/article/:slug#comment-N,comment_id}
- api.go:新增 APIErrorf(支持 %d/%s 占位符键如 comments_too_long)
- admin_comment.go:approve/reject/delete 改 JSON(parseUintParam 拒绝非
  数值 id 400),成功带原 ?saved=1&msg= 查询串 redirect
- main.go:PostComment 迁入 /api;评论审核三操作迁入 /api/admin/comments
- article.html:评论表单改 blogAPI 提交,错误内联 commentError div
- comment_list.html:审核操作改 to commentAct() 委托(confirm 在函数内,
  取消不发请求),成功 reload 保持筛选状态
- 测试:security_test env 路由同步 /api;session_upload 评论用例改 JSON
- main_test 冒烟补评论 API 路由断言;go build/vet/test 全绿
2026-08-27 19:36:09 +08:00

358 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/middleware"
"go_blog/models"
)
// MaxCommentLength 限制单条评论正文的长度(字符数)。
const MaxCommentLength = 4000
// guestCookieName 是用于标识匿名评论者的长期 Cookie,
// 使其能看到自己的待审/私密评论。
const guestCookieName = "comment_uid"
const guestCookieMaxAge = 365 * 24 * 3600 // 一年
// htmlTagPattern 匹配所有 HTML/XML 标签,以便在存储前从评论 Markdown 中剥除。
// Markdown 语法本身不含会冲突的尖括号形式(唯一类似结构是
// <http://…> 这样的自动链接,在评论中很少见,可以接受丢失)。
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")
}
}