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") } }