import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; import { SignageError, signageBoundary } from "../errors.js"; import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js"; import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js"; import { createImageDeliveryUrls, deleteNativeImage, ingestNativeImage, updateNativeImage, } from "../images/service.js"; import { readBoundedBody } from "../images/streaming.js"; import { createSignageStore } from "../store/drizzle.js"; import type { StoredImage } from "../store/types.js"; const FILENAME_MAX_LENGTH = 200; /** * A filename is a label an operator sees, never a path and never an object key. * * Path separators and control characters are refused rather than escaped, because a display name has * no business containing either — and the value is echoed back to a browser, where a control * character is a rendering hazard and a separator invites something to read it as a location. */ function isSafeLabel(value: string): boolean { if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) { return false; } for (const character of value) { const codePoint = character.codePointAt(0) ?? 0; if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) { return false; } if (character === "/" || character === "\\") { return false; } } return true; } function queryValue(request: Request, name: string): string | undefined { const value = new URL(request.url).searchParams.get(name); return value === null ? undefined : value; } function requireLabel(request: Request, field: string, fallback?: string): string { const value = queryValue(request, field)?.trim(); if (value === undefined || value.length === 0) { if (fallback !== undefined) { return fallback; } throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); } if (!isSafeLabel(value)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field }); } return value; } function readExpiresAt(request: Request): Date | null { const raw = queryValue(request, "expiresAt"); if (raw === undefined || raw === "") { // Null is permanent. Local age never invents an expiration for a church's own slide. return null; } const parsed = new Date(raw); if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" }); } return parsed; } /** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key * never leaves the server — a browser has no use for it, and it is the one durable thing Signage * stores about where bytes live. */ async function describe( context: ModuleRouteHandlerContext, image: StoredImage, ): Promise> { const delivery = await createImageDeliveryUrls(context.storage, image.renditions); return { byteSize: image.originalByteSize, contentSha256: image.contentSha256, contentType: image.originalContentType, createdAt: image.createdAt.toISOString(), displayName: image.displayName, expiresAt: image.expiresAt?.toISOString() ?? null, hasAlpha: image.hasAlpha, height: image.height, id: image.id, originalFilename: image.originalFilename, renditions: delivery, status: image.status, width: image.width, }; } export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void { http.route({ method: "GET", path: "/images", permission: "signage.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const images = await createSignageStore(context.services.database).listImages(); return Response.json({ images: await Promise.all(images.map((image) => describe(context, image))), }); }); }, }); http.route({ method: "POST", path: "/images", permission: "signage.assets.manage", // One image, streamed. The platform counts bytes as the handler consumes them and never // materializes the body; Signage counts them too and refuses at its own smaller limit first. // The filename and display name travel in the query string, so the body is exactly the image. maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES, bodyMode: "streamed", handler(request, context) { return signageBoundary(async () => { const filename = requireLabel(request, "filename"); const displayName = requireLabel(request, "displayName", filename); const expiresAt = readExpiresAt(request); // The whole body is read, and only then validated and committed. A `413` can arrive after a // streamed handler has started, which is exactly why nothing is written until this returns. const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES); const result = await ingestNativeImage({ bytes, displayName, expiresAt, filename, storage: context.storage, store: createSignageStore(context.services.database), }); return Response.json( { deduplicated: result.deduplicated, image: await describe(context, result.image) }, { status: result.deduplicated ? 200 : 201 }, ); }); }, }); http.route({ method: "GET", path: "/images/:imageId", permission: "signage.view", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const imageId = requireUuidParam(context, "imageId"); const image = await createSignageStore(context.services.database).findImage(imageId); if (!image) { throw new SignageError("SIGNAGE_NOT_FOUND"); } return Response.json({ image: await describe(context, image) }); }); }, }); http.route({ method: "PATCH", path: "/images/:imageId", permission: "signage.assets.manage", // A rename and an expiration date. Nothing about this route needs a large body. maxBodyBytes: 4096, handler(request, context) { return signageBoundary(async () => { const imageId = requireUuidParam(context, "imageId"); const body = await readJsonObject(request); const displayName = optionalString(body, "displayName", { maxLength: FILENAME_MAX_LENGTH, }); if (displayName !== undefined && !isSafeLabel(displayName)) { throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" }); } const image = await updateNativeImage({ assetId: imageId, displayName, expiresAt: optionalInstant(body, "expiresAt"), store: createSignageStore(context.services.database), }); return Response.json({ image: await describe(context, image) }); }); }, }); http.route({ method: "DELETE", path: "/images/:imageId", permission: "signage.assets.manage", maxBodyBytes: 0, handler(_request, context) { return signageBoundary(async () => { const imageId = requireUuidParam(context, "imageId"); await deleteNativeImage({ assetId: imageId, storage: context.storage, store: createSignageStore(context.services.database), }); return new Response(null, { status: 204 }); }); }, }); }