feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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,
|
||||
};
|
||||
const [display, thumbnail] = await Promise.all([
|
||||
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION),
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user