- 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>
107 lines
3.3 KiB
Go
107 lines
3.3 KiB
Go
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]))
|
|
}
|