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]))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CommentConfig holds the singleton (id=1) global comment policy.
|
||||
type CommentConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
|
||||
UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (CommentConfig) TableName() string {
|
||||
return "comment_configs"
|
||||
}
|
||||
|
||||
// defaultCommentConfig returns the in-memory fallback used before the DB row is
|
||||
// seeded, matching the seed defaults.
|
||||
func defaultCommentConfig() *CommentConfig {
|
||||
return &CommentConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var configCache = struct {
|
||||
mu sync.RWMutex
|
||||
site *SiteSetting
|
||||
upload *UploadConfig
|
||||
comment *CommentConfig
|
||||
types []UploadFileType
|
||||
baseURLs []DownloadBaseURL
|
||||
}{
|
||||
@@ -41,6 +42,13 @@ func LoadConfigCache(db *gorm.DB) {
|
||||
configCache.upload = &UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"}
|
||||
}
|
||||
|
||||
var cc CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err == nil {
|
||||
configCache.comment = &cc
|
||||
} else {
|
||||
configCache.comment = defaultCommentConfig()
|
||||
}
|
||||
|
||||
var t []UploadFileType
|
||||
if err := db.Order("sort asc, id asc").Find(&t).Error; err == nil {
|
||||
configCache.types = t
|
||||
@@ -73,6 +81,13 @@ func GetUploadConfig() *UploadConfig {
|
||||
return configCache.upload
|
||||
}
|
||||
|
||||
// GetCommentConfig returns the cached comment policy.
|
||||
func GetCommentConfig() *CommentConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.comment
|
||||
}
|
||||
|
||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||
func GetUploadFileTypes() []UploadFileType {
|
||||
configCache.mu.RLock()
|
||||
|
||||
+2
-1
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
seedCommentConfig(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
var count int64
|
||||
|
||||
@@ -39,6 +39,25 @@ func seedUploadConfig(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
|
||||
func seedCommentConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&CommentConfig{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
c := &CommentConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed comment_configs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||
// seeded on first run.
|
||||
var defaultUploadFileTypes = []UploadFileType{
|
||||
|
||||
Reference in New Issue
Block a user