import { createHash, createHmac, timingSafeEqual } from "node:crypto"; /* * The app ↔ renderer contract (docs/specs/URL_IMPORT_SUPPORT.md, «Рендерер»). * The renderer may run on its own VM (deploy/renderer), so every request is * signed: HMAC-SHA256 with RENDERER_SECRET over the timestamp, method, path * and the body's SHA-256. A request older or newer than 60 s is refused. * No dependencies beyond node:crypto: the renderer image copies this file. */ /** The renderer's honest User-Agent; /bot describes it, robots.txt addresses it as PolkaRenderer. */ export const RENDERER_USER_AGENT = "PolkaRenderer/1.0 (+https://polochka.app/bot)"; /** The robots.txt product token (RFC 9309 matches it case-insensitively). */ export const RENDERER_TOKEN = "polkarenderer"; export const RENDER_SKEW_SECONDS = 60; /** The largest document (and, separately, all frames together) the renderer returns. */ export const RENDER_MAX_HTML = 5 * 1024 * 1024; export const RENDER_TIMEOUT_MS = 25_000; export type RenderRequest = { url: string }; export type RenderError = | "source_blocked" | "robots_disallowed" | "robots_unavailable" | "timeout" | "not_allowed" | "navigation_failed" | "too_large" | "busy" | "bad_request" | "unauthorized"; export type RenderResult = | { finalUrl: string; title: string; html: string; frames: Array<{ url: string; html: string }>; } | { error: RenderError; detail?: string }; /** POST /fetch: one plain HTTP GET of a server-fetch page (no browser). */ export type FetchResult = { finalUrl: string; status: number; html: string } | { error: RenderError; detail?: string }; export const FETCH_TIMEOUT_MS = 15_000; /** * POST /snapshot {html, script}: a shelf cover (docs/specs/SHELF_COVERS.md). * The app sends the saved page itself; the renderer draws its first screen * at 1280×800 with no network at all and answers a JPEG of 960×600. * blank: the page drew nothing worth a picture (a script that needed the * network, an empty shell). */ export type SnapshotRequest = { html: string; script: boolean }; export type SnapshotResult = { image: string; blank: false } | { blank: true } | { error: RenderError; detail?: string }; /** The largest page the app sends: a built interactive version fits (8 MB), base64 images of 5 MB too. */ export const SNAPSHOT_MAX_BODY = 12 * 1024 * 1024; /** The largest picture the renderer answers (base64 of it is a third larger). */ export const SNAPSHOT_MAX_IMAGE = 256 * 1024; export const SNAPSHOT_TIMEOUT_MS = 15_000; export const SNAPSHOT_VIEWPORT = { width: 1280, height: 800 } as const; /** 1280×800 at 0.75: 960×600, twice the widest card on a 1440 screen. */ export const SNAPSHOT_SCALE = 0.75; /** * POST /pdf {html, script}: a PDF of the whole page (an accepted version's * snapshot for a company archive, docs/specs/EXTENSIONS.md). The same * sandbox as /snapshot: the page is served from a reserved address and * nothing else loads, so external fonts and pictures are missing. A4, the * screen look with its backgrounds. */ export type PdfRequest = SnapshotRequest; export type PdfResult = { pdf: string } | { error: RenderError; detail?: string }; export const PDF_MAX_BODY = SNAPSHOT_MAX_BODY; /** The largest PDF the renderer answers (base64 of it is a third larger). */ export const PDF_MAX_FILE = 20 * 1024 * 1024; export const PDF_TIMEOUT_MS = 30_000; const payload = (timestamp: string, method: string, path: string, body: string) => `${timestamp}\n${method.toUpperCase()}\n${path}\n${createHash("sha256").update(body).digest("hex")}`; export function signRenderRequest(secret: string, method: string, path: string, body: string, now = Date.now()) { const timestamp = String(Math.floor(now / 1000)); return { "x-polka-timestamp": timestamp, "x-polka-signature": createHmac("sha256", secret) .update(payload(timestamp, method, path, body)) .digest("hex"), }; } export function verifyRenderRequest( secret: string, headers: Record, method: string, path: string, body: string, now = Date.now(), ): boolean { const timestamp = headers["x-polka-timestamp"]; const signature = headers["x-polka-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string") return false; if (!/^\d{1,12}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) return false; if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > RENDER_SKEW_SECONDS) return false; const expected = createHmac("sha256", secret) .update(payload(timestamp, method, path, body)) .digest(); return timingSafeEqual(expected, Buffer.from(signature, "hex")); }