diff --git a/README.md b/README.md index 21880b6..ad236bb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/MILESTONES.md b/docs/MILESTONES.md index 017fdee..8d45638 100644 --- a/docs/MILESTONES.md +++ b/docs/MILESTONES.md @@ -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 `` (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=` + `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 diff --git a/drizzle/0010_windy_carmella_unuscione.sql b/drizzle/0010_windy_carmella_unuscione.sql new file mode 100644 index 0000000..f2dd588 --- /dev/null +++ b/drizzle/0010_windy_carmella_unuscione.sql @@ -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 +); diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..c6f23b6 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2510 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "86d5c445-1ae0-40e1-abfc-80d8b6b60a4a", + "prevId": "845a62e8-e661-452d-b8b9-3c0cb183b6be", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_created_by_users_id_fk": { + "name": "api_keys_created_by_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_localizations": { + "name": "article_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_localizations_article_id_articles_id_fk": { + "name": "article_localizations_article_id_articles_id_fk", + "tableFrom": "article_localizations", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_tags": { + "name": "article_tags", + "columns": { + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_tags_article_id_articles_id_fk": { + "name": "article_tags_article_id_articles_id_fk", + "tableFrom": "article_tags", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_tags_tag_id_tags_id_fk": { + "name": "article_tags_tag_id_tags_id_fk", + "tableFrom": "article_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_tags_article_id_tag_id_pk": { + "columns": [ + "article_id", + "tag_id" + ], + "name": "article_tags_article_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_versions": { + "name": "article_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_versions_article_id_articles_id_fk": { + "name": "article_versions_article_id_articles_id_fk", + "tableFrom": "article_versions", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_versions_created_by_users_id_fk": { + "name": "article_versions_created_by_users_id_fk", + "tableFrom": "article_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "articles": { + "name": "articles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comments_mode": { + "name": "comments_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inherit'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "articles_slug_unique": { + "name": "articles_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "articles_cover_media_id_media_id_fk": { + "name": "articles_cover_media_id_media_id_fk", + "tableFrom": "articles", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "articles_category_id_categories_id_fk": { + "name": "articles_category_id_categories_id_fk", + "tableFrom": "articles", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category_localizations": { + "name": "category_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "category_localizations_category_id_categories_id_fk": { + "name": "category_localizations_category_id_categories_id_fk", + "tableFrom": "category_localizations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_at": { + "name": "moderated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "comments_article_id_articles_id_fk": { + "name": "comments_article_id_articles_id_fk", + "tableFrom": "comments", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_moderated_by_users_id_fk": { + "name": "comments_moderated_by_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "moderated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_block_localizations": { + "name": "content_block_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "content_block_localizations_block_id_content_blocks_id_fk": { + "name": "content_block_localizations_block_id_content_blocks_id_fk", + "tableFrom": "content_block_localizations", + "tableTo": "content_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_blocks": { + "name": "content_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "content_blocks_key_unique": { + "name": "content_blocks_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "form_submissions_form_id_forms_id_fk": { + "name": "form_submissions_form_id_forms_id_fk", + "tableFrom": "form_submissions", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forms": { + "name": "forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "success_messages": { + "name": "success_messages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "forms_key_unique": { + "name": "forms_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_user_id": { + "name": "accepted_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invitations_accepted_user_id_users_id_fk": { + "name": "invitations_accepted_user_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_r2_key_unique": { + "name": "media_r2_key_unique", + "columns": [ + "r2_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "media_uploaded_by_users_id_fk": { + "name": "media_uploaded_by_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_folders": { + "name": "media_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "navigation_items": { + "name": "navigation_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "menu_id": { + "name": "menu_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "navigation_items_menu_id_navigation_menus_id_fk": { + "name": "navigation_items_menu_id_navigation_menus_id_fk", + "tableFrom": "navigation_items", + "tableTo": "navigation_menus", + "columnsFrom": [ + "menu_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "navigation_menus": { + "name": "navigation_menus", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "navigation_menus_key_unique": { + "name": "navigation_menus_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_localizations": { + "name": "page_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "page_localizations_page_id_pages_id_fk": { + "name": "page_localizations_page_id_pages_id_fk", + "tableFrom": "page_localizations", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_views": { + "name": "page_views", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "page_views_date_path_pk": { + "columns": [ + "date", + "path" + ], + "name": "page_views_date_path_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pages_author_id_users_id_fk": { + "name": "pages_author_id_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_log": { + "name": "search_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "term": { + "name": "term", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "no_results": { + "name": "no_results", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_redirects": { + "name": "slug_redirects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "old_slug": { + "name": "old_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_slug": { + "name": "new_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "slug_redirects_old_slug_unique": { + "name": "slug_redirects_old_slug_unique", + "columns": [ + "old_slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "slug_redirects_article_id_articles_id_fk": { + "name": "slug_redirects_article_id_articles_id_fk", + "tableFrom": "slug_redirects", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscribers": { + "name": "subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subscribers_email_unique": { + "name": "subscribers_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "subscribers_token_unique": { + "name": "subscribers_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_localizations": { + "name": "tag_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tag_localizations_tag_id_tags_id_fk": { + "name": "tag_localizations_tag_id_tags_id_fk", + "tableFrom": "tag_localizations", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_slug_unique": { + "name": "tags_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'author'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_excerpt": { + "name": "response_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_created_by_users_id_fk": { + "name": "webhooks_created_by_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index abc2227..1960032 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1777703335359, "tag": "0009_futuristic_vulcan", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1777709468165, + "tag": "0010_windy_carmella_unuscione", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index d039ef1..fb1b37d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -372,5 +372,43 @@ "comments_field_body": "Comment", "comments_thanks": "Thanks โ€” your comment is awaiting moderation.", "comments_submit": "Post comment", - "comments_closed": "Comments are closed for this article." + "comments_closed": "Comments are closed for this article.", + "cms_webhooks": "Webhooks", + "cms_webhooks_help": "Send platform events to external URLs. Each delivery is HMAC-SHA256 signed; receivers verify with the per-webhook secret.", + "cms_webhooks_new": "New webhook", + "cms_webhooks_create": "Create webhook", + "cms_webhooks_created": "Webhook created.", + "cms_webhooks_secret_hint": "The signing secret is shown when you click \"Edit\" on this webhook. Save it somewhere safe.", + "cms_webhooks_empty": "No webhooks yet.", + "cms_webhooks_label": "Label", + "cms_webhooks_url": "URL", + "cms_webhooks_events": "Events", + "cms_webhooks_subscribed": "Subscribed", + "cms_webhooks_enabled": "Live", + "cms_webhooks_disabled": "Off", + "cms_webhooks_enabled_label": "Enabled", + "cms_webhooks_show_secret": "Show signing secret", + "cms_webhooks_rotate": "Rotate secret", + "cms_webhooks_rotate_confirm": "Rotate the signing secret? The old one will stop working immediately and you'll need to update the receiver.", + "cms_api_keys": "API keys", + "cms_api_keys_help": "Bearer tokens for the public read-only API. The raw key is shown once at creation; we store only a SHA-256 hash.", + "cms_api_keys_new": "New API key", + "cms_api_keys_create": "Create key", + "cms_api_keys_created_title": "Key \"{label}\" created. Save the value below โ€” it won't be shown again.", + "cms_api_keys_created_warning": "After this page reloads we cannot recover the raw key. Copy it into your password manager now.", + "cms_api_keys_copy": "Copy", + "cms_api_keys_empty": "No API keys yet.", + "cms_api_keys_label": "Label", + "cms_api_keys_expires_at": "Expires at", + "cms_api_keys_expires_at_help": "Leave blank for no expiry.", + "cms_api_keys_scopes": "Scopes", + "cms_api_keys_col_label": "Label", + "cms_api_keys_col_prefix": "Prefix", + "cms_api_keys_col_scopes": "Scopes", + "cms_api_keys_col_last_used": "Last used", + "cms_api_keys_active": "Active", + "cms_api_keys_revoked": "Revoked", + "cms_api_keys_expired": "Expired", + "cms_api_keys_revoke": "Revoke", + "cms_api_keys_revoke_confirm": "Revoke this key? It will stop working immediately." } diff --git a/messages/th.json b/messages/th.json index 32a51c5..487fdca 100644 --- a/messages/th.json +++ b/messages/th.json @@ -372,5 +372,43 @@ "comments_field_body": "เธ„เธญเธกเน€เธกเธ™เธ•เนŒ", "comments_thanks": "เธ‚เธญเธšเธ„เธธเธ“ โ€” เธ„เธญเธกเน€เธกเธ™เธ•เนŒเธ‚เธญเธ‡เธ„เธธเธ“เธฃเธญเธเธฒเธฃเธ•เธฃเธงเธˆเธชเธญเธš", "comments_submit": "เธชเนˆเธ‡เธ„เธญเธกเน€เธกเธ™เธ•เนŒ", - "comments_closed": "เธ›เธดเธ”เธฃเธฑเธšเธ„เธญเธกเน€เธกเธ™เธ•เนŒเธชเธณเธซเธฃเธฑเธšเธšเธ—เธ„เธงเธฒเธกเธ™เธตเน‰" + "comments_closed": "เธ›เธดเธ”เธฃเธฑเธšเธ„เธญเธกเน€เธกเธ™เธ•เนŒเธชเธณเธซเธฃเธฑเธšเธšเธ—เธ„เธงเธฒเธกเธ™เธตเน‰", + "cms_webhooks": "เน€เธงเน‡เธšเธฎเธธเธ", + "cms_webhooks_help": "เธชเนˆเธ‡เน€เธซเธ•เธธเธเธฒเธฃเธ“เนŒเธˆเธฒเธเนเธžเธฅเธ•เธŸเธญเธฃเนŒเธกเน„เธ›เธขเธฑเธ‡ URL เธ เธฒเธขเธ™เธญเธ เธ—เธธเธเธเธฒเธฃเธชเนˆเธ‡เน€เธ‹เน‡เธ™เธ”เน‰เธงเธข HMAC-SHA256 เธœเธนเน‰เธฃเธฑเธšเธ•เธฃเธงเธˆเธชเธญเธšเธ”เน‰เธงเธขเธ‹เธตเน€เธ„เธฃเธ•เธ‚เธญเธ‡เนเธ•เนˆเธฅเธฐเน€เธงเน‡เธšเธฎเธธเธ", + "cms_webhooks_new": "เน€เธงเน‡เธšเธฎเธธเธเนƒเธซเธกเนˆ", + "cms_webhooks_create": "เธชเธฃเน‰เธฒเธ‡เน€เธงเน‡เธšเธฎเธธเธ", + "cms_webhooks_created": "เธชเธฃเน‰เธฒเธ‡เน€เธงเน‡เธšเธฎเธธเธเนเธฅเน‰เธง", + "cms_webhooks_secret_hint": "เธ‹เธตเน€เธ„เธฃเธ•เธชเธณเธซเธฃเธฑเธšเน€เธ‹เน‡เธ™เธˆเธฐเนเธชเธ”เธ‡เน€เธกเธทเนˆเธญเธ„เธฅเธดเธ \"เนเธเน‰เน„เธ‚\" เธ—เธตเนˆเน€เธงเน‡เธšเธฎเธธเธเธ™เธตเน‰ เธšเธฑเธ™เธ—เธถเธเน„เธงเน‰เนƒเธ™เธ—เธตเนˆเธ›เธฅเธญเธ”เธ เธฑเธข", + "cms_webhooks_empty": "เธขเธฑเธ‡เน„เธกเนˆเธกเธตเน€เธงเน‡เธšเธฎเธธเธ", + "cms_webhooks_label": "เธ›เน‰เธฒเธขเธเธณเธเธฑเธš", + "cms_webhooks_url": "URL", + "cms_webhooks_events": "เน€เธซเธ•เธธเธเธฒเธฃเธ“เนŒ", + "cms_webhooks_subscribed": "เธชเธกเธฑเธ„เธฃเธฃเธฑเธš", + "cms_webhooks_enabled": "เนƒเธŠเน‰เธ‡เธฒเธ™", + "cms_webhooks_disabled": "เธ›เธดเธ”", + "cms_webhooks_enabled_label": "เน€เธ›เธดเธ”เนƒเธŠเน‰เธ‡เธฒเธ™", + "cms_webhooks_show_secret": "เนเธชเธ”เธ‡เธ‹เธตเน€เธ„เธฃเธ•เธชเธณเธซเธฃเธฑเธšเน€เธ‹เน‡เธ™", + "cms_webhooks_rotate": "เธซเธกเธธเธ™เธ‹เธตเน€เธ„เธฃเธ•", + "cms_webhooks_rotate_confirm": "เธซเธกเธธเธ™เธ‹เธตเน€เธ„เธฃเธ•เธซเธฃเธทเธญเน„เธกเนˆ? เธ‹เธตเน€เธ„เธฃเธ•เน€เธเนˆเธฒเธˆเธฐเนƒเธŠเน‰เน„เธกเนˆเน„เธ”เน‰เธ—เธฑเธ™เธ—เธต เธ•เน‰เธญเธ‡เธญเธฑเธ›เน€เธ”เธ•เธ—เธตเนˆเธœเธนเน‰เธฃเธฑเธšเธ”เน‰เธงเธข", + "cms_api_keys": "API key", + "cms_api_keys_help": "Bearer token เธชเธณเธซเธฃเธฑเธš public REST API เนเธšเธšเธญเนˆเธฒเธ™ เธ„เธตเธขเนŒเธ”เธดเธšเธˆเธฐเนเธชเธ”เธ‡เธ„เธฃเธฑเน‰เธ‡เน€เธ”เธตเธขเธงเธ•เธญเธ™เธชเธฃเน‰เธฒเธ‡ เน€เธฃเธฒเน€เธเน‡เธšเน€เธ‰เธžเธฒเธฐ SHA-256 hash", + "cms_api_keys_new": "API key เนƒเธซเธกเนˆ", + "cms_api_keys_create": "เธชเธฃเน‰เธฒเธ‡เธ„เธตเธขเนŒ", + "cms_api_keys_created_title": "เธชเธฃเน‰เธฒเธ‡เธ„เธตเธขเนŒ \"{label}\" เนเธฅเน‰เธง เธšเธฑเธ™เธ—เธถเธเธ„เนˆเธฒเธ”เน‰เธฒเธ™เธฅเนˆเธฒเธ‡เธ—เธฑเธ™เธ—เธต โ€” เธˆเธฐเน„เธกเนˆเนเธชเธ”เธ‡เธญเธตเธ", + "cms_api_keys_created_warning": "เธซเธฅเธฑเธ‡เธˆเธฒเธเธฃเธตเน‚เธซเธฅเธ”เธซเธ™เน‰เธฒเธ™เธตเน‰ เธ„เธตเธขเนŒเธ”เธดเธšเธˆเธฐเธเธนเน‰เธ„เธทเธ™เน„เธกเนˆเน„เธ”เน‰ เธ„เธฑเธ”เธฅเธญเธเนƒเธชเนˆ password manager เธ•เธญเธ™เธ™เธตเน‰", + "cms_api_keys_copy": "เธ„เธฑเธ”เธฅเธญเธ", + "cms_api_keys_empty": "เธขเธฑเธ‡เน„เธกเนˆเธกเธต API key", + "cms_api_keys_label": "เธ›เน‰เธฒเธขเธเธณเธเธฑเธš", + "cms_api_keys_expires_at": "เธซเธกเธ”เธญเธฒเธขเธธเน€เธกเธทเนˆเธญ", + "cms_api_keys_expires_at_help": "เน€เธงเน‰เธ™เธงเนˆเธฒเธ‡เน„เธงเน‰เธ–เน‰เธฒเน„เธกเนˆเธซเธกเธ”เธญเธฒเธขเธธ", + "cms_api_keys_scopes": "เธ‚เธญเธšเน€เธ‚เธ•", + "cms_api_keys_col_label": "เธ›เน‰เธฒเธขเธเธณเธเธฑเธš", + "cms_api_keys_col_prefix": "เธ•เธฑเธงเธญเธขเนˆเธฒเธ‡", + "cms_api_keys_col_scopes": "เธ‚เธญเธšเน€เธ‚เธ•", + "cms_api_keys_col_last_used": "เนƒเธŠเน‰เธฅเนˆเธฒเธชเธธเธ”", + "cms_api_keys_active": "เนƒเธŠเน‰เธ‡เธฒเธ™", + "cms_api_keys_revoked": "เธขเธเน€เธฅเธดเธ", + "cms_api_keys_expired": "เธซเธกเธ”เธญเธฒเธขเธธ", + "cms_api_keys_revoke": "เธขเธเน€เธฅเธดเธ", + "cms_api_keys_revoke_confirm": "เธขเธเน€เธฅเธดเธเธ„เธตเธขเนŒเธ™เธตเน‰เธซเธฃเธทเธญเน„เธกเนˆ? เธˆเธฐเนƒเธŠเน‰เน„เธกเนˆเน„เธ”เน‰เธ—เธฑเธ™เธ—เธต" } diff --git a/src/lib/components/cms/sidebar-nav.ts b/src/lib/components/cms/sidebar-nav.ts index e8a7002..7853104 100644 --- a/src/lib/components/cms/sidebar-nav.ts +++ b/src/lib/components/cms/sidebar-nav.ts @@ -14,6 +14,8 @@ import { Inbox, Mail, MessageSquare, + Webhook, + KeyRound, } from "lucide-svelte"; import * as m from "$lib/paraglide/messages"; @@ -104,6 +106,18 @@ export const navGroups: ReadonlyArray = [ icon: Mail, roles: ["super_admin", "admin"], }, + { + href: "/cms/webhooks", + label: m.cms_webhooks, + icon: Webhook, + roles: ["super_admin", "admin"], + }, + { + href: "/cms/api-keys", + label: m.cms_api_keys, + icon: KeyRound, + roles: ["super_admin", "admin"], + }, { href: "/cms/settings", label: m.cms_settings, diff --git a/src/lib/server/api-auth/index.ts b/src/lib/server/api-auth/index.ts new file mode 100644 index 0000000..41219e1 --- /dev/null +++ b/src/lib/server/api-auth/index.ts @@ -0,0 +1,41 @@ +/** + * Public REST API authentication (v2.0d). + * + * Reads `Authorization: Bearer ` from the request and looks up + * the key against the api_keys table. Returns the key's record on a + * hit, null on miss / revoked / expired. + * + * The provider's `authenticateApiKey()` does the SHA-256 hash + DB + * lookup + lastUsedAt bump. This module is just the request-parsing + * shim plus the scope check. + */ +import type { ApiKeyRecord, ApiKeyScope, ContentProvider } from "$lib/server/content/types"; + +export interface AuthResult { + ok: boolean; + key: ApiKeyRecord | null; + /** Reason for failure โ€” used in error responses. */ + reason?: "missing" | "invalid" | "scope"; +} + +export async function authenticate( + request: Request, + content: ContentProvider, +): Promise { + const header = request.headers.get("authorization") ?? ""; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) return { ok: false, key: null, reason: "missing" }; + const rawKey = match[1].trim(); + if (!rawKey) return { ok: false, key: null, reason: "missing" }; + const key = await content.authenticateApiKey(rawKey); + if (!key) return { ok: false, key: null, reason: "invalid" }; + return { ok: true, key }; +} + +export function hasScope( + key: ApiKeyRecord, + required: ApiKeyScope, +): boolean { + if (key.scopes.includes("*:read")) return true; + return key.scopes.includes(required); +} diff --git a/src/lib/server/content/providers/d1.ts b/src/lib/server/content/providers/d1.ts index 5c34b9d..126812f 100644 --- a/src/lib/server/content/providers/d1.ts +++ b/src/lib/server/content/providers/d1.ts @@ -28,6 +28,10 @@ import type { SearchOptions, SiteSettings, Locale, + ApiKeyRecord, + ApiKeyCreateInput, + ApiKeyCreateResult, + ApiKeyScope, CommentRecord, CommentCreateInput, CommentFilter, @@ -49,6 +53,11 @@ import type { NavigationItemRecord, NavigationItemCreateInput, NavigationItemUpdateInput, + WebhookRecord, + WebhookCreateInput, + WebhookUpdateInput, + WebhookEvent, + WebhookDeliveryRecord, } from "../types"; export class D1ContentProvider implements ContentProvider { @@ -1805,4 +1814,279 @@ export class D1ContentProvider implements ContentProvider { moderatedAt: row.moderatedAt, }; } + + // โ”€โ”€โ”€ Webhooks (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async listWebhooks(): Promise { + const rows = await this.db + .select() + .from(schema.webhooks) + .orderBy(desc(schema.webhooks.createdAt)) + .all(); + return rows.map((r) => this.toWebhook(r)); + } + + async getWebhook(id: string): Promise { + const row = await this.db + .select() + .from(schema.webhooks) + .where(eq(schema.webhooks.id, id)) + .get(); + return row ? this.toWebhook(row) : null; + } + + async listWebhooksByEvent(event: WebhookEvent): Promise { + // SQLite has no native JSON-array filter; the events list per row + // is small (โ‰ค 6 today), so we filter in-memory after pulling the + // enabled set. Cheap up to a few hundred webhooks total. + const rows = await this.db + .select() + .from(schema.webhooks) + .where(eq(schema.webhooks.enabled, true)) + .all(); + return rows + .map((r) => this.toWebhook(r)) + .filter((w) => w.events.includes(event)); + } + + async createWebhook(data: WebhookCreateInput): Promise { + const id = nanoid(); + // 32 bytes = 256 bits of entropy. Shown to the operator at create + // time; HMAC key for every delivery from this webhook. + const secret = nanoid(48); + await this.db.insert(schema.webhooks).values({ + id, + label: data.label, + url: data.url, + secret, + events: JSON.stringify(data.events), + enabled: data.enabled ?? true, + createdBy: data.createdBy ?? null, + }); + return (await this.getWebhook(id))!; + } + + async updateWebhook( + id: string, + data: WebhookUpdateInput, + ): Promise { + const updateFields: Record = { + updatedAt: new Date().toISOString(), + }; + if (data.label !== undefined) updateFields.label = data.label; + if (data.url !== undefined) updateFields.url = data.url; + if (data.events !== undefined) + updateFields.events = JSON.stringify(data.events); + if (data.enabled !== undefined) updateFields.enabled = data.enabled; + await this.db + .update(schema.webhooks) + .set(updateFields) + .where(eq(schema.webhooks.id, id)); + return (await this.getWebhook(id))!; + } + + async deleteWebhook(id: string): Promise { + await this.db.delete(schema.webhooks).where(eq(schema.webhooks.id, id)); + } + + async rotateWebhookSecret(id: string): Promise { + await this.db + .update(schema.webhooks) + .set({ + secret: nanoid(48), + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.webhooks.id, id)); + return (await this.getWebhook(id))!; + } + + async recordWebhookDelivery(data: { + webhookId: string; + event: WebhookEvent; + payload: string; + responseStatus: number | null; + responseExcerpt: string | null; + durationMs: number | null; + attempt: number; + nextAttemptAt: string | null; + ok: boolean; + }): Promise { + const id = nanoid(); + await this.db.insert(schema.webhookDeliveries).values({ + id, + webhookId: data.webhookId, + event: data.event, + payload: data.payload, + responseStatus: data.responseStatus, + responseExcerpt: data.responseExcerpt, + durationMs: data.durationMs, + attempt: data.attempt, + nextAttemptAt: data.nextAttemptAt, + ok: data.ok, + }); + const row = await this.db + .select() + .from(schema.webhookDeliveries) + .where(eq(schema.webhookDeliveries.id, id)) + .get(); + return this.toWebhookDelivery(row!); + } + + async listWebhookDeliveries( + webhookId: string, + limit = 50, + ): Promise { + const rows = await this.db + .select() + .from(schema.webhookDeliveries) + .where(eq(schema.webhookDeliveries.webhookId, webhookId)) + .orderBy(desc(schema.webhookDeliveries.createdAt)) + .limit(limit) + .all(); + return rows.map((r) => this.toWebhookDelivery(r)); + } + + private toWebhook( + row: typeof schema.webhooks.$inferSelect, + ): WebhookRecord { + let events: WebhookEvent[] = []; + try { + const parsed = JSON.parse(row.events); + if (Array.isArray(parsed)) events = parsed; + } catch { + // tolerate malformed JSON + } + return { + id: row.id, + label: row.label, + url: row.url, + secret: row.secret, + events, + enabled: Boolean(row.enabled), + createdBy: row.createdBy, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private toWebhookDelivery( + row: typeof schema.webhookDeliveries.$inferSelect, + ): WebhookDeliveryRecord { + return { + id: row.id, + webhookId: row.webhookId, + event: row.event as WebhookEvent, + payload: row.payload, + responseStatus: row.responseStatus, + responseExcerpt: row.responseExcerpt, + durationMs: row.durationMs, + attempt: row.attempt, + nextAttemptAt: row.nextAttemptAt, + ok: Boolean(row.ok), + createdAt: row.createdAt, + }; + } + + // โ”€โ”€โ”€ API keys (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async listApiKeys(): Promise { + const rows = await this.db + .select() + .from(schema.apiKeys) + .orderBy(desc(schema.apiKeys.createdAt)) + .all(); + return rows.map((r) => this.toApiKey(r)); + } + + async getApiKey(id: string): Promise { + const row = await this.db + .select() + .from(schema.apiKeys) + .where(eq(schema.apiKeys.id, id)) + .get(); + return row ? this.toApiKey(row) : null; + } + + async authenticateApiKey(rawKey: string): Promise { + if (!rawKey) return null; + const hash = await sha256Hex(rawKey); + const row = await this.db + .select() + .from(schema.apiKeys) + .where(eq(schema.apiKeys.keyHash, hash)) + .get(); + if (!row) return null; + if (row.revokedAt) return null; + if (row.expiresAt && row.expiresAt < new Date().toISOString()) return null; + // Best-effort lastUsedAt bump. Don't await โ€” the API request + // shouldn't pay the write latency. + void this.db + .update(schema.apiKeys) + .set({ lastUsedAt: new Date().toISOString() }) + .where(eq(schema.apiKeys.id, row.id)); + return this.toApiKey(row); + } + + async createApiKey( + data: ApiKeyCreateInput, + ): Promise { + // 48-char URL-safe random key. Prefix by `kp_live_` so a leaked + // string is recognizable to scanners (GitHub secret scanning, + // etc.) and operators can spot it in logs. + const id = nanoid(); + const rawKey = `kp_live_${nanoid(48)}`; + const keyHash = await sha256Hex(rawKey); + const prefix = rawKey.slice(0, 12); + await this.db.insert(schema.apiKeys).values({ + id, + label: data.label, + keyHash, + prefix, + scopes: JSON.stringify(data.scopes), + expiresAt: data.expiresAt ?? null, + createdBy: data.createdBy ?? null, + }); + const record = (await this.getApiKey(id))!; + return { record, rawKey }; + } + + async revokeApiKey(id: string): Promise { + await this.db + .update(schema.apiKeys) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(schema.apiKeys.id, id)); + } + + async deleteApiKey(id: string): Promise { + await this.db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)); + } + + private toApiKey(row: typeof schema.apiKeys.$inferSelect): ApiKeyRecord { + let scopes: ApiKeyScope[] = []; + try { + const parsed = JSON.parse(row.scopes); + if (Array.isArray(parsed)) scopes = parsed; + } catch { + // tolerate + } + return { + id: row.id, + label: row.label, + prefix: row.prefix, + scopes, + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + lastUsedAt: row.lastUsedAt, + createdBy: row.createdBy, + createdAt: row.createdAt, + }; + } +} + +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); } diff --git a/src/lib/server/content/schema.ts b/src/lib/server/content/schema.ts index af78f43..a4729d7 100644 --- a/src/lib/server/content/schema.ts +++ b/src/lib/server/content/schema.ts @@ -606,3 +606,98 @@ export const comments = sqliteTable( moderatedAt: text("moderated_at"), }, ); + +// โ”€โ”€โ”€ Webhooks (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Editor-registered URLs that get pinged on platform events. +// The `secret` column holds an HMAC key generated server-side; the +// dispatcher signs every delivery with HMAC-SHA256 so receivers can +// verify the payload originated from this site. Best-effort delivery +// with retry โ€” see webhook_deliveries below. + +export const webhooks = sqliteTable("webhooks", { + id: text("id").primaryKey(), + /** Operator-facing label for the CMS list view. */ + label: text("label").notNull(), + url: text("url").notNull(), + /** HMAC-SHA256 signing key. Generated on create; shown to the + * operator only at create time. */ + secret: text("secret").notNull(), + /** JSON array of event names this webhook subscribes to. */ + events: text("events").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdBy: text("created_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updated_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + +// Each delivery attempt โ€” successful or failed. Bounded retention by +// the operator (delete old rows manually for now; a future v2.x cron +// can prune). The CMS delivery log reads from here. + +export const webhookDeliveries = sqliteTable("webhook_deliveries", { + id: text("id").primaryKey(), + webhookId: text("webhook_id") + .notNull() + .references(() => webhooks.id, { onDelete: "cascade" }), + event: text("event").notNull(), + /** JSON payload that was sent (or attempted). */ + payload: text("payload").notNull(), + /** HTTP status returned by the receiver, or null if the fetch + * itself threw (DNS, TLS, timeout). */ + responseStatus: integer("response_status"), + /** First 256 chars of the receiver's response body for debugging. */ + responseExcerpt: text("response_excerpt"), + /** Wall-clock ms taken by the fetch. */ + durationMs: integer("duration_ms"), + /** 1-based attempt counter; first try is 1. */ + attempt: integer("attempt").notNull().default(1), + /** When `attempt < max_attempts` and `ok=false`, this is the next + * retry time; the dispatcher polls due rows on a cron tick. Null + * when delivered or exhausted. */ + nextAttemptAt: text("next_attempt_at"), + ok: integer("ok", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + +// โ”€โ”€โ”€ API keys (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Bearer credentials for the public read-only REST API. The raw key +// is shown to the operator once at create time; we store only a +// SHA-256 hash so a leaked database row can't be used to authenticate. +// +// `scopes` is a JSON array of permission strings (e.g. ["articles:read"]). +// The default scope set is the read-everything bundle; future v2.x +// can grow finer-grained scopes without a migration. + +export const apiKeys = sqliteTable("api_keys", { + id: text("id").primaryKey(), + label: text("label").notNull(), + /** SHA-256 hex of the raw key. The raw key is shown ONCE at create + * time and never reconstructable from the database. */ + keyHash: text("key_hash").notNull().unique(), + /** First 8 chars of the raw key, kept for the CMS list view so + * operators can match a key against a screenshot or password + * manager entry. The full key cannot be reconstructed from this. */ + prefix: text("prefix").notNull(), + /** JSON array of scope strings. */ + scopes: text("scopes").notNull(), + /** When set, the dispatcher rejects this key after the timestamp. */ + expiresAt: text("expires_at"), + /** When set, the key is hard-revoked and rejected immediately. */ + revokedAt: text("revoked_at"), + /** Last successful request (for the CMS list view). Best-effort. */ + lastUsedAt: text("last_used_at"), + createdBy: text("created_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); diff --git a/src/lib/server/content/types.ts b/src/lib/server/content/types.ts index 481bdaf..dd77613 100644 --- a/src/lib/server/content/types.ts +++ b/src/lib/server/content/types.ts @@ -394,6 +394,48 @@ export interface ContentProvider { ipHash: string, sinceSeconds: number, ): Promise; + + // Webhooks (v2.0d) + listWebhooks(): Promise; + getWebhook(id: string): Promise; + /** Returns webhooks with `enabled=true` AND subscribed to `event`. */ + listWebhooksByEvent(event: WebhookEvent): Promise; + createWebhook(data: WebhookCreateInput): Promise; + updateWebhook( + id: string, + data: WebhookUpdateInput, + ): Promise; + deleteWebhook(id: string): Promise; + rotateWebhookSecret(id: string): Promise; + recordWebhookDelivery(data: { + webhookId: string; + event: WebhookEvent; + payload: string; + responseStatus: number | null; + responseExcerpt: string | null; + durationMs: number | null; + attempt: number; + nextAttemptAt: string | null; + ok: boolean; + }): Promise; + listWebhookDeliveries( + webhookId: string, + limit?: number, + ): Promise; + + // API keys (v2.0d) + listApiKeys(): Promise; + getApiKey(id: string): Promise; + /** + * Look up an API key by its raw bearer token. Hashes the input + * with SHA-256 and matches against `key_hash`. Returns null when + * no match (or when expired / revoked) โ€” caller treats null as + * 401. Side-effect: bumps `lastUsedAt` on a hit, best-effort. + */ + authenticateApiKey(rawKey: string): Promise; + createApiKey(data: ApiKeyCreateInput): Promise; + revokeApiKey(id: string): Promise; + deleteApiKey(id: string): Promise; } // โ”€โ”€โ”€ Comments (v2.0c) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -617,3 +659,113 @@ export interface SubscriberFilter { locale?: Locale; limit?: number; } + +// โ”€โ”€โ”€ Webhooks (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Events the dispatcher knows how to fire. Adding a new event here + * is the first step to wiring it up โ€” handlers live wherever the + * source-of-truth action runs (article publish, form submit, etc.). + */ +export type WebhookEvent = + | "article.publish" + | "article.unpublish" + | "article.delete" + | "comment.approve" + | "form.submit" + | "subscriber.confirm"; + +export const WEBHOOK_EVENTS: WebhookEvent[] = [ + "article.publish", + "article.unpublish", + "article.delete", + "comment.approve", + "form.submit", + "subscriber.confirm", +]; + +export interface WebhookRecord { + id: string; + label: string; + url: string; + /** HMAC-SHA256 signing key. Sent in `X-Khaopad-Signature` header. */ + secret: string; + events: WebhookEvent[]; + enabled: boolean; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface WebhookCreateInput { + label: string; + url: string; + events: WebhookEvent[]; + enabled?: boolean; + createdBy?: string; +} + +export interface WebhookUpdateInput { + label?: string; + url?: string; + events?: WebhookEvent[]; + enabled?: boolean; +} + +export interface WebhookDeliveryRecord { + id: string; + webhookId: string; + event: WebhookEvent; + /** Stored as JSON string. */ + payload: string; + responseStatus: number | null; + responseExcerpt: string | null; + durationMs: number | null; + attempt: number; + nextAttemptAt: string | null; + ok: boolean; + createdAt: string; +} + +// โ”€โ”€โ”€ API keys (v2.0d) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Permission strings for the public REST API. The `*:read` bundle is + * the v2.0 default; finer-grained scopes can grow without a schema + * change because we store them as JSON. + */ +export type ApiKeyScope = + | "articles:read" + | "categories:read" + | "tags:read" + | "pages:read" + | "*:read"; + +export interface ApiKeyRecord { + id: string; + label: string; + /** First 8 chars of the raw key, displayable in the CMS list. The + * full key cannot be recovered. */ + prefix: string; + scopes: ApiKeyScope[]; + expiresAt: string | null; + revokedAt: string | null; + lastUsedAt: string | null; + createdBy: string | null; + createdAt: string; +} + +export interface ApiKeyCreateInput { + label: string; + scopes: ApiKeyScope[]; + /** Optional expiration. Null = never expires. */ + expiresAt?: string | null; + createdBy?: string; +} + +export interface ApiKeyCreateResult { + record: ApiKeyRecord; + /** The raw bearer token. Show this to the operator ONCE โ€” we don't + * store it. */ + rawKey: string; +} diff --git a/src/lib/server/webhooks/index.ts b/src/lib/server/webhooks/index.ts new file mode 100644 index 0000000..5defe63 --- /dev/null +++ b/src/lib/server/webhooks/index.ts @@ -0,0 +1,158 @@ +/** + * Webhook dispatcher (v2.0d). + * + * Fires registered webhooks on platform events. Best-effort: a + * delivery failure NEVER breaks the action that triggered it. The + * dispatcher writes a `webhook_deliveries` row for every attempt + * (success or fail) so the operator can debug from the CMS. + * + * Signing: every request carries an `X-Khaopad-Signature` header + * with `sha256=` HMAC of the raw body using the webhook's + * stored secret. Receivers verify in the standard way. + * + * Retry strategy (deliberately simple for v2.0d): + * - Up to 3 attempts inline (this request). + * - Backoff: 250ms, 1500ms between attempts. + * - No persistent retry queue โ€” a future v2.x can drain + * `webhook_deliveries.next_attempt_at` rows on a cron tick. + * - We do schedule `next_attempt_at` on the failed-final row for + * forward-compat with that future cron worker. + */ +import type { + ContentProvider, + WebhookEvent, + WebhookRecord, +} from "$lib/server/content/types"; + +const MAX_INLINE_ATTEMPTS = 3; +const BACKOFF_MS = [250, 1500] as const; +const REQUEST_TIMEOUT_MS = 5000; + +export interface DispatchOptions { + /** Event name. */ + event: WebhookEvent; + /** Plain JSON-serializable payload โ€” the dispatcher serializes. */ + payload: Record; +} + +/** + * Fan out an event to every enabled webhook subscribed to it. + * Returns the count of webhooks attempted (not delivered โ€” failures + * still count). Promise resolves once all attempts complete; the + * caller should await this when it can spare the round-trip, or fire + * with `void dispatchEvent(...)` for full async behavior. + */ +export async function dispatchEvent( + content: ContentProvider, + opts: DispatchOptions, +): Promise { + let webhooks: WebhookRecord[]; + try { + webhooks = await content.listWebhooksByEvent(opts.event); + } catch { + return 0; + } + if (webhooks.length === 0) return 0; + + const body = JSON.stringify({ + event: opts.event, + deliveredAt: new Date().toISOString(), + data: opts.payload, + }); + + // Fan out in parallel โ€” webhooks are independent; one slow receiver + // shouldn't block others. + await Promise.all( + webhooks.map((wh) => deliverOne(content, wh, opts.event, body)), + ); + return webhooks.length; +} + +async function deliverOne( + content: ContentProvider, + webhook: WebhookRecord, + event: WebhookEvent, + body: string, +): Promise { + const signature = await sign(webhook.secret, body); + + for (let attempt = 1; attempt <= MAX_INLINE_ATTEMPTS; attempt++) { + const t0 = Date.now(); + let responseStatus: number | null = null; + let responseExcerpt: string | null = null; + let ok = false; + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + const res = await fetch(webhook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Khaopad-Webhook/1", + "X-Khaopad-Event": event, + "X-Khaopad-Signature": `sha256=${signature}`, + "X-Khaopad-Delivery": crypto.randomUUID(), + }, + body, + signal: ctrl.signal, + }); + clearTimeout(timer); + responseStatus = res.status; + // Read first 256 chars of the body for debugging. Bound the + // memory + bandwidth cost. + try { + const text = await res.text(); + responseExcerpt = text.slice(0, 256) || null; + } catch { + responseExcerpt = null; + } + // 2xx and 3xx (HTTP semantics: receiver got it) count as ok. + ok = res.status >= 200 && res.status < 400; + } catch (err) { + responseExcerpt = err instanceof Error ? err.message.slice(0, 256) : null; + } + + const durationMs = Date.now() - t0; + const isFinal = ok || attempt === MAX_INLINE_ATTEMPTS; + const nextAttemptAt = isFinal + ? null + : new Date(Date.now() + BACKOFF_MS[attempt - 1]).toISOString(); + + // Best-effort write โ€” a missing audit row beats blocking the loop. + try { + await content.recordWebhookDelivery({ + webhookId: webhook.id, + event, + payload: body, + responseStatus, + responseExcerpt, + durationMs, + attempt, + nextAttemptAt, + ok, + }); + } catch { + // ignore + } + + if (ok) return; + if (isFinal) return; + // Sleep then retry. + await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt - 1])); + } +} + +async function sign(secret: string, body: string): Promise { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/routes/(cms)/cms/api-keys/+page.server.ts b/src/routes/(cms)/cms/api-keys/+page.server.ts new file mode 100644 index 0000000..ee285e6 --- /dev/null +++ b/src/routes/(cms)/cms/api-keys/+page.server.ts @@ -0,0 +1,118 @@ +import { error, fail, redirect } from "@sveltejs/kit"; +import { canManageUsers } from "$lib/server/auth/permissions"; +import { logAudit } from "$lib/server/audit"; +import type { ApiKeyScope } from "$lib/server/content/types"; +import type { Actions, PageServerLoad } from "./$types"; + +const KNOWN_SCOPES: ApiKeyScope[] = [ + "*:read", + "articles:read", + "categories:read", + "tags:read", + "pages:read", +]; + +/** + * `/cms/api-keys` โ€” admin+ only. The raw key is shown ONCE on + * create; we return it via `form.rawKey` and the page surfaces a + * one-time secret card. Subsequent visits show only the `prefix` + * (first 12 chars) so the operator can match a key against a + * password manager entry. + */ +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) { + throw error(403, "Only admins can manage API keys."); + } + const keys = await locals.content.listApiKeys(); + return { keys, knownScopes: KNOWN_SCOPES }; +}; + +export const actions: Actions = { + create: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const label = String(fd.get("label") ?? "").trim(); + const scopes = fd + .getAll("scopes") + .map((v) => String(v)) + .filter((v): v is ApiKeyScope => + (KNOWN_SCOPES as string[]).includes(v), + ); + const expiresAtRaw = String(fd.get("expires_at") ?? "").trim(); + const expiresAt = expiresAtRaw + ? new Date(expiresAtRaw).toISOString() + : null; + + if (!label) return fail(400, { error: "Label is required." }); + if (scopes.length === 0) { + return fail(400, { error: "Pick at least one scope." }); + } + + const result = await locals.content.createApiKey({ + label, + scopes, + expiresAt, + createdBy: locals.user.id, + }); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + result.record.id, + { kind: "api_key.create", label, scopes }, + ); + } + // Return the raw key in form data โ€” the page shows it once and + // the operator copies it. After this response it's never + // recoverable (we only store the SHA-256 hash). + return { + ok: true, + created: { + id: result.record.id, + label: result.record.label, + rawKey: result.rawKey, + }, + }; + }, + + revoke: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + if (!id) return fail(400, { error: "Missing id" }); + await locals.content.revokeApiKey(id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + id, + { kind: "api_key.revoke" }, + ); + } + return { ok: true }; + }, + + delete: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + if (!id) return fail(400, { error: "Missing id" }); + await locals.content.deleteApiKey(id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + id, + { kind: "api_key.delete" }, + ); + } + return { ok: true }; + }, +}; diff --git a/src/routes/(cms)/cms/api-keys/+page.svelte b/src/routes/(cms)/cms/api-keys/+page.svelte new file mode 100644 index 0000000..73fdcb9 --- /dev/null +++ b/src/routes/(cms)/cms/api-keys/+page.svelte @@ -0,0 +1,208 @@ + + + + {m.cms_api_keys()} โ€” {m.cms_app_name()} + + +
+
+
+

{m.cms_api_keys()}

+

{m.cms_api_keys_help()}

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.ok && form.created} + +
+
+

+ {m.cms_api_keys_created_title({ label: form.created.label })} +

+

+ {m.cms_api_keys_created_warning()} +

+
+ + {form.created.rawKey} + + +
+ {/if} + + {#if createOpen} +
+ async ({ update, result }) => { + await update(); + if (result.type === 'success') createOpen = false; + }} + class="space-y-3 border border-border rounded-lg p-4 bg-muted/20" + > +

{m.cms_api_keys_new()}

+
+ + +
+
+ {m.cms_api_keys_scopes()} +
+ {#each data.knownScopes as scope (scope)} + + {/each} +
+
+ +
+ {/if} + + {#if data.keys.length === 0} +
+

{m.cms_api_keys_empty()}

+
+ {:else} +
+ + + + + + + + + + + + + {#each data.keys as k (k.id)} + {@const status = statusBadge(k)} + + + + + + + + + {/each} + +
{m.cms_api_keys_col_label()}{m.cms_api_keys_col_prefix()}{m.cms_api_keys_col_scopes()}{m.cms_api_keys_col_last_used()}{m.col_status()}
{k.label} + {k.prefix}โ€ฆ + + {k.scopes.join(', ')} + {fmt(k.lastUsedAt)} + {status.label} + + {#if !k.revokedAt} +
{ + if (!confirm(m.cms_api_keys_revoke_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} class="inline"> + + +
+ {/if} +
{ + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} class="inline"> + + +
+
+
+ {/if} +
diff --git a/src/routes/(cms)/cms/articles/[id]/+page.server.ts b/src/routes/(cms)/cms/articles/[id]/+page.server.ts index 3ae4298..3ccff47 100644 --- a/src/routes/(cms)/cms/articles/[id]/+page.server.ts +++ b/src/routes/(cms)/cms/articles/[id]/+page.server.ts @@ -6,6 +6,7 @@ import { } from "$lib/server/auth/permissions"; import { logAudit, type AuditAction } from "$lib/server/audit"; import { AnalyticsService } from "$lib/server/analytics"; +import { dispatchEvent } from "$lib/server/webhooks"; import { slugify } from "$lib/utils"; import { SUPPORTED_LOCALES } from "$lib/i18n"; import type { ArticleUpdateInput } from "$lib/server/content/types"; @@ -228,6 +229,23 @@ export const actions: Actions = { }); } } + + // v2.0d webhook fan-out. Fire-and-forget so the editor's save + // returns immediately. The dispatcher writes a webhook_deliveries + // row for every attempt, success or fail. + if (existing.status !== nextStatus) { + if (nextStatus === "published") { + void dispatchEvent(locals.content, { + event: "article.publish", + payload: { id: params.id, slug: existing.slug }, + }); + } else if (existing.status === "published") { + void dispatchEvent(locals.content, { + event: "article.unpublish", + payload: { id: params.id, slug: existing.slug }, + }); + } + } return { ok: true }; }, @@ -259,6 +277,10 @@ export const actions: Actions = { { slug: existing.slug }, ); } + void dispatchEvent(locals.content, { + event: next === "published" ? "article.publish" : "article.unpublish", + payload: { id: params.id, slug: existing.slug }, + }); return { ok: true, status: next }; }, @@ -277,6 +299,10 @@ export const actions: Actions = { slug: existing.slug, }); } + void dispatchEvent(locals.content, { + event: "article.delete", + payload: { id: params.id, slug: existing.slug }, + }); throw redirect(303, "/cms/articles"); }, }; diff --git a/src/routes/(cms)/cms/comments/+page.server.ts b/src/routes/(cms)/cms/comments/+page.server.ts index 32fcd9e..fd1d535 100644 --- a/src/routes/(cms)/cms/comments/+page.server.ts +++ b/src/routes/(cms)/cms/comments/+page.server.ts @@ -1,6 +1,7 @@ import { error, fail, redirect } from "@sveltejs/kit"; import { canManageTaxonomy } from "$lib/server/auth/permissions"; import { logAudit, type AuditAction } from "$lib/server/audit"; +import { dispatchEvent } from "$lib/server/webhooks"; import type { CommentStatus } from "$lib/server/content/types"; import type { Actions, PageServerLoad } from "./$types"; @@ -108,6 +109,19 @@ export const actions: Actions = { to: next, }); } + // v2.0d: only fire the webhook on approve. Spam/archive are + // moderation states most receivers don't care about. + if (next === "approved") { + void dispatchEvent(locals.content, { + event: "comment.approve", + payload: { + commentId: id, + articleId: before.articleId, + authorName: before.authorName, + body: before.body, + }, + }); + } return { ok: true }; }, diff --git a/src/routes/(cms)/cms/webhooks/+page.server.ts b/src/routes/(cms)/cms/webhooks/+page.server.ts new file mode 100644 index 0000000..fe3c138 --- /dev/null +++ b/src/routes/(cms)/cms/webhooks/+page.server.ts @@ -0,0 +1,135 @@ +import { error, fail, redirect } from "@sveltejs/kit"; +import { canManageUsers } from "$lib/server/auth/permissions"; +import { logAudit } from "$lib/server/audit"; +import { WEBHOOK_EVENTS, type WebhookEvent } from "$lib/server/content/types"; +import type { Actions, PageServerLoad } from "./$types"; + +/** + * `/cms/webhooks` โ€” admin+ only. Lists registered webhooks. Creating + * one shows the secret once at create time; the operator must save it + * (we hash with the same secret in the dispatcher's HMAC, but the + * stored secret stays visible in the CMS for verification โ€” webhooks + * aren't quite as sensitive as API keys, since the receiver chooses + * to consume the URL). + */ +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) { + throw error(403, "Only admins can manage webhooks."); + } + const webhooks = await locals.content.listWebhooks(); + return { webhooks, knownEvents: WEBHOOK_EVENTS }; +}; + +function parseEvents(form: FormData): WebhookEvent[] { + return form + .getAll("events") + .map((v) => String(v)) + .filter((v): v is WebhookEvent => + (WEBHOOK_EVENTS as string[]).includes(v), + ); +} + +export const actions: Actions = { + create: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const label = String(fd.get("label") ?? "").trim(); + const url = String(fd.get("url") ?? "").trim(); + const events = parseEvents(fd); + const enabled = fd.get("enabled") !== "off"; + if (!label || !url) { + return fail(400, { error: "Label and URL are required." }); + } + if (!url.startsWith("https://") && !url.startsWith("http://")) { + return fail(400, { error: "URL must start with http:// or https://" }); + } + if (events.length === 0) { + return fail(400, { error: "Pick at least one event." }); + } + const webhook = await locals.content.createWebhook({ + label, + url, + events, + enabled, + createdBy: locals.user.id, + }); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + webhook.id, + { kind: "webhook.create", url }, + ); + } + return { ok: true, webhookId: webhook.id }; + }, + + update: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + const label = String(fd.get("label") ?? "").trim(); + const url = String(fd.get("url") ?? "").trim(); + const events = parseEvents(fd); + const enabled = fd.get("enabled") !== "off"; + if (!id || !label || !url) { + return fail(400, { error: "Missing required fields." }); + } + if (events.length === 0) { + return fail(400, { error: "Pick at least one event." }); + } + await locals.content.updateWebhook(id, { label, url, events, enabled }); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + id, + { kind: "webhook.update", url }, + ); + } + return { ok: true }; + }, + + rotate: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + if (!id) return fail(400, { error: "Missing id" }); + await locals.content.rotateWebhookSecret(id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + id, + { kind: "webhook.rotate_secret" }, + ); + } + return { ok: true }; + }, + + delete: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageUsers(locals.user)) return fail(403, { error: "Forbidden" }); + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + if (!id) return fail(400, { error: "Missing id" }); + await locals.content.deleteWebhook(id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "settings.update", + id, + { kind: "webhook.delete" }, + ); + } + return { ok: true }; + }, +}; diff --git a/src/routes/(cms)/cms/webhooks/+page.svelte b/src/routes/(cms)/cms/webhooks/+page.svelte new file mode 100644 index 0000000..ae691be --- /dev/null +++ b/src/routes/(cms)/cms/webhooks/+page.svelte @@ -0,0 +1,275 @@ + + + + {m.cms_webhooks()} โ€” {m.cms_app_name()} + + +
+
+
+

{m.cms_webhooks()}

+

{m.cms_webhooks_help()}

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.ok && form.webhookId} +
+

+ {m.cms_webhooks_created()} +

+

+ {m.cms_webhooks_secret_hint()} +

+
+ {/if} + + {#if createOpen} +
+ async ({ update, result }) => { + await update(); + if (result.type === 'success') createOpen = false; + }} + class="space-y-4 border border-border rounded-lg p-4 bg-muted/20" + > +

{m.cms_webhooks_new()}

+
+ + +
+
+ {m.cms_webhooks_events()} +
+ {#each data.knownEvents as event (event)} + + {/each} +
+
+ +
+ {/if} + + {#if data.webhooks.length === 0} +
+

{m.cms_webhooks_empty()}

+
+ {:else} +
+ {#each data.webhooks as wh (wh.id)} +
+
+ + {wh.enabled ? m.cms_webhooks_enabled() : m.cms_webhooks_disabled()} + +
+
{wh.label}
+ + {wh.url} + +
+
+ +
{ + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + > + + +
+
+
+
+ {m.cms_webhooks_subscribed()}: + {#each wh.events as event (event)} + {event} + {/each} + {fmt(wh.createdAt)} +
+ {#if editingId === wh.id} +
+ async ({ update, result }) => { + await update(); + if (result.type === 'success') editingId = null; + }} + class="p-4 space-y-3 border-t border-border" + > + +
+ + +
+
+ {m.cms_webhooks_events()} +
+ {#each data.knownEvents as event (event)} + + {/each} +
+
+ +
+ +
+
+
{ + if (!confirm(m.cms_webhooks_rotate_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + class="px-4 pb-4" + > + + +
+
+ {m.cms_webhooks_show_secret()} + + {wh.secret} + +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/routes/api/forms/[key]/+server.ts b/src/routes/api/forms/[key]/+server.ts index 95bc05a..98b78e3 100644 --- a/src/routes/api/forms/[key]/+server.ts +++ b/src/routes/api/forms/[key]/+server.ts @@ -7,6 +7,7 @@ import { validateSubmission, } from "$lib/server/forms"; import { logAudit } from "$lib/server/audit"; +import { dispatchEvent } from "$lib/server/webhooks"; import type { RequestHandler } from "./$types"; /** @@ -96,6 +97,19 @@ export const POST: RequestHandler = async ({ ); } + // v2.0d: fan out the form.submit event. Receivers get the + // submission id + form key + the data payload so they can route + // (e.g. CRM ingest, Slack notification, etc.). + void dispatchEvent(locals.content, { + event: "form.submit", + payload: { + submissionId: submission.id, + formKey: form.key, + formLabel: form.label, + data: validation.data, + }, + }); + return json( { ok: true, diff --git a/src/routes/api/newsletter/confirm/+server.ts b/src/routes/api/newsletter/confirm/+server.ts index 63547c5..7799632 100644 --- a/src/routes/api/newsletter/confirm/+server.ts +++ b/src/routes/api/newsletter/confirm/+server.ts @@ -1,5 +1,6 @@ import { error, redirect } from "@sveltejs/kit"; import { logAudit } from "$lib/server/audit"; +import { dispatchEvent } from "$lib/server/webhooks"; import { localePath, DEFAULT_LOCALE } from "$lib/i18n"; import type { Locale } from "$lib/server/content/types"; import type { RequestHandler } from "./$types"; @@ -35,6 +36,19 @@ export const GET: RequestHandler = async ({ url, locals, platform }) => { ); } + // v2.0d webhook fan-out. Receivers can mirror to a CRM or trigger + // a welcome flow. Email is included so receivers can route by + // identity without a follow-up lookup. + void dispatchEvent(locals.content, { + event: "subscriber.confirm", + payload: { + subscriberId: subscriber.id, + email: subscriber.email, + locale: subscriber.locale, + source: subscriber.source, + }, + }); + // Redirect back to the locale home with a flash. Locale comes from // the subscriber record so we land them in their language. const locale = (subscriber.locale as Locale) ?? DEFAULT_LOCALE; diff --git a/src/routes/api/public/articles/+server.ts b/src/routes/api/public/articles/+server.ts new file mode 100644 index 0000000..f87e62c --- /dev/null +++ b/src/routes/api/public/articles/+server.ts @@ -0,0 +1,61 @@ +import { json } from "@sveltejs/kit"; +import { authenticate, hasScope } from "$lib/server/api-auth"; +import type { RequestHandler } from "./$types"; + +/** + * GET /api/public/articles + * + * Public read-only API (v2.0d). Bearer auth via `Authorization: Bearer + * kp_live_โ€ฆ` header. Requires `articles:read` (or `*:read`) scope. + * + * Query params: + * ?locale=en|th โ€” restrict to a single locale (default: all) + * ?limit=NUM โ€” max 100, default 20 + * ?page=NUM โ€” 1-based, default 1 + * + * Always returns published, non-future-dated articles only โ€” consumers + * never see drafts or scheduled posts. + */ +export const GET: RequestHandler = async ({ request, url, locals }) => { + const auth = await authenticate(request, locals.content); + if (!auth.ok || !auth.key) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasScope(auth.key, "articles:read")) { + return json({ error: "Forbidden โ€” articles:read scope required" }, { status: 403 }); + } + + const localeParam = url.searchParams.get("locale"); + const locale = + localeParam === "en" || localeParam === "th" ? localeParam : undefined; + const limit = Math.min( + 100, + Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20), + ); + const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1); + + const result = await locals.content.listArticles({ + status: "published", + onlyPublished: true, + locale, + limit, + page, + }); + + return json( + { + data: result.items.map((a) => ({ + id: a.id, + slug: a.slug, + publishedAt: a.publishedAt, + updatedAt: a.updatedAt, + coverMediaId: a.coverMediaId, + categoryId: a.categoryId, + tagIds: a.tagIds, + localizations: a.localizations, + })), + meta: { total: result.total, page: result.page, limit: result.limit }, + }, + { headers: { "cache-control": "public, max-age=60" } }, + ); +}; diff --git a/src/routes/api/public/articles/[slug]/+server.ts b/src/routes/api/public/articles/[slug]/+server.ts new file mode 100644 index 0000000..4655fcf --- /dev/null +++ b/src/routes/api/public/articles/[slug]/+server.ts @@ -0,0 +1,41 @@ +import { error, json } from "@sveltejs/kit"; +import { authenticate, hasScope } from "$lib/server/api-auth"; +import type { RequestHandler } from "./$types"; + +/** + * GET /api/public/articles/[slug] + * + * Single-article fetch by slug. Drafts and future-dated published + * articles 404 โ€” consumers never see unpublished content. + */ +export const GET: RequestHandler = async ({ request, params, locals }) => { + const auth = await authenticate(request, locals.content); + if (!auth.ok || !auth.key) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasScope(auth.key, "articles:read")) { + return json({ error: "Forbidden โ€” articles:read scope required" }, { status: 403 }); + } + + const article = await locals.content.getArticleBySlug(params.slug); + if (!article) throw error(404, "Article not found"); + if (article.status !== "published") throw error(404, "Article not found"); + if (article.publishedAt && new Date(article.publishedAt) > new Date()) { + throw error(404, "Article not found"); + } + + return json( + { + id: article.id, + slug: article.slug, + publishedAt: article.publishedAt, + updatedAt: article.updatedAt, + coverMediaId: article.coverMediaId, + categoryId: article.categoryId, + tagIds: article.tagIds, + commentsMode: article.commentsMode, + localizations: article.localizations, + }, + { headers: { "cache-control": "public, max-age=120" } }, + ); +}; diff --git a/src/routes/api/public/categories/+server.ts b/src/routes/api/public/categories/+server.ts new file mode 100644 index 0000000..83b4871 --- /dev/null +++ b/src/routes/api/public/categories/+server.ts @@ -0,0 +1,18 @@ +import { json } from "@sveltejs/kit"; +import { authenticate, hasScope } from "$lib/server/api-auth"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request, locals }) => { + const auth = await authenticate(request, locals.content); + if (!auth.ok || !auth.key) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasScope(auth.key, "categories:read")) { + return json({ error: "Forbidden โ€” categories:read scope required" }, { status: 403 }); + } + const categories = await locals.content.listCategories(); + return json( + { data: categories }, + { headers: { "cache-control": "public, max-age=300" } }, + ); +}; diff --git a/src/routes/api/public/pages/+server.ts b/src/routes/api/public/pages/+server.ts new file mode 100644 index 0000000..204aeae --- /dev/null +++ b/src/routes/api/public/pages/+server.ts @@ -0,0 +1,30 @@ +import { json } from "@sveltejs/kit"; +import { authenticate, hasScope } from "$lib/server/api-auth"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request, locals }) => { + const auth = await authenticate(request, locals.content); + if (!auth.ok || !auth.key) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasScope(auth.key, "pages:read")) { + return json({ error: "Forbidden โ€” pages:read scope required" }, { status: 403 }); + } + const pages = await locals.content.listPages({ + status: "published", + onlyPublished: true, + }); + return json( + { + data: pages.map((p) => ({ + id: p.id, + slug: p.slug, + template: p.template, + publishedAt: p.publishedAt, + updatedAt: p.updatedAt, + localizations: p.localizations, + })), + }, + { headers: { "cache-control": "public, max-age=120" } }, + ); +}; diff --git a/src/routes/api/public/tags/+server.ts b/src/routes/api/public/tags/+server.ts new file mode 100644 index 0000000..1f951fb --- /dev/null +++ b/src/routes/api/public/tags/+server.ts @@ -0,0 +1,18 @@ +import { json } from "@sveltejs/kit"; +import { authenticate, hasScope } from "$lib/server/api-auth"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request, locals }) => { + const auth = await authenticate(request, locals.content); + if (!auth.ok || !auth.key) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + if (!hasScope(auth.key, "tags:read")) { + return json({ error: "Forbidden โ€” tags:read scope required" }, { status: 403 }); + } + const tags = await locals.content.listTags(); + return json( + { data: tags }, + { headers: { "cache-control": "public, max-age=300" } }, + ); +};