import { createHash } from "node:crypto"; import sharp, { type Metadata, type OutputInfo } from "sharp"; import { SignageError } from "../errors.js"; import { SIGNAGE_IMAGE_DISPLAY_RENDITION, SIGNAGE_IMAGE_MAX_PIXELS, SIGNAGE_IMAGE_THUMBNAIL_RENDITION, } from "./limits.js"; /** * Signage's native image pipeline. * * Three properties matter more than the code: * * 1. **Format comes from the bytes.** A filename and a declared content type are attacker-controlled * and routinely wrong even when they are not; the decoder decides what this is. * 2. **The original is never rewritten.** It is a church's file, archived exactly as uploaded. Only * the two renditions Signage generates are transformed. * 3. **Nothing about presentation is baked in.** Background, blur, darkening, and shadow are * resolved at render time from a slot's own settings, so changing them is a database update * rather than a re-encode (ADR 0007). */ const SUPPORTED_FORMATS: ReadonlySet = new Set(["jpeg", "png", "webp"]); const CONTENT_TYPES: Readonly> = Object.freeze({ jpeg: "image/jpeg", png: "image/png", webp: "image/webp", }); const FILE_EXTENSIONS: Readonly> = Object.freeze({ jpeg: "jpg", png: "png", webp: "webp", }); export interface SignageImageRendition { readonly kind: "display" | "thumbnail"; readonly bytes: Uint8Array; readonly contentType: string; readonly contentSha256: string; readonly byteSize: number; readonly width: number; readonly height: number; } export interface SignageProcessedImage { readonly contentSha256: string; readonly byteSize: number; readonly format: string; readonly contentType: string; readonly fileExtension: string; /** How the original should appear once its own orientation is applied. */ readonly width: number; readonly height: number; /** What is actually encoded in the archived bytes. */ readonly encodedWidth: number; readonly encodedHeight: number; readonly orientation: number | null; readonly hasAlpha: boolean; readonly renditions: readonly SignageImageRendition[]; } export function sha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } /** Sharp reports a pixel-limit breach and a decode failure through the same exception type. The * message is inspected here and then **discarded** — nothing from the decoder reaches a caller. */ function decodeFailure(error: unknown): SignageError { const message = error instanceof Error ? error.message : ""; if (/pixel limit/iu.test(message)) { return new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS }); } return new SignageError("SIGNAGE_IMAGE_MALFORMED"); } async function readMetadata(bytes: Uint8Array): Promise { try { return await sharp(bytes, { animated: true, failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS, }).metadata(); } catch (error) { throw decodeFailure(error); } } function assertSupported(metadata: Metadata): void { const pages = metadata.pages ?? 1; if (pages > 1) { const animated = metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0); throw new SignageError(animated ? "SIGNAGE_IMAGE_ANIMATED" : "SIGNAGE_IMAGE_MULTIPAGE", { pages, }); } if (metadata.format === undefined || !SUPPORTED_FORMATS.has(metadata.format)) { // The detected format is echoed because the caller supplied the bytes it came from. Nothing // about the host, the decoder, or the process is in this response. throw new SignageError("SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", { detectedFormat: metadata.format ?? null, }); } if ( !Number.isSafeInteger(metadata.width) || (metadata.width ?? 0) < 1 || !Number.isSafeInteger(metadata.height) || (metadata.height ?? 0) < 1 ) { throw new SignageError("SIGNAGE_IMAGE_MALFORMED"); } if ((metadata.width ?? 0) * (metadata.height ?? 0) > SIGNAGE_IMAGE_MAX_PIXELS) { throw new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS }); } } async function renderRendition( bytes: Uint8Array, target: { readonly kind: "display" | "thumbnail"; maxWidth: number; maxHeight: number; quality: number; }, ): Promise { let output: { data: Buffer; info: OutputInfo }; try { output = await sharp(bytes, { failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS }) // Honour the original's own orientation, so a phone photograph is not shown on its side. .autoOrient() // Transparency becomes black rather than white: a signage display's ground is black, and a // white flash behind a logo is the artefact this avoids. .flatten({ background: { b: 0, g: 0, r: 0 } }) // `inside` never crops, and `withoutEnlargement` never upscales — a small image stays small // rather than being blown up into softness. .resize({ fit: "inside", height: target.maxHeight, width: target.maxWidth, withoutEnlargement: true, }) .toColourspace("srgb") // No `withMetadata()`: EXIF, GPS coordinates, and camera serial numbers are stripped from // anything Signage publishes. The original keeps them; the rendition a browser fetches // does not. .webp({ effort: 4, quality: target.quality }) .toBuffer({ resolveWithObject: true }); } catch (error) { throw decodeFailure(error); } const data = new Uint8Array(output.data); return { byteSize: data.byteLength, bytes: data, contentSha256: sha256Hex(data), contentType: CONTENT_TYPES.webp as string, height: output.info.height, kind: target.kind, width: output.info.width, }; } /** * Validates the uploaded bytes and produces both renditions, or throws one of Signage's own * sanitized codes. Deterministic: the same input always yields the same output bytes, which is what * makes a retry idempotent. */ export async function processSignageImage(bytes: Uint8Array): Promise { const metadata = await readMetadata(bytes); assertSupported(metadata); const format = metadata.format as string; const oriented = metadata.autoOrient ?? { height: metadata.height as number, width: metadata.width as number, }; // Keep peak native CPU and memory bounded inside the shared API process. Upload concurrency is // already bounded at the module UI; running both renditions for every upload in parallel would // multiply that concurrency again and can starve the database and storage clients on a small host. const display = await renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION); const thumbnail = await renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION); return { byteSize: bytes.byteLength, contentSha256: sha256Hex(bytes), contentType: CONTENT_TYPES[format] as string, encodedHeight: metadata.height as number, encodedWidth: metadata.width as number, fileExtension: FILE_EXTENSIONS[format] as string, format, hasAlpha: Boolean(metadata.hasAlpha), height: oriented.height, orientation: metadata.orientation ?? null, renditions: [display, thumbnail], width: oriented.width, }; }