diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index 6f65442..54eafab 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -132,29 +132,38 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { // CommentApprove 将评论标记为通过。 func CommentApprove(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - if id := c.Param("id"); id != "" { + if id := parseUintParam(c, "id"); id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } else { db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved) } - c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved") + APIOK(c, "/admin/comments?status=pending&saved=1&msg=approved", nil) } } // CommentReject 将评论标记为拒绝(前端隐藏,后台列表保留)。 func CommentReject(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - if id := c.Param("id"); id != "" { + if id := parseUintParam(c, "id"); id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } else { db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected) } - c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected") + APIOK(c, "/admin/comments?status=pending&saved=1&msg=rejected", nil) } } // CommentDelete 软删除一条评论。 func CommentDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - if id := c.Param("id"); id != "" { + if id := parseUintParam(c, "id"); id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } else { db.Where("id = ?", id).Delete(&models.Comment{}) } - c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted") + APIOK(c, "/admin/comments?status=all&saved=1&msg=deleted", nil) } } diff --git a/handlers/api.go b/handlers/api.go index 6f9cf04..f2a4573 100644 --- a/handlers/api.go +++ b/handlers/api.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "net/http" "github.com/gin-gonic/gin" @@ -40,6 +41,23 @@ func APIError(c *gin.Context, status int, trKey string) { }) } +// APIErrorf 同 APIError,但 i18n 文案可按 fmt.Sprintf 格式化 +// (针对含 %d/%s 占位符的键,如 comments_too_long)。 +func APIErrorf(c *gin.Context, status int, trKey string, args ...interface{}) { + tr := getTr(c) + msg := tr[trKey] + if msg == "" { + msg = tr["api_error"] + } else if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + c.JSON(status, gin.H{ + "ok": false, + "code": trKey, + "error": msg, + }) +} + // bindJSON 将 JSON 请求体绑定到 v。 // 绑定失败时返回 400 + api_invalid_request,并返回 false。 // 使用前必须保证请求是 JSON(Content-Type: application/json)。 diff --git a/handlers/comment.go b/handlers/comment.go index 133e588..68cdb2f 100644 --- a/handlers/comment.go +++ b/handlers/comment.go @@ -37,14 +37,15 @@ var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) // 这些协议在渲染为 innerHTML 时可能执行脚本。 var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`) -// commentForm 保存已提交评论表单的解析值,使校验失败后模板能重新填充输入。 +// commentForm 是 POST /api/article/:slug/comments 的 JSON 请求体。 +// 校验失败时返回 {ok:false,code,error},不再回填模板。 type commentForm struct { - Name string - Email string - Website string - Content string - IsPrivate bool - ParentID string + 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 协议。 @@ -91,65 +92,63 @@ func emailHash(email string) string { // PostComment 处理在文章上提交新评论(或回复)。 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) + APIError(c, http.StatusNotFound, "article_not_found") return } cfg := models.GetCommentConfig() if cfg == nil || !cfg.Enabled { - renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "") + APIError(c, http.StatusForbidden, "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"], "") + APIError(c, http.StatusForbidden, "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")), + 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 { - renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "") + APIError(c, http.StatusBadRequest, "comments_required_name") return } if form.Email == "" { - renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "") + APIError(c, http.StatusBadRequest, "comments_required_email") return } if _, err := mail.ParseAddress(form.Email); err != nil { - renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "") + 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 == "" { - renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "") + APIError(c, http.StatusBadRequest, "comments_invalid_url") return } } if form.Content == "" { - renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "") + APIError(c, http.StatusBadRequest, "comments_required_content") return } if len([]rune(form.Content)) > MaxCommentLength { - renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "") + APIErrorf(c, http.StatusBadRequest, "comments_too_long", MaxCommentLength) return } @@ -158,12 +157,12 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { 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"], "") + 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 { - renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "") + APIError(c, http.StatusBadRequest, "comments_required_content") return } id := uint(pid) @@ -199,7 +198,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { } if err := db.Create(&comment).Error; err != nil { - renderArticleDetail(c, db, &article, form, tr["article_error"], "") + APIError(c, http.StatusInternalServerError, "article_error") return } @@ -209,7 +208,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { } else { setCommentFlash(c, getTr(c)["comments_posted"]) } - c.Redirect(http.StatusFound, "/article/"+slug+anchor) + APIOK(c, "/article/"+slug+anchor, gin.H{"comment_id": comment.ID}) } } diff --git a/handlers/security_test.go b/handlers/security_test.go index f15ad03..fbbddcb 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -84,13 +84,13 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { r.GET("/login", LoginPage()) r.GET("/register", RegisterPage(db)) r.GET("/rss", RSSFeed(db)) - r.POST("/article/:slug/comments", PostComment(db)) api := r.Group("/api") { api.POST("/auth/login", Login(db, limiter)) api.POST("/auth/logout", Logout()) api.POST("/auth/register", Register(db)) + api.POST("/article/:slug/comments", PostComment(db)) } protected := r.Group("/my", middleware.AuthRequired(db)) diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index 6c8b2be..b96bac0 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -15,6 +15,8 @@ import ( "strings" "testing" + "github.com/gin-gonic/gin" + "go_blog/models" ) @@ -122,14 +124,13 @@ func TestDisabledUserCommentsRequireApproval(t *testing.T) { postComment := func() models.Comment { t.Helper() - form := url.Values{} - form.Set("name", "alice") - form.Set("email", "alice@example.com") - form.Set("content", "comment body") - form.Set("_csrf", token) - w := e.do(http.MethodPost, "/article/alice-post/comments", aliceCookie, - strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") - if w.Code != http.StatusFound { + w := postJSON(e, http.MethodPost, "/api/article/alice-post/comments", aliceCookie, token, + gin.H{ + "name": "alice", + "email": "alice@example.com", + "content": "comment body", + }) + if w.Code != http.StatusOK { t.Fatalf("POST comment: status=%d body=%s", w.Code, w.Body.String()) } var cm models.Comment diff --git a/main.go b/main.go index 9cdeca5..dd151a8 100644 --- a/main.go +++ b/main.go @@ -164,7 +164,6 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi router.GET("/login", handlers.LoginPage()) router.GET("/register", handlers.RegisterPage(db)) router.GET("/article/:slug", handlers.ArticleDetail(db)) - router.POST("/article/:slug/comments", handlers.PostComment(db)) // 公开 JSON API。 api := router.Group("/api") @@ -173,6 +172,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi api.POST("/auth/login", handlers.Login(db, loginLimiter)) api.POST("/auth/register", handlers.Register(db)) api.POST("/auth/logout", handlers.Logout()) + api.POST("/article/:slug/comments", handlers.PostComment(db)) } // 受保护的后台路由(仅管理员角色)。 @@ -193,9 +193,14 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { comments.GET("", handlers.CommentListPage(db)) - comments.POST("/:id/approve", handlers.CommentApprove(db)) - comments.POST("/:id/reject", handlers.CommentReject(db)) - comments.POST("/:id/delete", handlers.CommentDelete(db)) + } + + commentsAPI := router.Group("/api/admin/comments") + commentsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + commentsAPI.POST("/:id/approve", handlers.CommentApprove(db)) + commentsAPI.POST("/:id/reject", handlers.CommentReject(db)) + commentsAPI.POST("/:id/delete", handlers.CommentDelete(db)) } // 受保护的后台用户管理路由(仅管理员角色)。 diff --git a/main_test.go b/main_test.go index 2ea875f..eeea2e1 100644 --- a/main_test.go +++ b/main_test.go @@ -146,6 +146,11 @@ func TestRegisterRoutesSmoke(t *testing.T) { "POST /api/auth/login": "", "POST /api/auth/register": "", "POST /api/auth/logout": "", + // 评论 API。 + "POST /api/article/:slug/comments": "", + "POST /api/admin/comments/:id/approve": "", + "POST /api/admin/comments/:id/reject": "", + "POST /api/admin/comments/:id/delete": "", // 搬移的附件/头像端点。 "POST /api/admin/articles/attachments": "", "DELETE /api/admin/articles/attachments/:id": "", diff --git a/templates/admin/comment_list.html b/templates/admin/comment_list.html index 7060103..56967b0 100644 --- a/templates/admin/comment_list.html +++ b/templates/admin/comment_list.html @@ -61,20 +61,17 @@
{{if eq .Status 0}} -
- +
{{end}} {{if ne .Status 2}} -
- +
{{end}} -
- +
@@ -98,6 +95,19 @@ } }); })(); + +// 评论审核操作(approve/reject/delete)走 JSON API;成功 reload 保持筛选状态。 +window.commentAct = function (form) { + var act = form.getAttribute('data-act'); + var id = form.getAttribute('data-id'); + var confirmText = form.getAttribute('data-confirm'); + if (confirmText && !confirm(confirmText)) return false; + blogAPI('POST', '/api/admin/comments/' + id + '/' + act).then(function (r) { + if (r.ok) { window.location.reload(); } + else { alert(r.error || 'Failed'); } + }); + return false; +}; {{template "footer" .}} {{end}} diff --git a/templates/pages/article.html b/templates/pages/article.html index a4dd58c..00540ea 100644 --- a/templates/pages/article.html +++ b/templates/pages/article.html @@ -45,9 +45,7 @@
- {{if .CommentError}} -
{{.CommentError}}
- {{end}} +
{{.CommentError}}
{{if .CommentConfig}} {{if .CommentConfig.Enabled}} @@ -74,7 +72,7 @@ -
+
@@ -263,6 +261,22 @@ document.getElementById('comments').insertBefore(formWrap, document.getElementById('commentList').nextSibling); }); })(); + + // Comment submission: JSON API + client-side error display. + (function () { + var form = document.getElementById('commentForm'); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + var btn = e.submitter || null; + if (btn) btn.disabled = true; + blogAPI('POST', form.action, blogForm(form, btn)).then(function (r) { + if (btn) btn.disabled = false; + if (r.ok) { window.location.href = r.redirect || form.action; } + else { blogShowError('commentError', r.error || 'Failed to post comment.'); } + }); + }); + })(); {{template "footer" .}}