Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
/**
|
|
* Preview middleware for Durable Object-backed preview databases.
|
|
*
|
|
* This middleware intercepts requests to a preview service, validates
|
|
* signed preview URLs, creates/resolves DO sessions, populates snapshots,
|
|
* and overrides the request-context DB so all queries route to the
|
|
* isolated DO database.
|
|
*
|
|
* Designed to be registered as Astro middleware in a preview Worker.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // src/middleware.ts (in the preview Worker)
|
|
* import { createPreviewMiddleware } from "@emdash-cms/cloudflare/db/do";
|
|
*
|
|
* export const onRequest = createPreviewMiddleware({
|
|
* binding: "PREVIEW_DB",
|
|
* secret: import.meta.env.PREVIEW_SECRET,
|
|
* });
|
|
* ```
|
|
*/
|
|
|
|
import type { MiddlewareHandler } from "astro";
|
|
import { env } from "cloudflare:workers";
|
|
import { runWithContext } from "emdash/request-context";
|
|
import { Kysely } from "kysely";
|
|
import { ulid } from "ulidx";
|
|
|
|
import type { EmDashPreviewDB } from "./do-class.js";
|
|
import { PreviewDODialect } from "./do-dialect.js";
|
|
import type { PreviewDBStub } from "./do-dialect.js";
|
|
import { isBlockedInPreview } from "./do-preview-routes.js";
|
|
import { verifyPreviewSignature } from "./do-preview-sign.js";
|
|
import { renderPreviewToolbar } from "./preview-toolbar.js";
|
|
|
|
/** Configuration for the preview middleware */
|
|
export interface PreviewMiddlewareConfig {
|
|
/** Durable Object binding name (from wrangler.jsonc) */
|
|
binding: string;
|
|
/** HMAC secret for validating signed preview URLs */
|
|
secret: string;
|
|
/** TTL for preview data in seconds (default: 3600 = 1 hour) */
|
|
ttl?: number;
|
|
/** Cookie name for session token (default: "emdash_preview") */
|
|
cookieName?: string;
|
|
}
|
|
|
|
/**
|
|
* Simple loading interstitial HTML.
|
|
* Auto-reloads after a short delay to check if the snapshot is ready.
|
|
*/
|
|
function loadingPage(): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta http-equiv="refresh" content="2">
|
|
<title>Loading preview...</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'><rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23pb)' stroke-width='6'/><rect x='18' y='34' width='39.366' height='6.561' fill='url(%23pd)'/><defs><linearGradient id='pb' x1='-43' y1='124' x2='92.42' y2='-41.75' gradientUnits='userSpaceOnUse'><stop stop-color='%230F006B'/><stop offset='.08' stop-color='%23281A81'/><stop offset='.17' stop-color='%235D0C83'/><stop offset='.25' stop-color='%23911475'/><stop offset='.33' stop-color='%23CE2F55'/><stop offset='.42' stop-color='%23FF6633'/><stop offset='.5' stop-color='%23F6821F'/><stop offset='.58' stop-color='%23FBAD41'/><stop offset='.67' stop-color='%23FFCD89'/><stop offset='.75' stop-color='%23FFE9CB'/><stop offset='.83' stop-color='%23FFF7EC'/><stop offset='.92' stop-color='%23FFF8EE'/><stop offset='1' stop-color='white'/></linearGradient><linearGradient id='pd' x1='91.5' y1='27.5' x2='28.12' y2='54.18' gradientUnits='userSpaceOnUse'><stop stop-color='white'/><stop offset='.13' stop-color='%23FFF8EE'/><stop offset='.62' stop-color='%23FBAD41'/><stop offset='.85' stop-color='%23F6821F'/><stop offset='1' stop-color='%23FF6633'/></linearGradient></defs></svg>" />
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; color: #333; }
|
|
.spinner { width: 40px; height: 40px; border: 3px solid #e0e0e0; border-top-color: #333; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 16px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="spinner"></div>
|
|
<p>Loading preview…</p>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Create an Astro-compatible preview middleware.
|
|
*
|
|
* Returns a middleware function that can be used in `defineMiddleware()`
|
|
* or composed via `sequence()`.
|
|
*/
|
|
export function createPreviewMiddleware(config: PreviewMiddlewareConfig): MiddlewareHandler {
|
|
const { binding, secret, ttl = 3600, cookieName = "emdash_preview" } = config;
|
|
|
|
return async function previewMiddleware(context, next) {
|
|
const { url, cookies } = context;
|
|
|
|
// --- 0a. Reload endpoint ---
|
|
// The toolbar POSTs here to clear the httpOnly session cookie and
|
|
// redirect back with the original signed params for a fresh snapshot.
|
|
if (url.pathname === "/_preview/reload") {
|
|
cookies.delete(cookieName, { path: "/" });
|
|
let redirectTo = "/";
|
|
const paramsCookie = cookies.get(`${cookieName}_params`)?.value;
|
|
if (paramsCookie) {
|
|
const parts = decodeURIComponent(paramsCookie).split("\n");
|
|
if (parts.length === 3) {
|
|
const reloadUrl = new URL("/", url.origin);
|
|
reloadUrl.searchParams.set("source", parts[0]!);
|
|
reloadUrl.searchParams.set("exp", parts[1]!);
|
|
reloadUrl.searchParams.set("sig", parts[2]!);
|
|
redirectTo = reloadUrl.pathname + reloadUrl.search;
|
|
}
|
|
}
|
|
return context.redirect(redirectTo);
|
|
}
|
|
|
|
// --- 0b. Route gating ---
|
|
// Block admin UI, auth, and setup routes. These depend on state
|
|
// (users, sessions, credentials) that doesn't exist in preview snapshots.
|
|
if (isBlockedInPreview(url.pathname)) {
|
|
return Response.json(
|
|
{ error: { code: "PREVIEW_MODE", message: "Not available in preview mode" } },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
// --- 1. Resolve session token ---
|
|
let sessionToken: string | undefined = cookies.get(cookieName)?.value;
|
|
let sourceUrl: string | null = null;
|
|
let snapshotSignature: string | null = null;
|
|
|
|
if (!sessionToken) {
|
|
// No cookie — must have a signed URL
|
|
const source = url.searchParams.get("source");
|
|
const exp = url.searchParams.get("exp");
|
|
const sig = url.searchParams.get("sig");
|
|
|
|
if (!source || !exp || !sig) {
|
|
return new Response("Missing preview parameters", { status: 400 });
|
|
}
|
|
|
|
const expNum = parseInt(exp, 10);
|
|
if (isNaN(expNum) || expNum < Date.now() / 1000) {
|
|
return new Response("Preview link expired", { status: 403 });
|
|
}
|
|
|
|
const valid = await verifyPreviewSignature(source, expNum, sig, secret);
|
|
if (!valid) {
|
|
return new Response("Invalid preview signature", { status: 403 });
|
|
}
|
|
|
|
// Generate session
|
|
sessionToken = ulid();
|
|
sourceUrl = source;
|
|
// Build the signature header value for snapshot fetch: "source:exp:sig"
|
|
snapshotSignature = `${source}:${exp}:${sig}`;
|
|
|
|
cookies.set(cookieName, sessionToken, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: ttl,
|
|
});
|
|
// Store the signed params so the toolbar can trigger a reload.
|
|
// Not httpOnly — the toolbar script needs to read them.
|
|
cookies.set(`${cookieName}_params`, `${source}\n${exp}\n${sig}`, {
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: ttl,
|
|
});
|
|
}
|
|
|
|
// --- 2. Get DO stub ---
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
|
const ns = (env as Record<string, unknown>)[binding];
|
|
if (!ns) {
|
|
console.error(`Preview binding "${binding}" not found in environment`);
|
|
return new Response("Preview service misconfigured", { status: 500 });
|
|
}
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
|
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
|
const doId = namespace.idFromName(sessionToken);
|
|
const stub = namespace.get(doId);
|
|
|
|
// --- 3. Populate from snapshot if needed ---
|
|
let snapshotGeneratedAt: string | undefined;
|
|
let snapshotError: string | undefined;
|
|
|
|
if (!sourceUrl) {
|
|
// Returning session — get metadata from the DO
|
|
try {
|
|
const meta = await stub.getSnapshotMeta();
|
|
snapshotGeneratedAt = meta?.generatedAt;
|
|
} catch {
|
|
// DO may have expired or been cleaned up
|
|
}
|
|
}
|
|
|
|
if (sourceUrl && snapshotSignature) {
|
|
try {
|
|
// Pass the full signature header value (source:exp:sig) so the DO
|
|
// can send it as X-Preview-Signature when fetching the snapshot.
|
|
const result = await stub.populateFromSnapshot(sourceUrl, snapshotSignature, { ttl });
|
|
snapshotGeneratedAt = result.generatedAt;
|
|
|
|
// Snapshot loaded — redirect to strip signed params from the URL.
|
|
// Astro's cookie buffer flushes on context.redirect().
|
|
const cleanUrl = new URL(url);
|
|
cleanUrl.searchParams.delete("source");
|
|
cleanUrl.searchParams.delete("exp");
|
|
cleanUrl.searchParams.delete("sig");
|
|
return context.redirect(cleanUrl.pathname + cleanUrl.search);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error("Failed to populate preview snapshot:", message);
|
|
snapshotError = message;
|
|
|
|
// If this is the initial load (no session yet), show a loading page.
|
|
// If we already have a session, continue with stale data and show the error in the toolbar.
|
|
if (!cookies.get(cookieName)?.value) {
|
|
return new Response(loadingPage(), {
|
|
status: 503,
|
|
headers: {
|
|
"Content-Type": "text/html",
|
|
"Retry-After": "2",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 4. Create Kysely dialect pointing at the DO ---
|
|
const getStub = (): PreviewDBStub => {
|
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
|
|
return stub as unknown as PreviewDBStub;
|
|
};
|
|
const dialect = new PreviewDODialect({ getStub });
|
|
|
|
// --- 5. Create Kysely instance and override request-context DB ---
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const previewDb = new Kysely<any>({ dialect });
|
|
|
|
return runWithContext(
|
|
{
|
|
editMode: false,
|
|
db: previewDb,
|
|
},
|
|
async () => {
|
|
const response = await next();
|
|
return injectPreviewToolbar(response, {
|
|
generatedAt: snapshotGeneratedAt,
|
|
source: sourceUrl ?? undefined,
|
|
error: snapshotError,
|
|
});
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Inject preview toolbar HTML into an HTML response.
|
|
* Returns the original response unchanged for non-HTML responses.
|
|
*/
|
|
async function injectPreviewToolbar(
|
|
response: Response,
|
|
config: { generatedAt?: string; source?: string; error?: string },
|
|
): Promise<Response> {
|
|
const contentType = response.headers.get("content-type");
|
|
if (!contentType?.includes("text/html")) return response;
|
|
|
|
const html = await response.text();
|
|
if (!html.includes("</body>")) return new Response(html, response);
|
|
|
|
const toolbarHtml = renderPreviewToolbar(config);
|
|
const injected = html.replace("</body>", `${toolbarHtml}</body>`);
|
|
return new Response(injected, {
|
|
status: response.status,
|
|
headers: response.headers,
|
|
});
|
|
}
|