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 { 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 { 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 { 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 { 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))); }