/** * Theme preview signing endpoint * * POST /_emdash/api/themes/preview * * Generates a signed preview URL for the "Try with my data" feature. * * Uses the resolved preview secret: env override (`EMDASH_PREVIEW_SECRET`) * wins, otherwise an auto-generated stable per-site value persisted in the * options table is used. Processes that share the same database converge on * the same auto-generated value; only set `EMDASH_PREVIEW_SECRET` in both * processes when the verifying side runs without access to the EmDash DB * (e.g. a remote preview Worker). */ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess } from "#api/error.js"; import { getPublicOrigin } from "#api/public-url.js"; import { resolveSecretsCached } from "#config/secrets.js"; export const prerender = false; export const POST: APIRoute = async ({ request, url, locals }) => { const { emdash, user } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } const denied = requirePerm(user, "plugins:read"); if (denied) return denied; // Always non-empty after resolution; env override wins, otherwise a // stable DB-stored value is used. const { previewSecret: secret } = await resolveSecretsCached(emdash.db); let body: { previewUrl: string }; try { body = await request.json(); } catch { return apiError("INVALID_REQUEST", "Invalid JSON body", 400); } if (!body.previewUrl || typeof body.previewUrl !== "string") { return apiError("INVALID_REQUEST", "previewUrl is required", 400); } // Validate previewUrl is a valid HTTPS URL let parsedPreviewUrl: URL; try { parsedPreviewUrl = new URL(body.previewUrl); } catch { return apiError("INVALID_REQUEST", "previewUrl must be a valid URL", 400); } if (parsedPreviewUrl.protocol !== "https:") { return apiError("INVALID_REQUEST", "previewUrl must use HTTPS", 400); } const source = getPublicOrigin(url, emdash?.config); const ttl = 3600; // 1 hour const exp = Math.floor(Date.now() / 1000) + ttl; // HMAC-SHA256 sign: message = "source:exp" const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(`${source}:${exp}`)); const sig = Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join(""); const previewUrl = new URL(body.previewUrl); previewUrl.searchParams.set("source", source); previewUrl.searchParams.set("exp", String(exp)); previewUrl.searchParams.set("sig", sig); return apiSuccess({ url: previewUrl.toString() }); };