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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+62
-11
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
@@ -53,20 +54,46 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
|
||||
authorName := article.Author.Username
|
||||
if article.Author.DisplayName != "" {
|
||||
authorName = article.Author.DisplayName
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = article.Title
|
||||
data["Article"] = article
|
||||
data["AuthorName"] = authorName
|
||||
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
|
||||
c.HTML(http.StatusOK, "article", data)
|
||||
// One-time flash notice (set by PostComment on success/pending). Reading
|
||||
// consumes the flash, so refreshing the page no longer re-shows it.
|
||||
notice := readCommentFlash(c)
|
||||
renderArticleDetail(c, db, &article, commentForm{}, "", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// renderArticleDetail renders the article page, including the comment section.
|
||||
// formErr refills the form with an error banner; notice is a one-time
|
||||
// success/pending banner (already consumed from the session by the caller).
|
||||
func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) {
|
||||
tr := getTr(c)
|
||||
|
||||
authorName := article.Author.Username
|
||||
if article.Author.DisplayName != "" {
|
||||
authorName = article.Author.DisplayName
|
||||
}
|
||||
|
||||
viewer := viewerFromContext(c)
|
||||
|
||||
var comments []models.Comment
|
||||
db.Where("article_id = ?", article.ID).Order("created_at ASC").Find(&comments)
|
||||
cfg := models.GetCommentConfig()
|
||||
useGravatar := cfg != nil && cfg.UseGravatar
|
||||
tree := buildCommentTree(comments, viewer, tr, useGravatar)
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = article.Title
|
||||
data["Article"] = article
|
||||
data["AuthorName"] = authorName
|
||||
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
|
||||
data["Comments"] = tree
|
||||
data["CommentConfig"] = models.GetCommentConfig()
|
||||
data["CommentForm"] = form
|
||||
data["CommentError"] = formErr
|
||||
data["CommentNotice"] = notice
|
||||
data["MaxCommentLength"] = MaxCommentLength
|
||||
c.HTML(http.StatusOK, "article", data)
|
||||
}
|
||||
|
||||
// formatPublishTime returns the publication time as a readable string, falling
|
||||
// back to the creation time when the publish timestamp is unset.
|
||||
func formatPublishTime(publishedAt *time.Time) string {
|
||||
@@ -75,3 +102,27 @@ func formatPublishTime(publishedAt *time.Time) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commentFlashKey is the session key for the one-time comment notice.
|
||||
const commentFlashKey = "comment_flash"
|
||||
|
||||
// setCommentFlash stores a one-time comment notice in the session so the
|
||||
// following GET /article/:slug (after the POST/redirect) can show it once and
|
||||
// never again on refresh.
|
||||
func setCommentFlash(c *gin.Context, value string) {
|
||||
session := sessions.Default(c)
|
||||
session.Set(commentFlashKey, value)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
// readCommentFlash returns and clears the one-time comment notice, if any.
|
||||
func readCommentFlash(c *gin.Context) string {
|
||||
session := sessions.Default(c)
|
||||
v, ok := session.Get(commentFlashKey).(string)
|
||||
if ok && v != "" {
|
||||
session.Delete(commentFlashKey)
|
||||
session.Save()
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -334,3 +334,43 @@ func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.DownloadBaseURL{}, id)
|
||||
}
|
||||
|
||||
// ---------------- Comment settings ----------------
|
||||
|
||||
// CommentSettingsPage renders the comment policy form.
|
||||
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
var cc models.CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err != nil {
|
||||
cc = *models.GetCommentConfig()
|
||||
}
|
||||
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["comment_settings_title"]
|
||||
data["CommentConfig"] = cc
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_comment", data)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentSettingsSave persists the comment policy toggles and refreshes the
|
||||
// in-memory cache so subsequent requests see the change.
|
||||
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var cc models.CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err != nil {
|
||||
cc = models.CommentConfig{ID: 1}
|
||||
}
|
||||
cc.Enabled = c.PostForm("enabled") == "1"
|
||||
cc.AllowGuest = c.PostForm("allow_guest") == "1"
|
||||
cc.GuestRequireApproval = c.PostForm("guest_require_approval") == "1"
|
||||
cc.UseGravatar = c.PostForm("use_gravatar") == "1"
|
||||
cc.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&cc)
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user