diff --git a/.gitignore b/.gitignore index d1e240a..797a8db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ go_blog go_blog.exe main.exe +# fresh (live-reload) build output +tmp/ + # Auto-generated runtime data (config + database + uploads) win/ mac/ diff --git a/fresh.conf b/fresh.conf new file mode 100644 index 0000000..594e833 --- /dev/null +++ b/fresh.conf @@ -0,0 +1,9 @@ +root: . +tmp_path: ./tmp +build_args: -o ./tmp/go_blog +build_log: ./tmp/build.log +valid_ext: .go, .html +build_delay: 300 +command: ./tmp/go_blog +command_args: +kill_delay: 500 diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go new file mode 100644 index 0000000..0b84708 --- /dev/null +++ b/handlers/admin_comment.go @@ -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") + } +} diff --git a/handlers/comment.go b/handlers/comment.go new file mode 100644 index 0000000..1a8f63f --- /dev/null +++ b/handlers/comment.go @@ -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 +// , 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") + } +} diff --git a/handlers/home.go b/handlers/home.go index b258134..6c49bee 100644 --- a/handlers/home.go +++ b/handlers/home.go @@ -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 "" +} diff --git a/handlers/settings.go b/handlers/settings.go index dc44bdb..d4baf01 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -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") + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 1bc735e..57a594c 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -198,6 +198,77 @@ var translations = map[Lang]map[string]string{ "cat_archive": "Archive", "cat_video": "Video", "cat_other": "Other", + + // Comments + "comments_title": "Comments", + "comments_count": "%d Comments", + "comments_empty": "No comments yet. Be the first to comment.", + "comments_reply": "Reply", + "comments_cancel_reply": "Cancel reply", + "comments_replying_to": "Replying to %s", + "comments_name": "Name", + "comments_email": "Email", + "comments_email_hint": "Used for your Gravatar avatar; never shown publicly.", + "comments_website": "Website (optional)", + "comments_content": "Comment", + "comments_content_ph": "Markdown is supported...", + "comments_private": "Private comment", + "comments_private_hint": "Only you and the administrator can see this.", + "comments_preview": "Preview", + "comments_edit": "Edit", + "comments_emoji": "Emoji", + "comments_markdown_help": "Markdown help", + "comments_submit": "Post Comment", + "comments_posted": "Comment posted.", + "comments_pending_notice": "Your comment is awaiting moderation.", + "comments_disabled": "Comments are disabled.", + "comments_guests_disabled": "You must sign in to comment.", + "comments_required_name": "Name is required.", + "comments_required_email": "Email is required.", + "comments_required_content":"Comment is required.", + "comments_invalid_email": "Please enter a valid email address.", + "comments_invalid_url": "Please enter a valid URL.", + "comments_too_long": "Comment is too long (max %d characters).", + "comments_pending": "Pending", + "comments_approved": "Approved", + "comments_rejected": "Rejected", + "comments_private_badge": "Private", + "comments_markdown_bold": "**bold**", + "comments_markdown_italic": "*italic*", + "comments_markdown_code": "`code`", + "comments_markdown_link": "[text](url)", + "comments_markdown_quote": "> quote", + + // Admin: comments + "admin_comments": "Comments", + "admin_comments_title": "Comments", + "comment_status_pending": "Pending", + "comment_status_approved": "Approved", + "comment_status_rejected": "Rejected", + "comment_status_all": "All", + "comment_approve": "Approve", + "comment_reject": "Reject", + "comment_delete": "Delete", + "comment_delete_confirm": "Delete this comment?", + "comment_col_author": "Author", + "comment_col_article": "Article", + "comment_col_content": "Content", + "comment_col_status": "Status", + "comment_col_time": "Time", + "comment_col_actions": "Actions", + "comment_empty": "No comments.", + "comment_approved": "Comment approved.", + "comment_rejected": "Comment rejected.", + "comment_deleted": "Comment deleted.", + + // Admin: comment settings + "comment_settings_title": "Comment Settings", + "comment_settings_desc": "Control whether comments are enabled and how they are moderated.", + "comment_settings_enabled": "Enable comments", + "comment_settings_allow_guest": "Allow anonymous comments", + "comment_settings_guest_approval": "Require approval for guest comments", + "comment_settings_use_gravatar": "Use Gravatar avatars", + "comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.", }, ZH: { // 导航 @@ -381,6 +452,77 @@ var translations = map[Lang]map[string]string{ "cat_archive": "压缩包", "cat_video": "视频", "cat_other": "其他", + + // 评论 + "comments_title": "评论", + "comments_count": "%d 条评论", + "comments_empty": "暂无评论,快来抢沙发吧。", + "comments_reply": "回复", + "comments_cancel_reply": "取消回复", + "comments_replying_to": "回复 %s", + "comments_name": "名称", + "comments_email": "邮箱", + "comments_email_hint": "用于显示 Gravatar 头像,不会公开。", + "comments_website": "网址(可选)", + "comments_content": "评论内容", + "comments_content_ph": "支持 Markdown 语法...", + "comments_private": "私密评论", + "comments_private_hint": "仅你和管理员可见。", + "comments_preview": "预览", + "comments_edit": "编辑", + "comments_emoji": "表情", + "comments_markdown_help": "Markdown 帮助", + "comments_submit": "发表评论", + "comments_posted": "评论已发表。", + "comments_pending_notice": "你的评论正在等待审核。", + "comments_disabled": "评论功能已关闭。", + "comments_guests_disabled": "请先登录后再评论。", + "comments_required_name": "请填写名称。", + "comments_required_email": "请填写邮箱。", + "comments_required_content":"请填写评论内容。", + "comments_invalid_email": "请输入有效的邮箱地址。", + "comments_invalid_url": "请输入有效的网址。", + "comments_too_long": "评论内容过长(最多 %d 字)。", + "comments_pending": "待审核", + "comments_approved": "已通过", + "comments_rejected": "已拒绝", + "comments_private_badge": "私密", + "comments_markdown_bold": "**粗体**", + "comments_markdown_italic": "*斜体*", + "comments_markdown_code": "`代码`", + "comments_markdown_link": "[文本](网址)", + "comments_markdown_quote": "> 引用", + + // 后台:评论 + "admin_comments": "评论管理", + "admin_comments_title": "评论管理", + "comment_status_pending": "待审核", + "comment_status_approved": "已通过", + "comment_status_rejected": "已拒绝", + "comment_status_all": "全部", + "comment_approve": "通过", + "comment_reject": "拒绝", + "comment_delete": "删除", + "comment_delete_confirm": "删除该评论?", + "comment_col_author": "评论者", + "comment_col_article": "文章", + "comment_col_content": "内容", + "comment_col_status": "状态", + "comment_col_time": "时间", + "comment_col_actions": "操作", + "comment_empty": "暂无评论。", + "comment_approved": "评论已通过。", + "comment_rejected": "评论已拒绝。", + "comment_deleted": "评论已删除。", + + // 后台:评论设置 + "comment_settings_title": "评论设置", + "comment_settings_desc": "控制是否启用评论以及评论的审核方式。", + "comment_settings_enabled": "启用评论", + "comment_settings_allow_guest": "允许匿名评论", + "comment_settings_guest_approval": "非登录用户评论需审核", + "comment_settings_use_gravatar": "使用 Gravatar 头像", + "comment_settings_use_gravatar_hint": "关闭后,头像将显示作者名称首字母的彩色占位。", }, } diff --git a/main.go b/main.go index 36cf6c5..b29730f 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,7 @@ func main() { router.POST("/login", handlers.Login(db)) router.POST("/logout", handlers.Logout()) router.GET("/article/:slug", handlers.ArticleDetail(db)) + router.POST("/article/:slug/comments", handlers.PostComment(db)) // Protected admin routes. admin := router.Group("/admin") @@ -66,6 +67,11 @@ func main() { admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) + + admin.GET("/comments", handlers.CommentListPage(db)) + admin.POST("/comments/:id/approve", handlers.CommentApprove(db)) + admin.POST("/comments/:id/reject", handlers.CommentReject(db)) + admin.POST("/comments/:id/delete", handlers.CommentDelete(db)) } // Protected article attachment routes (AJAX uploads / management). @@ -87,6 +93,8 @@ func main() { settings.POST("/upload", handlers.UploadSettingsSave(db)) settings.GET("/download", handlers.DownloadSettingsPage(db)) settings.POST("/download", handlers.DownloadSettingsSave(db)) + settings.GET("/comments", handlers.CommentSettingsPage(db)) + settings.POST("/comments", handlers.CommentSettingsSave(db)) } // Protected profile routes. diff --git a/models/comment.go b/models/comment.go new file mode 100644 index 0000000..098512c --- /dev/null +++ b/models/comment.go @@ -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])) +} diff --git a/models/comment_config.go b/models/comment_config.go new file mode 100644 index 0000000..c8381d4 --- /dev/null +++ b/models/comment_config.go @@ -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, + } +} diff --git a/models/config_cache.go b/models/config_cache.go index e33b3bc..8760f00 100644 --- a/models/config_cache.go +++ b/models/config_cache.go @@ -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() diff --git a/models/db.go b/models/db.go index c4bc845..62c56d4 100644 --- a/models/db.go +++ b/models/db.go @@ -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 diff --git a/models/seed.go b/models/seed.go index 39d54e2..872d03e 100644 --- a/models/seed.go +++ b/models/seed.go @@ -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{ diff --git a/templates/admin/comment_list.html b/templates/admin/comment_list.html new file mode 100644 index 0000000..fdbe5ce --- /dev/null +++ b/templates/admin/comment_list.html @@ -0,0 +1,94 @@ +{{define "comment_list"}} +{{template "header" .}} +
+
+

