Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Khao Pad started as a CMS. Through v1.5 it became a complete content layer (writ
| **v1.7** | Pages, navigation, IA | βœ… Shipped | Media folders, reusable blocks, cookie consent, static pages, navigation manager, seed:legal |
| **v1.8** | Analytics & insight | βœ… Shipped | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline, optional CWA |
| **v1.9** | Performance & trust | βœ… Shipped | Responsive `srcset` via /cdn-cgi/image, edge cache-control hook, branded 404/500, /api/health endpoint |
| **v2.0** | Engagement & growth | 🚧 In progress | βœ… a Forms Β· βœ… b Newsletter (optional) Β· βœ… c Comments Β· 🚧 d Webhooks + Public REST API |
| **v2.0** | Engagement & growth | βœ… Shipped | a Forms Β· b Newsletter (optional) Β· c Comments Β· d Webhooks + Public REST API |

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

Expand Down
4 changes: 1 addition & 3 deletions docs/MILESTONES.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,7 @@ Shipping in four PRs grouped by theme. **a** + **b** done, **c** + **d** to foll

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

**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Γ—.

**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.
**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`.

### Backlog β€” bigger ideas, not committed

Expand Down
42 changes: 42 additions & 0 deletions drizzle/0010_windy_carmella_unuscione.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`label` text NOT NULL,
`key_hash` text NOT NULL,
`prefix` text NOT NULL,
`scopes` text NOT NULL,
`expires_at` text,
`revoked_at` text,
`last_used_at` text,
`created_by` text,
`created_at` text NOT NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
CREATE TABLE `webhook_deliveries` (
`id` text PRIMARY KEY NOT NULL,
`webhook_id` text NOT NULL,
`event` text NOT NULL,
`payload` text NOT NULL,
`response_status` integer,
`response_excerpt` text,
`duration_ms` integer,
`attempt` integer DEFAULT 1 NOT NULL,
`next_attempt_at` text,
`ok` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`webhook_id`) REFERENCES `webhooks`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `webhooks` (
`id` text PRIMARY KEY NOT NULL,
`label` text NOT NULL,
`url` text NOT NULL,
`secret` text NOT NULL,
`events` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_by` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
Loading
Loading