feat: initial Signage 0.1.0
This commit is contained in:
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user