Skip to content

Commit a05dcd2

Browse files
thunpisitclaude
andauthored
feat(v2.0d): webhooks + public REST API (matches upstream PR codustry#46) (#14)
Cherry-picks upstream PR codustry#46. Migration 0010 already applied to live D1. Field-merged i18n: 39 new cms_webhooks_* / cms_api_keys_* keys (EN + TH). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a70ed4a commit a05dcd2

26 files changed

Lines changed: 4355 additions & 6 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 | ✅ Shipped | 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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,7 @@ Shipping in four PRs grouped by theme. **a** + **b** done, **c** + **d** to foll
250250

251251
**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

253-
**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×.
254-
255-
**Public REST API**`/api/public/*` read-only endpoints (articles, categories, tags, pages) for headless consumers. API-key auth via a new `api_keys` table; per-key scopes (read articles only, etc.). Rate-limited per key.
253+
**v2.0d — Webhooks + Public REST API (shipped)** — Drizzle migration 0010: `webhooks` (id, label, url, secret 48-char nanoid, events JSON, enabled, audit fields), `webhook_deliveries` (per-attempt log: webhookId CASCADE, event, payload, responseStatus, responseExcerpt 256 chars, durationMs, attempt, nextAttemptAt, ok), `api_keys` (id, label, keyHash UNIQUE — SHA-256 hex of raw key, prefix kp_live_xxxx kept for display, scopes JSON, expiresAt, revokedAt, lastUsedAt, audit fields). New `WebhookEvent` union: `article.{publish,unpublish,delete}` / `comment.approve` / `form.submit` / `subscriber.confirm`. Dispatcher in `$lib/server/webhooks/`: HMAC-SHA256 signs body using webhook's secret, sends `X-Khaopad-Signature: sha256=<hex>` + `X-Khaopad-Event` + `X-Khaopad-Delivery` UUID headers, 5s timeout, 3 inline attempts with 250ms / 1500ms backoff. Best-effort writes a `webhook_deliveries` row for every attempt — operator debugs from CMS. `dispatchEvent()` is fire-and-forget at the call site; the originating action returns immediately. Wired into article publish/unpublish/delete (article edit + togglePublish), comment approve (single-target — spam/archive don't fire), form submit (public POST endpoint), and subscriber confirm (the email click target). Public REST API at `/api/public/articles` (paginated, locale filter), `/api/public/articles/[slug]`, `/api/public/categories`, `/api/public/tags`, `/api/public/pages`. Bearer auth via `Authorization: Bearer kp_live_…` header; SHA-256 hash lookup against `api_keys.keyHash` so a leaked DB row can't authenticate. Per-key scopes: `articles:read`, `categories:read`, `tags:read`, `pages:read`, or `*:read` for the read-everything bundle. Hard-revoked keys + expired keys return null (401). `lastUsedAt` is bumped fire-and-forget on every successful auth so the operator can spot stale keys. `kp_live_` prefix is recognizable to GitHub secret scanning. Two new CMS routes (`/cms/webhooks` + `/cms/api-keys`), both admin+ gated; the api-keys page surfaces the raw key ONCE on create with a clear "won't be shown again" warning + copy button. New `settings.update` audit rows tag `kind: webhook.create` / `webhook.update` / `webhook.rotate_secret` / `webhook.delete` / `api_key.create` / `api_key.revoke` / `api_key.delete`.
256254

257255
### Backlog — bigger ideas, not committed
258256

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
CREATE TABLE `api_keys` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`label` text NOT NULL,
4+
`key_hash` text NOT NULL,
5+
`prefix` text NOT NULL,
6+
`scopes` text NOT NULL,
7+
`expires_at` text,
8+
`revoked_at` text,
9+
`last_used_at` text,
10+
`created_by` text,
11+
`created_at` text NOT NULL,
12+
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
13+
);
14+
--> statement-breakpoint
15+
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
16+
CREATE TABLE `webhook_deliveries` (
17+
`id` text PRIMARY KEY NOT NULL,
18+
`webhook_id` text NOT NULL,
19+
`event` text NOT NULL,
20+
`payload` text NOT NULL,
21+
`response_status` integer,
22+
`response_excerpt` text,
23+
`duration_ms` integer,
24+
`attempt` integer DEFAULT 1 NOT NULL,
25+
`next_attempt_at` text,
26+
`ok` integer DEFAULT false NOT NULL,
27+
`created_at` text NOT NULL,
28+
FOREIGN KEY (`webhook_id`) REFERENCES `webhooks`(`id`) ON UPDATE no action ON DELETE cascade
29+
);
30+
--> statement-breakpoint
31+
CREATE TABLE `webhooks` (
32+
`id` text PRIMARY KEY NOT NULL,
33+
`label` text NOT NULL,
34+
`url` text NOT NULL,
35+
`secret` text NOT NULL,
36+
`events` text NOT NULL,
37+
`enabled` integer DEFAULT true NOT NULL,
38+
`created_by` text,
39+
`created_at` text NOT NULL,
40+
`updated_at` text NOT NULL,
41+
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
42+
);

0 commit comments

Comments
 (0)