yggdrasil/migrations/004_comments.sql
xfy 04737300e6 feat(comments): add complete comment system with guest commenting, moderation, and admin UI
Implements a fully self-built comment system for the blog:

Data layer:
- comments table with BIGSERIAL PK, parent_id self-reference (ON DELETE SET NULL),
  depth tracking (max 20), status workflow (pending/approved/spam/trash),
  content hashing for dedup, GDPR consent tracking, IP/UA storage with auto-purge
- 5 partial indexes optimized for read patterns
- updated_at auto-trigger

API (9 Dioxus server functions):
- Public: get_comments, get_comment_count, create_comment
- Admin: get_pending_comments, get_pending_count, get_all_comments,
  approve_comment (with ancestor auto-approval), spam_comment, trash_comment,
  batch_update_comment_status

Security:
- Function-level rate limiting (1/sec, burst 5) via FullstackContext IP extraction
- Input validation (name, email, URL scheme, content length, consent)
- Parent chain validation (must be approved, same post)
- Strict comment Markdown renderer (headings→strong, no img/id/data URIs, nofollow links)
- Honeypot anti-spam field
- 5-minute dedup window via SHA-256 content hash

Frontend:
- CommentSection with SuspenseBoundary isolation
- Flat-list rendering with depth-based CSS indentation (responsive)
- Gravatar via cravatar.cn (server-computed, email never exposed)
- Inline reply forms (one-at-a-time via Signal)
- Admin action buttons (approve/spam/delete) visible per-comment
- CommentForm with privacy consent, Markdown hint, loading states

Admin:
- /admin/comments page with status tabs, batch operations, pagination
- Pending count badge on admin dashboard

Infrastructure:
- Shared get_current_admin_user moved from posts/helpers to auth module
- COMMENT_LIMITER rate limiter tier
- Moka caches (60s TTL for comments, 10s for pending count)
- IP/UA purge background task (daily, 90-day retention)
2026-06-11 12:34:26 +08:00

59 lines
2.0 KiB
PL/PgSQL

CREATE TABLE IF NOT EXISTS comments (
id BIGSERIAL PRIMARY KEY,
post_id INT NOT NULL REFERENCES posts(id) ON DELETE RESTRICT,
parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL,
depth INT NOT NULL DEFAULT 0,
author_name VARCHAR(50) NOT NULL,
author_email VARCHAR(255) NOT NULL,
author_url VARCHAR(500),
content_md TEXT NOT NULL,
content_html TEXT,
content_hash VARCHAR(64),
status TEXT NOT NULL DEFAULT 'pending',
ip_address VARCHAR(45),
user_agent VARCHAR(500),
consented_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT comments_status_check
CHECK (status IN ('pending', 'approved', 'spam', 'trash')),
CONSTRAINT comments_depth_check
CHECK (depth >= 0 AND depth <= 20),
CONSTRAINT comments_content_not_empty
CHECK (length(trim(content_md)) >= 1),
CONSTRAINT comments_name_not_empty
CHECK (length(trim(author_name)) >= 1)
);
CREATE INDEX IF NOT EXISTS idx_comments_post_approved
ON comments(post_id, created_at) WHERE status = 'approved' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_top_level
ON comments(post_id, created_at)
WHERE parent_id IS NULL AND status = 'approved' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_pending
ON comments(created_at DESC) WHERE status = 'pending' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_admin_list
ON comments(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_comments_parent
ON comments(parent_id) WHERE parent_id IS NOT NULL;
CREATE OR REPLACE FUNCTION update_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comments_updated_at
BEFORE UPDATE ON comments
FOR EACH ROW
EXECUTE FUNCTION update_comments_updated_at();