{{index .Tr "admin_comments_title"}}

+
+ + + + + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+ {{range .Comments}} +
+
+ +
+
+ {{.AuthorName}} + {{if .Website}}{{.Website}}{{end}} + {{.MaskedEmail}} + · + {{.CreatedAt.Format "2006-01-02 15:04"}} + {{if .IPAddress}}IP: {{.IPAddress}}{{end}} + {{if .IsPrivate}}{{index $.Tr "comments_private_badge"}}{{end}} + {{.StatusLabel}} +
+ {{if .ArticleTitle}} + + ↗ {{.ArticleTitle}} + + {{end}} +
+
+
+
+ {{if eq .Status 0}} +
+ +
+ {{end}} + {{if ne .Status 2}} +
+ +
+ {{end}} +
+ +
+
+
+ {{else}} +
+ {{index .Tr "comment_empty"}} +
+ {{end}} +
+
+ + + + +{{template "footer" .}} +{{end}} diff --git a/templates/admin/settings_comment.html b/templates/admin/settings_comment.html new file mode 100644 index 0000000..65d6b26 --- /dev/null +++ b/templates/admin/settings_comment.html @@ -0,0 +1,49 @@ +{{define "settings_comment"}} +{{template "header" .}} +
+

{{index .Tr "comment_settings_title"}}

