Skip to content

Add native commenting system for blog posts#476

Merged
compscidr merged 2 commits intomainfrom
feature/native-comments
Mar 3, 2026
Merged

Add native commenting system for blog posts#476
compscidr merged 2 commits intomainfrom
feature/native-comments

Conversation

@compscidr
Copy link
Collaborator

@compscidr compscidr commented Mar 3, 2026

Fixes #16

Summary

  • Adds a Comment model with database-backed storage, public form submission (POST /comments) using Post-Redirect-Get, and admin-only DELETE /api/v1/comments endpoint
  • Spam prevention via hidden honeypot field and IP-based rate limiting (1 comment/min per IP)
  • Comment markdown rendered client-side via Showdown.js (already loaded on post pages), with <noscript> fallback for raw content
  • Admin dashboard shows recent comments table with delete buttons and links to parent posts
  • Fixes pre-existing bug: admin pages were missing recent template data, causing empty "Most Recent Post" footer section

Closes #16

Test plan

  • go test ./... passes
  • Navigate to a post — comment form appears at bottom
  • Submit a comment — redirects back with comment visible and anchor scroll
  • Comment markdown renders via Showdown (e.g. bold, code)
  • Submit with empty name/content — error message shown
  • Submit twice within 1 minute — rate limit error shown
  • Fill honeypot field — silently rejected, no error
  • As admin, delete button appears on each comment and works
  • Admin dashboard shows recent comments table with delete and post links
  • Admin footer "Most Recent Post" section now populates correctly

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 3, 2026 05:45
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@compscidr compscidr merged commit cb358d6 into main Mar 3, 2026
2 checks passed
@compscidr compscidr deleted the feature/native-comments branch March 3, 2026 05:50
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Comment model + 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 recent to 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.

Comment on lines +520 to +535
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()
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
c.Redirect(http.StatusSeeOther, redirect+"?comment_error=invalid_post")
return
}

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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
}

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +126
var preview = document.getElementById("comment-preview");
if (typeof converter !== 'undefined') {
preview.innerHTML = converter.makeHtml(content);
} else {
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +612 to +623
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))
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +201 to +205
contentType := c.Request.Header.Get("content-type")
if contentType != "application/json" {
c.JSON(http.StatusUnsupportedMediaType, "Expecting application/json")
return
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines 425 to 428
c.HTML(http.StatusOK, "post-admin.html", gin.H{
"logged_in": a.auth.IsAdmin(c),
"is_admin": a.auth.IsLoggedIn(c),
"post": post,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +87
var raw = {{ .Content }};
var el = document.getElementById("comment-content-{{ .ID }}");
if (el && typeof converter !== 'undefined') {
el.innerHTML = converter.makeHtml(raw);
} else if (el) {
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
{{ 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).
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
{{ 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.

Copilot uses AI. Check for mistakes.
Comment on lines +566 to +574
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
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to 34
commentLimiter map[string]time.Time
limiterMu sync.Mutex
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement comments

2 participants