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,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Comment status constants.
|
||||
const (
|
||||
CommentPending = 0 // 待审核
|
||||
CommentApproved = 1 // 已通过
|
||||
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
|
||||
)
|
||||
|
||||
// Comment represents one reader-submitted comment on an article. Comments may
|
||||
// be nested via ParentID and authored by either a logged-in user (UserID) or
|
||||
// an anonymous visitor identified by a long-lived GuestToken cookie.
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
|
||||
// Authorship: logged-in users get UserID; anonymous visitors get a random
|
||||
// GuestToken stored in a cookie so they can see their own pending/private
|
||||
// comments on subsequent page loads.
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
GuestToken string `gorm:"size:64;index" json:"-"`
|
||||
|
||||
AuthorName string `gorm:"not null;size:64" json:"author_name"`
|
||||
Email string `gorm:"not null;size:255" json:"-"`
|
||||
EmailHash string `gorm:"size:64;index" json:"email_hash"`
|
||||
Website string `gorm:"size:255" json:"website,omitempty"`
|
||||
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
|
||||
IsPrivate bool `gorm:"default:false;index" json:"is_private"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
|
||||
IPAddress string `gorm:"size:64" json:"-"`
|
||||
UserAgent string `gorm:"size:512" json:"-"`
|
||||
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
|
||||
// is the form expected by Gravatar.
|
||||
func HashEmail(email string) string {
|
||||
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
|
||||
// Falls back to the "identicon" default avatar when no Gravatar exists.
|
||||
func (c *Comment) GravatarURL(size int) string {
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
}
|
||||
hash := c.EmailHash
|
||||
if hash == "" {
|
||||
hash = HashEmail(c.Email)
|
||||
}
|
||||
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
|
||||
}
|
||||
|
||||
// MaskedEmail returns the email with the local part partially obscured, for
|
||||
// admin-side listings that should hint at identity without exposing the
|
||||
// full address.
|
||||
func (c *Comment) MaskedEmail() string {
|
||||
email := c.Email
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 {
|
||||
return email
|
||||
}
|
||||
local := email[:at]
|
||||
host := email[at:]
|
||||
if len(local) <= 2 {
|
||||
return string(local[0]) + "***" + host
|
||||
}
|
||||
return string(local[0]) + "***" + string(local[len(local)-1]) + host
|
||||
}
|
||||
|
||||
// AuthorInitial returns an uppercase first character of AuthorName for use as
|
||||
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
|
||||
// the name is empty.
|
||||
func (c *Comment) AuthorInitial() string {
|
||||
name := strings.TrimSpace(c.AuthorName)
|
||||
if name == "" {
|
||||
return "?"
|
||||
}
|
||||
r := []rune(name)
|
||||
return strings.ToUpper(string(r[0]))
|
||||
}
|
||||
Reference in New Issue
Block a user