Add native commenting system for blog posts#476
Conversation
Adds a Comment model stored in the database with public form submission (POST /comments) using Post-Redirect-Get pattern, admin-only DELETE API, spam prevention via honeypot field + IP-based rate limiting (1/min), markdown rendering via Showdown.js, and a recent comments table on the admin dashboard. Also fixes missing "recent" data in admin page footers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a first-party, database-backed commenting feature for blog posts (public submission + admin moderation), and updates admin templates to restore “recent” footer data.
Changes:
- Add
Commentmodel + DB migration, plus blog/admin handlers for submitting and deleting comments. - Render comments and add a comment form/preview on post pages; add “Recent Comments” table to the admin dashboard.
- Fix admin template data plumbing by consistently providing
recentto admin page renders.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| www/css/goblog.css | Adds basic styling for comment blocks. |
| tools/migrate.go | Migrates the new Comment table. |
| templates/post.html | Displays comment list + form; client-side markdown rendering and admin delete UI. |
| templates/admin_dashboard.html | Adds “Recent Comments” table and delete UI. |
| goblog.go | Registers new /comments and /api/v1/comments routes. |
| blog/comment.go | Introduces Comment model. |
| blog/blog.go | Implements comment fetching, rate limiting, and comment submission handler. |
| blog/blog_test.go | Adds tests for comment submission flows (redirects, honeypot, rate limit). |
| admin/admin.go | Adds admin-only comment deletion and wires recent comments into dashboard template data. |
| admin/admin_test.go | Adds tests for admin delete-comment authorization and success cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (b *Blog) canComment(ip string) bool { | ||
| b.limiterMu.Lock() | ||
| defer b.limiterMu.Unlock() | ||
| if last, ok := b.commentLimiter[ip]; ok { | ||
| if time.Since(last) < time.Minute { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| func (b *Blog) recordComment(ip string) { | ||
| b.limiterMu.Lock() | ||
| defer b.limiterMu.Unlock() | ||
| b.commentLimiter[ip] = time.Now() | ||
| } |
There was a problem hiding this comment.
The rate-limit check (canComment) and update (recordComment) are separate calls, so two concurrent requests from the same IP can both pass the check before either records, allowing multiple comments within the window. Consider combining check+record into a single locked operation (or use an atomic/TTL cache) to make the limit effective under concurrency.
| c.Redirect(http.StatusSeeOther, redirect+"?comment_error=invalid_post") | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
SubmitComment does not verify that post_id refers to an existing post before inserting. This allows creating orphaned comments that will never display (and could be abused for DB bloat). Consider checking the post exists (or enforcing a DB foreign key constraint) before Create.
| // Ensure the referenced post exists before creating a comment | |
| var post Post | |
| if err := (*b.db).First(&post, uint(postID)).Error; err != nil { | |
| if errors.Is(err, gorm.ErrRecordNotFound) { | |
| c.Redirect(http.StatusSeeOther, redirect+"?comment_error=invalid_post") | |
| } else { | |
| log.Printf("failed to load post %d for comment: %v", postID, err) | |
| c.Redirect(http.StatusSeeOther, redirect+"?comment_error=server_error") | |
| } | |
| return | |
| } |
| var preview = document.getElementById("comment-preview"); | ||
| if (typeof converter !== 'undefined') { | ||
| preview.innerHTML = converter.makeHtml(content); | ||
| } else { |
There was a problem hiding this comment.
The comment preview also injects the Showdown HTML output via innerHTML. If raw HTML is allowed in markdown, a user can execute scripts immediately in the preview (self-XSS) and it reinforces the stored-XSS issue on submission. Use the same sanitization approach as for rendered comments (or render preview using textContent / a sanitizer).
| comment := Comment{ | ||
| PostID: uint(postID), | ||
| Name: name, | ||
| Email: email, | ||
| Content: content, | ||
| IPAddress: ip, | ||
| } | ||
| (*b.db).Create(&comment) | ||
| b.recordComment(ip) | ||
|
|
||
| c.Redirect(http.StatusSeeOther, redirect+fmt.Sprintf("#comment-%d", comment.ID)) | ||
| } |
There was a problem hiding this comment.
Comment creation ignores the DB result. If Create fails (DB down, constraint issues, etc.), the handler still records the rate-limit timestamp and redirects as if successful (with comment.ID likely 0). Capture and handle the Create error; only record rate-limit and redirect to #comment-ID after a successful insert.
| contentType := c.Request.Header.Get("content-type") | ||
| if contentType != "application/json" { | ||
| c.JSON(http.StatusUnsupportedMediaType, "Expecting application/json") | ||
| return | ||
| } |
There was a problem hiding this comment.
DeleteComment requires the Content-Type header to equal exactly "application/json". Many clients send "application/json; charset=utf-8", which would be incorrectly rejected with 415. Consider parsing the media type (mime.ParseMediaType) or checking strings.HasPrefix(contentType, "application/json").
| c.HTML(http.StatusOK, "post-admin.html", gin.H{ | ||
| "logged_in": a.auth.IsAdmin(c), | ||
| "is_admin": a.auth.IsLoggedIn(c), | ||
| "post": post, |
There was a problem hiding this comment.
In the admin Post HTML render, logged_in and is_admin appear swapped (logged_in uses IsAdmin and is_admin uses IsLoggedIn). This can lead to incorrect navigation/menu state and inconsistent template behavior compared to other admin pages. Swap these to logged_in=IsLoggedIn and is_admin=IsAdmin.
| var raw = {{ .Content }}; | ||
| var el = document.getElementById("comment-content-{{ .ID }}"); | ||
| if (el && typeof converter !== 'undefined') { | ||
| el.innerHTML = converter.makeHtml(raw); | ||
| } else if (el) { |
There was a problem hiding this comment.
Comment markdown is converted to HTML client-side and injected via innerHTML. Since comment content is user-generated, this enables stored XSS (Showdown allows raw HTML by default), meaning a commenter can run arbitrary JS for all visitors (including admins). Consider sanitizing the generated HTML (e.g., DOMPurify) or rendering markdown server-side with an allowlist sanitizer and only injecting trusted HTML.
| {{ if eq .comment_error "missing_fields" }}Name and comment are required. | ||
| {{ else if eq .comment_error "rate_limit" }}Please wait a minute before posting another comment. | ||
| {{ else if eq .comment_error "name_too_long" }}Name is too long (max 100 characters). | ||
| {{ else if eq .comment_error "content_too_long" }}Comment is too long (max 5000 characters). |
There was a problem hiding this comment.
The handler can set comment_error=invalid_post and comment_error=email_too_long, but the template never shows specific messages for those values, so users will get the generic error. Add branches for these cases (or align the handler’s error codes with what the template renders).
| {{ else if eq .comment_error "content_too_long" }}Comment is too long (max 5000 characters). | |
| {{ else if eq .comment_error "content_too_long" }}Comment is too long (max 5000 characters). | |
| {{ else if eq .comment_error "invalid_post" }}This post cannot be found or is not available for comments. | |
| {{ else if eq .comment_error "email_too_long" }}Email address is too long. |
| redirect := c.PostForm("redirect") | ||
| if redirect == "" { | ||
| redirect = "/" | ||
| } | ||
|
|
||
| // Honeypot check - if website field is filled, silently redirect | ||
| if c.PostForm("website") != "" { | ||
| c.Redirect(http.StatusSeeOther, redirect) | ||
| return |
There was a problem hiding this comment.
SubmitComment trusts the POSTed "redirect" field and redirects to it directly. This allows open-redirect abuse if an attacker submits a crafted form (e.g., redirect=https://evil.example), and it also breaks when you append query strings via string concatenation. Parse/validate redirect as a same-site relative path (or ignore the field and derive redirect from post_id), then build query parameters/fragments via net/url.
| commentLimiter map[string]time.Time | ||
| limiterMu sync.Mutex | ||
| } |
There was a problem hiding this comment.
Rate limiting is implemented as a grow-only in-memory map keyed by IP with no eviction. Over time (or under attack using many source IPs), this can cause unbounded memory growth and it won’t work correctly across multiple instances/restarts. Consider a TTL cache with periodic cleanup or a shared store (DB/Redis) with expiry.
Fixes #16
Summary
Commentmodel with database-backed storage, public form submission (POST /comments) using Post-Redirect-Get, and admin-onlyDELETE /api/v1/commentsendpoint<noscript>fallback for raw contentrecenttemplate data, causing empty "Most Recent Post" footer sectionCloses #16
Test plan
go test ./...passescode)🤖 Generated with Claude Code