diff --git a/CHANGELOG.md b/CHANGELOG.md index 07211d0..b9b91f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to @dune/plugin-admin are documented here. This project follows [Semantic Versioning](https://semver.org). +## [Unreleased] + +### Changed + +- **Public forms and the incoming webhook moved to `@dune/core` (duneorg/dune#78).** + `POST /api/forms/:name`, `GET /api/forms/:name`, `POST /api/contact` and + `POST /api/webhook/incoming` are now served by core, so they work with this + plugin off. This plugin no longer registers them and no longer sends the + submission email or webhook (`public-api.ts`, `email.ts` and `webhook.ts` are + removed). The submissions UI is unchanged; it reads the same files through + `BootstrapResult.submissions`, and `createSubmissionManager` and the + submission types are re-exported from `@dune/core/forms`. **Requires a + `@dune/core` that provides `BootstrapResult.submissions`**; with an older + core the plugin stops at startup with a message saying so. +- **Incoming-webhook actions are still audited.** Core fires `onSystemAction` + and this plugin writes the `system.rebuild` / `system.cache_purge` audit + entries from it. +- **`mountDuneAdmin()` still gives a headless app the public form routes**, + by calling core's `registerFormRoutes()` (a no-op when `createDuneApp()` has + registered them). +- **Requires `@dune/core` 0.35.2 or newer.** The `@dune/core` range moves from + `0.35` to `^0.35.2`, the first core that serves the public form routes. +- **The data directory follows `system.data_dir`**, falling back to the + deprecated `admin.dataDir`, the same as core. + +### Security + +- **`dev/apply` refuses to set `system.webhooks`.** Incoming-webhook tokens + moved there from `admin.incoming_webhooks`, which the endpoint already + blocked; the new location is blocked too. + --- ## [3.1.3] — 2026-10-27 diff --git a/deno.json b/deno.json index 6d3bc41..0215216 100644 --- a/deno.json +++ b/deno.json @@ -18,7 +18,7 @@ "./admin/auth/provider": "./src/admin/auth/provider.ts" }, "imports": { - "@dune/core": "jsr:@dune/core@0.35", + "@dune/core": "jsr:@dune/core@^0.35.2", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", "preact/hooks": "npm:preact@^10/hooks", diff --git a/mod.ts b/mod.ts index df030c7..406e5fc 100644 --- a/mod.ts +++ b/mod.ts @@ -30,7 +30,7 @@ import { createWorkflowEngine } from "@dune/core/workflow"; import { createScheduler } from "@dune/core/workflow"; import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; -import { createSubmissionManager } from "./src/admin/submissions.ts"; +import { resolveFormsConfig } from "@dune/core/forms"; import { createStagingEngine } from "@dune/core/staging"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; @@ -177,7 +177,9 @@ export function createAdminPlugin( enabled: true, }; const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin"; - const dataDir = adminCfg.dataDir ?? "data"; + // `system.data_dir`, falling back to the deprecated `admin.dataDir` — + // the same resolution core uses for the submissions it stores. + const dataDir = resolveFormsConfig(config).dataDir; // ── Admin services ──────────────────────────────────────────────────────── @@ -293,9 +295,29 @@ export function createAdminPlugin( contentDir: config.system.content.dir, }); - const submissionManager = createSubmissionManager({ - storage, - submissionsDir: `${dataDir}/submissions`, + // Core accepts and stores public form submissions; the admin only + // manages them. An older core has no store to read. + const submissionManager = bootstrap.submissions; + if (!submissionManager) { + throw new Error( + "@dune/plugin-admin 3.2+ needs a @dune/core that stores form submissions itself " + + "(BootstrapResult.submissions). Upgrade @dune/core, or stay on @dune/plugin-admin 3.1.", + ); + } + + // The incoming webhook runs in core; keep the admin's audit trail. + bootstrap.hooks.on< + { action: "rebuild" | "purge-cache"; ip: string | null; userAgent: string | null } + >("onSystemAction", ({ data }) => { + void setupState?.auditLogger?.log({ + event: data.action === "rebuild" ? "system.rebuild" : "system.cache_purge", + actor: null, + ip: data.ip, + userAgent: data.userAgent, + target: { type: "system" }, + detail: {}, + outcome: "success", + }).catch(() => {}); }); // ── Default content editor ──────────────────────────────────────────────── diff --git a/scripts/generate-core-imports.ts b/scripts/generate-core-imports.ts index 808eb68..6f68b06 100644 --- a/scripts/generate-core-imports.ts +++ b/scripts/generate-core-imports.ts @@ -43,16 +43,18 @@ * auto-tracks patch releases within that minor — confirmed via `deno cache`: * "0.28" resolved to the latest 0.28.x, not just 0.28.0. * - * Bare minor again as of core 0.35: everything this package imports from core - * (`DunePlugin.mountEarly()` from 0.34.4, `./auth/authz-schema` from 0.34.2) - * exists from 0.35.0 on. If the package ever starts depending on something added - * at a *later* patch within a minor, re-floor to that patch (`^0.35.N`) — a bare - * "0.35" floors JSR's publish-time check at 0.35.0 (reproduced 2026-08-31 with - * "0.34": "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema', - * resolved to 0.34.0, has no export"). This constant and deno.json are only kept - * in step by `check:core-imports`, so run it before merging any pin change. + * Floored at 0.35.2: the public form routes and the submission store moved into + * core there (`BootstrapResult.submissions`, `registerFormRoutes()` and the + * `onSystemAction` hook from `@dune/core/forms`), and this package no longer + * registers them itself. A bare "0.35" would floor JSR's publish-time check at + * 0.35.0 (reproduced 2026-08-31 with "0.34": + * "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema', + * resolved to 0.34.0, has no export") and let a site pair this package with a + * core that cannot serve those routes. Back to a bare minor at the next core + * minor. This constant and deno.json are only kept in step by + * `check:core-imports`, so run it before merging any pin change. */ -const CORE_RANGE = "0.35"; +const CORE_RANGE = "^0.35.2"; const DENO_JSON = "deno.json"; const SCAN_ROOTS = ["mod.ts", "src", "tests"]; diff --git a/src/admin/email.ts b/src/admin/email.ts deleted file mode 100644 index 5772226..0000000 --- a/src/admin/email.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Email notification sender for form submissions. - * - * Uses nodemailer for SMTP transport. String config values beginning with "$" - * are expanded to their corresponding environment variable at send time: - * - * pass: "$SMTP_PASSWORD" → Deno.env.get("SMTP_PASSWORD") ?? "" - * - * Sending is fire-and-forget from the contact handler perspective — errors are - * logged but never bubble up to the submitter. - */ - -// @ts-types="npm:@types/nodemailer@^6" -import nodemailer from "nodemailer"; -import type { SmtpNotificationConfig } from "@dune/core/config"; -import type { Submission } from "./submissions.ts"; - -/** - * Expand a config string value: if it starts with "$", substitute the - * named environment variable. Returns an empty string when the variable is - * not set (rather than throwing) so misconfigured deployments degrade - * gracefully instead of crashing. - */ -function envExpand(value: string): string { - if (value.startsWith("$")) { - return Deno.env.get(value.slice(1)) ?? ""; - } - return value; -} - -/** - * Send an email notification for a new form submission. - * Throws on transport or authentication errors (caller should catch and log). - */ -export async function sendSubmissionEmail( - cfg: SmtpNotificationConfig, - submission: Submission, -): Promise { - const transporter = nodemailer.createTransport({ - host: cfg.smtp.host, - port: cfg.smtp.port, - secure: cfg.smtp.secure, - auth: { - user: envExpand(cfg.smtp.user), - pass: envExpand(cfg.smtp.pass), - }, - }); - - const subject = (cfg.subject ?? "New {form} submission") - .replace(/\{form\}/g, submission.form); - - const to = Array.isArray(cfg.to) ? cfg.to.join(", ") : cfg.to; - - // Plain-text body - const fieldLines = Object.entries(submission.fields) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"); - const receivedStr = new Date(submission.receivedAt).toISOString(); - const text = [ - `New submission received for form: ${submission.form}`, - `Received: ${receivedStr}`, - `ID: ${submission.id}`, - "", - "Fields:", - fieldLines, - ...(submission.files && submission.files.length > 0 - ? ["", "Attachments:", ...submission.files.map((f) => ` ${f.name} (${f.contentType}, ${f.size} bytes)`)] - : []), - ].join("\n"); - - // HTML body - const fieldRows = Object.entries(submission.fields) - .map(([k, v]) => `${escHtml(k)}${escHtml(v)}`) - .join(""); - const fileRows = (submission.files ?? []) - .map((f) => `attachment${escHtml(f.name)} (${escHtml(f.contentType)}, ${f.size} bytes)`) - .join(""); - - const html = ` -
-

New ${escHtml(submission.form)} submission

-

Received ${escHtml(receivedStr)} — ID: ${escHtml(submission.id)}

- - ${fieldRows}${fileRows} -
-
`.trim(); - - await transporter.sendMail({ from: cfg.from, to, subject, text, html }); -} - -function escHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} diff --git a/src/admin/mount.ts b/src/admin/mount.ts index ee4f199..f57603d 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -3,8 +3,7 @@ * their own routes and want Dune only for content management. * * Instead of calling `createDuneApp()` (which owns all routes including `/*`), - * headless developers use `mountDuneAdmin()` to bolt on Dune's admin panel and - * public API, then wire their own `routes/` directory via `app.fsRoutes()`. + * headless developers use `mountDuneAdmin()` to bolt on Dune's admin panel, then wire their own `routes/` directory via `app.fsRoutes()`. * * @example * ```ts @@ -38,6 +37,7 @@ import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; +import { registerFormRoutes } from "@dune/core/forms"; import { checkPermission } from "./routes/api/_utils.ts"; import type { AdminState } from "./types.ts"; import { @@ -46,12 +46,6 @@ import { adminMiddleware, adminRoutes, } from "./manifest.gen.ts"; -import { - handleContactSubmission, - handleFormSchema, - handleFormSubmission, - handleIncomingWebhook, -} from "./public-api.ts"; /** * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` @@ -244,24 +238,10 @@ export async function mountDuneAdmin( registerPluginPublicRoutes(app, ctx, { adminPrefix }); // ── Public form / webhook API ─────────────────────────────────────────────── - // Bind handlers to the per-site adminContext via closure so multisite - // deployments can't leak across tenants via a process-wide singleton. - if (adminContext) { - const adminCtx = adminContext; - app.post("/api/contact", (fc) => handleContactSubmission(adminCtx, fc.req)); - app.get( - "/api/forms/:name", - (fc) => handleFormSchema(adminCtx, fc.params.name), - ); - app.post( - "/api/forms/:name", - (fc) => handleFormSubmission(adminCtx, fc.req, fc.params.name), - ); - app.post( - "/api/webhook/incoming", - (fc) => handleIncomingWebhook(adminCtx, fc.req), - ); - } + // Served by core, with or without the admin panel. createDuneApp() already + // registers them, so this only matters for the headless path above, and it + // is a no-op when they are already there. + registerFormRoutes(app, ctx); } /** diff --git a/src/admin/public-api.ts b/src/admin/public-api.ts deleted file mode 100644 index f195b50..0000000 --- a/src/admin/public-api.ts +++ /dev/null @@ -1,558 +0,0 @@ -/** - * Public API handlers — no admin auth required. - * - * Routes handled here: - * POST /api/contact — Legacy contact form submission - * GET /api/forms/:name — Blueprint-driven form schema (JSON) - * POST /api/forms/:name — Blueprint-driven form submission - * POST /api/webhook/incoming — Token-authenticated incoming webhook - */ - -import type { AdminContext } from "./context.ts"; -import { sendSubmissionEmail } from "./email.ts"; -import { sendWebhookNotification } from "./webhook.ts"; -import { loadForm } from "@dune/core/forms"; -import { validateFormSubmission } from "@dune/core/forms"; -import { checkUpload } from "@dune/core/security"; -import { checkBodySize } from "@dune/core/security"; -import { RateLimiter, clientIp } from "@dune/core/security"; -import { encodeHex } from "@std/encoding/hex"; -import type { SubmissionFile } from "./submissions.ts"; -import type { WebhookNotificationConfig } from "@dune/core/config"; -import { timingSafeEqual } from "./timing-safe.ts"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -// json() shared with the admin API (Q-5) — the public variant previously -// pretty-printed with 2-space indent; compact JSON is equivalent for machine -// consumers and matches the admin API's output. publicServerError() (not -// serverError()) is the error mapper here deliberately: serverError() maps -// typed errors to specific statuses and includes their message, which is -// safe for the authenticated admin API but not for this unauthenticated -// surface — see publicServerError()'s own doc comment in http.ts. -import { json, publicServerError as serverError } from "./http.ts"; - -/** Same-origin path only — form.success_url is otherwise an open redirect. */ -function safeRedirectPath(target: string, req: Request): string { - if (typeof target !== "string" || target.length === 0 || target.length > 2048) { - return "/"; - } - if (target.includes("\0") || target.includes("\r") || target.includes("\n")) { - return "/"; - } - if (target.startsWith("//") || target.startsWith("\\\\")) return "/"; - try { - const requestUrl = new URL(req.url); - const parsed = new URL(target, requestUrl); - if (parsed.origin !== requestUrl.origin) return "/"; - if (!parsed.pathname.startsWith("/")) return "/"; - return parsed.pathname + parsed.search + parsed.hash; - } catch { - return "/"; - } -} - -// Hard ceiling on a single form submission body (multipart or urlencoded). -// Mirrors the per-file (10 MB) × per-submission (5 files) budget plus a small -// allowance for boundaries, headers, and non-file fields. -const MAX_SUBMISSION_BYTES = 55 * 1024 * 1024; - -// Webhook bodies are always small JSON envelopes — 1 MiB is generous and -// shuts down trivially-large unauthenticated POSTs before req.json() runs. -const MAX_WEBHOOK_BYTES = 1024 * 1024; - -// Rate limiter: 5 submissions per IP per minute (shared across contact + form routes) -const contactRateLimiter = new RateLimiter(5, 60 * 1000); - -// ── Shared submission pipeline (Q-1, Aug 2026 quality audit) ────────────────── -// -// handleFormSubmission and handleContactSubmission previously duplicated the -// rate-limit → body-size → parse → field-collapse → upload-storage pipeline -// (~150 lines each). Bugs fixed in one copy could silently miss the other — -// exactly how MED-19 (JSON bypassing the body-size cap) needed two fixes. -// The handlers below keep only their unique validation and response logic. - -interface ParsedSubmission { - /** Multi-value fields collapsed to comma-joined strings. */ - fields: Record; - uploadedFiles: Array<{ key: string; file: File }>; -} - -/** - * Rate-limit by IP, cap body size, and parse JSON/form-data bodies into - * collapsed fields + uploaded files. Returns a ready-made Response - * (429 / 413 / parse error) that the caller must return as-is. - */ -async function guardAndParseSubmission( - req: Request, - config: AdminContext["config"], -): Promise { - // Rate limit by IP: 5 submissions per minute. Honor X-Forwarded-For - // only when system.trusted_proxies is set (otherwise clients can spoof). - const trustForwardedFor = config.system?.trusted_proxies === true; - const ip = clientIp(req, { trustForwardedFor }); - if (!contactRateLimiter.check(ip)) { - const retryAfter = contactRateLimiter.retryAfter(ip); - return new Response(JSON.stringify({ error: "Too many requests" }), { - status: 429, - headers: { - "Content-Type": "application/json", - "Retry-After": String(retryAfter), - }, - }); - } - - // Body-size cap applies regardless of content-type — JSON used to bypass - // this guard and accept arbitrarily large payloads (MED-19, CWE-400). - const tooLarge = checkBodySize(req, MAX_SUBMISSION_BYTES); - if (tooLarge) return tooLarge; - - const contentType = req.headers.get("content-type") ?? ""; - const multiFields: Record = {}; - const uploadedFiles: Array<{ key: string; file: File }> = []; - - if (contentType.includes("application/json")) { - let body: unknown; - try { - body = await req.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - for (const [k, v] of Object.entries(body as Record)) { - if (typeof v === "string") multiFields[k] = [v]; - else if (Array.isArray(v)) multiFields[k] = v.filter((x) => typeof x === "string"); - } - } else { - // application/x-www-form-urlencoded or multipart/form-data - const formData = await req.formData(); - for (const [k, v] of formData.entries()) { - if (typeof v === "string") { - (multiFields[k] ??= []).push(v); - } else if (v instanceof File && v.size > 0) { - uploadedFiles.push({ key: k, file: v }); - } - } - } - - // Collapse multi-value fields to comma-joined strings - const fields: Record = {}; - for (const [k, vs] of Object.entries(multiFields)) { - fields[k] = vs.join(", "); - } - - return { fields, uploadedFiles }; -} - -const MAX_STORED_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file -const MAX_FILES_PER_SUBMISSION = 5; - -/** Sanitise a filename: strip path separators, collapse whitespace. */ -function sanitizeFilename(name: string): string { - return name - .replace(/[/\\:*?"<>|]/g, "_") - .replace(/\s+/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 200); -} - -/** - * Persist accepted uploads under data/uploads/
//. - * Oversized files and disallowed extensions are silently skipped (bots get - * no signal; legitimate submitters see their submission still recorded). - */ -async function storeUploads( - storage: AdminContext["storage"], - dataDir: string, - formName: string, - submissionId: string, - uploadedFiles: ParsedSubmission["uploadedFiles"], -): Promise { - const storedFiles: SubmissionFile[] = []; - for (const { file } of uploadedFiles.slice(0, MAX_FILES_PER_SUBMISSION)) { - if (file.size > MAX_STORED_FILE_SIZE) continue; - - const safeName = sanitizeFilename(file.name); - if (!safeName) continue; - const check = checkUpload(safeName); - if (!check.ok) continue; // silently skip disallowed extensions - - const storagePath = `${dataDir}/uploads/${formName}/${submissionId}/${safeName}`; - await storage.write(storagePath, new Uint8Array(await file.arrayBuffer())); - - storedFiles.push({ - name: safeName, - contentType: check.contentType, - size: file.size, - storagePath, - }); - } - return storedFiles; -} - -interface NotificationOverrides { - /** Per-form email override — replaces the `to` address. */ - emailTo?: string; - /** Per-form webhook override — replaces the URL, keeps global secret/headers. */ - webhookUrl?: string; -} - -/** Fire-and-forget email/webhook notifications for a stored submission. */ -function dispatchNotifications( - notifCfg: NonNullable["notifications"], - submission: Parameters[1], - overrides: NotificationOverrides = {}, -): void { - if (!notifCfg) return; - if (notifCfg.email) { - const emailCfg = overrides.emailTo - ? { ...notifCfg.email, to: overrides.emailTo } - : notifCfg.email; - sendSubmissionEmail(emailCfg, submission) - .catch((err: Error) => console.error(`[dune/forms] Email notification failed: ${err.message}`)); - } - if (notifCfg.webhook || overrides.webhookUrl) { - const webhookCfg = overrides.webhookUrl - ? { ...(notifCfg.webhook ?? {}), url: overrides.webhookUrl } as WebhookNotificationConfig - : notifCfg.webhook!; - sendWebhookNotification(webhookCfg, submission) - .catch((err: Error) => console.error(`[dune/forms] Webhook notification failed: ${err.message}`)); - } -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -/** GET /api/forms/:name — return the form schema as JSON. */ -export async function handleFormSchema(ctx: AdminContext, formName: string): Promise { - const form = await loadForm(ctx.storage, "forms", formName); - // Disabled forms are indistinguishable from missing forms to the public - // (MED-20): same 404 response so a public client can't enumerate the - // server's "this form exists but is paused" state. - if (!form || form.enabled === false) { - return json({ error: `Form "${formName}" not found` }, 404); - } - // Return the public schema — omit internal server-side config (emails, webhooks) - return json({ - name: formName, - title: form.title, - success_url: form.success_url ?? "/", - fields: form.fields, - // Expose honeypot field name so the front-end can render the hidden input - honeypot: form.honeypot ?? ctx.config.admin?.honeypot ?? "_hp", - }); -} - -/** POST /api/forms/:name — validate and store a blueprint-driven form submission. */ -export async function handleFormSubmission(ctx: AdminContext, req: Request, formName: string): Promise { - const { storage, submissions, config } = ctx; - - if (!submissions) { - return json({ error: "Submissions not enabled" }, 501); - } - - const form = await loadForm(storage, "forms", formName); - if (!form) { - return json({ error: `Form "${formName}" not found` }, 404); - } - if (form.enabled === false) { - // Form is paused (MED-20). Refuse the submission with a generic - // 403 instead of 404 so legitimate authors who hit a disabled form - // get a clearer signal in client logs. - return json({ error: "Form is not currently accepting submissions" }, 403); - } - - try { - const parsed = await guardAndParseSubmission(req, config); - if (parsed instanceof Response) return parsed; - const { fields, uploadedFiles } = parsed; - - // Honeypot anti-spam - const honeypotField = form.honeypot ?? config.admin?.honeypot ?? "_hp"; - if (fields[honeypotField]) { - const acceptsJson = req.headers.get("accept")?.includes("application/json"); - if (acceptsJson) return json({ ok: true }); - return new Response(null, { status: 302, headers: { Location: form.success_url ?? "/" } }); - } - delete fields[honeypotField]; - - // Schema validation - const validationErrors = validateFormSubmission(form, fields); - if (validationErrors.length > 0) { - const acceptsJson = req.headers.get("accept")?.includes("application/json"); - if (acceptsJson) { - return json({ error: "Validation failed", errors: validationErrors }, 422); - } - // For regular form POST, redirect back with error indicator - const requestOrigin = new URL(req.url).origin; - const referer = req.headers.get("referer"); - let redirectPath = "/"; - if (referer) { - try { - const u = new URL(referer); - if (u.origin === requestOrigin) { - u.searchParams.set("form_error", "1"); - redirectPath = u.pathname + u.search; - } - } catch { /* bad referer */ } - } - return new Response(null, { status: 302, headers: { Location: redirectPath } }); - } - - // File uploads - const submissionId = encodeHex(crypto.getRandomValues(new Uint8Array(6))); - const storedFiles = await storeUploads( - storage, - config.admin?.dataDir ?? "data", - formName, - submissionId, - uploadedFiles, - ); - - const submitterIp = clientIp(req, { trustForwardedFor: config.system?.trusted_proxies === true }); - const submission = await submissions.create(formName, fields, { - ip: submitterIp === "unknown" ? undefined : submitterIp, - language: req.headers.get("accept-language") ?? undefined, - userAgent: req.headers.get("user-agent") ?? undefined, - }, { id: submissionId, files: storedFiles }); - - // Notifications — per-form overrides replace only the destination. - dispatchNotifications(config.admin?.notifications, submission, { - emailTo: form.notifications?.email, - webhookUrl: form.notifications?.webhook, - }); - - const acceptsJson = req.headers.get("accept")?.includes("application/json"); - if (acceptsJson) return json({ ok: true }); - - const successUrl = safeRedirectPath(form.success_url ?? "/", req); - return new Response(null, { status: 302, headers: { Location: successUrl } }); - } catch (err) { - return serverError(err); - } -} - -// ── Incoming webhook handler ────────────────────────────────────────────────── -// POST /api/webhook/incoming -// Body: { token: string } — optional, token may also be in Authorization header -// Bearer OR body.token -// Matches token against config.admin.incoming_webhooks entries. -// Token values starting with "$" are expanded from environment variables. -// On match, dispatches the permitted actions requested in the body. -/** - * Handle `POST /api/webhook/incoming` — validates the token and dispatches permitted actions. - * Token may be in the `Authorization: Bearer` header or `body.token`. - */ -export async function handleIncomingWebhook(ctx: AdminContext, req: Request): Promise { - const { config, engine, auditLogger, imageCache } = ctx; - - const incomingWebhooks = config.admin?.incoming_webhooks; - if (!incomingWebhooks || incomingWebhooks.length === 0) { - return json({ error: "Incoming webhooks not configured" }, 501); - } - - // Cap body size before parsing JSON to prevent unauthenticated memory DoS: - // req.json() buffers the whole body before validating the token below. - const tooLarge = checkBodySize(req, MAX_WEBHOOK_BYTES); - if (tooLarge) return tooLarge; - - // Extract token from Authorization header (Bearer) or JSON body - let token: string | null = null; - const authHeader = req.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice(7).trim(); - } - - let body: Record = {}; - try { - body = await req.json(); - } catch { - // body optional — token may be in header only - } - - if (!token && typeof body.token === "string") { - token = body.token; - } - - if (!token) { - return json({ error: "Missing token" }, 401); - } - - // Find a matching webhook config entry (expand $ENV_VAR tokens) - const expandToken = (t: string): string => { - if (t.startsWith("$")) { - return Deno.env.get(t.slice(1)) ?? t; - } - return t; - }; - - // Compare in constant time (shared timingSafeEqual helper) to prevent an - // attacker from progressively recovering the configured token by - // measuring response timing. - const tokenBytes = new TextEncoder().encode(token); - let matched: (typeof incomingWebhooks)[number] | undefined; - for (const wh of incomingWebhooks) { - const candidate = new TextEncoder().encode(expandToken(wh.token)); - if (timingSafeEqual(candidate, tokenBytes)) matched = wh; - } - - if (!matched) { - return json({ error: "Invalid token" }, 401); - } - - // Determine which actions to run — request body may specify a subset - let requestedActions: string[]; - if (Array.isArray(body.actions)) { - requestedActions = body.actions.filter( - (a) => typeof a === "string" && matched.actions.includes(a as "rebuild" | "purge-cache"), - ); - } else { - // No specific action requested — run all permitted actions - requestedActions = matched.actions as string[]; - } - - if (requestedActions.length === 0) { - return json({ error: "No permitted actions match the request" }, 400); - } - - const executed: string[] = []; - // Trusted-proxy-aware IP for audit records — forwarded headers are only - // honored when system.trusted_proxies is set (same policy as _utils.getClientIp). - const auditIp = clientIp(req, { - trustForwardedFor: config.system?.trusted_proxies === true, - }); - - for (const action of requestedActions) { - if (action === "rebuild") { - // Fire-and-forget — don't block the response - engine.rebuild().catch((err: unknown) => { - console.error("[dune] incoming webhook rebuild error:", err); - }); - void auditLogger?.log({ - event: "system.rebuild", - actor: null, - ip: auditIp === "unknown" ? null : auditIp, - userAgent: req.headers.get("user-agent"), - target: { type: "system" }, - detail: {}, - outcome: "success", - }).catch(() => {}); - executed.push("rebuild"); - } else if (action === "purge-cache") { - if (imageCache) { - await imageCache.clear(); - } - void auditLogger?.log({ - event: "system.cache_purge", - actor: null, - ip: auditIp === "unknown" ? null : auditIp, - userAgent: req.headers.get("user-agent"), - target: { type: "system" }, - detail: {}, - outcome: "success", - }).catch(() => {}); - executed.push("purge-cache"); - } - } - - return json({ ok: true, executed }); -} - -// ── Contact form submission handler (public) ────────────────────────────────── - -/** - * Handle `POST /api/contact` — validates reCAPTCHA, persists the form submission, - * and optionally sends a notification email. - */ -export async function handleContactSubmission(ctx: AdminContext, req: Request): Promise { - const { storage, submissions, config } = ctx; - - if (!submissions) { - return json({ error: "Submissions not enabled" }, 501); - } - try { - const parsed = await guardAndParseSubmission(req, config); - if (parsed instanceof Response) return parsed; - const { fields, uploadedFiles } = parsed; - - // ── Honeypot anti-spam ──────────────────────────────────────────────── - // If the configured honeypot field is present and non-empty, a bot filled - // it in. Silently accept (so bots get no useful signal) but don't save. - const honeypotField = config.admin?.honeypot ?? "_hp"; - if (fields[honeypotField]) { - // Looks like a bot submission — return success without saving - const acceptsJson = req.headers.get("accept")?.includes("application/json"); - if (acceptsJson) return json({ ok: true }); - return new Response(null, { status: 302, headers: { "Location": "/" } }); - } - delete fields[honeypotField]; // remove the empty honeypot field from data - - // Basic required field validation - if (!fields.name && !fields.email) { - return json({ error: "Missing required fields" }, 400); - } - - const language = req.headers.get("accept-language") ?? undefined; - const userAgent = req.headers.get("user-agent") ?? undefined; - - // Use form_name field if provided (allows multiple forms), otherwise default to "contact". - // Validate form_name: it becomes a filesystem directory name, so restrict to safe chars. - // Only alphanumeric, hyphens, and underscores — no slashes, dots, or special chars. - const rawFormName = fields.form_name ?? "contact"; - delete fields.form_name; - const formName = /^[a-zA-Z0-9_-]{1,64}$/.test(rawFormName) ? rawFormName : "contact"; - - // Pre-generate submission ID so we can store files before creating the record. - const submissionId = encodeHex(crypto.getRandomValues(new Uint8Array(6))); - const storedFiles = await storeUploads( - storage, - config.admin?.dataDir ?? "data", - formName, - submissionId, - uploadedFiles, - ); - - const submitterIp = clientIp(req, { trustForwardedFor: config.system?.trusted_proxies === true }); - const submission = await submissions.create(formName, fields, { - ip: submitterIp === "unknown" ? undefined : submitterIp, - language, - userAgent, - }, { id: submissionId, files: storedFiles }); - - // Notifications (fire-and-forget) — global config only for the contact form. - dispatchNotifications(config.admin?.notifications, submission); - - // Support both JSON and form POST responses - const acceptsJson = req.headers.get("accept")?.includes("application/json"); - if (acceptsJson) { - return json({ ok: true }); - } - - // Redirect back (form POST) — validate Referer is same-origin to prevent open redirect. - // If Referer is missing, cross-origin, or unparseable, fall back to "/". - // Behind a reverse proxy req.url has the internal origin (http://localhost:PORT), - // so also accept the public origin derived from Host + X-Forwarded-Proto headers. - const requestOrigin = new URL(req.url).origin; - const host = req.headers.get("host"); - const proto = req.headers.get("x-forwarded-proto") ?? "https"; - const publicOrigin = host ? `${proto}://${host}` : null; - const refererHeader = req.headers.get("referer"); - let redirectPath = "/"; - if (refererHeader) { - try { - const refererUrl = new URL(refererHeader); - if (refererUrl.origin === requestOrigin || (publicOrigin && refererUrl.origin === publicOrigin)) { - // Safe: same-origin — keep the path+query, append ?submitted=1 - refererUrl.searchParams.set("submitted", "1"); - redirectPath = refererUrl.pathname + refererUrl.search; - } - } catch { - // Unparseable Referer — fall back to "/" - } - } - return new Response(null, { - status: 302, - headers: { "Location": redirectPath }, - }); - } catch (err) { - return serverError(err); - } -} diff --git a/src/admin/routes/api/dev/apply.ts b/src/admin/routes/api/dev/apply.ts index b719e74..61455a2 100644 --- a/src/admin/routes/api/dev/apply.ts +++ b/src/admin/routes/api/dev/apply.ts @@ -134,12 +134,15 @@ const BLOCKED_CONFIG_KEYS: readonly string[] = [ "system.debug", "system.trusted_proxies", "admin.incoming_webhooks", + // Where incoming-webhook tokens live now (and where core reads them from). + "system.webhooks", ]; function isBlockedConfigKey(key: string): boolean { const lower = key.toLowerCase(); return BLOCKED_CONFIG_KEYS.some((blocked) => lower === blocked) || - lower.startsWith("admin.incoming_webhooks."); + lower.startsWith("admin.incoming_webhooks.") || + lower.startsWith("system.webhooks."); } /** diff --git a/src/admin/submissions.ts b/src/admin/submissions.ts index f6c4d77..b8ff188 100644 --- a/src/admin/submissions.ts +++ b/src/admin/submissions.ts @@ -1,210 +1,17 @@ /** - * Submission manager — stores and retrieves contact form submissions. + * Form submissions. * - * Submissions are stored as JSON files in data/submissions/{form}/{id}.json - * (git-tracked alongside content — they are business records). - * - * File naming: {ISO-timestamp}_{id}.json for natural chronological sort. + * The store and its types live in `@dune/core/forms` — core accepts public + * form submissions itself, so a site works without this plugin. This module + * re-exports them for the submissions UI and for existing importers. */ -import { encodeHex } from "@std/encoding/hex"; -import type { StorageAdapter } from "@dune/core/storage"; - -// === Types === - -export interface Submission { - id: string; - form: string; - receivedAt: number; // Unix ms - status: SubmissionStatus; - fields: Record; - meta: SubmissionMeta; - /** Uploaded file attachments (set if form includes file inputs) */ - files?: SubmissionFile[]; -} - -export interface SubmissionFile { - /** Original filename from the upload */ - name: string; - /** Detected MIME type */ - contentType: string; - /** File size in bytes */ - size: number; - /** Storage path (relative to storage root), used internally for serving */ - storagePath: string; -} - -export type SubmissionStatus = "new" | "read" | "archived"; - -export interface SubmissionMeta { - /** Submitter IP (from X-Forwarded-For or request) */ - ip?: string; - /** Accept-Language header value */ - language?: string; - /** User-Agent header value */ - userAgent?: string; -} - -export interface SubmissionManagerConfig { - storage: StorageAdapter; - /** Base directory for all submissions, e.g. "data/submissions" */ - submissionsDir: string; -} - -export interface SubmissionManager { - /** - * Save a new submission. Returns the created record. - * Pass `id` to use a pre-generated ID (useful when files are stored before create()). - */ - create(form: string, fields: Record, meta?: SubmissionMeta, options?: { id?: string; files?: SubmissionFile[] }): Promise; - /** Get a single submission by form + id. */ - get(form: string, id: string): Promise; - /** List all submissions for a form, newest first. */ - list(form: string): Promise; - /** Update the status of a submission. */ - setStatus(form: string, id: string, status: SubmissionStatus): Promise; - /** Delete a submission permanently. */ - delete(form: string, id: string): Promise; - /** Count unread (status = "new") submissions across all forms, or for one form. */ - countNew(form?: string): Promise; - /** List all form names that have at least one submission directory. */ - listForms(): Promise; -} - -// === Implementation === - -export function createSubmissionManager(config: SubmissionManagerConfig): SubmissionManager { - const { storage, submissionsDir } = config; - - function dirFor(form: string): string { - // Sanitise form name — only alphanumeric + hyphens - const safe = form.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); - return `${submissionsDir}/${safe}`; - } - - function fileFor(form: string, id: string, receivedAt: number): string { - const ts = new Date(receivedAt).toISOString().replace(/[:.]/g, "-"); - return `${dirFor(form)}/${ts}_${id}.json`; - } - - async function create( - form: string, - fields: Record, - meta: SubmissionMeta = {}, - options?: { id?: string; files?: SubmissionFile[] }, - ): Promise { - const id = options?.id ?? await generateId(); - const receivedAt = Date.now(); - - const submission: Submission = { - id, - form, - receivedAt, - status: "new", - fields, - meta, - ...(options?.files && options.files.length > 0 ? { files: options.files } : {}), - }; - - const path = fileFor(form, id, receivedAt); - await storage.write(path, JSON.stringify(submission, null, 2)); - - return submission; - } - - async function get(form: string, id: string): Promise { - try { - const entries = await storage.list(dirFor(form)); - const entry = entries.find((e) => e.name.endsWith(`_${id}.json`)); - if (!entry) return null; - const data = await storage.read(`${dirFor(form)}/${entry.name}`); - return JSON.parse(new TextDecoder().decode(data)) as Submission; - } catch { - return null; - } - } - - async function list(form: string): Promise { - const submissions: Submission[] = []; - try { - const entries = await storage.list(dirFor(form)); - for (const entry of entries) { - if (entry.isDirectory || !entry.name.endsWith(".json")) continue; - try { - const data = await storage.read(`${dirFor(form)}/${entry.name}`); - submissions.push(JSON.parse(new TextDecoder().decode(data)) as Submission); - } catch { - // Skip corrupt files - } - } - } catch { - // Directory doesn't exist yet — no submissions - } - // Newest first (filenames sort chronologically, so reverse) - return submissions.sort((a, b) => b.receivedAt - a.receivedAt); - } - - async function setStatus(form: string, id: string, status: SubmissionStatus): Promise { - try { - const entries = await storage.list(dirFor(form)); - const entry = entries.find((e) => e.name.endsWith(`_${id}.json`)); - if (!entry) return false; - const path = `${dirFor(form)}/${entry.name}`; - const data = await storage.read(path); - const submission = JSON.parse(new TextDecoder().decode(data)) as Submission; - submission.status = status; - await storage.write(path, JSON.stringify(submission, null, 2)); - return true; - } catch { - return false; - } - } - - async function deleteSubmission(form: string, id: string): Promise { - try { - const entries = await storage.list(dirFor(form)); - const entry = entries.find((e) => e.name.endsWith(`_${id}.json`)); - if (!entry) return false; - await storage.delete(`${dirFor(form)}/${entry.name}`); - return true; - } catch { - return false; - } - } - - async function countNew(form?: string): Promise { - if (form) { - const items = await list(form); - return items.filter((s) => s.status === "new").length; - } - // Count across all forms - let total = 0; - try { - const formDirs = await storage.list(submissionsDir); - for (const dir of formDirs) { - if (!dir.isDirectory) continue; - const items = await list(dir.name); - total += items.filter((s) => s.status === "new").length; - } - } catch { - // No submissions dir yet - } - return total; - } - - async function listForms(): Promise { - try { - const formDirs = await storage.list(submissionsDir); - return formDirs.filter((d) => d.isDirectory).map((d) => d.name).sort(); - } catch { - return []; - } - } - - return { create, get, list, setStatus, delete: deleteSubmission, countNew, listForms }; -} - -async function generateId(): Promise { - const bytes = crypto.getRandomValues(new Uint8Array(6)); - return encodeHex(bytes); -} +export { createSubmissionManager } from "@dune/core/forms"; +export type { + Submission, + SubmissionFile, + SubmissionManager, + SubmissionManagerConfig, + SubmissionMeta, + SubmissionStatus, +} from "@dune/core/forms"; diff --git a/src/admin/webhook.ts b/src/admin/webhook.ts deleted file mode 100644 index e6fce6d..0000000 --- a/src/admin/webhook.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * HTTP webhook notification sender for form submissions. - * - * POSTs the full submission JSON to a configured URL. Optionally signs the - * request body with an HMAC-SHA256 secret so the receiver can verify the - * payload hasn't been tampered with. - * - * Secret values beginning with "$" are expanded from environment variables: - * secret: "$WEBHOOK_SECRET" → Deno.env.get("WEBHOOK_SECRET") ?? "" - * - * The receiver should: - * 1. Read the raw request body - * 2. Compute HMAC-SHA256 of the body using the shared secret - * 3. Compare to the X-Dune-Signature header (constant-time comparison) - */ - -import type { WebhookNotificationConfig } from "@dune/core/config"; -import type { Submission } from "./submissions.ts"; -import { safeFetch } from "@dune/core/security"; - -/** Expand "$VAR" → env variable value. */ -function envExpand(value: string): string { - if (value.startsWith("$")) { - return Deno.env.get(value.slice(1)) ?? ""; - } - return value; -} - -/** - * POST a submission to the configured webhook URL. - * Throws on non-2xx responses or network errors (caller should catch and log). - */ -export async function sendWebhookNotification( - cfg: WebhookNotificationConfig, - submission: Submission, -): Promise { - const body = JSON.stringify(submission); - const headers: Record = { - "Content-Type": "application/json; charset=utf-8", - "User-Agent": "Dune-CMS/0.3", - }; - - const secret = cfg.secret ? envExpand(cfg.secret) : null; - if (secret) { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sigBytes = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(body), - ); - const sigHex = Array.from(new Uint8Array(sigBytes)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - headers["X-Dune-Signature"] = `sha256=${sigHex}`; - } - - const resp = await safeFetch( - cfg.url, - { method: "POST", headers, body }, - { allowPrivateDestinations: cfg.allow_private === true }, - ); - if (!resp.ok) { - const text = await resp.text().catch(() => ""); - throw new Error( - `Webhook POST to ${cfg.url} failed: ${resp.status} ${resp.statusText}${text ? ` — ${text.slice(0, 200)}` : ""}`, - ); - } -} diff --git a/tests/admin/dev/apply_validation_test.ts b/tests/admin/dev/apply_validation_test.ts index 4c514e2..8dc6096 100644 --- a/tests/admin/dev/apply_validation_test.ts +++ b/tests/admin/dev/apply_validation_test.ts @@ -298,6 +298,8 @@ Deno.test("dev/apply: config op rejects security-sensitive keys (system.debug)", { op: "config", key: "system.debug", value: true }, { op: "config", key: "system.trusted_proxies", value: true }, { op: "config", key: "admin.incoming_webhooks.token", value: "$SECRET" }, + { op: "config", key: "system.webhooks.incoming", value: [] }, + { op: "config", key: "system.webhooks", value: {} }, ], }); const results = res.results as { key?: string; status: string; errors: string[] }[];