279 lines
9.9 KiB
TypeScript
279 lines
9.9 KiB
TypeScript
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)));
|
|
}
|