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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// adminCommentPageSize bounds the number of comments shown per admin page.
|
||||
const adminCommentPageSize = 30
|
||||
|
||||
// commentListView is a Comment plus derived display fields for the admin list.
|
||||
type commentListView struct {
|
||||
models.Comment
|
||||
GravatarURL string
|
||||
MaskedEmail string
|
||||
StatusLabel string
|
||||
StatusBadge string
|
||||
ArticleTitle string
|
||||
ArticleSlug string
|
||||
}
|
||||
|
||||
// CommentListPage renders the admin comment moderation list, filtered by
|
||||
// status via ?status=pending|approved|rejected|all.
|
||||
func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
q := db.Model(&models.Comment{}).Order("created_at DESC")
|
||||
switch status {
|
||||
case "approved":
|
||||
q = q.Where("status = ?", models.CommentApproved)
|
||||
case "rejected":
|
||||
q = q.Where("status = ?", models.CommentRejected)
|
||||
case "all":
|
||||
// no filter
|
||||
default: // pending
|
||||
status = "pending"
|
||||
q = q.Where("status = ?", models.CommentPending)
|
||||
}
|
||||
|
||||
var comments []models.Comment
|
||||
q.Limit(adminCommentPageSize).Find(&comments)
|
||||
|
||||
// Pull referenced articles in one query to avoid N+1.
|
||||
ids := make(map[uint]struct{})
|
||||
for _, cm := range comments {
|
||||
ids[cm.ArticleID] = struct{}{}
|
||||
}
|
||||
articleMap := make(map[uint]models.Article)
|
||||
if len(ids) > 0 {
|
||||
idList := make([]uint, 0, len(ids))
|
||||
for id := range ids {
|
||||
idList = append(idList, id)
|
||||
}
|
||||
var articles []models.Article
|
||||
db.Unscoped().Where("id IN ?", idList).Find(&articles)
|
||||
for _, a := range articles {
|
||||
articleMap[a.ID] = a
|
||||
}
|
||||
}
|
||||
|
||||
views := make([]commentListView, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
v := commentListView{
|
||||
Comment: cm,
|
||||
GravatarURL: cm.GravatarURL(40),
|
||||
MaskedEmail: cm.MaskedEmail(),
|
||||
}
|
||||
if a, ok := articleMap[cm.ArticleID]; ok {
|
||||
v.ArticleTitle = a.Title
|
||||
v.ArticleSlug = a.Slug
|
||||
}
|
||||
switch cm.Status {
|
||||
case models.CommentPending:
|
||||
v.StatusLabel = tr["comment_status_pending"]
|
||||
v.StatusBadge = "bg-yellow-100 text-yellow-700"
|
||||
case models.CommentApproved:
|
||||
v.StatusLabel = tr["comment_status_approved"]
|
||||
v.StatusBadge = "bg-green-100 text-green-700"
|
||||
case models.CommentRejected:
|
||||
v.StatusLabel = tr["comment_status_rejected"]
|
||||
v.StatusBadge = "bg-red-100 text-red-700"
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
// Pending count for the tab badge.
|
||||
var pendingCount int64
|
||||
db.Model(&models.Comment{}).Where("status = ?", models.CommentPending).Count(&pendingCount)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["admin_comments_title"]
|
||||
data["Comments"] = views
|
||||
data["Status"] = status
|
||||
data["PendingCount"] = pendingCount
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
switch c.Query("msg") {
|
||||
case "approved":
|
||||
data["Success"] = tr["comment_approved"]
|
||||
case "rejected":
|
||||
data["Success"] = tr["comment_rejected"]
|
||||
case "deleted":
|
||||
data["Success"] = tr["comment_deleted"]
|
||||
default:
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, "comment_list", data)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentApprove marks a comment approved.
|
||||
func CommentApprove(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved")
|
||||
}
|
||||
}
|
||||
|
||||
// CommentReject marks a comment rejected (hidden from the front end, retained
|
||||
// in the admin list).
|
||||
func CommentReject(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// CommentDelete soft-deletes a comment.
|
||||
func CommentDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
db.Where("id = ?", id).Delete(&models.Comment{})
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user