Files
Signage/src/server/images/processing.ts
T
ekklesia 88038476b3
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Failing after 2m14s
fix(images): bound external runtime processing
2026-10-10 10:54:33 -05:00

201 lines
7.2 KiB
TypeScript

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<string> = new Set(["jpeg", "png", "webp"]);
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
const FILE_EXTENSIONS: Readonly<Record<string, string>> = 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<Metadata> {
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<SignageImageRendition> {
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<SignageProcessedImage> {
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,
};
}