210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
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 });
|
|
});
|
|
},
|
|
});
|
|
}
|