201 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|