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 });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
isUuid,
|
||||
optionalInstant,
|
||||
readJsonObject,
|
||||
requiredString,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import type { SignagePlaybackService } from "../playback/service.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
|
||||
|
||||
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
|
||||
* of the platform uses for a local identifier. */
|
||||
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
|
||||
return {
|
||||
channels: detail.channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
isDefault: channel.isDefault,
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
})),
|
||||
id: detail.group.id,
|
||||
name: detail.group.name,
|
||||
revision: detail.group.revision,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerPlaybackRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
playback: () => SignagePlaybackService,
|
||||
): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/channel-groups",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groups = await createSignageStore(context.services.database).listChannelGroups();
|
||||
return Response.json({
|
||||
groups: await Promise.all(
|
||||
groups.map(async (group) => ({
|
||||
...describeGroup(group),
|
||||
playback: await playback().snapshot(group.group.id),
|
||||
})),
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/channel-groups",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const store = createSignageStore(context.services.database);
|
||||
const groupId = randomUUID();
|
||||
// A group is created with its default channel and its playback row together: a group
|
||||
// without a default channel has nowhere for a viewer to land.
|
||||
await store.createChannelGroup({
|
||||
channelId: randomUUID(),
|
||||
channelName: "Default",
|
||||
groupId,
|
||||
name: requiredString(body, "name", { maxLength: 120 }),
|
||||
});
|
||||
const created = await store.findChannelGroup(groupId);
|
||||
if (!created) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return Response.json(describeGroup(created), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/channel-groups/:groupId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const store = createSignageStore(context.services.database);
|
||||
const groups = await store.listChannelGroups();
|
||||
if (groups.length <= 1) {
|
||||
// An installation always has somewhere for a viewer to land.
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
||||
}
|
||||
await store.deleteChannelGroup(groupId);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/channel-groups/:groupId/channels",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const body = await readJsonObject(request);
|
||||
const key = requiredString(body, "key", { maxLength: 64 });
|
||||
if (!AUDIENCE_KEY.test(key) || key === "default") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
|
||||
}
|
||||
const store = createSignageStore(context.services.database);
|
||||
const group = await store.findChannelGroup(groupId);
|
||||
if (!group) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
if (group.channels.some((channel) => channel.key === key)) {
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
|
||||
}
|
||||
await store.addChannel({
|
||||
groupId,
|
||||
id: randomUUID(),
|
||||
isDefault: false,
|
||||
key,
|
||||
name: requiredString(body, "name", { maxLength: 120 }),
|
||||
});
|
||||
const updated = await store.findChannelGroup(groupId);
|
||||
if (!updated) {
|
||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||
}
|
||||
return Response.json(describeGroup(updated), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/channels/:channelId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const channelId = requireUuidParam(context, "channelId");
|
||||
try {
|
||||
await createSignageStore(context.services.database).removeChannel(channelId);
|
||||
} catch (error) {
|
||||
if (error instanceof SignageStoreConflict) {
|
||||
// A group's default channel is the one a viewer lands on. It cannot be removed.
|
||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/playback/:groupId",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const store = createSignageStore(context.services.database);
|
||||
const snapshot = await playback().snapshot(groupId);
|
||||
const selection =
|
||||
snapshot.currentSelectionId === null
|
||||
? undefined
|
||||
: await store.findSelection(snapshot.currentSelectionId);
|
||||
const history = await store.listSelections(groupId, 20);
|
||||
return Response.json({
|
||||
current:
|
||||
selection === undefined
|
||||
? null
|
||||
: {
|
||||
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
|
||||
durationMs: selection.durationMs,
|
||||
id: selection.id,
|
||||
itemId: selection.itemId,
|
||||
itemRevision: selection.itemRevision,
|
||||
providerId: selection.providerId,
|
||||
rendererContributionId: selection.rendererContributionId,
|
||||
revision: selection.revision,
|
||||
stale: selection.stale,
|
||||
startedAtMs: selection.startedAt?.getTime() ?? null,
|
||||
withdrawn: selection.withdrawn,
|
||||
},
|
||||
// Identity and timing only. A payload belongs to a display, which asks for the channel
|
||||
// state it is authorized for.
|
||||
history: history.map((entry) => ({
|
||||
id: entry.id,
|
||||
itemId: entry.itemId,
|
||||
providerId: entry.providerId,
|
||||
sequence: entry.sequence,
|
||||
startedAtMs: entry.startedAt?.getTime() ?? null,
|
||||
})),
|
||||
snapshot,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function command(
|
||||
path: string,
|
||||
run: (
|
||||
service: SignagePlaybackService,
|
||||
groupId: string,
|
||||
body: Record<string, unknown>,
|
||||
context: ModuleRouteHandlerContext,
|
||||
) => Promise<unknown>,
|
||||
options: { readonly maxBodyBytes: number },
|
||||
): void {
|
||||
http.route({
|
||||
method: "POST",
|
||||
path,
|
||||
permission: "signage.playback.control",
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const groupId = requireUuidParam(context, "groupId");
|
||||
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
|
||||
return Response.json(await run(playback(), groupId, body, context));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
command(
|
||||
"/playback/:groupId/activate",
|
||||
async (service, groupId, body) => {
|
||||
const timelineId = body.timelineId;
|
||||
if (!isUuid(timelineId)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
|
||||
}
|
||||
const at = optionalInstant(body, "at");
|
||||
if (at) {
|
||||
return service.activate(groupId, timelineId, { at });
|
||||
}
|
||||
const when = body.when === "next-boundary" ? "next-boundary" : "now";
|
||||
return service.activate(groupId, timelineId, when);
|
||||
},
|
||||
{ maxBodyBytes: 2048 },
|
||||
);
|
||||
|
||||
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
|
||||
maxBodyBytes: 0,
|
||||
});
|
||||
command(
|
||||
"/playback/:groupId/blackout",
|
||||
(service, groupId, body) => {
|
||||
if (typeof body.on !== "boolean") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
|
||||
}
|
||||
return service.setBlackout(groupId, body.on);
|
||||
},
|
||||
{ maxBodyBytes: 1024 },
|
||||
);
|
||||
command(
|
||||
"/playback/:groupId/static",
|
||||
(service, groupId, body) => {
|
||||
const assetId = body.assetId;
|
||||
if (assetId === null) {
|
||||
return service.setStaticHold(groupId, null);
|
||||
}
|
||||
if (!isUuid(assetId)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
|
||||
}
|
||||
return service.setStaticHold(groupId, assetId);
|
||||
},
|
||||
{ maxBodyBytes: 1024 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import { readSignageSettings } from "../settings.js";
|
||||
|
||||
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
|
||||
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
|
||||
* bound before any lookup. */
|
||||
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function requireProviderId(context: ModuleRouteHandlerContext): string {
|
||||
const value = context.params.providerId;
|
||||
if (value === undefined || !PROVIDER_ID.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider health and discovery.
|
||||
*
|
||||
* Everything these routes return is either a provider's own declared identity or a count Signage
|
||||
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
|
||||
* makes "an incompatible provider is clearly reported in management" true without also making a
|
||||
* provider's internals an operator-visible surface.
|
||||
*/
|
||||
export function registerProviderRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
registry: () => SignageProviderRegistry,
|
||||
): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler() {
|
||||
return signageBoundary(() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
// Only what is actually registered right now. A registration is a running fact, not a
|
||||
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
|
||||
// exists.
|
||||
providers: registry().health(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers/:providerId/sources",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const providerId = requireProviderId(context);
|
||||
const settings = readSignageSettings(context.config);
|
||||
// A provider that is not registered, is failing, or answers with something unusable yields
|
||||
// an empty list rather than an error: the editor should say "no sources right now", not
|
||||
// break, and provider health already reports why.
|
||||
const sources = await registry().listSources(providerId, {
|
||||
at: new Date().toISOString(),
|
||||
timezone: settings.timezone,
|
||||
});
|
||||
return Response.json({ providerId, sources });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/providers/:providerId/items",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const providerId = requireProviderId(context);
|
||||
const settings = readSignageSettings(context.config);
|
||||
const sourceId = new URL(request.url).searchParams.get("sourceId");
|
||||
const items = await registry().listItems(providerId, {
|
||||
at: new Date().toISOString(),
|
||||
timezone: settings.timezone,
|
||||
...(sourceId === null ? {} : { sourceId }),
|
||||
});
|
||||
return Response.json({ items, providerId });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
isUuid,
|
||||
optionalBoolean,
|
||||
optionalInstant,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonObject,
|
||||
requiredString,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
|
||||
import {
|
||||
addProviderSource,
|
||||
addSlot,
|
||||
createTimeline,
|
||||
deleteTimeline,
|
||||
duplicateTimeline,
|
||||
removeProviderSource,
|
||||
removeSlot,
|
||||
reorderSlots,
|
||||
requireTimeline,
|
||||
SIGNAGE_TIMELINE_LIMITS,
|
||||
updateSlot,
|
||||
updateTimeline,
|
||||
} from "../timelines/service.js";
|
||||
|
||||
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||
return createSignageStore(context.services.database);
|
||||
}
|
||||
|
||||
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
|
||||
* receive — never an invented epoch or a locale-formatted representation. */
|
||||
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
|
||||
return {
|
||||
slots: detail.slots.map((slot) => ({
|
||||
durationOverrideMs: slot.durationOverrideMs,
|
||||
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
|
||||
enabled: slot.enabled,
|
||||
id: slot.id,
|
||||
itemId: slot.itemId,
|
||||
kind: slot.kind,
|
||||
position: slot.position,
|
||||
presentation: slot.presentation,
|
||||
providerId: slot.providerId,
|
||||
sourceId: slot.sourceId,
|
||||
})),
|
||||
sources: detail.sources.map((source) => ({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
providerId: source.providerId,
|
||||
sourceId: source.sourceId,
|
||||
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
|
||||
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
|
||||
})),
|
||||
timeline: {
|
||||
description: detail.timeline.description,
|
||||
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
|
||||
id: detail.timeline.id,
|
||||
name: detail.timeline.name,
|
||||
revision: detail.timeline.revision,
|
||||
updatedAt: detail.timeline.updatedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readPresentation(
|
||||
body: Record<string, unknown>,
|
||||
): Partial<StoredSlotPresentation> | undefined {
|
||||
const raw = body.presentation;
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
const mode = value.backgroundMode;
|
||||
if (
|
||||
mode !== undefined &&
|
||||
mode !== null &&
|
||||
mode !== "blurred" &&
|
||||
mode !== "solid" &&
|
||||
mode !== "black"
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
|
||||
}
|
||||
return {
|
||||
...(mode === undefined
|
||||
? {}
|
||||
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
|
||||
...(value.backgroundColor === undefined
|
||||
? {}
|
||||
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
|
||||
...(value.backgroundDarkened === undefined
|
||||
? {}
|
||||
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
|
||||
...(value.blurPixels === undefined
|
||||
? {}
|
||||
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
|
||||
...(value.foregroundShadow === undefined
|
||||
? {}
|
||||
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
|
||||
const value = body[field];
|
||||
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/timelines",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelines = await store(context).listTimelines();
|
||||
return Response.json({
|
||||
timelines: timelines.map((timeline) => ({
|
||||
description: timeline.description,
|
||||
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
|
||||
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
|
||||
id: timeline.id,
|
||||
name: timeline.name,
|
||||
revision: timeline.revision,
|
||||
updatedAt: timeline.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await createTimeline({
|
||||
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
|
||||
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
|
||||
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
|
||||
name: requiredString(body, "name", {
|
||||
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||
}),
|
||||
store: store(context),
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
return Response.json(describe(await requireTimeline(store(context), timelineId)));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await updateTimeline({
|
||||
changes: {
|
||||
...(body.name === undefined
|
||||
? {}
|
||||
: {
|
||||
name: requiredString(body, "name", {
|
||||
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||
}),
|
||||
}),
|
||||
...(body.description === undefined
|
||||
? {}
|
||||
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
|
||||
...(body.eligibleFrom === undefined
|
||||
? {}
|
||||
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||
...(body.eligibleUntil === undefined
|
||||
? {}
|
||||
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||
},
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
await deleteTimeline({ store: store(context), timelineId });
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/duplicate",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await duplicateTimeline({
|
||||
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/sources",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const detail = await addProviderSource({
|
||||
label: requiredString(body, "label", { maxLength: 200 }),
|
||||
providerId: requireOpaqueId(body, "providerId"),
|
||||
sourceId: requireOpaqueId(body, "sourceId"),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId/sources/:sourceId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const sourceId = requireUuidParam(context, "sourceId");
|
||||
return Response.json(
|
||||
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/timelines/:timelineId/slots",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const sourceId = body.sourceId;
|
||||
const detail = await addSlot({
|
||||
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||
}),
|
||||
eligibleFrom: optionalInstant(body, "eligibleFrom"),
|
||||
eligibleUntil: optionalInstant(body, "eligibleUntil"),
|
||||
enabled: optionalBoolean(body, "enabled") ?? true,
|
||||
...(sourceId === undefined
|
||||
? {}
|
||||
: {
|
||||
source: {
|
||||
sourceId: isUuid(sourceId)
|
||||
? sourceId
|
||||
: (() => {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
...(body.itemId === undefined
|
||||
? {}
|
||||
: {
|
||||
pinned: {
|
||||
itemId: requireOpaqueId(body, "itemId"),
|
||||
providerId: requireOpaqueId(body, "providerId"),
|
||||
},
|
||||
}),
|
||||
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
|
||||
presentation: readPresentation(body),
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail), { status: 201 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/timelines/:timelineId/slots/:slotId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 8192,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const slotId = requireUuidParam(context, "slotId");
|
||||
const body = await readJsonObject(request);
|
||||
const presentation = readPresentation(body);
|
||||
const detail = await updateSlot({
|
||||
changes: {
|
||||
...(body.enabled === undefined
|
||||
? {}
|
||||
: { enabled: optionalBoolean(body, "enabled") ?? true }),
|
||||
...(body.durationOverrideMs === undefined
|
||||
? {}
|
||||
: {
|
||||
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||
}) as number | null,
|
||||
}),
|
||||
...(body.eligibleFrom === undefined
|
||||
? {}
|
||||
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||
...(body.eligibleUntil === undefined
|
||||
? {}
|
||||
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
|
||||
...(presentation === undefined ? {} : { presentation }),
|
||||
},
|
||||
slotId,
|
||||
store: store(context),
|
||||
timelineId,
|
||||
});
|
||||
return Response.json(describe(detail));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/timelines/:timelineId/slots/:slotId",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const slotId = requireUuidParam(context, "slotId");
|
||||
return Response.json(
|
||||
describe(await removeSlot({ slotId, store: store(context), timelineId })),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PUT",
|
||||
path: "/timelines/:timelineId/slot-order",
|
||||
permission: "signage.timelines.manage",
|
||||
maxBodyBytes: 16_384,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const timelineId = requireUuidParam(context, "timelineId");
|
||||
const body = await readJsonObject(request);
|
||||
const ids = body.slotIds;
|
||||
if (
|
||||
!Array.isArray(ids) ||
|
||||
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
|
||||
!ids.every((id) => isUuid(id))
|
||||
) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
|
||||
}
|
||||
return Response.json(
|
||||
describe(
|
||||
await reorderSlots({
|
||||
orderedSlotIds: ids as string[],
|
||||
store: store(context),
|
||||
timelineId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||
import { buildDisplayChannelState } from "../display/state.js";
|
||||
import { SignageError, signageBoundary } from "../errors.js";
|
||||
import {
|
||||
optionalBoolean,
|
||||
optionalInteger,
|
||||
optionalString,
|
||||
readJsonObject,
|
||||
requireUuidParam,
|
||||
} from "../http.js";
|
||||
import type { SignagePlaybackService } from "../playback/service.js";
|
||||
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||
import { readSignageSettings } from "../settings.js";
|
||||
import { createSignageStore } from "../store/drizzle.js";
|
||||
import type { SignageStore, StoredChannel } from "../store/types.js";
|
||||
import {
|
||||
describeViewerHealth,
|
||||
openViewerSession,
|
||||
recordViewerHeartbeat,
|
||||
SIGNAGE_VIEWER_NAME_MAX,
|
||||
updateViewerAssignment,
|
||||
} from "../viewers/service.js";
|
||||
|
||||
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||
|
||||
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||
return createSignageStore(context.services.database);
|
||||
}
|
||||
|
||||
/**
|
||||
* The safe Core device identifier, when the caller is a paired display.
|
||||
*
|
||||
* A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats
|
||||
* it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing,
|
||||
* revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or
|
||||
* forwards one.
|
||||
*/
|
||||
function deviceIdOf(context: ModuleRouteHandlerContext): string | null {
|
||||
return context.principal.kind === "device" ? context.principal.deviceId : null;
|
||||
}
|
||||
|
||||
async function requireChannel(
|
||||
signageStore: SignageStore,
|
||||
channelId: string,
|
||||
): Promise<StoredChannel> {
|
||||
const groups = await signageStore.listChannelGroups();
|
||||
for (const group of groups) {
|
||||
const channel = group.channels.find((candidate) => candidate.id === channelId);
|
||||
if (channel) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
export function registerViewerRoutes(
|
||||
http: ModuleHttpRouteRegistrar,
|
||||
playback: () => SignagePlaybackService,
|
||||
registry: () => SignageProviderRegistry,
|
||||
): void {
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/viewers/session",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 2048,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const body = await readJsonObject(request);
|
||||
const viewerKey = body.viewerKey;
|
||||
if (typeof viewerKey !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||
}
|
||||
// A display registers with the identity it keeps in its own IndexedDB and lands on the
|
||||
// default channel. It never chooses one: a public screen that could would let anyone who
|
||||
// reaches the page decide what a wall shows (ADR 0004).
|
||||
const session = await openViewerSession({
|
||||
at: new Date(),
|
||||
deviceId: deviceIdOf(context),
|
||||
store: store(context),
|
||||
viewerKey,
|
||||
});
|
||||
return Response.json({
|
||||
channelId: session.channel.id,
|
||||
channelKey: session.channel.key,
|
||||
groupId: session.groupId,
|
||||
heartbeatIntervalMs: 12_000,
|
||||
viewerId: session.viewer.id,
|
||||
viewerName: session.viewer.name,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/viewers/:viewerId/heartbeat",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const body = await readJsonObject(request);
|
||||
const settings = readSignageSettings(context.config);
|
||||
const rawError = body.error;
|
||||
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
||||
// local cursor is never accepted as truth.
|
||||
const result = await recordViewerHeartbeat({
|
||||
at: new Date(),
|
||||
heartbeat: {
|
||||
cachedAssetCount:
|
||||
optionalInteger(body, "cachedAssetCount", {
|
||||
maximum: 100_000,
|
||||
minimum: 0,
|
||||
}) ?? undefined,
|
||||
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
|
||||
reportedContributionId:
|
||||
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
|
||||
reportedPlaybackRevision:
|
||||
optionalInteger(body, "reportedPlaybackRevision", {
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
minimum: 0,
|
||||
}) ?? null,
|
||||
reportedProviderId:
|
||||
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
|
||||
reportedSelectionId:
|
||||
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
|
||||
reportedTimelineId:
|
||||
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
|
||||
...(rawError === undefined
|
||||
? {}
|
||||
: {
|
||||
error:
|
||||
rawError === null
|
||||
? null
|
||||
: {
|
||||
code: readErrorCode(rawError),
|
||||
detail: readErrorDetail(rawError),
|
||||
},
|
||||
}),
|
||||
},
|
||||
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
||||
store: store(context),
|
||||
viewerId,
|
||||
});
|
||||
// The response says what a display needs to keep itself honest, and nothing about other
|
||||
// displays or the installation.
|
||||
return Response.json({ acknowledged: true, recorded: result.written });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/channels/:channelId/display-state",
|
||||
permission: "signage.display.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const channelId = requireUuidParam(context, "channelId");
|
||||
const signageStore = store(context);
|
||||
const channel = await requireChannel(signageStore, channelId);
|
||||
const snapshot = await playback().snapshot(channel.groupId);
|
||||
return Response.json(
|
||||
await buildDisplayChannelState({
|
||||
atMs: Date.now(),
|
||||
channel,
|
||||
playback: snapshot,
|
||||
registry: registry(),
|
||||
settings: readSignageSettings(context.config),
|
||||
storage: context.storage,
|
||||
store: signageStore,
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/viewers",
|
||||
permission: "signage.view",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const atMs = Date.now();
|
||||
const viewers = await store(context).listViewers();
|
||||
return Response.json({
|
||||
viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/viewers/:viewerId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 4096,
|
||||
handler(request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const body = await readJsonObject(request);
|
||||
const channelId = body.channelId;
|
||||
if (channelId !== undefined && typeof channelId !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" });
|
||||
}
|
||||
const viewer = await updateViewerAssignment({
|
||||
...(channelId === undefined ? {} : { channelId }),
|
||||
name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }),
|
||||
store: store(context),
|
||||
viewerId,
|
||||
});
|
||||
return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "DELETE",
|
||||
path: "/viewers/:viewerId",
|
||||
permission: "signage.displays.manage",
|
||||
maxBodyBytes: 0,
|
||||
handler(_request, context) {
|
||||
return signageBoundary(async () => {
|
||||
const viewerId = requireUuidParam(context, "viewerId");
|
||||
const signageStore = store(context);
|
||||
if (!(await signageStore.findViewer(viewerId))) {
|
||||
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||
}
|
||||
// Removing a screen from the roster is a record-keeping action. A display that checks in
|
||||
// again is simply registered afresh, because its own identity is the one it keeps.
|
||||
await signageStore.deleteViewer(viewerId);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readErrorCode(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
|
||||
}
|
||||
const code = (value as { code?: unknown }).code;
|
||||
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function readErrorDetail(value: unknown): string | null {
|
||||
const detail = (value as { detail?: unknown }).detail;
|
||||
if (detail === undefined || detail === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof detail !== "string") {
|
||||
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
Reference in New Issue
Block a user