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
+209
View File
@@ -0,0 +1,209 @@
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<Record<string, unknown>> {
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 });
});
},
});
}