feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* The bounds Signage's native image ingest enforces, in one place.
*
* These are Signage's own product limits, deliberately inside the platform's. The platform's
* absolute request-body ceiling is 32 MiB; Signage declares 25 MiB and refuses anything larger
* itself rather than relying on the platform's refusal. Two limits that agree are two chances to
* catch a mistake, and a module that only had the platform's would silently change its own product
* behaviour if the platform's ever moved.
*
* The upload route carries **raw image bytes**, not a multipart envelope, so the declared allowance
* is exactly the image limit. One request is one file; nothing in Signage can express an aggregate
* upload, and the filename and display name travel in the query string.
*/
/** The largest image file an operator may upload. */
export const SIGNAGE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
/**
* The decoded-pixel budget.
*
* File size does not bound decoding: a few hundred kilobytes of PNG can decode to gigabytes of
* pixels, which is the actual shape of a decompression bomb. 40 million pixels is roughly a 7700 ×
* 5200 photograph — far beyond any signage slide, and far below anything that threatens the process.
*/
export const SIGNAGE_IMAGE_MAX_PIXELS = 40_000_000;
/** How many files the dashboard offers to queue at once. One request each, always. */
export const SIGNAGE_UPLOAD_BATCH_MAX_FILES = 20;
/** How many of those requests the dashboard runs concurrently. Bounded so a batch cannot pin the
* API's memory with several simultaneous full-size images, and so the dashboard stays responsive. */
export const SIGNAGE_UPLOAD_CONCURRENCY = 3;
export const SIGNAGE_IMAGE_DISPLAY_RENDITION = Object.freeze({
kind: "display" as const,
maxWidth: 1920,
maxHeight: 1080,
quality: 82,
});
export const SIGNAGE_IMAGE_THUMBNAIL_RENDITION = Object.freeze({
kind: "thumbnail" as const,
maxWidth: 480,
maxHeight: 270,
quality: 72,
});
+199
View File
@@ -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,
};
}
+278
View File
@@ -0,0 +1,278 @@
import { randomUUID } from "node:crypto";
import {
type ModuleStorage,
type ModuleStorageBucket,
ModuleStorageError,
} from "@ekklesia/module-sdk";
import { SignageError } from "../errors.js";
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js";
import { processSignageImage, type SignageProcessedImage } from "./processing.js";
/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential —
* which is what lets the same code run under Ekklesia and under a future standalone host. */
export const SIGNAGE_IMAGE_BUCKET = "native-images";
export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
return storage.bucket(SIGNAGE_IMAGE_BUCKET);
}
/**
* Object keys are scoped to the asset they belong to.
*
* Until the asset row commits these are **temporary**: nothing references them, nothing can select
* them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a
* throwaway upload id is what makes a successful upload cost exactly one write of each object
* instead of two — the commit is the database row, not a second copy of 25 MiB.
*/
export function originalObjectKey(assetId: string, extension: string): string {
return `assets/${assetId}/original.${extension}`;
}
export function renditionObjectKey(assetId: string, kind: string): string {
return `assets/${assetId}/${kind}.webp`;
}
/**
* Every storage failure becomes one of Signage's own codes.
*
* `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it
* further keeps even *which* storage operation failed out of Signage's responses, because that is
* not a caller's business.
*/
function storageFailure(error: unknown): SignageError {
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") {
return new SignageError("SIGNAGE_NOT_FOUND");
}
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
}
export interface IngestNativeImageInput {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly bytes: Uint8Array;
readonly filename: string;
readonly displayName: string;
readonly expiresAt: Date | null;
/** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */
readonly newId?: () => string;
}
export interface IngestNativeImageResult {
readonly image: StoredImage;
/** True when identical bytes were already stored, so nothing new was written anywhere. */
readonly deduplicated: boolean;
}
/**
* Ingests one native image.
*
* The order **is** the contract (ADR 0007):
*
* 1. The caller has already read the whole request body under Signage's own bound.
* 2. Decode and validate. An invalid image has cost zero storage writes and zero rows.
* 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes.
* 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them.
* 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist,
* so a partial write can never be promoted into something playback could select.
* 6. On any failure after step 4, delete every object this attempt wrote.
*/
export async function ingestNativeImage(
input: IngestNativeImageInput,
): Promise<IngestNativeImageResult> {
if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) {
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES });
}
const processed: SignageProcessedImage = await processSignageImage(input.bytes);
const existing = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (existing) {
return { deduplicated: true, image: existing };
}
const bucket = nativeImageBucket(input.storage);
const assetId = (input.newId ?? randomUUID)();
const original = originalObjectKey(assetId, processed.fileExtension);
const written: string[] = [];
try {
await bucket.put(original, input.bytes, { contentType: processed.contentType });
written.push(original);
const renditions = [];
for (const rendition of processed.renditions) {
const key = renditionObjectKey(assetId, rendition.kind);
await bucket.put(key, rendition.bytes, { contentType: rendition.contentType });
written.push(key);
renditions.push({
byteSize: rendition.byteSize,
contentSha256: rendition.contentSha256,
contentType: rendition.contentType,
height: rendition.height,
kind: rendition.kind,
objectKey: key,
width: rendition.width,
});
}
await input.store.insertImage({
contentSha256: processed.contentSha256,
displayName: input.displayName,
encodedHeight: processed.encodedHeight,
encodedWidth: processed.encodedWidth,
expiresAt: input.expiresAt,
hasAlpha: processed.hasAlpha,
height: processed.height,
id: assetId,
orientation: processed.orientation,
originalByteSize: processed.byteSize,
originalContentType: processed.contentType,
originalFilename: input.filename,
originalObjectKey: original,
renditions,
status: "ready",
width: processed.width,
});
} catch (error) {
// Nothing this attempt wrote is referenced by anything, so removing all of it is always safe.
await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined)));
if (error instanceof SignageStoreConflict) {
// Another request committed the identical bytes while this one was writing. For the caller
// that is a success, not a failure: the image they uploaded is stored.
const raced = await input.store.findReadyImageByContentHash(processed.contentSha256);
if (raced) {
return { deduplicated: true, image: raced };
}
throw new SignageError("SIGNAGE_CONFLICT");
}
if (error instanceof SignageError) {
throw error;
}
if (error instanceof ModuleStorageError) {
throw storageFailure(error);
}
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
const image = await input.store.findImage(assetId);
if (!image) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return { deduplicated: false, image };
}
export interface SignageImageDelivery {
readonly kind: string;
readonly url: string;
readonly expiresAt: string;
readonly width: number;
readonly height: number;
}
/**
* Fresh, short-lived delivery URLs for one image's renditions.
*
* Requested when something is about to be delivered, and then forgotten. Signage persists the
* logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one
* in a table would be storing a credential that stops working.
*/
export async function createImageDeliveryUrls(
storage: ModuleStorage,
renditions: readonly {
readonly kind: string;
readonly objectKey: string;
readonly width: number;
readonly height: number;
}[],
): Promise<readonly SignageImageDelivery[]> {
const bucket = nativeImageBucket(storage);
try {
return await Promise.all(
renditions.map(async (rendition) => {
const url = await bucket.createReadUrl(rendition.objectKey);
return {
expiresAt: url.expiresAt.toISOString(),
height: rendition.height,
kind: rendition.kind,
url: url.url,
width: rendition.width,
};
}),
);
} catch (error) {
throw storageFailure(error);
}
}
export async function updateNativeImage(input: {
readonly store: SignageStore;
readonly assetId: string;
readonly displayName?: string | undefined;
readonly expiresAt?: Date | null | undefined;
}): Promise<StoredImage> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const changes: { displayName?: string; expiresAt?: Date | null } = {};
if (input.displayName !== undefined && input.displayName !== existing.displayName) {
changes.displayName = input.displayName;
}
if (input.expiresAt !== undefined) {
const current = existing.expiresAt?.getTime() ?? null;
const next = input.expiresAt?.getTime() ?? null;
if (current !== next) {
changes.expiresAt = input.expiresAt;
}
}
// An exact no-op writes nothing at all.
if (Object.keys(changes).length === 0) {
return existing;
}
await input.store.updateImage(input.assetId, changes);
const updated = await input.store.findImage(input.assetId);
if (!updated) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return updated;
}
/**
* Deletes an image, and refuses while something still refers to it.
*
* A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service.
* Refusing with a count of what refers to it is something an operator can act on; a silent cascade
* is not.
*/
export async function deleteNativeImage(input: {
readonly store: SignageStore;
readonly storage: ModuleStorage;
readonly assetId: string;
}): Promise<void> {
const existing = await input.store.findImage(input.assetId);
if (!existing) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
const references = await input.store.countImageReferences(input.assetId);
if (references.slots > 0 || references.staticHolds > 0) {
throw new SignageError("SIGNAGE_ASSET_IN_USE", {
heldByChannelGroups: references.staticHolds,
referencedBySlots: references.slots,
});
}
// The row goes first: an asset nothing can select is the state that matters, and an object left
// behind by a storage outage is inert rather than dangerous.
await input.store.deleteImage(input.assetId);
const bucket = nativeImageBucket(input.storage);
const keys = [
existing.originalObjectKey,
...existing.renditions.map((rendition) => rendition.objectKey),
];
await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined)));
}
+76
View File
@@ -0,0 +1,76 @@
import { SignageError } from "../errors.js";
/**
* Reads a streamed request body under a hard byte bound.
*
* The upload route runs in the platform's `streamed` body mode, which means the platform counts
* bytes **as they are consumed** and never materializes the body itself. Signage counts them too,
* and stops at its own smaller limit — so an oversized upload is refused by Signage's own code with
* Signage's own message, rather than only by the platform's later `413`.
*
* The bytes are accumulated because an image cannot be validated, hashed, or resized from a partial
* stream: a decoder needs the whole file, and the decoded-pixel budget can only be checked once the
* header is present. The bound is what makes that safe — the peak is one image, never a request of
* unknown size, and never more than {@link maxBytes}.
*
* The source is cancelled as soon as the bound is passed, so a client sending more stops being read
* rather than being read and discarded.
*/
export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array> {
const declared = request.headers.get("content-length");
if (declared !== null) {
const length = Number(declared);
// A declared length over the limit is refused before a byte is read. A missing or lying one
// bounds nothing, which is exactly why the running total below is the real check.
if (Number.isFinite(length) && length > maxBytes) {
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
}
}
const body = request.body;
if (!body) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
}
chunks.push(value);
}
} catch (error) {
if (error instanceof SignageError) {
throw error;
}
// The platform errors the stream when *its* limit is passed, and cancels the source. Nothing
// about that error reaches a caller: it becomes Signage's own bounded code.
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
} finally {
reader.releaseLock();
}
if (total === 0) {
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}