Skip to content

Commit 4ef2ce7

Browse files
thunpisitclaude
andauthored
feat(v2.0c): comments — third slice of v2.0 engagement (#45)
Per-article visitor comments with editor moderation. Dual-toggle policy so a fresh deploy never accidentally exposes a comment form. Schema (Drizzle migration 0009) ------------------------------- - comments: id, articleId FK CASCADE, parentId (forward-compat for threading; UI is flat), authorName, authorEmail (collected, never displayed publicly), body (plain text — never markdown to keep XSS surface minimal), status enum (pending/approved/spam/ archived), ipHash (16-char SHA-256 truncate; never raw IP), submittedAt, moderatedBy + moderatedAt. - articles.commentsMode: new column. enum (inherit/on/off), default 'inherit'. Dual toggle ----------- - Site-wide: settings.commentsEnabled defaults to false. New Comments card in /cms/settings. - Per-article: radio (Inherit / On / Off) on the article form. - $lib/server/comments.commentsAllowedForArticle() is the one-line truth table both the public render and the POST endpoint use. Public surface -------------- - POST /api/comments. Reuses v2.0a hashIp + rate-limit (3/min per ipHash per article). Honeypot _hp via $lib/forms/constants (extracted to a non-server module so client + server share the field name). 410 when commentsAllowed=false; 429 on rate limit. - (www)/[locale]/blog/[slug] page-server load now also fetches approved comments + computes commentsOpen. - New CommentSection.svelte renders approved comments + a fetch()-posted submission form. Fields: name (required, ≤80), email (required, ≤254, server-side regex), body (required, ≤4000). Plain-text rendering with whitespace-pre-wrap. - Page svelte mounts CommentSection when there are approved comments OR when commentsOpen=true. CMS surface ----------- - /cms/comments moderation queue. Status tabs (pending/approved/ spam/archived). Each row: status badge, timestamp, author + masked email (a***@e***.com via $lib/comments/mask), linked article title (batch-resolved per page), body, mark-as buttons (approved/ spam/archived), reply-via-email mailto, delete. Pagination 50/page. Pending count exposed for future sidebar badge. - Sidebar entry under taxonomy group, gated to editor+. Audit ----- - New comment.{create,approve,spam,archive,delete} AuditAction members. - Public submission writes comment.create with userId=null. - Moderation actions write the matching status-change action with the editor's userId. Constants --------- - HONEYPOT_FIELD + RATE_LIMIT_WINDOW_SECONDS + RATE_LIMIT_MAX_PER_ WINDOW moved from $lib/server/forms (server-only) to $lib/forms/ constants so client components can import them. Server module re-exports for backwards compat. i18n: 26 new comment_* / comments_* / cms_settings_comments_* keys (EN + TH). Migration 0009 already applied to live D1. Out of scope (deliberate non-goals for this slice): - Threaded replies — parentId is forward-compat only; UI is flat. - Comments on Pages — Pages are typically static. - Akismet/ML spam filtering — honeypot+rate-limit is the v2.0 floor. - Email notifications when comment approved. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3f52dbc commit 4ef2ce7

29 files changed

Lines changed: 3315 additions & 515 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ Khao Pad started as a CMS. Through v1.5 it became a complete content layer (writ
284284
| **v1.7** | Pages, navigation, IA | ✅ Shipped | Media folders, reusable blocks, cookie consent, static pages, navigation manager, seed:legal |
285285
| **v1.8** | Analytics & insight | ✅ Shipped | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline, optional CWA |
286286
| **v1.9** | Performance & trust | ✅ Shipped | Responsive `srcset` via /cdn-cgi/image, edge cache-control hook, branded 404/500, /api/health endpoint |
287-
| **v2.0** | Engagement & growth | 🚧 In progress | ✅ a Forms · ✅ b Newsletter (optional) · 🚧 c Comments · 🚧 d Webhooks + Public REST API |
287+
| **v2.0** | Engagement & growth | 🚧 In progress | ✅ a Forms · ✅ b Newsletter (optional) · c Comments · 🚧 d Webhooks + Public REST API |
288288

289289
**Backlog** (not committed): OAuth providers, block-based editor, AI-assisted authoring, multi-site / workspaces, A/B testing, member-only / paid content.
290290

docs/MILESTONES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ Shipping in four PRs grouped by theme. **a** + **b** done, **c** + **d** to foll
248248

249249
**v2.0b — Newsletter (shipped, fully optional)** — Drizzle migration 0008: `subscribers` (email UNIQUE, locale, token UNIQUE, confirmedAt, unsubscribedAt, source). Optional everywhere: when no email provider is configured, public signups go single-opt-in (subscribers immediately confirmed) — clearly documented. When the operator sets a Resend API key + sender address in `/cms/settings → Newsletter`, public signups become double-opt-in: a confirmation email goes out via Resend, subscriber is "active" only after they click the link. Public endpoints: `POST /api/newsletter/subscribe` (form-data with email + locale, honeypot `_hp`, per-IP rate-limit-ready via the v2.0a hashIp helper), `GET /api/newsletter/confirm?token=...` (idempotent click target → 302 to localized home with `?newsletter=confirmed`), `GET /api/newsletter/unsubscribe?token=...` (one-click, no interstitial — GDPR/CAN-SPAM compliance). Admin endpoint `POST /api/newsletter/send-digest?days=7&dryRun=1` iterates active subscribers, groups by locale, picks the last week's published articles per locale, sends one email per subscriber via Resend. CMS `/cms/subscribers` (admin+ only) lists subscribers with status badge (pending/active/unsubscribed), exposes manual "Send digest now" + dry-run button when a provider is configured, shows a clear "no provider configured" banner with a link to settings when not. New `newsletter.{subscribe,confirm,unsubscribe,delete,digest_sent}` audit actions. Cron-trigger wiring deferred to operator's wrangler.toml.
250250

251-
**Comments** — Per-article comments with name + email, queued for moderation by default. CMS moderation queue at `/cms/comments`. Akismet-style spam filter is out of scope; rate limit + honeypot is the v2.0 floor. Optional: anchor in `<article>` body.
251+
**v2.0c — Comments (shipped, dual-toggle)** — Drizzle migration 0009: `comments` (id, articleId FK CASCADE, parentId for forward-compat threading, authorName, authorEmail, body plain-text, status enum pending/approved/spam/archived, ipHash 16-char SHA-256 truncate, submittedAt, moderatedBy + moderatedAt) + new `articles.commentsMode` column (`inherit` | `on` | `off`, defaults to `inherit`). Two-layer policy: a site-wide `commentsEnabled` setting in `/cms/settings → Comments` (defaults to **off** so a fresh deploy never accidentally exposes a comment form) AND a per-article radio (`Inherit` / `On` / `Off`) on the article form. The `commentsAllowedForArticle()` helper is a one-line truth table both the public render and the POST endpoint consult. Public `POST /api/comments` (form-data: `article_id`, `name`, `email`, `body`, honeypot `_hp`) reuses the v2.0a hashIp + rate-limit pattern (3 / minute per ip-hash per article). Returns 410 when commentsAllowed=false, 429 when rate-limited. Approved comments render below the article body in a generic `<CommentSection>` (oldest → newest, plain-text only — no markdown/HTML to keep the XSS surface minimal). The submission form posts via `fetch()` so the page doesn't reload; success message says "awaiting moderation". CMS `/cms/comments` (editor+) is a moderation queue with status tabs (pending/approved/spam/archived), batched-resolved article titles for each row, masked email display (`a***@e***.com`), mark-as buttons, mailto reply, and a sidebar entry. The pending count drives a future sidebar badge (read once on dashboard load). New `comment.{create,approve,spam,archive,delete}` audit actions. **Out of scope (deliberate):** threaded replies (parentId is forward-compat schema only — UI is flat), comments on Pages (Pages are typically static), Akismet/ML spam filtering (honeypot+rate-limit is the v2.0 floor), email notifications to commenters when approved.
252252

253253
**Webhooks**`/cms/webhooks` lets admins register URLs to ping on `article.publish`, `article.unpublish`, `form.submit`, `subscriber.confirm`. Signed with HMAC-SHA256 using a per-webhook secret. Retried with exponential backoff up to 5×.
254254

drizzle/0009_futuristic_vulcan.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE `comments` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`article_id` text NOT NULL,
4+
`parent_id` text,
5+
`author_name` text NOT NULL,
6+
`author_email` text NOT NULL,
7+
`body` text NOT NULL,
8+
`status` text DEFAULT 'pending' NOT NULL,
9+
`ip_hash` text,
10+
`submitted_at` text NOT NULL,
11+
`moderated_by` text,
12+
`moderated_at` text,
13+
FOREIGN KEY (`article_id`) REFERENCES `articles`(`id`) ON UPDATE no action ON DELETE cascade,
14+
FOREIGN KEY (`moderated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
15+
);
16+
--> statement-breakpoint
17+
ALTER TABLE `articles` ADD `comments_mode` text DEFAULT 'inherit' NOT NULL;

0 commit comments

Comments
 (0)