/** * Response builder utilities for route handlers */ import { addPageImageUrls } from "#db/image-urls.ts"; import { settings } from "#db/settings.ts"; import type { AdminDestinationId, AdminPathParams, } from "#shared/admin-surface/ids.ts"; import { adminPath } from "#shared/admin-surface.ts"; import { buildFlashCookie, type FlashLevel } from "#shared/cookies.ts"; import { stashForm } from "#shared/form-stash.ts"; import { getSavedFormData } from "#shared/forms/saved-data.ts"; import { appendIframeParam, getRequestId } from "#shared/request-context.ts"; import { databaseBusyPage, MIGRATION_IN_PROGRESS_REFRESH_SECONDS, migrationInProgressPage, notFoundPage, TEMPORARY_ERROR_REFRESH_SECONDS, temporaryErrorPage, } from "#templates/public/errors.tsx"; /** * Shared TextEncoder for pre-encoding string response bodies to Uint8Array. * Bunny Edge's runtime intermittently fails to decode JS string bodies * ("Unknown: error decoding response body"). Pre-encoding to bytes * bypasses the runtime's string-to-UTF8 path entirely. */ const encoder = new TextEncoder(); export const encodeBody = (text: string): ArrayBuffer => encoder.encode(text).buffer as ArrayBuffer; /** One response builder of a kind: a body, and a status that defaults to 200. */ export type EncodedResponse = (body: string, status?: number) => Response; /** * A response of one kind, ready to take a body. Every kind we send is text we * built ourselves, so the charset is always UTF-8. */ const encodedResponse = (contentType: string): EncodedResponse => (body, status = 200) => new Response(encodeBody(body), { headers: { "content-type": `${contentType}; charset=utf-8` }, status, }); /** * Hand on something that was loaded, unless it came back as a Response. A * Response is already the answer — a 404, a 400 — so it goes straight back. */ export const orResponse = async ( loaded: T | Response, use: (value: T) => Response | Promise, ): Promise => (loaded instanceof Response ? loaded : use(loaded)); /** Create HTML response */ export const htmlResponse: EncodedResponse = encodedResponse("text/html"); /** * Respond with a rendered public page. The page renders the site footer, so * the footer's img#N references resolve like the page's own before the page * renders. The lookup no-ops outside a request. A failed lookup still renders * the page: an unresolved img#N falls back to its alt text. */ export const publicPageResponse = async ( renderPage: () => string, status = 200, ): Promise => { try { await addPageImageUrls([settings.siteFooter]); } catch { // The database is unavailable. The footer shows its alt text instead. } return htmlResponse(renderPage(), status); }; /** * Create 404 not found response. */ export const notFoundResponse = (): Promise => publicPageResponse(notFoundPage, 404); type RequestErrorResponse = (method: string) => Promise; // A refresh loads the URL as GET. It cannot preserve a submitted form. const requestErrorResponse = ( page: (autoRefresh: boolean) => string, retryAfterSeconds: number, ): RequestErrorResponse => async (method) => withHeaders( await publicPageResponse( () => page(method === "GET" || method === "HEAD"), 503, ), { "retry-after": `${retryAfterSeconds}` }, ); export const temporaryErrorResponse: RequestErrorResponse = requestErrorResponse(temporaryErrorPage, TEMPORARY_ERROR_REFRESH_SECONDS); export const databaseBusyResponse: RequestErrorResponse = requestErrorResponse( databaseBusyPage, TEMPORARY_ERROR_REFRESH_SECONDS, ); /** * Create "migration in progress" response, shown while another isolate runs a * database migration and pre-migration backup. 503 with an auto-refresh, like * temporaryErrorResponse, but with a message that explains the wait instead of * presenting it as an error. */ export const migrationInProgressResponse = async (): Promise => withHeaders(await publicPageResponse(migrationInProgressPage, 503), { "retry-after": `${MIGRATION_IN_PROGRESS_REFRESH_SECONDS}`, }); /** * Create bare 302 redirect response (no message). * Use for external URLs, setup flow, public pages, and other cases * where the target page doesn't render success/error banners. * For admin redirects that should show a message, use `redirect` instead. * Automatically appends ?iframe=true when the current request is in iframe mode. */ export const redirectResponse = (url: string, cookie?: string): Response => { const headers: HeadersInit = { "content-type": "text/html; charset=utf-8", location: appendIframeParam(url), }; if (cookie) { headers["set-cookie"] = cookie; } return new Response(null, { headers, status: 302 }); }; /** Options for redirect */ type RedirectOpts = { formId?: string; cookie?: string; form?: URLSearchParams; result?: string; /** Override the flash level; defaults to success/error from `succeeded`. */ level?: FlashLevel; }; type RedirectFeedback = [ message: string, succeeded: boolean, opts?: RedirectOpts, ]; type UrlRedirect = (url: string, ...feedback: RedirectFeedback) => Response; type AdminRedirect = ( endpoint: Id, params: AdminPathParams, ...feedback: RedirectFeedback ) => Response; /** * Field-name segments whose values must never be written to the re-fill stash, * even briefly: secrets the user should re-enter rather than have persisted in * server memory (passwords, API/secret keys, signing tokens, the CSRF token). * Matched as whole underscore-delimited segments, so "monkey"/"keyword" are * safe. PII is deliberately not listed — restoring it is the point of the * feature, and it is never reflected for sensitive field *types* anyway. */ const SENSITIVE_STASH_KEY = /(?:^|_)(?:password|secret|token|key)(?:_|$)/i; /** * On a failed PRG redirect, stash the just-submitted form values so the * follow-up GET can re-fill the fields. Prefers an explicitly passed form, * otherwise the per-request submission captured during CSRF parsing. Secret * fields are dropped — they should be re-entered, never persisted. Returns the * redemption token, or null when there is nothing (eligible) to stash. */ const maybeStashForm = (explicit?: URLSearchParams): string | null => { const source = explicit ?? getSavedFormData(); if (!source) return null; const safe = new URLSearchParams(); for (const [key, value] of source) { if (!SENSITIVE_STASH_KEY.test(key)) safe.append(key, value); } const serialized = safe.toString(); return serialized ? stashForm(serialized) : null; }; /** * Redirect with a success or error message (PRG pattern). * Stores the message in a flash cookie instead of the query string * to avoid leaking potentially sensitive data in URLs, browser history, * and referrer headers. * When formId is provided, adds a `form` param and `#formId` anchor so the * browser scrolls to the form that was just submitted. */ export const redirect: UrlRedirect = (url, message, succeeded, opts) => { const target = opts?.form?.get("return_url") || url; if (!URL.canParse(target, "http://localhost")) { throw new TypeError("Invalid redirect URL"); } const u = new URL(target, "http://localhost"); const flashId = getRequestId(); u.searchParams.set("flash", flashId); if (opts?.formId) { u.searchParams.set("form", opts.formId); u.hash = opts.formId; } const flash = buildFlashCookie( flashId, message, succeeded, opts?.result, opts?.level, succeeded ? undefined : (maybeStashForm(opts?.form) ?? undefined), ); const response = redirectResponse(u.pathname + u.search + u.hash, flash); if (opts?.cookie) withCookie(response, opts.cookie); return response; }; /** Redirect to a declared admin destination with the existing flash and form rules. */ export const adminRedirect: AdminRedirect = ( endpoint, params, message, succeeded, opts, ) => redirect(adminPath(endpoint, params), message, succeeded, opts); /** * Redirect with an error message (PRG pattern). * Shorthand for `redirect(url, message, false, { formId })`. */ export const errorRedirect = ( url: string, message: string, formId?: string, ): Response => redirect(url, message, false, formId ? { formId } : undefined); /** The `onInvalid` handler that returns to one row's own detail page. */ export const redirectToDetail = (endpoint: Id) => (args: { error: string; params: AdminPathParams }): Response => adminRedirect(endpoint, args.params, args.error, false); /** * Redirect with a neutral informational message (PRG pattern), e.g. confirming * an opt-out. Rendered in the info style rather than success or error. */ export const infoRedirect = (url: string, message: string): Response => redirect(url, message, true, { level: "info" }); const jsonBody = encodedResponse("application/json"); /** Create JSON response */ export const jsonResponse = (data: unknown, status = 200): Response => jsonBody(JSON.stringify(data), status); /** One JSON response whose body is a record re-read from the primary, so a * lagging replica cannot 404 the row the write just made. */ export const reReadResponse = (key: string, read: (id: number) => Promise) => async (id: number, status?: number): Promise => jsonResponse({ [key]: await read(id) }, status); /** Create plain text response */ export const plainResponse: EncodedResponse = encodedResponse("text/plain"); /** Create iCalendar response */ export const icsResponse: EncodedResponse = encodedResponse("text/calendar"); /** Create RSS/XML response */ export const rssResponse: EncodedResponse = encodedResponse( "application/rss+xml", ); /** * Build a file-download response: the body, a fixed content-type, and a * `Content-Disposition: attachment` header that names the download. The * `filename` is already validated by the caller (it is operator-chosen and * constrained — a backup leaf name, a slug-based catalog name, or a CSV * name), so it is quoted directly without RFC 5987 encoding. */ export const downloadResponse = ( body: BodyInit, filename: string, contentType: string, ): Response => new Response(body, { headers: { "content-disposition": `attachment; filename="${filename}"`, "content-type": contentType, }, }); /** * Set headers on a built response. * Takes a response the app built: a redirect or fetched response has * immutable headers. * Mutates headers in-place to avoid re-reading the response body. */ export const withHeaders = ( response: Response, headers: Record, ): Response => { for (const [name, value] of Object.entries(headers)) { response.headers.set(name, value); } return response; }; /** * Add cookie header to response. * Mutates headers in-place to avoid re-reading the response body. */ export const withCookie = (response: Response, cookie: string): Response => { response.headers.append("set-cookie", cookie); return response; };