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