+

{{index .Tr "comment_settings_desc"}}

+ + + + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+ + + +
+ +

{{index .Tr "comment_settings_use_gravatar_hint"}}

+
+ +
+
+{{template "footer" .}} +{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index e04808a..7b66702 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -47,6 +47,12 @@ {{index .Tr "admin_panel"}} + + {{index .Tr "article_manage"}} + + + {{index .Tr "admin_comments"}} + {{end}}
diff --git a/templates/pages/article.html b/templates/pages/article.html index 662744a..f9b407a 100644 --- a/templates/pages/article.html +++ b/templates/pages/article.html @@ -2,11 +2,24 @@ {{template "header" .}} +
- - ← {{index .Tr "article_back_home"}} - +

{{.Article.Title}}

@@ -26,13 +39,210 @@
+ + {{if .CommentError}} +
{{.CommentError}}
+ {{end}} + + {{if .CommentConfig}} + {{if .CommentConfig.Enabled}} + +
+

{{index .Tr "comments_title"}}

+ + {{if .CommentNotice}} +
{{.CommentNotice}}
+ {{end}} + + +
+ {{range .Comments}} + {{template "comment_node" .}} + {{else}} +

{{index $.Tr "comments_empty"}}

+ {{end}} +
+ + +
+ + + +
+
+ + +
+
+ + +

{{index .Tr "comments_email_hint"}}

+
+
+ + +
+
+ + +
+ +
+ {{index .Tr "comments_emoji"}} +
+
+
+ {{index .Tr "comments_markdown_help"}} +
+
{{index .Tr "comments_markdown_bold"}}
+
{{index .Tr "comments_markdown_italic"}}
+
{{index .Tr "comments_markdown_code"}}
+
{{index .Tr "comments_markdown_link"}}
+
{{index .Tr "comments_markdown_quote"}}
+
+
+ +
+ + + + +
+ + +
+ +
+
+ {{end}} + {{end}}
{{template "footer" .}} diff --git a/templates/pages/comment_node.html b/templates/pages/comment_node.html new file mode 100644 index 0000000..c090afa --- /dev/null +++ b/templates/pages/comment_node.html @@ -0,0 +1,33 @@ +{{define "comment_node"}} +
+
+ {{if .UseGravatar}} + + {{else}} +
{{.Comment.AuthorInitial}}
+ {{end}} +
+
+ {{if .Comment.Website}} + {{.Comment.AuthorName}} + {{else}} + {{.Comment.AuthorName}} + {{end}} + {{.RelTime}} + {{if .Comment.IsPrivate}}{{index .Tr "comments_private_badge"}}{{end}} + {{if eq .Comment.Status 0}}{{index .Tr "comments_pending"}}{{end}} +
+
+ +
+
+ {{if .Children}} +
+ {{range .Children}} + {{template "comment_node" .}} + {{end}} +
+ {{end}} +
+{{end}}