Author SHA1 Message Date
MediaandClaude Opus 5 993986c25e Complete Phase 3B authoritative playback
Audit, fix, and verify the Phase 3B playback engine under Node 22 with a
full backend suite, project lint/build checks, Docker builds, Compose
validation, and isolated runtime playback verification.

Two concrete correctness bugs were found and fixed, each with a
regression test that fails without the fix:

reconcileRotating() evaluated boundaries against a stale entry. After
fastForwardStable() skipped whole cycles it could move the stable cursor,
but the loop kept using the pre-skip entry, so that entry's schedule end
or expiration cut the successor's slide short and shifted every later
absolute deadline. Re-evaluate the loop when the cursor moves.

resumeHeldEntry() could reconstruct a timeline whose slide_started_at was
in the future. A remainder captured under a longer duration was replayed
verbatim after the effective duration shrank, publishing a snapshot whose
current slide had not started yet and whose remaining time exceeded its
own effective duration. Clamp the remainder to the effective duration.

Two Phase 3A tests built playback_state rows by raw SQL that the service
can never hold: an activation queued behind a playlist with no playable
entry, and a static hold that still carried a live absolute timeline.
Both now drive the authoritative service, keeping their original
assertions about deletion protection and revision bookkeeping.

Record the static-target deletion refusal as a known interim integration
point rather than final behavior; playback already tolerates a deleted
static asset, so Phase 3B does not depend on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:22:03 -05:00
Media 68273d457f WIP: Phase 3B playback engine handoff 2026-08-16 21:50:19 -05:00
Media e2ecdc97e2 Implement Phase 3A playlist management 2026-08-16 20:34:36 -05:00
Media 722c6cf77d Implement Phase 2 asset storage pipeline 2026-08-16 19:59:10 -05:00
Media eb19675a5c Build Phase 1 application foundation 2026-08-16 19:16:45 -05:00
61 changed files with 14665 additions and 2148 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitignore
.agents
.codex
.env
.env.*
!.env.example
**/node_modules
.pnpm-store
**/dist
**/*.log
**/*.sqlite
**/*.sqlite-shm
**/*.sqlite-wal
**/*.sqlite-journal
data
backups
client/pnpm-lock.yaml
+46
View File
@@ -0,0 +1,46 @@
# A distinct project name and host port let v1 and v2 run side by side.
COMPOSE_PROJECT_NAME=announcements-v2
WEB_PORT=5175
# Docker Compose pins these internal values. They are shown here to document the
# standalone API configuration contract; do not uncomment them for normal Compose use.
# NODE_ENV=production
# HOST=0.0.0.0
# PORT=7532
# DATABASE_PATH=/data/db/announcements.sqlite
# S3_ENDPOINT=http://seaweedfs:8333
# S3_FORCE_PATH_STYLE=true
# Optional local Vite development proxy target (not used by Compose).
# VITE_API_PROXY_TARGET=http://127.0.0.1:7532
# Application configuration exposed by Compose
APP_TIMEZONE=America/Chicago
SHUTDOWN_TIMEOUT_MS=10000
DATABASE_BUSY_TIMEOUT_MS=5000
TRUST_PROXY=true
# Private SeaweedFS S3 configuration
S3_REGION=us-east-1
S3_BUCKET=announcements-v2
S3_ACCESS_KEY_ID=replace-with-a-random-access-key
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-secret
S3_CONNECT_TIMEOUT_MS=2000
# Maximum socket inactivity during one S3 operation (not a total upload deadline).
S3_REQUEST_TIMEOUT_MS=5000
S3_MAX_ATTEMPTS=2
S3_HEALTH_PROBE_INTERVAL_MS=30000
# Image upload and decode limits. The default proxy body limit is 512 MiB,
# which accommodates the default twenty 25 MiB files plus multipart overhead.
UPLOAD_MAX_FILE_BYTES=26214400
UPLOAD_MAX_FILES=20
IMAGE_MAX_INPUT_PIXELS=40000000
# UPLOAD_TEMP_DIRECTORY=/tmp/announcements-uploads
# Optional integrations. Leave blank to disable them during Phase 2.
MQTT_URL=
MQTT_USERNAME=
MQTT_PASSWORD=
HA_WEBHOOK_URL=
HA_WEBHOOK_SECRET=
+16 -1
View File
@@ -1 +1,16 @@
node_modules node_modules/
.pnpm-store/
# Local configuration and secrets
.env
.env.*
!.env.example
# Local build and runtime data
dist/
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
data/
backups/
+195
View File
@@ -0,0 +1,195 @@
# Announcements v2
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
S3-compatible signage application. Version 2 is being built in phases and is
intended to run beside the existing production version until cutover.
## Development
The repository is a pnpm workspace with one lockfile. It requires Node.js
22.13 or newer and pnpm 10.29.3.
```sh
cp .env.example .env
pnpm install --frozen-lockfile
pnpm check
docker compose up --build
```
Only the Nginx web service publishes a host port (5175 by default). The API and
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
S3 credentials before starting the stack.
## Library assets
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
field. An optional `display_name` (or `displayName`) is accepted for a
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
extensions and multipart content types are not trusted. Animated, multipage,
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
Default limits are:
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
- 20 files per request (`UPLOAD_MAX_FILES`)
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
multipart overhead. If the backend limits are raised, update
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
a ten-minute inactivity timeout so a valid large batch can finish processing.
Original bytes are archived unchanged. Content identity is the SHA-256 of
those bytes, so uploading the same content returns the existing asset even
when the filename differs. Display-name collisions with different content
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
locally before persisting any of them. If a later storage operation fails,
already completed items remain valid and retrying the request is idempotent.
Sharp produces two metadata-free, auto-oriented WebP renditions:
- display: inside-fit at most 1920×1080, quality 82
- thumbnail: inside-fit at most 480×270, quality 72
Neither rendition crops or upscales. Transparency is flattened against black.
Presentation effects such as blur, shadows, darkening, and framing are not
baked into stored media.
## Asset and media API
- `GET /api/assets`
- `POST /api/assets`
- `GET /api/assets/:assetId`
- `PATCH /api/assets/:assetId`
- `GET /api/assets/:assetId/references`
- `DELETE /api/assets/:assetId`
- `GET /api/assets/:assetId/original`
- `GET /media/assets/:assetId/display/:sha256.webp`
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
PATCH accepts only display name and expiration. A null expiration is permanent.
Manual deletion refuses playlist, current playback, and static references. It
marks an asset as deleting before idempotently removing its three objects and
removes SQLite metadata only after storage succeeds. A failed processing row
can be inspected, safely deleted, and uploaded again; no automatic retry loop
is run.
Media URLs are derived from SQLite metadata and never accept object keys. The
display and thumbnail routes stream private S3 objects through the API with a
one-year immutable cache policy. Original downloads use private, no-store
caching.
## Playlists
Playlist and entry metadata is authoritative in SQLite. The API surface is:
- `GET /api/playlists`
- `POST /api/playlists`
- `GET /api/playlists/:playlistId`
- `PATCH /api/playlists/:playlistId`
- `DELETE /api/playlists/:playlistId`
- `POST /api/playlists/:playlistId/entries`
- `PATCH /api/playlists/:playlistId/entries/:entryId`
- `DELETE /api/playlists/:playlistId/entries/:entryId`
- `PUT /api/playlists/:playlistId/entries/order`
- `POST /api/playlists/:playlistId/activate`
Entry positions are zero-based and contiguous. Reordering requires the complete
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
playlist, but the same asset can be used independently by any number of other
playlists. Only ready, unexpired assets can be newly added.
Entry duration overrides may be null (inherit the global setting) or 1,000 to
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
inherit/off/on boolean and blur always comes from the global setting. Schedule
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
UTC offset, and intervals are evaluated as `[start, end)`.
Playlist detail responses include effective duration and presentation,
server-evaluated eligibility, immutable display/thumbnail URLs, and the
settings revision and evaluation time used. Playlist revisions increment once
per semantic content change. Exact no-ops and reads do not increment them.
Before a playback-relevant playlist, settings, or asset mutation, the playback
service catches up using the old metadata. The domain transaction then commits
and playback synchronously reconciles the new values at the mutation instant.
This prevents a late timer or restart boundary from being rewritten by a later
edit. If the post-commit reconciliation unexpectedly fails, the API returns an
error even though the preceding domain commit may already be durable; clients
must refetch current state before deciding whether to retry.
Activation is explicit. A request is held in the existing pending-activation
state while a playable normal slide is mid-duration; otherwise the target
playlist is prepared immediately at its first currently playable entry. The
output-off flag remains only a visual gate, and static hold is preserved while
the hidden normal playlist changes. The authoritative playback service consumes
a pending activation at the current slide boundary and never merges the old and
new playlists.
Socket.IO publishes `playlists:changed` as a refetch invalidation after
committed playlist changes and after settings or referenced-asset edits that
affect resolved playlist data. Socket.IO is not a mutation API.
## Authoritative playback
One serialized service owns persisted global playback, timer reconciliation,
and every playback control path. Its REST surface is:
- `GET /api/playback`
- `POST /api/playback/pause`
- `POST /api/playback/resume`
- `POST /api/playback/next`
- `POST /api/playback/previous`
- `POST /api/playback/static`
- `PUT /api/playback/mode`
- `POST /api/playback/reload`
Visual output precedence is mode off (black), a valid static asset, the current
normal entry, then black when no playable target exists. Mode off is only a
visual gate: normal playback continues underneath unless it is independently
paused or held by static mode. Pause and static both preserve the normal cursor
and remaining duration. Resume clears both static and paused state together and
continues the normal entry with its preserved remaining time. Next and previous
wrap across currently playable entries; they change the held entry without
unpausing when paused and are no-ops while static.
Normal slides use persisted absolute start times and deadlines. Chained
timeouts reconcile from the prior authoritative deadline, including late
callbacks and restart catch-up, so delays do not accumulate as timing drift.
The service also arms exact schedule and asset-expiration boundaries. It skips
disabled, out-of-schedule, expired, and non-ready entries, and safely returns to
an eligible entry when one becomes available. Pending playlist activation is
applied at the current normal slide boundary; an empty target playlist still
becomes active with black normal output.
Socket.IO sends `playback:state` immediately on connection and after meaningful
committed playback changes. The authoritative snapshot includes server time and
revisions, mode and transport state, active playlist and timeline data, current
and static targets, crossfade settings, and a display-cache manifest with
effective duration, presentation, scheduling, expiration, and immutable media
metadata. Enabled future-scheduled entries remain in the manifest; disabled
entries and private S3 details do not. Reads and connections do not increment
the playback revision.
Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT
command wiring and the browser display renderer remain later phases.
Deleting a library asset that is the current static target is still refused by
the Phase 2 deletion model. That refusal is a known interim integration point,
not the intended final behavior: deleting the statically held slide is meant to
exit static safely, return to normal rotation, and only then delete the asset.
Phase 3B does not depend on the refusal, so the change belongs to the later
phase that revisits asset lifecycle.
## Storage and health
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
`S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.
Connection timeout, socket-inactivity timeout (`S3_REQUEST_TIMEOUT_MS`),
attempts, and background probe interval are configurable in `.env.example`.
Startup verifies or safely creates only the configured bucket.
Storage outages do not stop the API or fail process readiness. `/health/ready`
reports storage as degraded while remaining HTTP 200 when SQLite and the API
runtime are healthy. `/api/health` and `/api/status` expose cached, redacted
storage state and recover after a successful probe or storage operation.
+19 -9
View File
@@ -1,14 +1,24 @@
FROM node:22-alpine AS build FROM node:22.23.2-bookworm-slim AS build
ARG PNPM_VERSION=10.29.3
WORKDIR /app WORKDIR /app
RUN corepack enable RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
COPY . . RUN pnpm install --frozen-lockfile --filter @announcements/client...
RUN pnpm build
FROM nginx:alpine COPY client ./client
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 RUN pnpm --filter @announcements/client build
FROM nginx:1.30.4-alpine
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/client/dist /usr/share/nginx/html
EXPOSE 80
+50
View File
@@ -0,0 +1,50 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Matches the default API allowance of twenty 25 MiB files plus multipart
# overhead. Keep this in sync if the backend upload limits are raised.
client_max_body_size 512m;
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_origin http://api:7532;
location ~ ^/(api|health|media)(/|$) {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_connect_timeout 5s;
# Multipart requests return only after image processing and private-S3
# writes complete. This is an inactivity timeout, not a size limit.
proxy_read_timeout 10m;
proxy_send_timeout 10m;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 75s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+4 -1
View File
@@ -1,8 +1,11 @@
{ {
"name": "client", "name": "@announcements/client",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"engines": {
"node": ">=22.13.0"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
-1572
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import "./App.css"; import "./App.css";
const socket = io("http://10.40.0.2:7532"); const socket = io();
export default function App() { export default function App() {
const [slides, setSlides] = useState([]); const [slides, setSlides] = useState([]);
@@ -64,4 +64,4 @@ export default function App() {
/> />
</div> </div>
); );
} }
+19 -6
View File
@@ -1,7 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
// https://vite.dev/config/ export default defineConfig(({ mode }) => {
export default defineConfig({ const environment = loadEnv(mode, ".", "VITE_");
plugins: [react()], const target = environment.VITE_API_PROXY_TARGET || "http://127.0.0.1:7532";
}) const proxy = { target, changeOrigin: true };
return {
plugins: [react()],
server: {
proxy: {
"/api": proxy,
"/health": proxy,
"/media": proxy,
"/socket.io": { ...proxy, ws: true },
},
},
};
});
+105 -14
View File
@@ -1,20 +1,111 @@
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
services: services:
announcements-server: web:
build:
context: .
dockerfile: client/Dockerfile
restart: unless-stopped
init: true
ports:
- "${WEB_PORT:-5175}:80"
depends_on:
api:
condition: service_healthy
networks:
- app
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
api:
build: build:
context: . context: .
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
container_name: announcements-server
restart: unless-stopped restart: unless-stopped
ports: init: true
- "7532:7532" environment:
NODE_ENV: production
announcements-client: HOST: 0.0.0.0
build: PORT: "7532"
context: ./client DATABASE_PATH: /data/db/announcements.sqlite
dockerfile: Dockerfile APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
container_name: announcements-client SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
restart: unless-stopped DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
ports: TRUST_PROXY: "${TRUST_PROXY:-true}"
- "5174:80" S3_ENDPOINT: http://seaweedfs:8333
S3_REGION: "${S3_REGION:-us-east-1}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_FORCE_PATH_STYLE: "true"
S3_CONNECT_TIMEOUT_MS: "${S3_CONNECT_TIMEOUT_MS:-2000}"
S3_REQUEST_TIMEOUT_MS: "${S3_REQUEST_TIMEOUT_MS:-5000}"
S3_MAX_ATTEMPTS: "${S3_MAX_ATTEMPTS:-2}"
S3_HEALTH_PROBE_INTERVAL_MS: "${S3_HEALTH_PROBE_INTERVAL_MS:-30000}"
UPLOAD_TEMP_DIRECTORY: /tmp/announcements-uploads
UPLOAD_MAX_FILE_BYTES: "${UPLOAD_MAX_FILE_BYTES:-26214400}"
UPLOAD_MAX_FILES: "${UPLOAD_MAX_FILES:-20}"
IMAGE_MAX_INPUT_PIXELS: "${IMAGE_MAX_INPUT_PIXELS:-40000000}"
MQTT_URL: "${MQTT_URL:-}"
MQTT_USERNAME: "${MQTT_USERNAME:-}"
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
volumes:
- app-data:/data
depends_on: depends_on:
- announcements-server seaweedfs:
condition: service_started
networks:
- app
- storage
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
stop_grace_period: 15s
seaweedfs:
image: chrislusf/seaweedfs:4.39
restart: unless-stopped
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
environment:
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
volumes:
- seaweedfs-data:/data
expose:
- "8333"
networks:
- storage
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
wget -q -O /dev/null http://127.0.0.1:8333/readyz
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
stop_grace_period: 30s
networks:
app:
storage:
internal: true
volumes:
app-data:
seaweedfs-data:
+14 -19
View File
@@ -1,24 +1,19 @@
{ {
"name": "Annoucements", "name": "announcements",
"version": "1.0.0", "version": "2.0.0",
"description": "", "private": true,
"main": "index.js", "description": "Church announcement management and signage",
"scripts": { "scripts": {
"dev": "nodemon server/index.js", "dev:server": "pnpm --filter @announcements/server dev",
"start": "node server/index.js" "dev:client": "pnpm --filter @announcements/client dev",
"start": "pnpm --filter @announcements/server start",
"build": "pnpm --filter @announcements/client build",
"lint": "pnpm --filter @announcements/client lint",
"test": "pnpm --filter @announcements/server test",
"check": "pnpm lint && pnpm test && pnpm build"
}, },
"keywords": [], "engines": {
"author": "", "node": ">=22.13.0"
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"fast-xml-parser": "^5.8.0",
"mqtt": "^5.15.1",
"socket.io": "^4.8.3"
}, },
"devDependencies": { "packageManager": "[email protected]"
"nodemon": "^3.1.14"
}
} }
+2059 -388
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- client
- server
onlyBuiltDependencies:
- better-sqlite3
- sharp
+24 -5
View File
@@ -1,13 +1,32 @@
FROM node:22-alpine FROM node:22.23.2-bookworm-slim AS dependencies
ARG PNPM_VERSION=10.29.3
WORKDIR /app WORKDIR /app
RUN corepack enable RUN apt-get update \
&& apt-get install --yes --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./ RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
RUN pnpm install --frozen-lockfile --prod
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
COPY server ./server COPY server ./server
FROM node:22.23.2-bookworm-slim
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/server ./server
ENV NODE_ENV=production
EXPOSE 7532 EXPOSE 7532
CMD ["node", "server/index.js"] CMD ["node", "server/src/index.js"]
-131
View File
@@ -1,131 +0,0 @@
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const app = express();
app.use(cors());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
},
});
const PORT = 7532;
let currentSlide = 0;
const slideSeconds = 7;
const { XMLParser } = require("fast-xml-parser");
let slides = [];
async function loadSlides() {
try {
const response = await fetch(
"http://10.40.0.2:9000/announcements?list-type=2"
);
const xml = await response.text();
const parser = new XMLParser();
const data = parser.parse(xml);
const contents = data.ListBucketResult?.Contents || [];
const files = Array.isArray(contents)
? contents
: [contents];
slides = files
.map((file) => file.Key)
.filter((name) =>
/\.(png|jpg|jpeg|webp)$/i.test(name)
)
.map((name) => {
const encodedName = name
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return `http://10.40.0.2:9000/announcements/${encodedName}`;
});
console.log("Slides loaded:", slides.length);
if (currentSlide >= slides.length) {
currentSlide = 0;
}
broadcastState();
} catch (err) {
console.error("Failed to load slides:", err);
}
}
const mqtt = require("mqtt");
let mode = "on";
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
mqttClient.on("connect", () => {
console.log("MQTT connected");
mqttClient.subscribe("announcements/mode");
});
mqttClient.on("message", (topic, message) => {
if (topic === "announcements/mode") {
const value = message.toString().trim().toLowerCase();
if (value === "on" || value === "off") {
mode = value;
broadcastState();
console.log("Announcement mode:", mode);
}
}
});
function broadcastState() {
io.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
}
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
});
setInterval(() => {
if (slides.length === 0) return;
currentSlide = (currentSlide + 1) % slides.length;
broadcastState();
}, slideSeconds * 1000);
app.get("/", (req, res) => {
res.send("Announcement server is running");
});
loadSlides();
setInterval(() => {
loadSlides();
}, 30000);
server.listen(PORT, () => {
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
});
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@announcements/server",
"version": "2.0.0",
"private": true,
"description": "Announcements v2 API and realtime server",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"engines": {
"node": ">=22.13.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1101.0",
"better-sqlite3": "13.0.2",
"dotenv": "^16.5.0",
"express": "^5.2.1",
"multer": "2.0.2",
"sharp": "0.35.3",
"socket.io": "^4.8.3"
}
}
+182
View File
@@ -0,0 +1,182 @@
const express = require("express");
const {
createDetailedHealthRouter,
createHealthRouter,
} = require("./health/routes");
const { createAssetRouter } = require("./assets/routes");
const { createMediaRouter } = require("./assets/media-routes");
const { createAssetService } = require("./assets/service");
const { createPlaybackService } = require("./playback");
const { createPlaybackRouter } = require("./playback/routes");
const {
createPlaylistRouter,
createPlaylistService,
} = require("./playlists");
const { createSettingsRouter } = require("./settings/routes");
const { createSettingsService } = require("./settings/service");
function safeNotifier(notifier, logger, failureMessage) {
const warn = (error) => {
try {
logger?.warn?.(failureMessage, error);
} catch {
// A best-effort notification or warning must not undo a database commit.
}
};
return (event) => {
try {
const result = notifier(event);
result?.catch?.(warn);
} catch (error) {
warn(error);
}
};
}
function createApp({
config,
database,
imageProcessor,
lifecycle,
logger = console,
migrations,
now = Date.now,
onPlaylistsChanged = () => {},
playbackService: suppliedPlaybackService,
storage,
}) {
const app = express();
const notifyPlaylistsChanged = safeNotifier(
onPlaylistsChanged,
logger,
"Playlist invalidation notification failed",
);
const playbackService =
suppliedPlaybackService ||
createPlaybackService({
database,
now,
onActivationChanged: notifyPlaylistsChanged,
});
if (!suppliedPlaybackService) {
playbackService.initialize();
}
const reconcilePlaybackDomain = (change) =>
playbackService.domainChanged(change);
const reconcileBeforeDomainChange = () =>
playbackService.reconcile("before_domain_change");
const notifyPlaylistMutation = (change) => {
reconcilePlaybackDomain(change);
const publicChange = { ...change };
delete publicChange.invalidBoundaryChanged;
notifyPlaylistsChanged(publicChange);
};
const settingsService = createSettingsService(database);
const assetService = createAssetService({
database,
imageProcessor,
storage,
});
const playlistService = createPlaylistService({
database,
now,
onBeforeChange: reconcileBeforeDomainChange,
onChanged: notifyPlaylistMutation,
});
app.locals.playbackService = playbackService;
app.disable("x-powered-by");
app.set("trust proxy", config.trustProxy);
app.use(express.json({ limit: "1mb" }));
app.get("/api", (request, response) => {
response.json({ name: "Announcements API", version: 2 });
});
app.use(
"/health",
createHealthRouter({ database, lifecycle, migrations, storage }),
);
const detailedHealthRouterOptions = {
config,
database,
lifecycle,
migrations,
storage,
};
app.use(
"/api/health",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use(
"/api/status",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use(
"/api/settings",
createSettingsRouter(settingsService, {
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
onPlaylistsChanged: notifyPlaylistsChanged,
onPlaybackDomainChanged: reconcilePlaybackDomain,
}),
);
app.use(
"/api/assets",
createAssetRouter({
assetService,
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
onPlaylistsChanged: notifyPlaylistsChanged,
onPlaybackDomainChanged: reconcilePlaybackDomain,
processingConfig: config.processing,
}),
);
app.use(
"/api/playlists",
createPlaylistRouter(playlistService, {
playbackService,
}),
);
app.use("/api/playback", createPlaybackRouter(playbackService));
app.use("/media", createMediaRouter(assetService));
app.use((request, response) => {
response.status(404).json({
error: {
code: "NOT_FOUND",
message: "Route not found",
},
});
});
app.use((error, request, response, next) => {
if (response.headersSent) {
next(error);
return;
}
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
response.status(400).json({
error: {
code: "INVALID_JSON",
message: "Request body must contain valid JSON",
},
});
return;
}
const statusCode = error.statusCode || 500;
response.status(statusCode).json({
error: {
code: error.code || "INTERNAL_ERROR",
message:
statusCode >= 500 ? "An internal server error occurred" : error.message,
...(error.details ? { details: error.details } : {}),
},
});
});
return app;
}
module.exports = {
createApp,
};
+23
View File
@@ -0,0 +1,23 @@
class AssetError extends Error {
constructor(code, message, { statusCode = 400, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "AssetError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function assetNotFound(assetId) {
return new AssetError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId },
});
}
module.exports = {
AssetError,
assetNotFound,
};
+49
View File
@@ -0,0 +1,49 @@
const express = require("express");
const { sendImmutableMedia } = require("./streaming");
function createMediaRouter(assetService) {
const router = express.Router();
async function sendMedia(request, response, next, kind) {
const version = request.params.version;
if (!version.endsWith(".webp")) {
response.status(404).json({
error: {
code: "MEDIA_NOT_FOUND",
message: "Asset media version not found",
},
});
return;
}
try {
const descriptor = assetService.mediaDescriptor(
request.params.assetId,
kind,
version.slice(0, -".webp".length),
);
await sendImmutableMedia(assetService, response, descriptor);
} catch (error) {
if (response.headersSent) {
response.destroy(error);
return;
}
next(error);
}
}
router.get("/assets/:assetId/display/:version", (request, response, next) =>
sendMedia(request, response, next, "display"),
);
router.get(
"/assets/:assetId/thumbnail/:version",
(request, response, next) =>
sendMedia(request, response, next, "thumbnail"),
);
return router;
}
module.exports = {
createMediaRouter,
};
+235
View File
@@ -0,0 +1,235 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const multer = require("multer");
const { AssetError } = require("./errors");
const ALLOWED_FILE_FIELDS = new Set(["file", "files"]);
const ALLOWED_TEXT_FIELDS = new Set(["display_name", "displayName"]);
const MAX_FIELD_BYTES = 4 * 1024;
const MAX_ORIGINAL_FILENAME_LENGTH = 255;
const TEMPORARY_UPLOAD_PATHS = Symbol("temporaryUploadPaths");
const TEMP_STORAGE_ERROR_CODES = new Set([
"EACCES",
"EEXIST",
"EIO",
"EMFILE",
"ENFILE",
"ENOSPC",
"ENOTDIR",
"EPERM",
"EROFS",
]);
function safeOriginalFilename(value) {
const normalized = String(value || "")
.replaceAll("\\", "/")
.replace(/[\u0000-\u001f\u007f]/g, "");
const leafName = path.posix.basename(normalized);
const bounded = [...leafName].slice(0, MAX_ORIGINAL_FILENAME_LENGTH).join("");
if (!bounded.trim()) {
throw new AssetError(
"INVALID_ORIGINAL_FILENAME",
"Uploaded files must include a usable filename",
);
}
return bounded;
}
function mapMulterError(error, config) {
if (!(error instanceof multer.MulterError)) {
if (TEMP_STORAGE_ERROR_CODES.has(error?.code)) {
return new AssetError(
"UPLOAD_TEMP_STORAGE_UNAVAILABLE",
"Temporary upload storage is unavailable",
{ statusCode: 503, cause: error },
);
}
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload could not be read",
{ cause: error },
);
}
if (error.code === "LIMIT_FILE_SIZE") {
return new AssetError(
"UPLOAD_FILE_TOO_LARGE",
"An uploaded file exceeds the configured size limit",
{
statusCode: 413,
details: { maxFileBytes: config.maxFileBytes },
},
);
}
if (error.code === "LIMIT_FILE_COUNT") {
return new AssetError(
"UPLOAD_FILE_COUNT_EXCEEDED",
"The upload contains too many files",
{
statusCode: 413,
details: { maxFiles: config.maxFiles },
},
);
}
if (
[
"LIMIT_FIELD_COUNT",
"LIMIT_FIELD_KEY",
"LIMIT_FIELD_VALUE",
"LIMIT_PART_COUNT",
"LIMIT_UNEXPECTED_FILE",
].includes(error.code)
) {
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload contains too many or invalid parts",
{ details: { reason: error.code } },
);
}
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload could not be read",
{ details: { reason: error.code } },
);
}
function createTemporaryStorage(tempDirectory) {
return multer.diskStorage({
destination(request, file, callback) {
fs.mkdir(tempDirectory, { recursive: true, mode: 0o700 }, (error) => {
callback(error, tempDirectory);
});
},
filename(request, file, callback) {
const temporaryName = crypto.randomUUID();
if (!request[TEMPORARY_UPLOAD_PATHS]) {
request[TEMPORARY_UPLOAD_PATHS] = [];
}
request[TEMPORARY_UPLOAD_PATHS].push(
path.join(tempDirectory, temporaryName),
);
callback(null, temporaryName);
},
});
}
function temporaryUploadFiles(request) {
const paths = new Set(request[TEMPORARY_UPLOAD_PATHS] || []);
for (const file of Array.isArray(request.files) ? request.files : []) {
if (file?.path) paths.add(file.path);
}
return [...paths].map((temporaryPath) => ({ path: temporaryPath }));
}
function createMultipartUpload(config) {
const middleware = multer({
storage: createTemporaryStorage(config.tempDirectory),
limits: {
fileSize: config.maxFileBytes,
files: config.maxFiles,
fields: 2,
parts: config.maxFiles + 2,
fieldNameSize: 64,
fieldSize: MAX_FIELD_BYTES,
headerPairs: 100,
},
}).any();
return function receiveAssetUpload(request, response) {
return new Promise((resolve, reject) => {
middleware(request, response, (error) => {
if (error) {
reject(mapMulterError(error, config));
return;
}
const files = Array.isArray(request.files) ? request.files : [];
try {
if (files.length === 0) {
throw new AssetError(
"UPLOAD_FILE_REQUIRED",
"At least one image file is required",
);
}
const unexpectedFile = files.find(
(file) => !ALLOWED_FILE_FIELDS.has(file.fieldname),
);
if (unexpectedFile) {
throw new AssetError(
"UNEXPECTED_UPLOAD_FIELD",
"Image files must use the file or files multipart field",
{ details: { field: unexpectedFile.fieldname } },
);
}
const body = request.body || {};
const unknownFields = Object.keys(body).filter(
(name) => !ALLOWED_TEXT_FIELDS.has(name),
);
if (unknownFields.length > 0) {
throw new AssetError(
"UNEXPECTED_UPLOAD_FIELD",
"The multipart upload contains unsupported fields",
{ details: { fields: unknownFields } },
);
}
if (
Object.hasOwn(body, "display_name") &&
Object.hasOwn(body, "displayName")
) {
throw new AssetError(
"DUPLICATE_DISPLAY_NAME_FIELD",
"Provide only one display name field",
);
}
const displayName = body.display_name ?? body.displayName ?? null;
if (displayName !== null && files.length !== 1) {
throw new AssetError(
"DISPLAY_NAME_REQUIRES_SINGLE_FILE",
"An explicit display name can be used only with one uploaded file",
);
}
resolve({
displayName,
files: files.map((file) => ({
path: file.path,
byteSize: file.size,
originalFilename: safeOriginalFilename(file.originalname),
suppliedContentType: file.mimetype,
})),
});
} catch (validationError) {
reject(validationError);
}
});
});
};
}
async function cleanupTemporaryFiles(files = []) {
await Promise.all(
files.map(async (file) => {
if (!file?.path) return;
try {
await fs.promises.rm(file.path, { force: true });
} catch {
// Temporary cleanup is best-effort and must not mask the API result.
}
}),
);
}
module.exports = {
cleanupTemporaryFiles,
createMultipartUpload,
mapMulterError,
safeOriginalFilename,
temporaryUploadFiles,
};
+137
View File
@@ -0,0 +1,137 @@
const express = require("express");
const {
cleanupTemporaryFiles,
createMultipartUpload,
temporaryUploadFiles,
} = require("./multipart");
const { sendOriginal } = require("./streaming");
function createAssetRouter({
assetService,
onBeforePlaybackDomainChange = () => {},
onPlaylistsChanged = () => {},
onPlaybackDomainChanged = () => {},
processingConfig,
}) {
const router = express.Router();
const receiveUpload = createMultipartUpload(processingConfig);
router.get("/", (request, response, next) => {
try {
response.json({ assets: assetService.list() });
} catch (error) {
next(error);
}
});
router.post("/", async (request, response, next) => {
const cleanupAfterAbort = () => {
void cleanupTemporaryFiles(temporaryUploadFiles(request));
};
request.once("aborted", cleanupAfterAbort);
try {
const upload = await receiveUpload(request, response);
const results = await assetService.upload(upload.files, {
displayName: upload.displayName,
});
const createdCount = results.filter((result) => result.created).length;
if (results.length === 1) {
response.status(createdCount > 0 ? 201 : 200).json(results[0]);
return;
}
response.status(createdCount > 0 ? 201 : 200).json({
results,
createdCount,
duplicateCount: results.length - createdCount,
});
} catch (error) {
next(error);
} finally {
request.off("aborted", cleanupAfterAbort);
await cleanupTemporaryFiles(temporaryUploadFiles(request));
}
});
router.get("/:assetId/references", (request, response, next) => {
try {
response.json(assetService.references(request.params.assetId));
} catch (error) {
next(error);
}
});
router.get("/:assetId/original", async (request, response, next) => {
try {
const descriptor = assetService.originalDescriptor(request.params.assetId);
await sendOriginal(assetService, response, descriptor);
} catch (error) {
if (response.headersSent) {
response.destroy(error);
return;
}
next(error);
}
});
router.get("/:assetId", (request, response, next) => {
try {
response.json(assetService.get(request.params.assetId));
} catch (error) {
next(error);
}
});
router.patch("/:assetId", (request, response, next) => {
try {
const previous = assetService.get(request.params.assetId);
const playlistIds = [
...new Set(
assetService
.references(request.params.assetId)
.playlists.map((reference) => reference.playlistId),
),
];
onBeforePlaybackDomainChange({
reason: "before_asset_update",
assetId: previous.id,
});
const updated = assetService.update(request.params.assetId, request.body);
if (updated.updatedAt !== previous.updatedAt) {
const change = {
reason: "asset_updated",
playlistIds,
assetId: updated.id,
...(updated.expirationAt !== previous.expirationAt
? { invalidBoundaryChanged: true }
: {}),
};
onPlaybackDomainChanged(change);
if (playlistIds.length > 0) {
const publicChange = { ...change };
delete publicChange.invalidBoundaryChanged;
onPlaylistsChanged(publicChange);
}
}
response.json(updated);
} catch (error) {
next(error);
}
});
router.delete("/:assetId", async (request, response, next) => {
try {
response.json(await assetService.remove(request.params.assetId));
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createAssetRouter,
};
+835
View File
@@ -0,0 +1,835 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { AssetError, assetNotFound } = require("./errors");
const { StorageObjectNotFoundError } = require("../storage");
const MAX_DISPLAY_NAME_LENGTH = 200;
const MAX_PROCESSING_ERROR_LENGTH = 1_000;
const CONCURRENT_UPLOAD_WAIT_MS = 15_000;
const CONCURRENT_UPLOAD_POLL_MS = 50;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function normalizeDisplayName(value) {
if (typeof value !== "string") {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
"Asset display name must be a string",
);
}
const normalized = value
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
"Asset display name cannot be empty",
);
}
if ([...normalized].length > MAX_DISPLAY_NAME_LENGTH) {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
`Asset display name cannot exceed ${MAX_DISPLAY_NAME_LENGTH} characters`,
{ details: { maxLength: MAX_DISPLAY_NAME_LENGTH } },
);
}
return normalized;
}
function defaultDisplayName(originalFilename) {
const extension = path.extname(originalFilename);
const withoutExtension = extension
? originalFilename.slice(0, -extension.length)
: originalFilename;
return normalizeDisplayName(withoutExtension);
}
function normalizeExpiration(value) {
if (value === null) return null;
if (Number.isSafeInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
const timestamp = Date.parse(value);
if (Number.isSafeInteger(timestamp) && timestamp >= 0) {
return timestamp;
}
}
throw new AssetError(
"INVALID_ASSET_EXPIRATION",
"Asset expiration must be null, an epoch-millisecond integer, or an ISO date",
);
}
function mediaUrls(row) {
if (row.status !== "ready") {
return {
originalUrl: null,
displayUrl: null,
thumbnailUrl: null,
};
}
return {
originalUrl: `/api/assets/${row.id}/original`,
displayUrl: `/media/assets/${row.id}/display/${row.display_sha256}.webp`,
thumbnailUrl: `/media/assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
};
}
function mapAsset(row) {
if (!row) return null;
const urls = mediaUrls(row);
return {
id: row.id,
displayName: row.display_name,
originalFilename: row.original_filename,
status: row.status,
processingError: row.processing_error || null,
sourceSha256: row.source_sha256,
expirationAt: row.expiration_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
referenceCount: Number(row.reference_count || 0),
original: row.original_content_type
? {
contentType: row.original_content_type,
byteSize: row.original_byte_size,
width: row.original_width,
height: row.original_height,
url: urls.originalUrl,
}
: null,
display: row.display_content_type
? {
sha256: row.display_sha256,
contentType: row.display_content_type,
byteSize: row.display_byte_size,
width: row.display_width,
height: row.display_height,
url: urls.displayUrl,
}
: null,
thumbnail: row.thumbnail_content_type
? {
sha256: row.thumbnail_sha256,
contentType: row.thumbnail_content_type,
byteSize: row.thumbnail_byte_size,
width: row.thumbnail_width,
height: row.thumbnail_height,
url: urls.thumbnailUrl,
}
: null,
};
}
async function hashFile(filename) {
const hash = crypto.createHash("sha256");
let byteSize = 0;
for await (const chunk of fs.createReadStream(filename)) {
byteSize += chunk.length;
hash.update(chunk);
}
return { sourceSha256: hash.digest("hex"), byteSize };
}
function objectKeys(assetId, processed) {
return {
original: `assets/${assetId}/original/${processed.original.sha256}`,
display: `assets/${assetId}/display/${processed.display.sha256}.webp`,
thumbnail: `assets/${assetId}/thumbnail/${processed.thumbnail.sha256}.webp`,
};
}
function boundedProcessingError(error) {
const code = typeof error?.code === "string" ? error.code : "PROCESSING_FAILED";
const message =
typeof error?.message === "string" && error.message
? error.message
: "Asset processing failed";
return `${code}: ${message}`.slice(0, MAX_PROCESSING_ERROR_LENGTH);
}
function createAssetService({
database,
storage,
imageProcessor,
now = Date.now,
createId = crypto.randomUUID,
wait = delay,
concurrentUploadWaitMs = CONCURRENT_UPLOAD_WAIT_MS,
}) {
const assetSelect = `
SELECT a.*,
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.asset_id = a.id)
AS reference_count
FROM assets a
`;
const listAssets = database.prepare(`${assetSelect} ORDER BY a.created_at DESC, a.id`);
const findById = database.prepare(`${assetSelect} WHERE a.id = ?`);
const findByHash = database.prepare(`${assetSelect} WHERE a.source_sha256 = ?`);
const findByName = database.prepare(`${assetSelect} WHERE a.display_name = ? COLLATE NOCASE`);
const insertProcessing = database.prepare(`
INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
created_at, updated_at
) VALUES (
@id, @sourceSha256, @displayName, @originalFilename, 'processing',
@createdAt, @updatedAt
)
`);
const markReady = database.prepare(`
UPDATE assets
SET status = 'ready',
processing_error = NULL,
original_object_key = @originalObjectKey,
original_content_type = @originalContentType,
original_byte_size = @originalByteSize,
original_width = @originalWidth,
original_height = @originalHeight,
display_object_key = @displayObjectKey,
display_sha256 = @displaySha256,
display_content_type = @displayContentType,
display_byte_size = @displayByteSize,
display_width = @displayWidth,
display_height = @displayHeight,
thumbnail_object_key = @thumbnailObjectKey,
thumbnail_sha256 = @thumbnailSha256,
thumbnail_content_type = @thumbnailContentType,
thumbnail_byte_size = @thumbnailByteSize,
thumbnail_width = @thumbnailWidth,
thumbnail_height = @thumbnailHeight,
updated_at = @updatedAt
WHERE id = @id AND status = 'processing'
`);
const markFailed = database.prepare(`
UPDATE assets
SET status = 'failed',
processing_error = @processingError,
original_object_key = @originalObjectKey,
original_content_type = @originalContentType,
original_byte_size = @originalByteSize,
original_width = @originalWidth,
original_height = @originalHeight,
display_object_key = @displayObjectKey,
display_sha256 = @displaySha256,
display_content_type = @displayContentType,
display_byte_size = @displayByteSize,
display_width = @displayWidth,
display_height = @displayHeight,
thumbnail_object_key = @thumbnailObjectKey,
thumbnail_sha256 = @thumbnailSha256,
thumbnail_content_type = @thumbnailContentType,
thumbnail_byte_size = @thumbnailByteSize,
thumbnail_width = @thumbnailWidth,
thumbnail_height = @thumbnailHeight,
updated_at = @updatedAt
WHERE id = @id AND status = 'processing'
`);
const updateEditable = database.prepare(`
UPDATE assets
SET display_name = @displayName,
expiration_at = @expirationAt,
updated_at = @updatedAt
WHERE id = @id AND status != 'deleting'
`);
const playlistReferences = database.prepare(`
SELECT
pe.id AS entry_id,
pe.playlist_id,
p.name AS playlist_name,
pe.enabled,
pe.position,
EXISTS (
SELECT 1 FROM playback_state active
WHERE active.active_playlist_id = pe.playlist_id
) AS is_active_playlist,
EXISTS (
SELECT 1 FROM playback_state current
WHERE current.current_entry_id = pe.id
) AS is_current_normal_target
FROM playlist_entries pe
JOIN playlists p ON p.id = pe.playlist_id
WHERE pe.asset_id = ?
ORDER BY p.name COLLATE NOCASE, pe.position, pe.id
`);
const playbackReferences = database.prepare(`
SELECT
output_id,
mode,
active_playlist_id,
current_entry_id,
static_asset_id
FROM playback_state
WHERE static_asset_id = ?
OR current_entry_id IN (
SELECT id FROM playlist_entries WHERE asset_id = ?
)
ORDER BY output_id
`);
const markDeleting = database.prepare(`
UPDATE assets
SET status = 'deleting', updated_at = ?
WHERE id = ? AND status != 'processing'
`);
const deleteAssetRow = database.prepare(
"DELETE FROM assets WHERE id = ? AND status = 'deleting'",
);
const completeAsset = database.transaction((metadata) => {
const result = markReady.run(metadata);
if (result.changes !== 1) {
throw new Error("Asset processing state changed before completion");
}
return findById.get(metadata.id);
});
function getRaw(assetId) {
const row = findById.get(assetId);
if (!row) throw assetNotFound(assetId);
return row;
}
function duplicateStateError(row) {
if (row.status === "failed") {
return new AssetError(
"ASSET_PROCESSING_FAILED",
"These bytes match an asset whose processing previously failed",
{ statusCode: 409, details: { asset: mapAsset(row) } },
);
}
if (row.status === "deleting") {
return new AssetError(
"ASSET_DELETING",
"These bytes match an asset that is being deleted",
{ statusCode: 409, details: { assetId: row.id } },
);
}
return new AssetError(
"ASSET_PROCESSING",
"These bytes are already being processed",
{ statusCode: 409, details: { assetId: row.id } },
);
}
async function resolveExistingHash(row) {
if (row.status === "ready") {
return { created: false, duplicate: true, asset: mapAsset(row) };
}
if (row.status !== "processing") {
throw duplicateStateError(row);
}
const deadline = Date.now() + concurrentUploadWaitMs;
while (Date.now() < deadline) {
await wait(CONCURRENT_UPLOAD_POLL_MS);
const latest = findByHash.get(row.source_sha256);
if (!latest) break;
if (latest.status === "ready") {
return { created: false, duplicate: true, asset: mapAsset(latest) };
}
if (latest.status !== "processing") {
throw duplicateStateError(latest);
}
}
throw duplicateStateError(findByHash.get(row.source_sha256) || row);
}
function nameConflict(displayName, row = findByName.get(displayName)) {
return new AssetError(
"ASSET_NAME_CONFLICT",
"Another library asset already uses that display name",
{
statusCode: 409,
details: row ? { asset: mapAsset(row) } : { displayName },
},
);
}
function processingMetadata(id, processed, keys, error) {
return {
id,
processingError: error ? boundedProcessingError(error) : null,
originalObjectKey: keys.original,
originalContentType: processed.original.contentType,
originalByteSize: processed.original.byteSize,
originalWidth: processed.original.width,
originalHeight: processed.original.height,
displayObjectKey: keys.display,
displaySha256: processed.display.sha256,
displayContentType: processed.display.contentType,
displayByteSize: processed.display.byteSize,
displayWidth: processed.display.width,
displayHeight: processed.display.height,
thumbnailObjectKey: keys.thumbnail,
thumbnailSha256: processed.thumbnail.sha256,
thumbnailContentType: processed.thumbnail.contentType,
thumbnailByteSize: processed.thumbnail.byteSize,
thumbnailWidth: processed.thumbnail.width,
thumbnailHeight: processed.thumbnail.height,
updatedAt: now(),
};
}
async function cleanObjects(keys) {
const results = await Promise.allSettled(
[...new Set(Object.values(keys).filter(Boolean))].map((key) =>
storage.deleteObject(key),
),
);
const rejected = results.find((result) => result.status === "rejected");
if (rejected) throw rejected.reason;
}
async function createProcessedAsset(plan) {
const id = createId();
const timestamp = now();
try {
insertProcessing.run({
id,
sourceSha256: plan.sourceSha256,
displayName: plan.displayName,
originalFilename: plan.file.originalFilename,
createdAt: timestamp,
updatedAt: timestamp,
});
} catch (error) {
const hashMatch = findByHash.get(plan.sourceSha256);
if (hashMatch) return resolveExistingHash(hashMatch);
const nameMatch = findByName.get(plan.displayName);
if (nameMatch) throw nameConflict(plan.displayName, nameMatch);
throw error;
}
const keys = objectKeys(id, plan.processed);
try {
await storage.putObject({
key: keys.original,
body: fs.createReadStream(plan.file.path),
contentType: plan.processed.original.contentType,
contentLength: plan.processed.original.byteSize,
metadata: { sha256: plan.processed.original.sha256 },
});
await storage.putObject({
key: keys.display,
body: plan.processed.display.buffer,
contentType: plan.processed.display.contentType,
contentLength: plan.processed.display.byteSize,
metadata: { sha256: plan.processed.display.sha256 },
});
await storage.putObject({
key: keys.thumbnail,
body: plan.processed.thumbnail.buffer,
contentType: plan.processed.thumbnail.contentType,
contentLength: plan.processed.thumbnail.byteSize,
metadata: { sha256: plan.processed.thumbnail.sha256 },
});
const row = completeAsset(processingMetadata(id, plan.processed, keys));
return { created: true, duplicate: false, asset: mapAsset(row) };
} catch (error) {
const failure = processingMetadata(id, plan.processed, keys, error);
try {
markFailed.run(failure);
} catch {
// Preserve the original failure; the processing row remains diagnosable.
}
try {
await cleanObjects(keys);
} catch {
// The failed row retains deterministic keys so DELETE can retry cleanup.
}
if (error?.statusCode) {
error.details = { ...(error.details || {}), assetId: id, retryable: true };
}
throw error;
}
}
async function upload(files, { displayName = null } = {}) {
if (!Array.isArray(files) || files.length === 0) {
throw new AssetError(
"UPLOAD_FILE_REQUIRED",
"At least one image file is required",
);
}
const plans = [];
const firstPlanByHash = new Map();
const plannedNames = new Map();
for (const [index, file] of files.entries()) {
const identity = await hashFile(file.path);
if (identity.byteSize !== file.byteSize) {
throw new AssetError(
"UPLOAD_CHANGED_DURING_PROCESSING",
"An uploaded temporary file changed before processing",
{ statusCode: 409 },
);
}
const existing = findByHash.get(identity.sourceSha256);
if (existing) {
plans.push({ index, existing });
continue;
}
const priorPlan = firstPlanByHash.get(identity.sourceSha256);
if (priorPlan) {
plans.push({ index, duplicateOf: priorPlan });
continue;
}
const proposedName = normalizeDisplayName(
displayName ?? defaultDisplayName(file.originalFilename),
);
const existingName = findByName.get(proposedName);
if (existingName) throw nameConflict(proposedName, existingName);
const normalizedNameKey = proposedName.toLowerCase();
const batchNameMatch = plannedNames.get(normalizedNameKey);
if (batchNameMatch) throw nameConflict(proposedName);
const plan = {
index,
file,
displayName: proposedName,
sourceSha256: identity.sourceSha256,
};
firstPlanByHash.set(identity.sourceSha256, plan);
plannedNames.set(normalizedNameKey, plan);
plans.push(plan);
}
// Decode every new image before any database or object-storage mutation.
for (const plan of plans) {
if (plan.existing || plan.duplicateOf) continue;
plan.processed = await imageProcessor.process(plan.file.path);
if (plan.processed.original.sha256 !== plan.sourceSha256) {
throw new AssetError(
"UPLOAD_CHANGED_DURING_PROCESSING",
"An uploaded temporary file changed before processing",
{ statusCode: 409 },
);
}
}
const resultByPlan = new Map();
const results = new Array(files.length);
for (const plan of plans) {
let result;
if (plan.existing) {
result = await resolveExistingHash(plan.existing);
} else if (plan.duplicateOf) {
const originalResult = resultByPlan.get(plan.duplicateOf);
result = {
created: false,
duplicate: true,
asset: originalResult.asset,
};
} else {
result = await createProcessedAsset(plan);
resultByPlan.set(plan, result);
}
results[plan.index] = result;
}
return results;
}
function list() {
return listAssets.all().map(mapAsset);
}
function get(assetId) {
return mapAsset(getRaw(assetId));
}
function update(assetId, input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new AssetError("INVALID_ASSET_UPDATE", "Asset update must be an object");
}
const allowed = new Set([
"displayName",
"display_name",
"expirationAt",
"expiration_at",
]);
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new AssetError(
"INVALID_ASSET_UPDATE",
"Asset update contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (Object.keys(input).length === 0) {
throw new AssetError("INVALID_ASSET_UPDATE", "At least one field is required");
}
if (
Object.hasOwn(input, "displayName") &&
Object.hasOwn(input, "display_name")
) {
throw new AssetError("INVALID_ASSET_UPDATE", "Display name was provided twice");
}
if (
Object.hasOwn(input, "expirationAt") &&
Object.hasOwn(input, "expiration_at")
) {
throw new AssetError("INVALID_ASSET_UPDATE", "Expiration was provided twice");
}
const current = getRaw(assetId);
if (current.status === "deleting") {
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
statusCode: 409,
details: { assetId },
});
}
const hasDisplayName =
Object.hasOwn(input, "displayName") || Object.hasOwn(input, "display_name");
const hasExpiration =
Object.hasOwn(input, "expirationAt") || Object.hasOwn(input, "expiration_at");
const nextDisplayName = hasDisplayName
? normalizeDisplayName(
Object.hasOwn(input, "displayName")
? input.displayName
: input.display_name,
)
: current.display_name;
const nextExpiration = hasExpiration
? normalizeExpiration(
Object.hasOwn(input, "expirationAt")
? input.expirationAt
: input.expiration_at,
)
: current.expiration_at;
if (
nextDisplayName === current.display_name &&
nextExpiration === current.expiration_at
) {
return mapAsset(current);
}
try {
const result = updateEditable.run({
id: assetId,
displayName: nextDisplayName,
expirationAt: nextExpiration,
updatedAt: Math.max(now(), current.updated_at + 1),
});
if (result.changes !== 1) {
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
statusCode: 409,
details: { assetId },
});
}
} catch (error) {
const nameMatch = findByName.get(nextDisplayName);
if (nameMatch && nameMatch.id !== assetId) {
throw nameConflict(nextDisplayName, nameMatch);
}
throw error;
}
return mapAsset(getRaw(assetId));
}
function references(assetId) {
const asset = getRaw(assetId);
const playlists = playlistReferences.all(assetId).map((row) => ({
playlistId: row.playlist_id,
playlistName: row.playlist_name,
playlistEntryId: row.entry_id,
enabled: Boolean(row.enabled),
position: row.position,
isActivePlaylist: Boolean(row.is_active_playlist),
isCurrentNormalTarget: Boolean(row.is_current_normal_target),
}));
const playback = playbackReferences.all(assetId, assetId).map((row) => ({
outputId: row.output_id,
mode: row.mode,
activePlaylistId: row.active_playlist_id,
currentPlaylistEntryId: row.current_entry_id,
isCurrentNormalTarget: playlists.some(
(entry) => entry.playlistEntryId === row.current_entry_id,
),
isCurrentStaticTarget: row.static_asset_id === assetId,
}));
const currentNormalTarget = playback.some((state) => state.isCurrentNormalTarget);
const currentStaticTarget = playback.some((state) => state.isCurrentStaticTarget);
const blockerCodes = [];
if (playlists.length > 0) blockerCodes.push("PLAYLIST_REFERENCES");
if (currentNormalTarget) blockerCodes.push("CURRENT_PLAYBACK_TARGET");
if (currentStaticTarget) blockerCodes.push("CURRENT_STATIC_TARGET");
if (asset.status === "processing") blockerCodes.push("ASSET_PROCESSING");
if (asset.status === "deleting") blockerCodes.push("ASSET_DELETING");
return {
assetId,
playlists,
playback,
currentNormalTarget,
currentStaticTarget,
canDelete: blockerCodes.length === 0,
blockerCodes,
};
}
const beginDelete = database.transaction((assetId) => {
const asset = getRaw(assetId);
const referenceInfo = references(assetId);
const relationshipBlockers = referenceInfo.blockerCodes.filter((code) =>
[
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
].includes(code),
);
if (relationshipBlockers.length > 0) {
throw new AssetError(
referenceInfo.playlists.length > 0 ? "ASSET_REFERENCED" : "ASSET_IN_USE",
"The library asset is still referenced and cannot be deleted",
{
statusCode: 409,
details: { ...referenceInfo, blockerCodes: relationshipBlockers },
},
);
}
if (asset.status === "processing") {
throw new AssetError(
"ASSET_PROCESSING",
"An asset cannot be deleted while it is processing",
{ statusCode: 409, details: { assetId } },
);
}
if (asset.status !== "deleting") {
markDeleting.run(Math.max(now(), asset.updated_at + 1), assetId);
}
return findById.get(assetId);
});
async function remove(assetId) {
const deleting = beginDelete(assetId);
const keys = {
original: deleting.original_object_key,
display: deleting.display_object_key,
thumbnail: deleting.thumbnail_object_key,
};
try {
await cleanObjects(keys);
} catch (error) {
if (error?.statusCode) {
error.details = { ...(error.details || {}), assetId, retryable: true };
}
throw error;
}
try {
const result = deleteAssetRow.run(assetId);
if (result.changes !== 1) throw assetNotFound(assetId);
} catch (error) {
if (error?.code?.startsWith?.("SQLITE_CONSTRAINT")) {
throw new AssetError(
"ASSET_REFERENCED",
"The library asset became referenced before deletion completed",
{ statusCode: 409, details: { assetId, retryable: true } },
);
}
throw error;
}
return { id: assetId, deleted: true };
}
function mediaDescriptor(assetId, kind, requestedHash) {
if (!SHA256_PATTERN.test(requestedHash)) {
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
statusCode: 404,
});
}
const asset = getRaw(assetId);
const fields =
kind === "display"
? {
hash: asset.display_sha256,
key: asset.display_object_key,
contentType: asset.display_content_type,
contentLength: asset.display_byte_size,
}
: {
hash: asset.thumbnail_sha256,
key: asset.thumbnail_object_key,
contentType: asset.thumbnail_content_type,
contentLength: asset.thumbnail_byte_size,
};
if (asset.status !== "ready" || fields.hash !== requestedHash || !fields.key) {
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
statusCode: 404,
});
}
return { asset, kind, ...fields };
}
function originalDescriptor(assetId) {
const asset = getRaw(assetId);
if (asset.status !== "ready" || !asset.original_object_key) {
throw new AssetError("ASSET_ORIGINAL_NOT_READY", "Asset original is not ready", {
statusCode: 409,
details: { assetId, status: asset.status },
});
}
return {
asset,
key: asset.original_object_key,
hash: asset.source_sha256,
contentType: asset.original_content_type,
contentLength: asset.original_byte_size,
};
}
async function readStoredObject(descriptor) {
try {
return await storage.getObject(descriptor.key);
} catch (error) {
if (error instanceof StorageObjectNotFoundError) {
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Asset metadata exists but its stored bytes are unavailable",
{
statusCode: 503,
details: { assetId: descriptor.asset.id, retryable: true },
cause: error,
},
);
}
throw error;
}
}
return Object.freeze({
list,
get,
upload,
update,
references,
remove,
mediaDescriptor,
originalDescriptor,
readStoredObject,
});
}
module.exports = {
createAssetService,
defaultDisplayName,
mapAsset,
normalizeDisplayName,
normalizeExpiration,
};
+66
View File
@@ -0,0 +1,66 @@
const { Readable } = require("node:stream");
const { pipeline } = require("node:stream/promises");
const { AssetError } = require("./errors");
function readableBody(body) {
if (body && typeof body.pipe === "function") return body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
return Readable.from([body]);
}
if (body && typeof body.getReader === "function") {
return Readable.fromWeb(body);
}
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Stored asset bytes could not be streamed",
{ statusCode: 503 },
);
}
function validateDescriptor(descriptor) {
if (
typeof descriptor.contentType !== "string" ||
!Number.isSafeInteger(descriptor.contentLength) ||
descriptor.contentLength < 0
) {
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Stored asset metadata is incomplete",
{ statusCode: 503 },
);
}
}
function setCommonHeaders(response, descriptor) {
response.set("Content-Type", descriptor.contentType);
response.set("Content-Length", String(descriptor.contentLength));
response.set("X-Content-Type-Options", "nosniff");
}
async function storedBody(assetService, descriptor) {
const stored = await assetService.readStoredObject(descriptor);
return readableBody(stored.body);
}
async function sendImmutableMedia(assetService, response, descriptor) {
validateDescriptor(descriptor);
const body = await storedBody(assetService, descriptor);
setCommonHeaders(response, descriptor);
response.set("Cache-Control", "public, max-age=31536000, immutable");
response.set("ETag", `"${descriptor.hash}"`);
await pipeline(body, response);
}
async function sendOriginal(assetService, response, descriptor) {
validateDescriptor(descriptor);
const body = await storedBody(assetService, descriptor);
response.attachment(descriptor.asset.original_filename);
setCommonHeaders(response, descriptor);
response.set("Cache-Control", "private, no-store");
await pipeline(body, response);
}
module.exports = {
sendImmutableMedia,
sendOriginal,
};
+256
View File
@@ -0,0 +1,256 @@
const path = require("node:path");
class ConfigError extends Error {
constructor(message, field) {
super(message);
this.name = "ConfigError";
this.field = field;
}
}
function requiredString(env, name) {
const value = env[name]?.trim();
if (!value) {
throw new ConfigError(`${name} is required`, name);
}
return value;
}
function optionalString(env, name) {
const value = env[name]?.trim();
return value || null;
}
function integer(env, name, defaultValue, { min, max } = {}) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
if (!/^-?\d+$/.test(raw)) {
throw new ConfigError(`${name} must be an integer`, name);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new ConfigError(`${name} must be a safe integer`, name);
}
if (min !== undefined && value < min) {
throw new ConfigError(`${name} must be at least ${min}`, name);
}
if (max !== undefined && value > max) {
throw new ConfigError(`${name} must be at most ${max}`, name);
}
return value;
}
function boolean(env, name, defaultValue) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
const normalized = raw.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off"].includes(normalized)) {
return false;
}
throw new ConfigError(`${name} must be a boolean`, name);
}
function url(env, name, { required = false, protocols } = {}) {
const raw = required ? requiredString(env, name) : optionalString(env, name);
if (raw === null) {
return null;
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new ConfigError(`${name} must be a valid URL`, name);
}
if (protocols && !protocols.includes(parsed.protocol)) {
throw new ConfigError(
`${name} must use one of these protocols: ${protocols.join(", ")}`,
name,
);
}
return parsed.toString().replace(/\/$/, "");
}
function bucketName(env) {
const value = requiredString(env, "S3_BUCKET");
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
throw new ConfigError(
"S3_BUCKET must be a valid lowercase S3 bucket name",
"S3_BUCKET",
);
}
return value;
}
function nodeEnvironment(env) {
const value = env.NODE_ENV?.trim() || "development";
if (!["development", "test", "production"].includes(value)) {
throw new ConfigError(
"NODE_ENV must be development, test, or production",
"NODE_ENV",
);
}
return value;
}
function timeZone(env) {
const value = requiredString(env, "APP_TIMEZONE");
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
} catch {
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
}
return value;
}
function storageCredential(env, name, environment) {
const value = optionalString(env, name);
if (environment === "production" && value === null) {
throw new ConfigError(`${name} is required in production`, name);
}
return value;
}
function loadConfig(env = process.env) {
const environment = nodeEnvironment(env);
const databasePath = requiredString(env, "DATABASE_PATH");
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
}
return Object.freeze({
environment,
host: env.HOST?.trim() || "0.0.0.0",
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
timeZone: timeZone(env),
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
min: 1_000,
max: 60_000,
}),
trustProxy: boolean(env, "TRUST_PROXY", true),
database: Object.freeze({
path: databasePath,
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
min: 0,
max: 60_000,
}),
}),
storage: Object.freeze({
endpoint: url(env, "S3_ENDPOINT", {
required: true,
protocols: ["http:", "https:"],
}),
region: env.S3_REGION?.trim() || "us-east-1",
bucket: bucketName(env),
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
healthProbeIntervalMs: integer(
env,
"S3_HEALTH_PROBE_INTERVAL_MS",
30_000,
{ min: 1_000, max: 300_000 },
),
connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, {
min: 250,
max: 60_000,
}),
requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, {
min: 1_000,
max: 120_000,
}),
maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }),
}),
processing: Object.freeze({
tempDirectory: (() => {
const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads";
if (!path.isAbsolute(value)) {
throw new ConfigError(
"UPLOAD_TEMP_DIRECTORY must be an absolute path",
"UPLOAD_TEMP_DIRECTORY",
);
}
return value;
})(),
maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, {
min: 1,
max: 1024 * 1024 * 1024,
}),
maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }),
maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, {
min: 1,
max: 500_000_000,
}),
}),
mqtt: Object.freeze({
url: url(env, "MQTT_URL", {
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
}),
username: optionalString(env, "MQTT_USERNAME"),
password: optionalString(env, "MQTT_PASSWORD"),
}),
notifications: Object.freeze({
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
protocols: ["http:", "https:"],
}),
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
}),
});
}
function redactedConfig(config) {
return {
environment: config.environment,
host: config.host,
port: config.port,
timeZone: config.timeZone,
trustProxy: config.trustProxy,
database: {
configured: Boolean(config.database.path),
},
storage: {
configured: Boolean(
config.storage.endpoint &&
config.storage.bucket &&
config.storage.accessKeyId &&
config.storage.secretAccessKey,
),
region: config.storage.region,
bucket: config.storage.bucket,
forcePathStyle: config.storage.forcePathStyle,
healthProbeIntervalMs: config.storage.healthProbeIntervalMs,
connectionTimeoutMs: config.storage.connectionTimeoutMs,
requestTimeoutMs: config.storage.requestTimeoutMs,
maxAttempts: config.storage.maxAttempts,
},
processing: {
tempDirectoryConfigured: Boolean(config.processing.tempDirectory),
maxFileBytes: config.processing.maxFileBytes,
maxFiles: config.processing.maxFiles,
maxInputPixels: config.processing.maxInputPixels,
},
mqtt: {
configured: Boolean(config.mqtt.url),
},
notifications: {
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
},
};
}
module.exports = {
ConfigError,
loadConfig,
redactedConfig,
};
+32
View File
@@ -0,0 +1,32 @@
const fs = require("node:fs");
const path = require("node:path");
const Database = require("better-sqlite3");
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
if (filename !== ":memory:") {
fs.mkdirSync(path.dirname(filename), { recursive: true });
}
const database = new Database(filename, {
timeout: busyTimeoutMs,
});
database.pragma("foreign_keys = ON");
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
if (filename !== ":memory:") {
database.pragma("journal_mode = WAL");
database.pragma("synchronous = NORMAL");
}
return database;
}
function checkDatabase(database) {
database.prepare("SELECT 1 AS ok").get();
return true;
}
module.exports = {
checkDatabase,
openDatabase,
};
+122
View File
@@ -0,0 +1,122 @@
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
const migrations = fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => {
const match = MIGRATION_FILENAME.exec(entry.name);
if (!match) {
throw new Error(`Invalid migration filename: ${entry.name}`);
}
return {
version: Number(match[1]),
name: match[2],
filename: entry.name,
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
};
})
.sort((left, right) => left.version - right.version);
const versions = new Set();
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
throw new Error(`Invalid migration version: ${migration.filename}`);
}
if (versions.has(migration.version)) {
throw new Error(`Duplicate migration version: ${migration.version}`);
}
versions.add(migration.version);
}
return migrations;
}
function ensureMigrationsTable(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at INTEGER NOT NULL
)
`);
}
function readAppliedMigrations(database) {
return database
.prepare(
`SELECT version, name, applied_at AS appliedAt
FROM schema_migrations
ORDER BY version`,
)
.all();
}
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const available = discoverMigrations(directory);
const applied = readAppliedMigrations(database);
const appliedVersions = new Set(applied.map((migration) => migration.version));
return {
currentVersion: applied.at(-1)?.version ?? 0,
latestAvailableVersion: available.at(-1)?.version ?? 0,
pendingVersions: available
.filter((migration) => !appliedVersions.has(migration.version))
.map((migration) => migration.version),
applied,
};
}
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const migrations = discoverMigrations(directory);
const applied = new Map(
readAppliedMigrations(database).map((migration) => [
migration.version,
migration.name,
]),
);
const availableVersions = new Set(migrations.map((migration) => migration.version));
for (const [version] of applied) {
if (!availableVersions.has(version)) {
throw new Error(`Database contains unknown migration version ${version}`);
}
}
const insertMigration = database.prepare(
`INSERT INTO schema_migrations (version, name, applied_at)
VALUES (?, ?, ?)`,
);
const applyMigration = database.transaction((migration) => {
database.exec(migration.sql);
insertMigration.run(migration.version, migration.name, Date.now());
});
for (const migration of migrations) {
const appliedName = applied.get(migration.version);
if (appliedName !== undefined) {
if (appliedName !== migration.name) {
throw new Error(
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
);
}
continue;
}
applyMigration(migration);
}
return migrationStatus(database, directory);
}
module.exports = {
DEFAULT_MIGRATIONS_DIRECTORY,
discoverMigrations,
migrationStatus,
runMigrations,
};
@@ -0,0 +1,214 @@
CREATE TABLE assets (
id TEXT PRIMARY KEY,
source_sha256 TEXT NOT NULL UNIQUE
CHECK (length(source_sha256) = 64),
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(display_name)) > 0),
original_filename TEXT NOT NULL
CHECK (length(trim(original_filename)) > 0),
status TEXT NOT NULL
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
processing_error TEXT,
expiration_at INTEGER,
original_object_key TEXT,
original_content_type TEXT,
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
display_object_key TEXT,
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
display_content_type TEXT,
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
thumbnail_object_key TEXT,
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
thumbnail_content_type TEXT,
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(name)) > 0),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE playlist_entries (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
duration_override_ms INTEGER
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
background_mode_override TEXT
CHECK (
background_mode_override IS NULL OR
background_mode_override IN ('blurred', 'solid', 'black')
),
background_color_override TEXT,
background_darkened_override INTEGER
CHECK (
background_darkened_override IS NULL OR
background_darkened_override IN (0, 1)
),
schedule_start_at INTEGER,
schedule_end_at INTEGER,
position INTEGER NOT NULL CHECK (position >= 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
UNIQUE (playlist_id, position),
UNIQUE (playlist_id, asset_id),
CHECK (
schedule_start_at IS NULL OR
schedule_end_at IS NULL OR
schedule_start_at < schedule_end_at
)
);
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
CREATE INDEX playlist_entries_schedule_idx
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
CREATE TABLE app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
default_duration_ms INTEGER NOT NULL DEFAULT 7000
CHECK (default_duration_ms > 0),
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
CHECK (length(trim(default_solid_background_color)) > 0),
default_background_darkened INTEGER NOT NULL DEFAULT 0
CHECK (default_background_darkened IN (0, 1)),
default_blur_px INTEGER NOT NULL DEFAULT 20
CHECK (default_blur_px >= 0),
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
CHECK (crossfade_duration_ms >= 0),
updated_at INTEGER NOT NULL
);
INSERT INTO app_settings (id, updated_at)
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE playback_state (
output_id TEXT PRIMARY KEY,
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
active_playlist_id TEXT,
pending_playlist_id TEXT,
pending_playlist_requested_at INTEGER,
current_entry_id TEXT,
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
paused_remaining_ms INTEGER
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
static_asset_id TEXT,
slide_started_at INTEGER,
next_slide_at INTEGER,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_at INTEGER NOT NULL,
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CHECK (
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
)
);
INSERT INTO playback_state (output_id, updated_at)
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE asset_import_sources (
id TEXT PRIMARY KEY,
source_system TEXT NOT NULL,
source_bucket TEXT NOT NULL,
source_key TEXT NOT NULL,
source_etag TEXT,
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
asset_id TEXT,
initial_playlist_entry_id TEXT,
status TEXT NOT NULL
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
error TEXT,
imported_at INTEGER,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
UNIQUE (source_system, source_bucket, source_key)
);
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
CREATE TABLE notification_incidents (
id TEXT PRIMARY KEY,
condition_key TEXT NOT NULL,
episode INTEGER NOT NULL CHECK (episode > 0),
event TEXT NOT NULL,
severity TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
message TEXT NOT NULL,
context_json TEXT NOT NULL DEFAULT '{}',
opened_at INTEGER NOT NULL,
last_observed_at INTEGER NOT NULL,
recovered_at INTEGER,
UNIQUE (condition_key, episode),
CHECK (
(state = 'open' AND recovered_at IS NULL) OR
(state = 'recovered' AND recovered_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX notification_incidents_one_open_idx
ON notification_incidents (condition_key)
WHERE state = 'open';
CREATE TABLE notification_outbox (
id TEXT PRIMARY KEY,
incident_id TEXT NOT NULL,
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
next_attempt_at INTEGER NOT NULL,
delivered_at INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
UNIQUE (incident_id, transition)
);
CREATE INDEX notification_outbox_due_idx
ON notification_outbox (next_attempt_at)
WHERE delivered_at IS NULL;
CREATE TABLE display_clients (
client_id TEXT PRIMARY KEY,
output_id TEXT NOT NULL DEFAULT 'global',
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
active_playlist_id TEXT,
playlist_revision INTEGER,
playback_revision INTEGER,
current_entry_id TEXT,
current_asset_id TEXT,
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
last_error TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
+143
View File
@@ -0,0 +1,143 @@
const express = require("express");
const { checkDatabase } = require("../database");
const { redactedConfig } = require("../config");
function databaseHealth(database) {
try {
checkDatabase(database);
return { status: "ok" };
} catch (error) {
return {
status: "error",
message: "SQLite is unavailable",
};
}
}
function storageHealth(storage) {
if (!storage) {
return {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_NOT_INITIALIZED",
message: "Object storage has not been initialized",
};
}
try {
const result = storage.health();
if (result?.status === "healthy") {
return {
status: "healthy",
checkedAt: result.checkedAt || null,
lastSuccessAt: result.lastSuccessAt || result.checkedAt || null,
};
}
return {
status: "degraded",
checkedAt: result?.checkedAt || null,
lastSuccessAt: result?.lastSuccessAt || null,
code:
result?.code === "STORAGE_NOT_CHECKED"
? "STORAGE_NOT_CHECKED"
: "STORAGE_UNAVAILABLE",
message:
result?.code === "STORAGE_NOT_CHECKED"
? "Object storage has not been checked"
: "Object storage is unavailable",
};
} catch {
// A health endpoint must remain available when its dependency is not.
}
return {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
}
function createHealthRouter({ database, lifecycle, migrations, storage }) {
const router = express.Router();
router.get("/live", (request, response) => {
response.json({ status: "ok" });
});
router.get("/ready", async (request, response) => {
const databaseCheck = databaseHealth(database);
const storageCheck = storageHealth(storage);
const migrationsCurrent = migrations.pendingVersions.length === 0;
const ready =
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
response.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
checks: {
database: databaseCheck.status,
migrations: migrationsCurrent ? "current" : "pending",
storage: storageCheck.status,
},
});
});
return router;
}
function createDetailedHealthRouter({ config, database, lifecycle, migrations, storage }) {
const router = express.Router();
router.get("/", async (request, response) => {
const databaseCheck = databaseHealth(database);
const storageCheck = storageHealth(storage);
const ready =
lifecycle.ready &&
databaseCheck.status === "ok" &&
migrations.pendingVersions.length === 0;
const configuration = redactedConfig(config);
const status = !ready
? "not_ready"
: storageCheck.status === "healthy"
? "ok"
: "degraded";
response.status(ready ? 200 : 503).json({
status,
configuration,
services: {
database: {
...databaseCheck,
currentSchemaVersion: migrations.currentVersion,
latestSchemaVersion: migrations.latestAvailableVersion,
},
storage: {
...storageCheck,
configured: configuration.storage.configured,
},
mqtt: {
status: "not_started",
configured: configuration.mqtt.configured,
},
notifications: {
status: "not_started",
configured: configuration.notifications.configured,
},
playback: { status: "not_started" },
displays: { status: "not_started" },
},
});
});
return router;
}
module.exports = {
createDetailedHealthRouter,
createHealthRouter,
databaseHealth,
storageHealth,
};
+330
View File
@@ -0,0 +1,330 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const sharp = require("sharp");
const SUPPORTED_FORMATS = Object.freeze(new Set(["jpeg", "png", "webp"]));
const CONTENT_TYPES = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
const BLACK = Object.freeze({ r: 0, g: 0, b: 0, alpha: 1 });
const DEFAULT_IMAGE_DERIVATIVE_OPTIONS = Object.freeze({
display: Object.freeze({ maxWidth: 1920, maxHeight: 1080, quality: 82 }),
thumbnail: Object.freeze({ maxWidth: 480, maxHeight: 270, quality: 72 }),
});
class ImageProcessingError extends Error {
constructor(code, message, { statusCode = 422, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "ImageProcessingError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function requireInteger(value, name, { min, max } = {}) {
if (!Number.isSafeInteger(value)) {
throw new TypeError(`${name} must be an integer`);
}
if (min !== undefined && value < min) {
throw new RangeError(`${name} must be at least ${min}`);
}
if (max !== undefined && value > max) {
throw new RangeError(`${name} must be at most ${max}`);
}
return value;
}
function normalizeTargetOptions(target, name, defaults) {
if (
target !== undefined &&
(target === null || typeof target !== "object" || Array.isArray(target))
) {
throw new TypeError(`${name} image options must be an object`);
}
const configured = { ...defaults, ...target };
return Object.freeze({
maxWidth: requireInteger(configured.maxWidth, `${name}.maxWidth`, { min: 1 }),
maxHeight: requireInteger(configured.maxHeight, `${name}.maxHeight`, { min: 1 }),
quality: requireInteger(configured.quality, `${name}.quality`, {
min: 1,
max: 100,
}),
});
}
function normalizeOptions(options) {
if (options === null || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Image processing options are required");
}
return Object.freeze({
maxInputPixels: requireInteger(
options.maxInputPixels,
"maxInputPixels",
{ min: 1 },
),
display: normalizeTargetOptions(
options.display,
"display",
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.display,
),
thumbnail: normalizeTargetOptions(
options.thumbnail,
"thumbnail",
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.thumbnail,
),
});
}
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function hashFile(filename) {
const hash = crypto.createHash("sha256");
let byteSize = 0;
try {
for await (const chunk of fs.createReadStream(filename)) {
byteSize += chunk.length;
hash.update(chunk);
}
} catch (error) {
throw new ImageProcessingError(
"IMAGE_INPUT_UNREADABLE",
"The uploaded image could not be read",
{ statusCode: 422, cause: error },
);
}
return { byteSize, sha256: hash.digest("hex") };
}
async function describeInput(input) {
if (Buffer.isBuffer(input)) {
return {
sharpInput: input,
byteSize: input.length,
sha256: sha256(input),
};
}
if (typeof input !== "string" || input.length === 0) {
throw new ImageProcessingError(
"INVALID_IMAGE_INPUT",
"Image input must be a Buffer or temporary file path",
{ statusCode: 400 },
);
}
let stats;
try {
stats = await fs.promises.stat(input);
} catch (error) {
throw new ImageProcessingError(
"IMAGE_INPUT_UNREADABLE",
"The uploaded image could not be read",
{ statusCode: 422, cause: error },
);
}
if (!stats.isFile()) {
throw new ImageProcessingError(
"INVALID_IMAGE_INPUT",
"Image input path must identify a regular file",
{ statusCode: 400 },
);
}
return {
sharpInput: input,
...(await hashFile(input)),
};
}
function decodeError(error, maxInputPixels) {
if (error instanceof ImageProcessingError) {
return error;
}
if (/pixel limit/i.test(error?.message || "")) {
return new ImageProcessingError(
"IMAGE_PIXEL_LIMIT_EXCEEDED",
"The uploaded image exceeds the configured decoded-pixel limit",
{
statusCode: 413,
details: { maxInputPixels },
cause: error,
},
);
}
return new ImageProcessingError(
"MALFORMED_IMAGE",
"The uploaded file is not a valid decodable image",
{ statusCode: 422, cause: error },
);
}
async function readMetadata(input, maxInputPixels) {
try {
return await sharp(input, {
animated: true,
failOn: "error",
limitInputPixels: maxInputPixels,
}).metadata();
} catch (error) {
throw decodeError(error, maxInputPixels);
}
}
function validateMetadata(metadata) {
const pages = metadata.pages ?? 1;
if (pages > 1) {
const hasAnimationTiming =
metadata.loop !== undefined ||
(Array.isArray(metadata.delay) && metadata.delay.length > 0);
throw new ImageProcessingError(
hasAnimationTiming
? "ANIMATED_IMAGE_NOT_SUPPORTED"
: "MULTIPAGE_IMAGE_NOT_SUPPORTED",
hasAnimationTiming
? "Animated images are not supported"
: "Multipage images are not supported",
{ statusCode: 422, details: { pages } },
);
}
if (!SUPPORTED_FORMATS.has(metadata.format)) {
throw new ImageProcessingError(
"UNSUPPORTED_IMAGE_FORMAT",
"Only still JPEG, PNG, and WebP images are supported",
{
statusCode: 415,
details: {
detectedFormat: metadata.format || null,
supportedFormats: [...SUPPORTED_FORMATS],
},
},
);
}
if (
!Number.isSafeInteger(metadata.width) ||
metadata.width < 1 ||
!Number.isSafeInteger(metadata.height) ||
metadata.height < 1
) {
throw new ImageProcessingError(
"MALFORMED_IMAGE",
"The uploaded image does not contain valid dimensions",
{ statusCode: 422 },
);
}
}
async function generateDerivative(input, target, maxInputPixels) {
let output;
try {
output = await sharp(input, {
failOn: "error",
limitInputPixels: maxInputPixels,
})
.autoOrient()
.flatten({ background: BLACK })
.resize({
width: target.maxWidth,
height: target.maxHeight,
fit: "inside",
withoutEnlargement: true,
})
.toColourspace("srgb")
.webp({ quality: target.quality, effort: 4 })
.toBuffer({ resolveWithObject: true });
} catch (error) {
throw decodeError(error, maxInputPixels);
}
return {
buffer: output.data,
sha256: sha256(output.data),
format: "webp",
contentType: CONTENT_TYPES.webp,
byteSize: output.data.length,
width: output.info.width,
height: output.info.height,
};
}
function originalMetadata(metadata, inputDescription) {
const orientedDimensions = metadata.autoOrient || {
width: metadata.width,
height: metadata.height,
};
return {
sha256: inputDescription.sha256,
format: metadata.format,
contentType: CONTENT_TYPES[metadata.format],
byteSize: inputDescription.byteSize,
// Width and height describe how the original should appear after applying
// its source orientation. The encoded dimensions remain available for
// diagnostics without implying that the archived bytes were rewritten.
width: orientedDimensions.width,
height: orientedDimensions.height,
encodedWidth: metadata.width,
encodedHeight: metadata.height,
orientation: metadata.orientation ?? null,
pages: metadata.pages ?? 1,
channels: metadata.channels,
hasAlpha: Boolean(metadata.hasAlpha),
};
}
function createImageProcessor(options) {
const config = normalizeOptions(options);
async function process(input) {
const inputDescription = await describeInput(input);
const metadata = await readMetadata(
inputDescription.sharpInput,
config.maxInputPixels,
);
validateMetadata(metadata);
const [display, thumbnail] = await Promise.all([
generateDerivative(
inputDescription.sharpInput,
config.display,
config.maxInputPixels,
),
generateDerivative(
inputDescription.sharpInput,
config.thumbnail,
config.maxInputPixels,
),
]);
return {
original: originalMetadata(metadata, inputDescription),
display,
thumbnail,
};
}
return Object.freeze({ process });
}
module.exports = {
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
ImageProcessingError,
createImageProcessor,
};
+51
View File
@@ -0,0 +1,51 @@
const path = require("node:path");
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
const { loadConfig, redactedConfig } = require("./config");
const { createRuntime } = require("./runtime");
async function startApplication({ env = process.env, logger = console } = {}) {
const config = loadConfig(env);
const runtime = createRuntime({ config, logger });
const address = await runtime.start();
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
logger.info?.(
`Announcements API listening on http://${displayedHost}:${address.port}`,
);
logger.info?.("Configuration loaded", redactedConfig(config));
let shutdownStarted = false;
const shutdown = async (signal) => {
if (shutdownStarted) return;
shutdownStarted = true;
try {
await runtime.stop(signal);
} catch (error) {
logger.error?.("Graceful shutdown failed", error);
process.exitCode = 1;
}
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
return runtime;
}
async function main() {
try {
await startApplication();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = {
startApplication,
};
+106
View File
@@ -0,0 +1,106 @@
const ELIGIBILITY_REASONS = Object.freeze({
DISABLED: "disabled",
SCHEDULED_FUTURE: "scheduled_future",
SCHEDULED_ENDED: "scheduled_ended",
ASSET_NOT_READY: "asset_not_ready",
ASSET_EXPIRED: "asset_expired",
});
function read(object, snakeCaseName, camelCaseName) {
if (object && Object.hasOwn(object, snakeCaseName)) {
return object[snakeCaseName];
}
return object?.[camelCaseName];
}
function validateNow(nowMs) {
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer");
}
}
function evaluateEntryEligibility(entry, asset, nowMs) {
validateNow(nowMs);
const enabled = read(entry, "enabled", "enabled");
if (enabled !== true && enabled !== 1) {
return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED };
}
const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt");
if (scheduleStartAt != null && nowMs < scheduleStartAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE };
}
const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt");
if (scheduleEndAt != null && nowMs >= scheduleEndAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED };
}
if (read(asset, "status", "status") !== "ready") {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY };
}
const expirationAt = read(asset, "expiration_at", "expirationAt");
if (expirationAt != null && nowMs >= expirationAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED };
}
return { playable: true, reason: null };
}
function resolveEntrySettings(entry, appSettings) {
const durationOverrideMs = read(
entry,
"duration_override_ms",
"durationOverrideMs",
);
const backgroundModeOverride = read(
entry,
"background_mode_override",
"backgroundModeOverride",
);
const backgroundColorOverride = read(
entry,
"background_color_override",
"backgroundColorOverride",
);
const backgroundDarkenedOverride = read(
entry,
"background_darkened_override",
"backgroundDarkenedOverride",
);
return {
durationMs:
durationOverrideMs ??
read(appSettings, "default_duration_ms", "defaultDurationMs"),
presentation: {
backgroundMode:
backgroundModeOverride ??
read(appSettings, "default_background_mode", "defaultBackgroundMode"),
backgroundColor:
backgroundColorOverride ??
read(
appSettings,
"default_solid_background_color",
"defaultSolidBackgroundColor",
),
backgroundDarkened: Boolean(
backgroundDarkenedOverride ??
read(
appSettings,
"default_background_darkened",
"defaultBackgroundDarkened",
),
),
blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"),
},
};
}
module.exports = {
ELIGIBILITY_REASONS,
evaluateEntryEligibility,
resolveEntrySettings,
};
+1
View File
@@ -0,0 +1 @@
module.exports = require("./eligibility");
+29
View File
@@ -0,0 +1,29 @@
class PlaybackError extends Error {
constructor(code, message, { statusCode = 400, details } = {}) {
super(message);
this.name = "PlaybackError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) this.details = details;
}
}
function playlistNotFound(playlistId) {
return new PlaybackError("PLAYLIST_NOT_FOUND", "Playlist not found", {
statusCode: 404,
details: { playlistId },
});
}
function assetNotFound(assetId) {
return new PlaybackError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId },
});
}
module.exports = {
PlaybackError,
assetNotFound,
playlistNotFound,
};
+7
View File
@@ -0,0 +1,7 @@
const { PlaybackError } = require("./errors");
const { createPlaybackService } = require("./service");
module.exports = {
PlaybackError,
createPlaybackService,
};
+75
View File
@@ -0,0 +1,75 @@
const express = require("express");
function createPlaybackRouter(playbackService) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json(playbackService.getSnapshot());
} catch (error) {
next(error);
}
});
router.post("/pause", (request, response, next) => {
try {
response.json(playbackService.pause());
} catch (error) {
next(error);
}
});
router.post("/resume", (request, response, next) => {
try {
response.json(playbackService.resume());
} catch (error) {
next(error);
}
});
router.post("/next", (request, response, next) => {
try {
response.json(playbackService.next());
} catch (error) {
next(error);
}
});
router.post("/previous", (request, response, next) => {
try {
response.json(playbackService.previous());
} catch (error) {
next(error);
}
});
router.post("/static", (request, response, next) => {
try {
response.json(playbackService.enterStatic(request.body?.assetId));
} catch (error) {
next(error);
}
});
router.put("/mode", (request, response, next) => {
try {
response.json(playbackService.setMode(request.body?.mode));
} catch (error) {
next(error);
}
});
router.post("/reload", (request, response, next) => {
try {
response.json(playbackService.reload());
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createPlaybackRouter,
};
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
class PlaylistError extends Error {
constructor(code, message, { statusCode = 400, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "PlaylistError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function playlistNotFound(playlistId) {
return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", {
statusCode: 404,
details: { playlistId },
});
}
function playlistEntryNotFound(playlistId, entryId) {
return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", {
statusCode: 404,
details: { playlistId, entryId },
});
}
module.exports = {
PlaylistError,
playlistEntryNotFound,
playlistNotFound,
};
+7
View File
@@ -0,0 +1,7 @@
const { createPlaylistRouter } = require("./routes");
const { createPlaylistService } = require("./service");
module.exports = {
createPlaylistRouter,
createPlaylistService,
};
+143
View File
@@ -0,0 +1,143 @@
const express = require("express");
const { PlaylistError } = require("./errors");
function orderedEntryIds(body) {
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must be an object containing entryIds",
);
}
const fields = Object.keys(body);
if (fields.length !== 1 || fields[0] !== "entryIds") {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must contain only entryIds",
{
details: {
fields,
requiredFields: ["entryIds"],
},
},
);
}
return body.entryIds;
}
function createPlaylistRouter(
playlistService,
{ playbackService } = {},
) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json({ playlists: playlistService.list() });
} catch (error) {
next(error);
}
});
router.post("/", (request, response, next) => {
try {
response.status(201).json(playlistService.create(request.body));
} catch (error) {
next(error);
}
});
router.get("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.get(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId", (request, response, next) => {
try {
response.json(
playlistService.update(request.params.playlistId, request.body),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.remove(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.post("/:playlistId/entries", (request, response, next) => {
try {
response
.status(201)
.json(playlistService.addEntry(request.params.playlistId, request.body));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.updateEntry(
request.params.playlistId,
request.params.entryId,
request.body,
),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.removeEntry(
request.params.playlistId,
request.params.entryId,
),
);
} catch (error) {
next(error);
}
});
router.put("/:playlistId/entries/order", (request, response, next) => {
try {
response.json(
playlistService.reorderEntries(
request.params.playlistId,
orderedEntryIds(request.body),
),
);
} catch (error) {
next(error);
}
});
router.post("/:playlistId/activate", (request, response, next) => {
try {
if (!playbackService) {
throw new Error("The playback service is required for playlist activation");
}
const result = playbackService.activatePlaylist(request.params.playlistId);
response.json(result);
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createPlaylistRouter,
orderedEntryIds,
};
+884
View File
@@ -0,0 +1,884 @@
const crypto = require("node:crypto");
const {
evaluateEntryEligibility,
resolveEntrySettings,
} = require("../lifecycle");
const {
PlaylistError,
playlistEntryNotFound,
playlistNotFound,
} = require("./errors");
const MIN_DURATION_MS = 1_000;
const MAX_DURATION_MS = 3_600_000;
const MAX_PLAYLIST_NAME_LENGTH = 200;
const BACKGROUND_MODES = new Set(["blurred", "solid", "black"]);
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const ENTRY_PATCH_FIELDS = new Set([
"enabled",
"durationOverrideMs",
"backgroundModeOverride",
"backgroundColorOverride",
"backgroundDarkenedOverride",
"scheduleStartAt",
"scheduleEndAt",
]);
function normalizeObject(input, code, message) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new PlaylistError(code, message);
}
return input;
}
function normalizePlaylistName(value) {
if (typeof value !== "string") {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
"Playlist name must be a string",
);
}
const normalized = value
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
"Playlist name cannot be empty",
);
}
if ([...normalized].length > MAX_PLAYLIST_NAME_LENGTH) {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
`Playlist name cannot exceed ${MAX_PLAYLIST_NAME_LENGTH} characters`,
{ details: { maxLength: MAX_PLAYLIST_NAME_LENGTH } },
);
}
return normalized;
}
function normalizeBoolean(value, field) {
if (typeof value !== "boolean") {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} must be a boolean`,
{ details: { field } },
);
}
return value ? 1 : 0;
}
function normalizeNullableDuration(value) {
if (value === null) return null;
if (
!Number.isSafeInteger(value) ||
value < MIN_DURATION_MS ||
value > MAX_DURATION_MS
) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`durationOverrideMs must be null or an integer from ${MIN_DURATION_MS} through ${MAX_DURATION_MS}`,
{
details: {
field: "durationOverrideMs",
min: MIN_DURATION_MS,
max: MAX_DURATION_MS,
},
},
);
}
return value;
}
function normalizeNullableBackgroundMode(value) {
if (value === null) return null;
if (typeof value !== "string" || !BACKGROUND_MODES.has(value)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"backgroundModeOverride must be null, blurred, solid, or black",
{ details: { field: "backgroundModeOverride" } },
);
}
return value;
}
function normalizeNullableColor(value) {
if (value === null) return null;
if (typeof value !== "string" || !HEX_COLOR_PATTERN.test(value)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"backgroundColorOverride must be null or a six-digit hex color",
{ details: { field: "backgroundColorOverride" } },
);
}
return value.toUpperCase();
}
function normalizeNullableDarkening(value) {
if (value === null) return null;
return normalizeBoolean(value, "backgroundDarkenedOverride");
}
function normalizeNullableInstant(value, field) {
if (value === null) return null;
if (Number.isSafeInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
const instant = value.trim();
if (!/([zZ]|[+-]\d{2}:\d{2})$/.test(instant)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} ISO dates must include Z or a numeric UTC offset`,
{ details: { field } },
);
}
const timestamp = Date.parse(instant);
if (Number.isSafeInteger(timestamp) && timestamp >= 0) return timestamp;
}
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} must be null, an epoch-millisecond integer, or an ISO date`,
{ details: { field } },
);
}
function validateSchedule(startAt, endAt) {
if (startAt !== null && endAt !== null && startAt >= endAt) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
"scheduleStartAt must be earlier than scheduleEndAt",
{ details: { scheduleStartAt: startAt, scheduleEndAt: endAt } },
);
}
}
function mapPlaylist(row) {
return {
id: row.id,
name: row.name,
revision: row.revision,
entryCount: Number(row.entry_count || 0),
isActive: Boolean(row.is_active),
isPendingActivation: Boolean(row.is_pending),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mediaUrl(assetId, kind, sha256) {
return sha256
? `/media/assets/${assetId}/${kind}/${sha256}.webp`
: null;
}
function mapResolvedEntry(row, settings, nowMs) {
const eligibility = evaluateEntryEligibility(row, row, nowMs);
const resolved = resolveEntrySettings(row, settings);
return {
id: row.entry_id,
playlistId: row.playlist_id,
assetId: row.asset_id,
enabled: Boolean(row.enabled),
position: row.position,
durationOverrideMs: row.duration_override_ms,
effectiveDurationMs: resolved.durationMs,
backgroundModeOverride: row.background_mode_override,
backgroundColorOverride: row.background_color_override,
backgroundDarkenedOverride:
row.background_darkened_override === null
? null
: Boolean(row.background_darkened_override),
effectivePresentation: resolved.presentation,
scheduleStartAt: row.schedule_start_at,
scheduleEndAt: row.schedule_end_at,
playable: eligibility.playable,
notPlayableReason: eligibility.reason,
eligibility,
asset: {
id: row.asset_id,
displayName: row.asset_display_name,
originalFilename: row.asset_original_filename,
status: row.status,
expirationAt: row.expiration_at,
display: {
width: row.display_width,
height: row.display_height,
url: mediaUrl(row.asset_id, "display", row.display_sha256),
},
thumbnail: {
width: row.thumbnail_width,
height: row.thumbnail_height,
url: mediaUrl(row.asset_id, "thumbnail", row.thumbnail_sha256),
},
},
createdAt: row.entry_created_at,
updatedAt: row.entry_updated_at,
};
}
function createPlaylistService({
database,
now = Date.now,
createId = crypto.randomUUID,
onBeforeChange = () => {},
onChanged = () => {},
}) {
const playlistSelect = `
SELECT p.*,
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.playlist_id = p.id)
AS entry_count,
EXISTS (
SELECT 1 FROM playback_state ps WHERE ps.active_playlist_id = p.id
) AS is_active,
EXISTS (
SELECT 1 FROM playback_state ps WHERE ps.pending_playlist_id = p.id
) AS is_pending
FROM playlists p
`;
const listPlaylists = database.prepare(
`${playlistSelect} ORDER BY p.name COLLATE NOCASE, p.id`,
);
const findPlaylist = database.prepare(`${playlistSelect} WHERE p.id = ?`);
const findPlaylistByName = database.prepare(
`${playlistSelect} WHERE p.name = ? COLLATE NOCASE`,
);
const insertPlaylist = database.prepare(`
INSERT INTO playlists (id, name, revision, created_at, updated_at)
VALUES (?, ?, 1, ?, ?)
`);
const renamePlaylist = database.prepare(`
UPDATE playlists
SET name = ?, revision = revision + 1, updated_at = ?
WHERE id = ?
`);
const deletePlaylist = database.prepare("DELETE FROM playlists WHERE id = ?");
const bumpPlaylistRevision = database.prepare(`
UPDATE playlists
SET revision = revision + 1, updated_at = ?
WHERE id = ?
`);
const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1");
const readPlayback = database.prepare(
"SELECT * FROM playback_state WHERE output_id = 'global'",
);
const resolvedEntries = database.prepare(`
SELECT
pe.id AS entry_id,
pe.playlist_id,
pe.asset_id,
pe.enabled,
pe.duration_override_ms,
pe.background_mode_override,
pe.background_color_override,
pe.background_darkened_override,
pe.schedule_start_at,
pe.schedule_end_at,
pe.position,
pe.created_at AS entry_created_at,
pe.updated_at AS entry_updated_at,
a.display_name AS asset_display_name,
a.original_filename AS asset_original_filename,
a.status,
a.expiration_at,
a.display_sha256,
a.display_width,
a.display_height,
a.thumbnail_sha256,
a.thumbnail_width,
a.thumbnail_height
FROM playlist_entries pe
JOIN assets a ON a.id = pe.asset_id
WHERE pe.playlist_id = ?
ORDER BY pe.position, pe.id
`);
const findEntry = database.prepare(`
SELECT pe.*
FROM playlist_entries pe
WHERE pe.playlist_id = ? AND pe.id = ?
`);
const findEntryByAsset = database.prepare(`
SELECT pe.*
FROM playlist_entries pe
WHERE pe.playlist_id = ? AND pe.asset_id = ?
`);
const findEntryOwner = database.prepare(
"SELECT playlist_id FROM playlist_entries WHERE id = ?",
);
const listEntryIds = database.prepare(`
SELECT id, position
FROM playlist_entries
WHERE playlist_id = ?
ORDER BY position, id
`);
const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?");
const nextPosition = database.prepare(`
SELECT COALESCE(MAX(position), -1) + 1 AS position
FROM playlist_entries
WHERE playlist_id = ?
`);
const insertEntry = database.prepare(`
INSERT INTO playlist_entries (
id, playlist_id, asset_id, enabled, duration_override_ms,
background_mode_override, background_color_override,
background_darkened_override, schedule_start_at, schedule_end_at,
position, created_at, updated_at
) VALUES (
@id, @playlistId, @assetId, @enabled, @durationOverrideMs,
@backgroundModeOverride, @backgroundColorOverride,
@backgroundDarkenedOverride, @scheduleStartAt, @scheduleEndAt,
@position, @createdAt, @updatedAt
)
`);
const updateEntry = database.prepare(`
UPDATE playlist_entries
SET enabled = @enabled,
duration_override_ms = @durationOverrideMs,
background_mode_override = @backgroundModeOverride,
background_color_override = @backgroundColorOverride,
background_darkened_override = @backgroundDarkenedOverride,
schedule_start_at = @scheduleStartAt,
schedule_end_at = @scheduleEndAt,
updated_at = @updatedAt
WHERE playlist_id = @playlistId AND id = @entryId
`);
const deleteEntry = database.prepare(
"DELETE FROM playlist_entries WHERE playlist_id = ? AND id = ?",
);
const moveEntriesOutOfRange = database.prepare(`
UPDATE playlist_entries
SET position = position + ?
WHERE playlist_id = ?
`);
const setEntryPosition = database.prepare(`
UPDATE playlist_entries
SET position = ?, updated_at = ?
WHERE playlist_id = ? AND id = ?
`);
function getPlaylistRow(playlistId) {
const row = findPlaylist.get(playlistId);
if (!row) throw playlistNotFound(playlistId);
return row;
}
function getEntryRow(playlistId, entryId) {
getPlaylistRow(playlistId);
const row = findEntry.get(playlistId, entryId);
if (!row) throw playlistEntryNotFound(playlistId, entryId);
return row;
}
function nextTimestamp(row) {
return Math.max(now(), row.updated_at + 1);
}
function notify(change) {
// The transaction has committed before this synchronous domain hook runs.
// A reconciliation failure is surfaced to the caller so playback cannot
// silently remain inconsistent with a successful playlist mutation.
onChanged(change);
}
function duplicateName(name, row = findPlaylistByName.get(name)) {
return new PlaylistError(
"PLAYLIST_NAME_CONFLICT",
"Another playlist already uses that name",
{
statusCode: 409,
details: row ? { playlist: mapPlaylist(row) } : { name },
},
);
}
function validateCreate(input) {
normalizeObject(input, "INVALID_PLAYLIST", "Playlist must be an object");
const unknown = Object.keys(input).filter((key) => key !== "name");
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!Object.hasOwn(input, "name")) {
throw new PlaylistError("INVALID_PLAYLIST", "Playlist name is required", {
details: { field: "name" },
});
}
return normalizePlaylistName(input.name);
}
function validateEntryFields(input, { creating = false, current = null } = {}) {
normalizeObject(
input,
"INVALID_PLAYLIST_ENTRY",
"Playlist entry must be an object",
);
const allowed = creating
? new Set(["assetId", ...ENTRY_PATCH_FIELDS])
: ENTRY_PATCH_FIELDS;
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"Playlist entry contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!creating && Object.keys(input).length === 0) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"At least one playlist entry field is required",
);
}
if (creating && (typeof input.assetId !== "string" || !input.assetId.trim())) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"assetId is required",
{ details: { field: "assetId" } },
);
}
const value = (field, fallback) =>
Object.hasOwn(input, field) ? input[field] : fallback;
const normalized = {
assetId: creating ? input.assetId.trim() : current.asset_id,
enabled: Object.hasOwn(input, "enabled")
? normalizeBoolean(input.enabled, "enabled")
: (current?.enabled ?? 1),
durationOverrideMs: Object.hasOwn(input, "durationOverrideMs")
? normalizeNullableDuration(input.durationOverrideMs)
: (current?.duration_override_ms ?? null),
backgroundModeOverride: Object.hasOwn(input, "backgroundModeOverride")
? normalizeNullableBackgroundMode(input.backgroundModeOverride)
: (current?.background_mode_override ?? null),
backgroundColorOverride: Object.hasOwn(input, "backgroundColorOverride")
? normalizeNullableColor(input.backgroundColorOverride)
: (current?.background_color_override ?? null),
backgroundDarkenedOverride: Object.hasOwn(
input,
"backgroundDarkenedOverride",
)
? normalizeNullableDarkening(input.backgroundDarkenedOverride)
: (current?.background_darkened_override ?? null),
scheduleStartAt: normalizeNullableInstant(
value("scheduleStartAt", current?.schedule_start_at ?? null),
"scheduleStartAt",
),
scheduleEndAt: normalizeNullableInstant(
value("scheduleEndAt", current?.schedule_end_at ?? null),
"scheduleEndAt",
),
};
validateSchedule(normalized.scheduleStartAt, normalized.scheduleEndAt);
return normalized;
}
function entryValuesEqual(current, next) {
return (
current.enabled === next.enabled &&
current.duration_override_ms === next.durationOverrideMs &&
current.background_mode_override === next.backgroundModeOverride &&
current.background_color_override === next.backgroundColorOverride &&
current.background_darkened_override === next.backgroundDarkenedOverride &&
current.schedule_start_at === next.scheduleStartAt &&
current.schedule_end_at === next.scheduleEndAt
);
}
function resolvedDetail(playlistId, nowMs = now()) {
const playlist = mapPlaylist(getPlaylistRow(playlistId));
const settings = readSettings.get();
if (!settings) throw new Error("The app_settings singleton row is missing");
return {
...playlist,
evaluatedAt: nowMs,
settingsRevision: settings.revision,
entries: resolvedEntries
.all(playlistId)
.map((row) => mapResolvedEntry(row, settings, nowMs)),
};
}
const createTransaction = database.transaction((input) => {
const name = validateCreate(input);
const existing = findPlaylistByName.get(name);
if (existing) throw duplicateName(name, existing);
const timestamp = now();
const id = createId();
try {
insertPlaylist.run(id, name, timestamp, timestamp);
} catch (error) {
const conflict = findPlaylistByName.get(name);
if (conflict) throw duplicateName(name, conflict);
throw error;
}
return mapPlaylist(getPlaylistRow(id));
});
function create(input) {
const playlist = createTransaction(input);
notify({ reason: "created", playlistId: playlist.id, revision: playlist.revision });
return playlist;
}
function list() {
return listPlaylists.all().map(mapPlaylist);
}
function get(playlistId) {
return resolvedDetail(playlistId);
}
const updateTransaction = database.transaction((playlistId, input) => {
normalizeObject(input, "INVALID_PLAYLIST", "Playlist update must be an object");
const unknown = Object.keys(input).filter((key) => key !== "name");
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist update contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!Object.hasOwn(input, "name") || Object.keys(input).length !== 1) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist update requires a name",
{ details: { field: "name" } },
);
}
const current = getPlaylistRow(playlistId);
const name = normalizePlaylistName(input.name);
if (current.name === name) {
return { changed: false, playlist: mapPlaylist(current) };
}
const conflict = findPlaylistByName.get(name);
if (conflict && conflict.id !== playlistId) throw duplicateName(name, conflict);
try {
renamePlaylist.run(name, nextTimestamp(current), playlistId);
} catch (error) {
const raced = findPlaylistByName.get(name);
if (raced && raced.id !== playlistId) throw duplicateName(name, raced);
throw error;
}
return { changed: true, playlist: mapPlaylist(getPlaylistRow(playlistId)) };
});
function update(playlistId, input) {
onBeforeChange({ reason: "before_playlist_update", playlistId });
const result = updateTransaction(playlistId, input);
if (result.changed) {
notify({
reason: "renamed",
playlistId,
revision: result.playlist.revision,
});
}
return result.playlist;
}
const removeTransaction = database.transaction((playlistId) => {
const playlist = getPlaylistRow(playlistId);
const state = readPlayback.get();
if (!state) throw new Error("The global playback_state row is missing");
if (state.active_playlist_id === playlistId) {
throw new PlaylistError(
"PLAYLIST_ACTIVE",
"The active playlist cannot be deleted",
{ statusCode: 409, details: { playlistId } },
);
}
if (state.pending_playlist_id === playlistId) {
throw new PlaylistError(
"PLAYLIST_PENDING_ACTIVATION",
"A playlist pending activation cannot be deleted",
{ statusCode: 409, details: { playlistId } },
);
}
deletePlaylist.run(playlistId);
return playlist;
});
function remove(playlistId) {
onBeforeChange({ reason: "before_playlist_delete", playlistId });
const playlist = removeTransaction(playlistId);
notify({ reason: "deleted", playlistId, revision: playlist.revision });
return { id: playlistId, deleted: true };
}
const addEntryTransaction = database.transaction((playlistId, input) => {
const playlist = getPlaylistRow(playlistId);
const fields = validateEntryFields(input, { creating: true });
const operationNow = now();
const asset = findAsset.get(fields.assetId);
if (!asset) {
throw new PlaylistError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId: fields.assetId },
});
}
if (asset.status !== "ready") {
throw new PlaylistError(
"ASSET_NOT_READY",
"Only ready library assets can be added to playlists",
{
statusCode: 409,
details: { assetId: asset.id, status: asset.status },
},
);
}
if (asset.expiration_at !== null && asset.expiration_at <= operationNow) {
throw new PlaylistError(
"ASSET_EXPIRED",
"Expired library assets cannot be added to playlists",
{
statusCode: 409,
details: { assetId: asset.id, expirationAt: asset.expiration_at },
},
);
}
const existing = findEntryByAsset.get(playlistId, fields.assetId);
if (existing) {
throw new PlaylistError(
"PLAYLIST_ENTRY_EXISTS",
"This asset already appears in the playlist",
{
statusCode: 409,
details: { playlistId, assetId: fields.assetId, entryId: existing.id },
},
);
}
const timestamp = Math.max(operationNow, playlist.updated_at + 1);
const id = createId();
try {
insertEntry.run({
id,
playlistId,
...fields,
position: nextPosition.get(playlistId).position,
createdAt: timestamp,
updatedAt: timestamp,
});
} catch (error) {
const raced = findEntryByAsset.get(playlistId, fields.assetId);
if (raced) {
throw new PlaylistError(
"PLAYLIST_ENTRY_EXISTS",
"This asset already appears in the playlist",
{
statusCode: 409,
details: { playlistId, assetId: fields.assetId, entryId: raced.id },
},
);
}
throw error;
}
bumpPlaylistRevision.run(timestamp, playlistId);
return {
playlist: getPlaylistRow(playlistId),
entryId: id,
detail: resolvedDetail(playlistId, now()),
};
});
function addEntry(playlistId, input) {
onBeforeChange({ reason: "before_entry_add", playlistId });
const result = addEntryTransaction(playlistId, input);
notify({
reason: "entry_added",
playlistId,
revision: result.playlist.revision,
entryId: result.entryId,
});
return result.detail.entries.find((entry) => entry.id === result.entryId);
}
const patchEntryTransaction = database.transaction((playlistId, entryId, input) => {
const playlist = getPlaylistRow(playlistId);
const current = getEntryRow(playlistId, entryId);
const fields = validateEntryFields(input, { current });
if (entryValuesEqual(current, fields)) {
return {
changed: false,
invalidBoundaryChanged: false,
playlist,
detail: resolvedDetail(playlistId, now()),
};
}
const timestamp = nextTimestamp(playlist);
updateEntry.run({
playlistId,
entryId,
...fields,
updatedAt: timestamp,
});
bumpPlaylistRevision.run(timestamp, playlistId);
return {
changed: true,
invalidBoundaryChanged:
current.schedule_end_at !== fields.scheduleEndAt,
playlist: getPlaylistRow(playlistId),
detail: resolvedDetail(playlistId, now()),
};
});
function updateEntryById(playlistId, entryId, input) {
onBeforeChange({
reason: "before_entry_update",
playlistId,
entryId,
});
const result = patchEntryTransaction(playlistId, entryId, input);
if (result.changed) {
notify({
reason: "entry_updated",
playlistId,
revision: result.playlist.revision,
entryId,
...(result.invalidBoundaryChanged
? { invalidBoundaryChanged: true }
: {}),
});
}
return result.detail.entries.find((entry) => entry.id === entryId);
}
const removeEntryTransaction = database.transaction((playlistId, entryId) => {
const playlist = getPlaylistRow(playlistId);
getEntryRow(playlistId, entryId);
const timestamp = nextTimestamp(playlist);
const result = deleteEntry.run(playlistId, entryId);
if (result.changes !== 1) throw playlistEntryNotFound(playlistId, entryId);
const remaining = listEntryIds.all(playlistId);
if (remaining.some((entry, index) => entry.position !== index)) {
moveEntriesOutOfRange.run(remaining.length + 1, playlistId);
remaining.forEach((entry, position) => {
setEntryPosition.run(position, timestamp, playlistId, entry.id);
});
}
bumpPlaylistRevision.run(timestamp, playlistId);
return { playlist: getPlaylistRow(playlistId) };
});
function removeEntryById(playlistId, entryId) {
onBeforeChange({
reason: "before_entry_delete",
playlistId,
entryId,
});
const result = removeEntryTransaction(playlistId, entryId);
notify({
reason: "entry_deleted",
playlistId,
revision: result.playlist.revision,
entryId,
});
return { id: entryId, deleted: true };
}
function validateOrder(playlistId, orderedEntryIds) {
if (!Array.isArray(orderedEntryIds)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must be a complete array of entry IDs",
);
}
if (orderedEntryIds.some((id) => typeof id !== "string" || !id)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Every ordered entry ID must be a non-empty string",
);
}
const duplicates = orderedEntryIds.filter(
(id, index) => orderedEntryIds.indexOf(id) !== index,
);
if (duplicates.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_DUPLICATE_IDS",
"Playlist order contains duplicate entry IDs",
{ details: { entryIds: [...new Set(duplicates)] } },
);
}
const current = listEntryIds.all(playlistId);
const currentIds = new Set(current.map((entry) => entry.id));
const suppliedIds = new Set(orderedEntryIds);
const extra = orderedEntryIds.filter((id) => !currentIds.has(id));
if (extra.length > 0) {
const foreign = extra.filter((id) => {
const owner = findEntryOwner.get(id);
return owner && owner.playlist_id !== playlistId;
});
if (foreign.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_FOREIGN_ENTRY",
"Playlist order contains an entry from another playlist",
{ details: { entryIds: foreign } },
);
}
throw new PlaylistError(
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
"Playlist order contains an unknown entry ID",
{ details: { entryIds: extra } },
);
}
const missing = current
.map((entry) => entry.id)
.filter((id) => !suppliedIds.has(id));
if (missing.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_INCOMPLETE",
"Playlist order is missing current entries",
{ details: { entryIds: missing } },
);
}
return current;
}
const reorderTransaction = database.transaction((playlistId, orderedEntryIds) => {
const playlist = getPlaylistRow(playlistId);
const current = validateOrder(playlistId, orderedEntryIds);
if (current.every((entry, index) => entry.id === orderedEntryIds[index])) {
return { changed: false, playlist, detail: resolvedDetail(playlistId, now()) };
}
const timestamp = nextTimestamp(playlist);
const offset = Math.max(current.length, 1);
moveEntriesOutOfRange.run(offset, playlistId);
orderedEntryIds.forEach((entryId, position) => {
setEntryPosition.run(position, timestamp, playlistId, entryId);
});
bumpPlaylistRevision.run(timestamp, playlistId);
return {
changed: true,
playlist: getPlaylistRow(playlistId),
detail: resolvedDetail(playlistId, now()),
};
});
function reorderEntries(playlistId, orderedEntryIds) {
onBeforeChange({ reason: "before_entries_reorder", playlistId });
const result = reorderTransaction(playlistId, orderedEntryIds);
if (result.changed) {
notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision });
}
return result.detail;
}
return {
addEntry,
create,
get,
list,
remove,
removeEntry: removeEntryById,
reorderEntries,
update,
updateEntry: updateEntryById,
};
}
module.exports = {
MAX_DURATION_MS,
MIN_DURATION_MS,
createPlaylistService,
normalizePlaylistName,
};
+213
View File
@@ -0,0 +1,213 @@
const http = require("node:http");
const { Server: SocketServer } = require("socket.io");
const { createApp } = require("./app");
const { openDatabase } = require("./database");
const { runMigrations } = require("./database/migrate");
const { createImageProcessor } = require("./image-processing");
const { createPlaybackService } = require("./playback");
const { createStorageService } = require("./storage");
function listen(httpServer, port, host) {
return new Promise((resolve, reject) => {
function onError(error) {
httpServer.off("listening", onListening);
reject(error);
}
function onListening() {
httpServer.off("error", onError);
resolve(httpServer.address());
}
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
});
}
function createRuntime({
config,
imageProcessor: suppliedImageProcessor,
logger = console,
storage: suppliedStorage,
}) {
const database = openDatabase(config.database);
let migrations;
try {
migrations = runMigrations(database);
} catch (error) {
database.close();
throw error;
}
const lifecycle = {
ready: false,
stopping: false,
};
let storage;
let imageProcessor;
try {
storage = suppliedStorage || createStorageService(config.storage, { logger });
imageProcessor =
suppliedImageProcessor ||
createImageProcessor({ maxInputPixels: config.processing.maxInputPixels });
} catch (error) {
database.close();
throw error;
}
let app;
let io = null;
let playbackService;
function emitSocketEvent(eventName, payload) {
try {
io?.emit(eventName, payload);
} catch (error) {
try {
logger.warn?.(`Socket.IO ${eventName} publish failed`, error);
} catch {
// Socket invalidation remains best effort after the authoritative commit.
}
}
}
try {
playbackService = createPlaybackService({
database,
logger,
onActivationChanged(event) {
emitSocketEvent("playlists:changed", event);
},
onStateChanged(snapshot) {
emitSocketEvent("playback:state", snapshot);
},
});
app = createApp({
config,
database,
imageProcessor,
lifecycle,
logger,
migrations,
onPlaylistsChanged(event) {
emitSocketEvent("playlists:changed", event);
},
playbackService,
storage,
});
} catch (error) {
storage.stop();
database.close();
throw error;
}
const httpServer = http.createServer(app);
io = new SocketServer(httpServer, {
serveClient: false,
});
io.on("connection", (socket) => {
logger.info?.(`Socket.IO client connected: ${socket.id}`);
try {
socket.emit("playback:state", playbackService.getSnapshot());
} catch (error) {
logger.warn?.("Initial playback snapshot could not be sent", error);
}
socket.on("disconnect", () => {
logger.info?.(`Socket.IO client disconnected: ${socket.id}`);
});
});
let address = null;
let startPromise = null;
let stopPromise = null;
async function start() {
if (stopPromise) {
throw new Error("Cannot start a runtime after shutdown has begun");
}
if (startPromise) {
return startPromise;
}
startPromise = Promise.resolve()
.then(() => playbackService.initialize())
.then(() => playbackService.start())
.then(() => listen(httpServer, config.port, config.host))
.then((listeningAddress) => {
address = listeningAddress;
lifecycle.ready = true;
Promise.resolve()
.then(() => storage.start())
.catch(() => {
logger.warn?.("Object storage health checks could not be started");
});
return listeningAddress;
})
.catch((error) => {
playbackService.stop();
storage.stop();
database.close();
throw error;
});
return startPromise;
}
async function stop(reason = "shutdown") {
if (stopPromise) {
return stopPromise;
}
lifecycle.ready = false;
lifecycle.stopping = true;
playbackService.stop();
stopPromise = new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
storage.stop();
if (database.open) {
database.close();
}
resolve();
};
const timeout = setTimeout(() => {
logger.warn?.(
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
);
httpServer.closeAllConnections?.();
finish();
}, config.shutdownTimeoutMs);
timeout.unref?.();
if (!httpServer.listening) {
io.close();
finish();
return;
}
logger.info?.(`Stopping Announcements API (${reason})`);
io.close(finish);
});
return stopPromise;
}
return {
app,
config,
database,
httpServer,
io,
lifecycle,
migrations,
playbackService,
storage,
imageProcessor,
get address() {
return address;
},
start,
stop,
};
}
module.exports = {
createRuntime,
};
+42
View File
@@ -0,0 +1,42 @@
const express = require("express");
function createSettingsRouter(
settingsService,
{
onBeforePlaybackDomainChange = () => {},
onPlaylistsChanged = () => {},
onPlaybackDomainChanged = () => {},
} = {},
) {
const router = express.Router();
router.get("/", (request, response) => {
response.json(settingsService.get());
});
router.patch("/", (request, response, next) => {
try {
const previous = settingsService.get();
onBeforePlaybackDomainChange({ reason: "before_settings_update" });
const updated = settingsService.update(request.body);
if (updated.revision !== previous.revision) {
const change = {
reason: "settings_updated",
playlistIds: null,
settingsRevision: updated.revision,
};
onPlaybackDomainChanged(change);
onPlaylistsChanged(change);
}
response.json(updated);
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createSettingsRouter,
};
+157
View File
@@ -0,0 +1,157 @@
const SETTING_COLUMNS = Object.freeze({
defaultDurationMs: "default_duration_ms",
defaultBackgroundMode: "default_background_mode",
defaultSolidBackgroundColor: "default_solid_background_color",
defaultBackgroundDarkened: "default_background_darkened",
defaultBlurPx: "default_blur_px",
crossfadeDurationMs: "crossfade_duration_ms",
});
class SettingsValidationError extends Error {
constructor(message, details = {}) {
super(message);
this.name = "SettingsValidationError";
this.statusCode = 400;
this.code = "INVALID_SETTINGS";
this.details = details;
}
}
function mapSettings(row) {
return {
revision: row.revision,
defaultDurationMs: row.default_duration_ms,
defaultBackgroundMode: row.default_background_mode,
defaultSolidBackgroundColor: row.default_solid_background_color,
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
defaultBlurPx: row.default_blur_px,
crossfadeDurationMs: row.crossfade_duration_ms,
updatedAt: row.updated_at,
};
}
function validateSafeInteger(name, value, { positive = false } = {}) {
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
const qualifier = positive ? "a positive" : "a non-negative";
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
field: name,
});
}
}
function validatePatch(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new SettingsValidationError("Settings payload must be a JSON object");
}
const keys = Object.keys(input);
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
if (unknown.length > 0) {
throw new SettingsValidationError("Settings payload contains unknown fields", {
fields: unknown,
});
}
if (keys.length === 0) {
throw new SettingsValidationError("At least one setting is required");
}
if ("defaultDurationMs" in input) {
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
positive: true,
});
}
if (
"defaultBackgroundMode" in input &&
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
) {
throw new SettingsValidationError(
"defaultBackgroundMode must be blurred, solid, or black",
{ field: "defaultBackgroundMode" },
);
}
if (
"defaultSolidBackgroundColor" in input &&
(typeof input.defaultSolidBackgroundColor !== "string" ||
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
) {
throw new SettingsValidationError(
"defaultSolidBackgroundColor must be a six-digit hex color",
{ field: "defaultSolidBackgroundColor" },
);
}
if (
"defaultBackgroundDarkened" in input &&
typeof input.defaultBackgroundDarkened !== "boolean"
) {
throw new SettingsValidationError(
"defaultBackgroundDarkened must be a boolean",
{ field: "defaultBackgroundDarkened" },
);
}
if ("defaultBlurPx" in input) {
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
}
if ("crossfadeDurationMs" in input) {
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
}
return input;
}
function createSettingsService(database, { now = Date.now } = {}) {
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
const update = database.prepare(`
UPDATE app_settings
SET revision = revision + 1,
default_duration_ms = @defaultDurationMs,
default_background_mode = @defaultBackgroundMode,
default_solid_background_color = @defaultSolidBackgroundColor,
default_background_darkened = @defaultBackgroundDarkened,
default_blur_px = @defaultBlurPx,
crossfade_duration_ms = @crossfadeDurationMs,
updated_at = @updatedAt
WHERE id = 1
`);
function get() {
const row = read.get();
if (!row) {
throw new Error("The app_settings singleton row is missing");
}
return mapSettings(row);
}
const updateTransaction = database.transaction((patch) => {
validatePatch(patch);
const current = get();
const next = { ...current, ...patch };
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
if (!changed) {
return current;
}
update.run({
defaultDurationMs: next.defaultDurationMs,
defaultBackgroundMode: next.defaultBackgroundMode,
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
defaultBlurPx: next.defaultBlurPx,
crossfadeDurationMs: next.crossfadeDurationMs,
updatedAt: Math.max(now(), current.updatedAt + 1),
});
return get();
});
return {
get,
update: updateTransaction,
};
}
module.exports = {
SettingsValidationError,
createSettingsService,
mapSettings,
validatePatch,
};
+398
View File
@@ -0,0 +1,398 @@
const {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} = require("@aws-sdk/client-s3");
class StorageError extends Error {
constructor(message, { code, statusCode, cause } = {}) {
super(message, { cause });
this.name = "StorageError";
this.code = code || "STORAGE_ERROR";
this.statusCode = statusCode || 500;
}
}
class StorageUnavailableError extends StorageError {
constructor(cause) {
super("Object storage is unavailable", {
code: "STORAGE_UNAVAILABLE",
statusCode: 503,
cause,
});
this.name = "StorageUnavailableError";
}
}
class StorageObjectNotFoundError extends StorageError {
constructor(key, cause) {
super("Stored object was not found", {
code: "STORAGE_OBJECT_NOT_FOUND",
statusCode: 404,
cause,
});
this.name = "StorageObjectNotFoundError";
this.key = key;
}
}
function errorStatus(error) {
return error?.$metadata?.httpStatusCode || error?.statusCode;
}
function isMissingBucket(error) {
return (
error?.name === "NoSuchBucket" ||
error?.Code === "NoSuchBucket" ||
error?.code === "NoSuchBucket" ||
errorStatus(error) === 404
);
}
function isMissingObject(error) {
if (
error?.name === "NoSuchBucket" ||
error?.Code === "NoSuchBucket" ||
error?.code === "NoSuchBucket"
) {
return false;
}
return (
error?.name === "NoSuchKey" ||
error?.name === "NotFound" ||
error?.Code === "NoSuchKey" ||
error?.code === "NoSuchKey" ||
errorStatus(error) === 404
);
}
function isBucketCreationConflict(error) {
return (
error?.name === "BucketAlreadyOwnedByYou" ||
error?.name === "BucketAlreadyExists" ||
error?.Code === "BucketAlreadyOwnedByYou" ||
error?.Code === "BucketAlreadyExists" ||
error?.code === "BucketAlreadyOwnedByYou" ||
error?.code === "BucketAlreadyExists"
);
}
function isConsumerCancellation(error) {
return (
error?.name === "AbortError" ||
error?.code === "ABORT_ERR" ||
error?.code === "ERR_STREAM_PREMATURE_CLOSE"
);
}
function assertObjectKey(key) {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("Storage object key must be a non-empty string");
}
const segments = key.split("/");
if (
key.startsWith("/") ||
key.includes("\\") ||
key.includes("\0") ||
segments.some((segment) => segment === "." || segment === "..")
) {
throw new TypeError("Storage object key is invalid");
}
}
function publicHealth(health) {
return Object.freeze({ ...health });
}
function createS3Client(config) {
const options = {
endpoint: config.endpoint,
region: config.region,
forcePathStyle: config.forcePathStyle,
maxAttempts: config.maxAttempts,
requestHandler: {
connectionTimeout: config.connectionTimeoutMs,
// Use an inactivity timeout rather than an absolute request deadline so
// a large but continuously progressing upload is not aborted.
socketTimeout: config.requestTimeoutMs,
},
// Optional AWS checksums are not uniformly implemented by S3-compatible
// providers. Source and derivative SHA-256 hashes are verified by the app.
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
};
if (config.accessKeyId && config.secretAccessKey) {
options.credentials = {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
};
}
return new S3Client(options);
}
function createStorageService(config, { client, logger = console, now = () => new Date() } = {}) {
const s3 = client || createS3Client(config);
let ensurePromise = null;
let healthTimer = null;
let bucketReady = false;
let issuedHealthSequence = 0;
let appliedHealthSequence = 0;
let currentHealth = {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_NOT_CHECKED",
message: "Object storage has not been checked",
};
function nextHealthSequence() {
issuedHealthSequence += 1;
return issuedHealthSequence;
}
function markHealthy(sequence) {
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
appliedHealthSequence = sequence;
bucketReady = true;
const checkedAt = now().toISOString();
currentHealth = {
status: "healthy",
checkedAt,
lastSuccessAt: checkedAt,
};
return publicHealth(currentHealth);
}
function markDegraded(sequence) {
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
appliedHealthSequence = sequence;
bucketReady = false;
currentHealth = {
status: "degraded",
checkedAt: now().toISOString(),
lastSuccessAt: currentHealth.lastSuccessAt,
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
return publicHealth(currentHealth);
}
function unavailable(error, sequence) {
markDegraded(sequence);
return error instanceof StorageUnavailableError
? error
: new StorageUnavailableError(error);
}
async function ensureBucketOnce() {
const sequence = nextHealthSequence();
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: false };
} catch (error) {
if (!isMissingBucket(error)) {
throw unavailable(error, sequence);
}
}
try {
await s3.send(new CreateBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: true };
} catch (error) {
if (isBucketCreationConflict(error)) {
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: false };
} catch (verificationError) {
throw unavailable(verificationError, sequence);
}
}
throw unavailable(error, sequence);
}
}
async function ensureBucket() {
if (!ensurePromise) {
ensurePromise = ensureBucketOnce().finally(() => {
ensurePromise = null;
});
}
return ensurePromise;
}
async function initialize() {
try {
await ensureBucket();
} catch (error) {
logger.warn?.("Object storage is unavailable during initialization");
}
return publicHealth(currentHealth);
}
async function start() {
if (!healthTimer) {
healthTimer = setInterval(() => {
ensureBucket().catch(() => {});
}, config.healthProbeIntervalMs || 30_000);
healthTimer.unref?.();
}
return initialize();
}
async function probe() {
const sequence = nextHealthSequence();
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
return markHealthy(sequence);
} catch (error) {
return markDegraded(sequence);
}
}
async function putObject({ key, body, contentType, contentLength, metadata }) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
const input = {
Bucket: config.bucket,
Key: key,
Body: body,
...(contentType ? { ContentType: contentType } : {}),
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
...(metadata ? { Metadata: metadata } : {}),
};
try {
const result = await s3.send(new PutObjectCommand(input));
markHealthy(sequence);
return {
key,
etag: result.ETag || null,
versionId: result.VersionId || null,
};
} catch (error) {
throw unavailable(error, sequence);
}
}
function objectMetadata(key, result) {
return {
key,
contentType: result.ContentType || null,
contentLength:
result.ContentLength === undefined ? null : Number(result.ContentLength),
etag: result.ETag || null,
lastModified: result.LastModified || null,
metadata: result.Metadata || {},
};
}
async function getObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
const result = await s3.send(
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
result.Body?.once?.("error", (error) => {
if (!isConsumerCancellation(error)) {
markDegraded(nextHealthSequence());
}
});
return {
...objectMetadata(key, result),
body: result.Body,
};
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
throw new StorageObjectNotFoundError(key, error);
}
throw unavailable(error, sequence);
}
}
async function headObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
const result = await s3.send(
new HeadObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
return objectMetadata(key, result);
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
return null;
}
throw unavailable(error, sequence);
}
}
async function objectExists(key) {
return (await headObject(key)) !== null;
}
async function deleteObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
await s3.send(
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
return;
}
throw unavailable(error, sequence);
}
}
function stop() {
if (healthTimer) {
clearInterval(healthTimer);
healthTimer = null;
}
s3.destroy?.();
}
return Object.freeze({
initialize,
start,
stop,
ensureBucket,
probe,
health: () => publicHealth(currentHealth),
putObject,
getObject,
headObject,
objectExists,
deleteObject,
destroy: stop,
});
}
module.exports = {
StorageError,
StorageObjectNotFoundError,
StorageUnavailableError,
createS3Client,
createStorageService,
};
+442
View File
@@ -0,0 +1,442 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { Readable } = require("node:stream");
const sharp = require("sharp");
const { createApp } = require("../src/app");
const { loadConfig } = require("../src/config");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createImageProcessor } = require("../src/image-processing");
const { StorageObjectNotFoundError } = require("../src/storage");
async function readBody(body) {
if (Buffer.isBuffer(body)) return body;
const chunks = [];
for await (const chunk of body) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks);
}
function memoryStorage() {
const objects = new Map();
return {
objects,
health() {
return {
status: "healthy",
checkedAt: "2033-05-18T03:33:20.000Z",
lastSuccessAt: "2033-05-18T03:33:20.000Z",
};
},
async putObject(input) {
objects.set(input.key, {
body: await readBody(input.body),
contentType: input.contentType,
metadata: input.metadata,
});
return { key: input.key };
},
async getObject(key) {
const object = objects.get(key);
if (!object) throw new StorageObjectNotFoundError(key);
return {
body: Readable.from([object.body]),
contentLength: object.body.length,
contentType: object.contentType,
};
},
async deleteObject(key) {
objects.delete(key);
return { key, deleted: true };
},
};
}
async function createHarness(t, { maxFileBytes = 1024 * 1024 } = {}) {
const tempDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "announcements-api-uploads-"),
);
const config = loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://private-seaweedfs:8333",
S3_BUCKET: "announcements-test",
UPLOAD_TEMP_DIRECTORY: tempDirectory,
UPLOAD_MAX_FILE_BYTES: String(maxFileBytes),
UPLOAD_MAX_FILES: "5",
IMAGE_MAX_INPUT_PIXELS: "1000000",
});
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
const storage = memoryStorage();
const app = createApp({
config,
database,
imageProcessor: createImageProcessor({
maxInputPixels: config.processing.maxInputPixels,
}),
lifecycle: { ready: true },
migrations,
storage,
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
database.close();
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
database,
storage,
tempDirectory,
};
}
function appendFile(form, field, buffer, filename, contentType) {
form.append(field, new Blob([buffer], { type: contentType }), filename);
}
async function jsonResponse(baseUrl, route, init) {
const response = await fetch(`${baseUrl}${route}`, init);
return { response, body: await response.json() };
}
async function eventuallyEmpty(directory) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.readdirSync(directory).length === 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(fs.readdirSync(directory), []);
}
async function eventuallyContainsFile(directory) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.readdirSync(directory).length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail("The upload did not create its temporary file");
}
async function sampleImages() {
const source = sharp({
create: {
width: 80,
height: 60,
channels: 4,
background: { r: 31, g: 72, b: 120, alpha: 0.5 },
},
});
return {
jpeg: await source.clone().jpeg().toBuffer(),
png: await source.clone().png().toBuffer(),
webp: await source.clone().webp().toBuffer(),
};
}
test("asset HTTP API accepts JPEG, PNG, and WebP and serves private immutable media", async (t) => {
const harness = await createHarness(t);
const images = await sampleImages();
const form = new FormData();
appendFile(form, "files", images.jpeg, "welcome.jpg", "image/jpeg");
appendFile(form, "files", images.png, "../../bulletin.png", "image/png");
appendFile(form, "files", images.webp, "events.webp", "image/webp");
const uploaded = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: form,
});
assert.equal(uploaded.response.status, 201);
assert.equal(uploaded.body.createdCount, 3);
assert.equal(uploaded.body.duplicateCount, 0);
assert.equal(uploaded.body.results.length, 3);
assert.deepEqual(
uploaded.body.results.map((result) => result.asset.original.contentType),
["image/jpeg", "image/png", "image/webp"],
);
assert.equal(uploaded.body.results[1].asset.originalFilename, "bulletin.png");
assert.doesNotMatch(JSON.stringify(uploaded.body), /objectKey|object_key|private-seaweedfs/);
assert.equal(harness.storage.objects.size, 9);
await eventuallyEmpty(harness.tempDirectory);
const asset = uploaded.body.results[0].asset;
const listed = await jsonResponse(harness.baseUrl, "/api/assets");
assert.equal(listed.response.status, 200);
assert.equal(listed.body.assets.length, 3);
assert.deepEqual(
(await jsonResponse(harness.baseUrl, `/api/assets/${asset.id}`)).body,
asset,
);
for (const media of [asset.display, asset.thumbnail]) {
const response = await fetch(`${harness.baseUrl}${media.url}`);
const body = Buffer.from(await response.arrayBuffer());
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "image/webp");
assert.equal(
response.headers.get("cache-control"),
"public, max-age=31536000, immutable",
);
assert.equal(response.headers.get("content-length"), String(body.length));
assert.equal(response.headers.get("etag"), `"${media.sha256}"`);
assert.equal(crypto.createHash("sha256").update(body).digest("hex"), media.sha256);
}
const original = await fetch(`${harness.baseUrl}${asset.original.url}`);
assert.equal(original.status, 200);
assert.equal(original.headers.get("content-type"), "image/jpeg");
assert.equal(original.headers.get("cache-control"), "private, no-store");
assert.match(original.headers.get("content-disposition"), /^attachment;/);
assert.deepEqual(Buffer.from(await original.arrayBuffer()), images.jpeg);
const wrongVersion = await jsonResponse(
harness.baseUrl,
`/media/assets/${asset.id}/display/${"a".repeat(64)}.webp`,
);
assert.equal(wrongVersion.response.status, 404);
assert.equal(wrongVersion.body.error.code, "MEDIA_NOT_FOUND");
const arbitraryKey = await jsonResponse(
harness.baseUrl,
`/media/assets/${asset.id}/display/${encodeURIComponent("../../original/key")}.webp`,
);
assert.equal(arbitraryKey.response.status, 404);
assert.equal(arbitraryKey.body.error.code, "MEDIA_NOT_FOUND");
const displayKey = [...harness.storage.objects.keys()].find(
(key) => key.includes(`assets/${asset.id}/display/`),
);
harness.storage.objects.delete(displayKey);
const missingStoredBytes = await jsonResponse(
harness.baseUrl,
asset.display.url,
);
assert.equal(missingStoredBytes.response.status, 503);
assert.equal(missingStoredBytes.body.error.code, "STORAGE_INCONSISTENCY");
assert.equal(
(await jsonResponse(harness.baseUrl, "/health/live")).response.status,
200,
);
});
test("asset HTTP API reports duplicates, name conflicts, and persists editable metadata", async (t) => {
const harness = await createHarness(t);
const { png, jpeg } = await sampleImages();
const firstForm = new FormData();
appendFile(firstForm, "file", png, "weekly.png", "application/octet-stream");
firstForm.append("display_name", "Weekly Announcement");
const first = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: firstForm,
});
assert.equal(first.response.status, 201);
assert.equal(first.body.created, true);
const assetId = first.body.asset.id;
const duplicateForm = new FormData();
appendFile(duplicateForm, "file", png, "unrelated-name.png", "image/png");
duplicateForm.append("display_name", "Ignored duplicate proposal");
const duplicate = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: duplicateForm,
});
assert.equal(duplicate.response.status, 200);
assert.equal(duplicate.body.created, false);
assert.equal(duplicate.body.duplicate, true);
assert.equal(duplicate.body.asset.id, assetId);
assert.equal(harness.storage.objects.size, 3);
const conflictForm = new FormData();
appendFile(conflictForm, "file", jpeg, "different.jpg", "image/jpeg");
conflictForm.append("displayName", "weekly announcement");
const conflict = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: conflictForm,
});
assert.equal(conflict.response.status, 409);
assert.equal(conflict.body.error.code, "ASSET_NAME_CONFLICT");
const expiresAt = Date.parse("2034-01-02T03:04:05.000Z");
const edited = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
display_name: "Renamed announcement",
expiration_at: expiresAt,
}),
});
assert.equal(edited.response.status, 200);
assert.equal(edited.body.displayName, "Renamed announcement");
assert.equal(edited.body.expirationAt, expiresAt);
const permanent = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ expiration_at: null }),
});
assert.equal(permanent.body.expirationAt, null);
assert.equal(harness.storage.objects.size, 3);
await eventuallyEmpty(harness.tempDirectory);
const timestamp = Date.now();
harness.database
.prepare(
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.run("api-playlist", "API playlist", timestamp, timestamp);
harness.database
.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, position, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?)`,
)
.run("api-entry", "api-playlist", assetId, timestamp, timestamp);
const references = await jsonResponse(
harness.baseUrl,
`/api/assets/${assetId}/references`,
);
assert.equal(references.response.status, 200);
assert.equal(references.body.canDelete, false);
assert.equal(references.body.playlists[0].playlistEntryId, "api-entry");
const blockedDelete = await jsonResponse(
harness.baseUrl,
`/api/assets/${assetId}`,
{ method: "DELETE" },
);
assert.equal(blockedDelete.response.status, 409);
assert.equal(blockedDelete.body.error.code, "ASSET_REFERENCED");
assert.equal(harness.storage.objects.size, 3);
harness.database
.prepare("DELETE FROM playlist_entries WHERE id = ?")
.run("api-entry");
const removed = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "DELETE",
});
assert.equal(removed.response.status, 200);
assert.deepEqual(removed.body, { id: assetId, deleted: true });
assert.equal(harness.storage.objects.size, 0);
assert.equal(
(await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`)).response.status,
404,
);
});
test("upload validation returns structured errors and always cleans temporary files", async (t) => {
const harness = await createHarness(t, { maxFileBytes: 1024 });
const malformed = new FormData();
appendFile(malformed, "file", Buffer.from("not an image"), "broken.png", "image/png");
const malformedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: malformed,
});
assert.equal(malformedResult.response.status, 422);
assert.equal(malformedResult.body.error.code, "MALFORMED_IMAGE");
await eventuallyEmpty(harness.tempDirectory);
const unsupportedBuffer = await sharp({
create: {
width: 20,
height: 20,
channels: 3,
background: "red",
},
})
.gif()
.toBuffer();
const unsupported = new FormData();
appendFile(unsupported, "file", unsupportedBuffer, "still.gif", "image/png");
const unsupportedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: unsupported,
});
assert.equal(unsupportedResult.response.status, 415);
assert.equal(unsupportedResult.body.error.code, "UNSUPPORTED_IMAGE_FORMAT");
await eventuallyEmpty(harness.tempDirectory);
const oversized = new FormData();
appendFile(
oversized,
"file",
crypto.randomBytes(1025),
"too-large.png",
"image/png",
);
const oversizedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: oversized,
});
assert.equal(oversizedResult.response.status, 413);
assert.equal(oversizedResult.body.error.code, "UPLOAD_FILE_TOO_LARGE");
assert.deepEqual(oversizedResult.body.error.details, { maxFileBytes: 1024 });
await eventuallyEmpty(harness.tempDirectory);
const tooMany = new FormData();
for (let index = 0; index < 6; index += 1) {
appendFile(tooMany, "files", Buffer.from("x"), `${index}.png`, "image/png");
}
const tooManyResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: tooMany,
});
assert.equal(tooManyResult.response.status, 413);
assert.equal(tooManyResult.body.error.code, "UPLOAD_FILE_COUNT_EXCEEDED");
assert.deepEqual(tooManyResult.body.error.details, { maxFiles: 5 });
await eventuallyEmpty(harness.tempDirectory);
const absent = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: new FormData(),
});
assert.equal(absent.response.status, 400);
assert.equal(absent.body.error.code, "UPLOAD_FILE_REQUIRED");
assert.equal(harness.storage.objects.size, 0);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
0,
);
});
test("an aborted multipart request removes its in-flight temporary file", async (t) => {
const harness = await createHarness(t);
const boundary = `announcements-${crypto.randomUUID()}`;
const preamble = Buffer.from(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="file"; filename="aborted.png"\r\n' +
"Content-Type: image/png\r\n\r\n",
);
const declaredLength = preamble.length + 512 * 1024;
const request = http.request(`${harness.baseUrl}/api/assets`, {
method: "POST",
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(declaredLength),
},
});
request.on("error", () => {});
request.write(preamble);
request.write(Buffer.alloc(64 * 1024, 1));
await eventuallyContainsFile(harness.tempDirectory);
const closed = new Promise((resolve) => request.once("close", resolve));
request.destroy();
await closed;
await eventuallyEmpty(harness.tempDirectory);
assert.equal(harness.storage.objects.size, 0);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
0,
);
});
+35
View File
@@ -0,0 +1,35 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
mapMulterError,
safeOriginalFilename,
} = require("../src/assets/multipart");
test("original filename metadata keeps a safe leaf without path traversal", () => {
assert.equal(safeOriginalFilename("../../Sunday/welcome.png"), "welcome.png");
assert.equal(safeOriginalFilename("..\\..\\Sunday\\welcome.png"), "welcome.png");
assert.equal(safeOriginalFilename(" welcome sign.png "), " welcome sign.png ");
});
test("original filename metadata removes controls and remains bounded", () => {
assert.equal(safeOriginalFilename("wel\u0000come\n.png"), "welcome.png");
assert.equal([...safeOriginalFilename(`${"a".repeat(300)}.png`)].length, 255);
assert.throws(
() => safeOriginalFilename(" \u0000\n "),
(error) => error.code === "INVALID_ORIGINAL_FILENAME",
);
});
test("temporary-storage failures are server errors, not invalid client uploads", () => {
const storageFailure = mapMulterError(
Object.assign(new Error("disk full at a private path"), { code: "ENOSPC" }),
{},
);
assert.equal(storageFailure.code, "UPLOAD_TEMP_STORAGE_UNAVAILABLE");
assert.equal(storageFailure.statusCode, 503);
assert.doesNotMatch(storageFailure.message, /private path/);
const malformed = mapMulterError(new Error("Unexpected end of form"), {});
assert.equal(malformed.code, "INVALID_MULTIPART_UPLOAD");
assert.equal(malformed.statusCode, 400);
});
+608
View File
@@ -0,0 +1,608 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Readable } = require("node:stream");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createAssetService } = require("../src/assets/service");
const {
StorageObjectNotFoundError,
StorageUnavailableError,
} = require("../src/storage");
async function bufferBody(body) {
if (Buffer.isBuffer(body)) return body;
if (body instanceof Uint8Array) return Buffer.from(body);
const chunks = [];
for await (const chunk of body) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks);
}
function createMemoryStorage() {
const objects = new Map();
const calls = [];
let failNextPut = null;
let successfulPutsBeforeFailure = 0;
let failNextDelete = null;
let putDelayMs = 0;
return {
objects,
calls,
setPutFailure(error, putsBeforeFailure = 0) {
failNextPut = error;
successfulPutsBeforeFailure = putsBeforeFailure;
},
setDeleteFailure(error) {
failNextDelete = error;
},
setPutDelay(milliseconds) {
putDelayMs = milliseconds;
},
async putObject(input) {
calls.push({ operation: "put", ...input, body: undefined });
if (putDelayMs) {
await new Promise((resolve) => setTimeout(resolve, putDelayMs));
}
if (failNextPut) {
if (successfulPutsBeforeFailure === 0) {
const error = failNextPut;
failNextPut = null;
throw error;
}
successfulPutsBeforeFailure -= 1;
}
objects.set(input.key, {
body: await bufferBody(input.body),
contentType: input.contentType,
metadata: input.metadata,
});
return { key: input.key };
},
async getObject(key) {
calls.push({ operation: "get", key });
const object = objects.get(key);
if (!object) throw new StorageObjectNotFoundError(key);
return {
body: Readable.from(object.body),
contentLength: object.body.length,
contentType: object.contentType,
};
},
async deleteObject(key) {
calls.push({ operation: "delete", key });
if (failNextDelete) {
const error = failNextDelete;
failNextDelete = null;
throw error;
}
objects.delete(key);
return { key, deleted: true };
},
};
}
function createFakeImageProcessor({ reject } = {}) {
const calls = [];
return {
calls,
async process(filename) {
calls.push(filename);
if (reject) throw reject;
const original = await fs.promises.readFile(filename);
const sourceSha256 = crypto.createHash("sha256").update(original).digest("hex");
const displayBuffer = Buffer.from(`display:${sourceSha256}`);
const thumbnailBuffer = Buffer.from(`thumbnail:${sourceSha256}`);
return {
original: {
sha256: sourceSha256,
contentType: "image/png",
byteSize: original.length,
width: 1200,
height: 800,
},
display: {
buffer: displayBuffer,
sha256: crypto.createHash("sha256").update(displayBuffer).digest("hex"),
contentType: "image/webp",
byteSize: displayBuffer.length,
width: 1200,
height: 800,
},
thumbnail: {
buffer: thumbnailBuffer,
sha256: crypto.createHash("sha256").update(thumbnailBuffer).digest("hex"),
contentType: "image/webp",
byteSize: thumbnailBuffer.length,
width: 405,
height: 270,
},
};
},
};
}
function createHarness(options = {}) {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-assets-"));
const storage = options.storage || createMemoryStorage();
const imageProcessor =
options.imageProcessor || createFakeImageProcessor(options.processorOptions);
let idCounter = 0;
let clock = 2_000_000_000_000;
const service = createAssetService({
database,
storage,
imageProcessor,
now: () => ++clock,
createId:
options.createId ||
(() => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`),
concurrentUploadWaitMs: options.concurrentUploadWaitMs ?? 2_000,
});
function uploadFile(originalFilename, contents) {
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
const filename = path.join(directory, crypto.randomUUID());
fs.writeFileSync(filename, buffer);
return { path: filename, byteSize: buffer.length, originalFilename };
}
return {
database,
directory,
imageProcessor,
service,
storage,
uploadFile,
close() {
database.close();
fs.rmSync(directory, { recursive: true, force: true });
},
};
}
function rawAsset(database, assetId) {
return database.prepare("SELECT * FROM assets WHERE id = ?").get(assetId);
}
test("upload stores exact bytes under content-addressed keys without leaking keys", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const original = Buffer.from("exact uploaded bytes");
const [result] = await harness.service.upload([
harness.uploadFile(" Sunday Welcome.PNG ", original),
]);
assert.equal(result.created, true);
assert.equal(result.duplicate, false);
assert.match(result.asset.id, /^[0-9a-f-]{36}$/);
assert.equal(result.asset.displayName, "Sunday Welcome");
assert.equal(result.asset.originalFilename, " Sunday Welcome.PNG ");
assert.equal(result.asset.expirationAt, null);
assert.equal(result.asset.status, "ready");
assert.equal(result.asset.original.byteSize, original.length);
assert.equal(result.asset.display.contentType, "image/webp");
assert.equal(result.asset.thumbnail.width, 405);
assert.match(result.asset.display.url, /^\/media\/assets\//);
assert.doesNotMatch(
JSON.stringify(result.asset),
/object_key|originalObjectKey|displayObjectKey|thumbnailObjectKey|seaweed/i,
);
const row = rawAsset(harness.database, result.asset.id);
const expectedSourceHash = crypto.createHash("sha256").update(original).digest("hex");
assert.equal(row.source_sha256, expectedSourceHash);
assert.equal(row.original_object_key, `assets/${row.id}/original/${expectedSourceHash}`);
assert.equal(
row.display_object_key,
`assets/${row.id}/display/${row.display_sha256}.webp`,
);
assert.equal(
row.thumbnail_object_key,
`assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
);
assert.deepEqual(harness.storage.objects.get(row.original_object_key).body, original);
assert.equal(harness.storage.objects.size, 3);
assert.deepEqual(harness.service.list(), [result.asset]);
assert.deepEqual(harness.service.get(result.asset.id), result.asset);
});
test("exact duplicate detection takes precedence over filename and display name", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const contents = Buffer.from("same image bytes");
const [created] = await harness.service.upload([
harness.uploadFile("first.png", contents),
]);
const [sameFilename] = await harness.service.upload([
harness.uploadFile("first.png", contents),
]);
const [differentFilename] = await harness.service.upload(
[harness.uploadFile("completely different name.webp", contents)],
{ displayName: "A conflicting requested name is ignored for an exact duplicate" },
);
for (const duplicate of [sameFilename, differentFilename]) {
assert.equal(duplicate.created, false);
assert.equal(duplicate.duplicate, true);
assert.equal(duplicate.asset.id, created.asset.id);
}
assert.equal(harness.service.list().length, 1);
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
assert.equal(harness.imageProcessor.calls.length, 1);
});
test("different content with the same proposed name is a conflict, not a duplicate", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
await harness.service.upload([harness.uploadFile("bulletin.png", "image one")]);
await assert.rejects(
harness.service.upload([harness.uploadFile("bulletin.jpg", "image two")]),
(error) => {
assert.equal(error.statusCode, 409);
assert.equal(error.code, "ASSET_NAME_CONFLICT");
assert.equal(error.details.asset.displayName, "bulletin");
return true;
},
);
assert.equal(harness.service.list().length, 1);
assert.equal(harness.imageProcessor.calls.length, 1);
const [created] = await harness.service.upload(
[harness.uploadFile("bulletin.jpg", "image two")],
{ displayName: "Second bulletin" },
);
assert.equal(created.created, true);
assert.notEqual(created.asset.sourceSha256, harness.service.list()[1].sourceSha256);
assert.equal(harness.service.list().length, 2);
});
test("multiple-file upload preserves order and deduplicates repeated bytes in the batch", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const results = await harness.service.upload([
harness.uploadFile("alpha.png", "alpha bytes"),
harness.uploadFile("beta.png", "beta bytes"),
harness.uploadFile("alpha-copy.png", "alpha bytes"),
]);
assert.deepEqual(
results.map(({ created, duplicate }) => ({ created, duplicate })),
[
{ created: true, duplicate: false },
{ created: true, duplicate: false },
{ created: false, duplicate: true },
],
);
assert.equal(results[0].asset.id, results[2].asset.id);
assert.equal(harness.service.list().length, 2);
assert.equal(harness.imageProcessor.calls.length, 2);
assert.equal(harness.storage.objects.size, 6);
});
test("all new images are decoded before the batch mutates SQLite or storage", async (t) => {
const expected = Object.assign(new Error("bad second image"), {
code: "MALFORMED_IMAGE",
statusCode: 422,
});
let processCount = 0;
const regularProcessor = createFakeImageProcessor();
const harness = createHarness({
imageProcessor: {
async process(filename) {
processCount += 1;
if (processCount === 2) throw expected;
return regularProcessor.process(filename);
},
},
});
t.after(() => harness.close());
await assert.rejects(
harness.service.upload([
harness.uploadFile("valid.png", "valid"),
harness.uploadFile("broken.png", "broken"),
]),
expected,
);
assert.equal(harness.service.list().length, 0);
assert.equal(harness.storage.objects.size, 0);
assert.equal(harness.storage.calls.length, 0);
});
test("concurrent identical uploads converge on one ready asset", async (t) => {
const storage = createMemoryStorage();
storage.setPutDelay(30);
const harness = createHarness({ storage });
t.after(() => harness.close());
const bytes = Buffer.from("racing identical upload");
const [[left], [right]] = await Promise.all([
harness.service.upload([harness.uploadFile("race.png", bytes)]),
harness.service.upload([harness.uploadFile("other-name.png", bytes)]),
]);
assert.equal(harness.service.list().length, 1);
assert.equal(left.asset.id, right.asset.id);
assert.deepEqual([left.created, right.created].sort(), [false, true]);
assert.deepEqual([left.duplicate, right.duplicate].sort(), [false, true]);
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 3);
});
test("a partial storage failure leaves a diagnosable failed row and no ready URLs", async (t) => {
const storage = createMemoryStorage();
storage.setPutFailure(new StorageUnavailableError(new Error("offline")), 1);
const harness = createHarness({ storage });
t.after(() => harness.close());
await assert.rejects(
harness.service.upload([harness.uploadFile("failure.png", "failure bytes")]),
(error) => {
assert.equal(error.statusCode, 503);
assert.equal(error.details.retryable, true);
assert.match(error.details.assetId, /^[0-9a-f-]{36}$/);
return true;
},
);
const [failed] = harness.service.list();
assert.equal(failed.status, "failed");
assert.match(failed.processingError, /^STORAGE_UNAVAILABLE:/);
assert.equal(failed.display.url, null);
assert.equal(failed.thumbnail.url, null);
assert.equal(failed.original.url, null);
assert.equal(storage.objects.size, 0);
const row = rawAsset(harness.database, failed.id);
assert.match(
row.original_object_key,
new RegExp(`^assets/${failed.id}/original/[0-9a-f]{64}$`),
);
assert.equal(
row.display_object_key,
`assets/${failed.id}/display/${row.display_sha256}.webp`,
);
assert.equal(
row.thumbnail_object_key,
`assets/${failed.id}/thumbnail/${row.thumbnail_sha256}.webp`,
);
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 2);
assert.deepEqual(
new Set(
storage.calls
.filter((call) => call.operation === "delete")
.map((call) => call.key),
),
new Set([
row.original_object_key,
row.display_object_key,
row.thumbnail_object_key,
]),
);
await assert.rejects(
harness.service.upload([harness.uploadFile("retry.png", "failure bytes")]),
(error) => error.code === "ASSET_PROCESSING_FAILED" && error.statusCode === 409,
);
assert.deepEqual(await harness.service.remove(failed.id), {
id: failed.id,
deleted: true,
});
assert.equal(harness.service.list().length, 0);
});
test("rename and expiration edits change only approved SQLite metadata", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("editable.png", "editable bytes"),
]);
const before = rawAsset(harness.database, upload.asset.id);
const expiring = harness.service.update(upload.asset.id, {
display_name: " New display name ",
expiration_at: "2035-05-06T07:08:09.000Z",
});
assert.equal(expiring.displayName, "New display name");
assert.equal(expiring.expirationAt, Date.parse("2035-05-06T07:08:09.000Z"));
const permanent = harness.service.update(upload.asset.id, { expirationAt: null });
assert.equal(permanent.expirationAt, null);
const after = rawAsset(harness.database, upload.asset.id);
assert.equal(after.original_object_key, before.original_object_key);
assert.equal(after.display_object_key, before.display_object_key);
assert.equal(after.thumbnail_object_key, before.thumbnail_object_key);
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
await assert.rejects(
async () => harness.service.update(upload.asset.id, { status: "failed" }),
(error) => error.code === "INVALID_ASSET_UPDATE",
);
assert.throws(
() => harness.service.update(upload.asset.id, { expirationAt: -1 }),
(error) => error.code === "INVALID_ASSET_EXPIRATION",
);
});
test("references describe playlist, normal, and static use and block deletion", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("referenced.png", "referenced bytes"),
]);
const id = upload.asset.id;
const now = 2_100_000_000_000;
harness.database
.prepare(
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.run("playlist-1", "Sunday", now, now);
harness.database
.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, enabled, position, created_at, updated_at)
VALUES (?, ?, ?, 1, 0, ?, ?)`,
)
.run("entry-1", "playlist-1", id, now, now);
harness.database
.prepare(
`UPDATE playback_state
SET active_playlist_id = ?, current_entry_id = ?, static_asset_id = ?, updated_at = ?
WHERE output_id = 'global'`,
)
.run("playlist-1", "entry-1", id, now);
const references = harness.service.references(id);
assert.equal(references.canDelete, false);
assert.deepEqual(references.blockerCodes, [
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
]);
assert.deepEqual(references.playlists[0], {
playlistId: "playlist-1",
playlistName: "Sunday",
playlistEntryId: "entry-1",
enabled: true,
position: 0,
isActivePlaylist: true,
isCurrentNormalTarget: true,
});
assert.equal(references.playback[0].isCurrentNormalTarget, true);
assert.equal(references.playback[0].isCurrentStaticTarget, true);
const callsBeforeDelete = harness.storage.calls.length;
await assert.rejects(
harness.service.remove(id),
(error) => {
assert.equal(error.code, "ASSET_REFERENCED");
assert.equal(error.statusCode, 409);
assert.deepEqual(error.details.blockerCodes, [
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
]);
return true;
},
);
assert.equal(harness.storage.calls.length, callsBeforeDelete);
assert.equal(rawAsset(harness.database, id).status, "ready");
harness.database
.prepare(
`UPDATE playback_state
SET active_playlist_id = NULL, current_entry_id = NULL, static_asset_id = NULL
WHERE output_id = 'global'`,
)
.run();
harness.database.prepare("DELETE FROM playlist_entries WHERE id = ?").run("entry-1");
assert.equal(harness.service.references(id).canDelete, true);
await harness.service.remove(id);
assert.equal(harness.storage.objects.size, 0);
assert.equal(rawAsset(harness.database, id), undefined);
});
test("failed storage deletion retains a retryable deleting row", async (t) => {
const storage = createMemoryStorage();
const harness = createHarness({ storage });
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("delete-me.png", "delete me"),
]);
storage.setDeleteFailure(new StorageUnavailableError(new Error("offline")));
await assert.rejects(harness.service.remove(upload.asset.id), (error) => {
assert.equal(error.statusCode, 503);
assert.equal(error.details.assetId, upload.asset.id);
assert.equal(error.details.retryable, true);
return true;
});
assert.equal(rawAsset(harness.database, upload.asset.id).status, "deleting");
assert.throws(
() => harness.service.update(upload.asset.id, { displayName: "Cannot edit" }),
(error) => error.code === "ASSET_DELETING",
);
await harness.service.remove(upload.asset.id);
assert.equal(rawAsset(harness.database, upload.asset.id), undefined);
assert.equal(storage.objects.size, 0);
});
test("processing assets cannot be deleted and storage is not touched", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const timestamp = Date.now();
harness.database
.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'processing', ?, ?)`,
)
.run(
"00000000-0000-4000-8000-999999999999",
"f".repeat(64),
"Still processing",
"processing.png",
timestamp,
timestamp,
);
assert.equal(
harness.service.references("00000000-0000-4000-8000-999999999999")
.canDelete,
false,
);
await assert.rejects(
harness.service.remove("00000000-0000-4000-8000-999999999999"),
(error) => error.code === "ASSET_PROCESSING" && error.statusCode === 409,
);
assert.equal(harness.storage.calls.length, 0);
assert.equal(
rawAsset(harness.database, "00000000-0000-4000-8000-999999999999").status,
"processing",
);
});
test("media descriptors only allow the recorded version and map missing bytes to 503", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("media.png", "media bytes"),
]);
const { asset } = upload;
const display = harness.service.mediaDescriptor(
asset.id,
"display",
asset.display.sha256,
);
assert.equal(display.contentType, "image/webp");
assert.equal(display.contentLength, asset.display.byteSize);
const stored = await harness.service.readStoredObject(display);
assert.deepEqual(await bufferBody(stored.body), harness.storage.objects.get(display.key).body);
for (const requestedHash of ["a".repeat(64), "../original/key", "not-a-hash"]) {
assert.throws(
() => harness.service.mediaDescriptor(asset.id, "display", requestedHash),
(error) => error.code === "MEDIA_NOT_FOUND" && error.statusCode === 404,
);
}
harness.storage.objects.delete(display.key);
await assert.rejects(harness.service.readStoredObject(display), (error) => {
assert.equal(error.code, "STORAGE_INCONSISTENCY");
assert.equal(error.statusCode, 503);
assert.deepEqual(error.details, { assetId: asset.id, retryable: true });
return true;
});
});
+128
View File
@@ -0,0 +1,128 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
function validEnvironment(overrides = {}) {
return {
NODE_ENV: "production",
HOST: "127.0.0.1",
PORT: "7532",
DATABASE_PATH: "/data/db/announcements.sqlite",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "test-access-key",
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_USERNAME: "mqtt-user",
MQTT_PASSWORD: "mqtt-password",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
HA_WEBHOOK_SECRET: "webhook-secret",
...overrides,
};
}
test("loadConfig validates and normalizes the supported environment", () => {
const config = loadConfig(validEnvironment());
assert.equal(config.environment, "production");
assert.equal(config.port, 7532);
assert.equal(config.timeZone, "America/Chicago");
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
assert.equal(config.storage.forcePathStyle, true);
assert.equal(config.storage.healthProbeIntervalMs, 30_000);
assert.equal(config.storage.connectionTimeoutMs, 2_000);
assert.equal(config.storage.requestTimeoutMs, 5_000);
assert.equal(config.storage.maxAttempts, 2);
assert.equal(config.processing.tempDirectory, "/tmp/announcements-uploads");
assert.equal(config.processing.maxFileBytes, 25 * 1024 * 1024);
assert.equal(config.processing.maxFiles, 20);
assert.equal(config.processing.maxInputPixels, 40_000_000);
});
test("production requires private S3 credentials", () => {
assert.throws(
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
(error) =>
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
);
const development = loadConfig(
validEnvironment({
NODE_ENV: "development",
S3_ACCESS_KEY_ID: "",
S3_SECRET_ACCESS_KEY: "",
}),
);
assert.equal(development.storage.accessKeyId, null);
assert.equal(development.storage.secretAccessKey, null);
});
test("loadConfig rejects invalid typed values", () => {
assert.throws(
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
/PORT must be an integer/,
);
assert.throws(
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
/valid IANA time zone/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
/must be a boolean/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_HEALTH_PROBE_INTERVAL_MS: "999" })),
/S3_HEALTH_PROBE_INTERVAL_MS must be at least 1000/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_REQUEST_TIMEOUT_MS: "999" })),
/S3_REQUEST_TIMEOUT_MS must be at least 1000/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_CONNECT_TIMEOUT_MS: "249" })),
/S3_CONNECT_TIMEOUT_MS must be at least 250/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_MAX_ATTEMPTS: "6" })),
/S3_MAX_ATTEMPTS must be at most 5/,
);
assert.throws(
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
/must be an absolute path/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_TEMP_DIRECTORY: "relative" })),
/UPLOAD_TEMP_DIRECTORY must be an absolute path/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILE_BYTES: "0" })),
/UPLOAD_MAX_FILE_BYTES must be at least 1/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILES: "101" })),
/UPLOAD_MAX_FILES must be at most 100/,
);
assert.throws(
() => loadConfig(validEnvironment({ IMAGE_MAX_INPUT_PIXELS: "no-limit" })),
/IMAGE_MAX_INPUT_PIXELS must be an integer/,
);
});
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
const summary = redactedConfig(loadConfig(validEnvironment()));
const serialized = JSON.stringify(summary);
assert.equal(summary.storage.configured, true);
assert.equal(summary.mqtt.configured, true);
assert.equal(summary.notifications.configured, true);
assert.doesNotMatch(serialized, /test-access-key/);
assert.doesNotMatch(serialized, /test-secret-key/);
assert.doesNotMatch(serialized, /mqtt-password/);
assert.doesNotMatch(serialized, /webhook-secret/);
assert.doesNotMatch(serialized, /announcements\.sqlite/);
assert.doesNotMatch(serialized, /seaweedfs/);
assert.doesNotMatch(serialized, /home-assistant/);
});
+275
View File
@@ -0,0 +1,275 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { openDatabase } = require("../src/database");
const { migrationStatus, runMigrations } = require("../src/database/migrate");
function migratedDatabase() {
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
return { database, migrations };
}
function columnNames(database, table) {
return database.pragma(`table_info(${table})`).map((column) => column.name);
}
test("initial migration creates every approved Phase 0 table and seed row", (t) => {
const { database, migrations } = migratedDatabase();
t.after(() => database.close());
const tables = database
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all()
.map((row) => row.name);
assert.deepEqual(tables, [
"app_settings",
"asset_import_sources",
"assets",
"display_clients",
"notification_incidents",
"notification_outbox",
"playback_state",
"playlist_entries",
"playlists",
"schema_migrations",
]);
assert.equal(migrations.currentVersion, 1);
assert.equal(migrations.latestAvailableVersion, 1);
assert.deepEqual(migrations.pendingVersions, []);
assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), {
id: 1,
revision: 1,
default_duration_ms: 7000,
default_background_mode: "blurred",
default_solid_background_color: "#000000",
default_background_darkened: 0,
default_blur_px: 20,
crossfade_duration_ms: 1000,
updated_at: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
const playback = database.prepare("SELECT * FROM playback_state").get();
assert.equal(playback.output_id, "global");
assert.equal(playback.mode, "on");
assert.equal(playback.pending_playlist_id, null);
assert.equal(playback.pending_playlist_requested_at, null);
assert.ok(columnNames(database, "playlist_entries").includes(
"background_darkened_override",
));
});
test("migrations are idempotent and report their status", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const rerun = runMigrations(database);
assert.equal(rerun.applied.length, 1);
assert.deepEqual(rerun.pendingVersions, []);
assert.deepEqual(migrationStatus(database), rerun);
});
test("a failed migration rolls back its schema and version record", (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-"));
const migrationPath = path.join(directory, "001_test.sql");
fs.writeFileSync(
migrationPath,
"CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;",
);
const database = openDatabase({ path: ":memory:" });
t.after(() => {
database.close();
fs.rmSync(directory, { recursive: true, force: true });
});
assert.throws(() => runMigrations(database, directory));
assert.equal(
database
.prepare(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'",
)
.get().count,
0,
);
assert.equal(
database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count,
0,
);
fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);");
const status = runMigrations(database, directory);
assert.equal(status.currentVersion, 1);
assert.equal(
database
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'")
.get().count,
1,
);
});
test("a library asset can occur only once in a given playlist", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
)
.run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-1", "Sunday", now, now);
const insert = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
);
insert.run("entry-1", "playlist-1", "asset-1", 0, now, now);
assert.throws(
() => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now),
/UNIQUE constraint failed/,
);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-2", "Midweek", now, now);
assert.doesNotThrow(() =>
insert.run("entry-3", "playlist-2", "asset-1", 0, now, now),
);
assert.throws(
() => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(),
/FOREIGN KEY constraint failed/,
);
});
test("darkening overrides accept inherited, off, and on only", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
const insertAsset = database.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
);
const insertEntry = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at)
VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`,
);
for (const [index, override] of [null, 0, 1].entries()) {
const assetId = `asset-${index}`;
insertAsset.run(
assetId,
String(index).repeat(64),
`Asset ${index}`,
`asset-${index}.png`,
now,
now,
);
assert.doesNotThrow(() =>
insertEntry.run(
`entry-${index}`,
assetId,
override,
index,
now,
now,
),
);
}
insertAsset.run(
"asset-invalid",
"f".repeat(64),
"Invalid override",
"invalid.png",
now,
now,
);
assert.throws(
() => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now),
/CHECK constraint failed/,
);
assert.throws(
() =>
database
.prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1")
.run(),
/CHECK constraint failed/,
);
});
test("pending playlist activation requires a playlist and request time together", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
assert.throws(
() =>
database
.prepare(
"UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'",
)
.run(),
/CHECK constraint failed/,
);
assert.doesNotThrow(() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
);
assert.throws(
() => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(),
/FOREIGN KEY constraint failed/,
);
assert.throws(
() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'missing', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
/FOREIGN KEY constraint failed/,
);
});
+218
View File
@@ -0,0 +1,218 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
ELIGIBILITY_REASONS,
evaluateEntryEligibility,
resolveEntrySettings,
} = require("../src/lifecycle");
const NOW = Date.parse("2035-05-06T07:08:09.000Z");
function readyAsset(overrides = {}) {
return { status: "ready", expiration_at: null, ...overrides };
}
function enabledEntry(overrides = {}) {
return {
enabled: 1,
schedule_start_at: null,
schedule_end_at: null,
...overrides,
};
}
test("an enabled, ready, permanent, unscheduled entry is playable", () => {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW),
{ playable: true, reason: null },
);
});
test("disabled entries are not playable", () => {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry({ enabled: 0 }), readyAsset(), NOW),
{ playable: false, reason: ELIGIBILITY_REASONS.DISABLED },
);
});
test("schedule start is inclusive", () => {
const entry = enabledEntry({ schedule_start_at: NOW });
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
playable: false,
reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
});
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
playable: true,
reason: null,
});
});
test("schedule end is exclusive", () => {
const entry = enabledEntry({ schedule_end_at: NOW });
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
playable: true,
reason: null,
});
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
playable: false,
reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED,
});
});
test("start-only, end-only, and bounded schedules use UTC epoch milliseconds", () => {
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW - 1 }),
readyAsset(),
NOW,
).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_end_at: NOW + 1 }),
readyAsset(),
NOW,
).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW, schedule_end_at: NOW + 1 }),
readyAsset(),
NOW,
).playable,
true,
);
});
test("only ready assets are playable", () => {
for (const status of ["processing", "failed", "deleting"]) {
assert.deepEqual(
evaluateEntryEligibility(enabledEntry(), readyAsset({ status }), NOW),
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY },
);
}
});
test("permanent assets stay playable and expiration is exclusive", () => {
assert.equal(
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW).playable,
true,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry(),
readyAsset({ expiration_at: NOW + 1 }),
NOW,
).playable,
true,
);
assert.deepEqual(
evaluateEntryEligibility(
enabledEntry(),
readyAsset({ expiration_at: NOW }),
NOW,
),
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED },
);
});
test("not-playable reasons follow the documented precedence", () => {
const asset = readyAsset({ status: "failed", expiration_at: NOW });
assert.equal(
evaluateEntryEligibility(
enabledEntry({ enabled: 0, schedule_start_at: NOW + 1 }),
asset,
NOW,
).reason,
ELIGIBILITY_REASONS.DISABLED,
);
assert.equal(
evaluateEntryEligibility(
enabledEntry({ schedule_start_at: NOW + 1, schedule_end_at: NOW }),
asset,
NOW,
).reason,
ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
);
assert.equal(
evaluateEntryEligibility(enabledEntry({ schedule_end_at: NOW }), asset, NOW)
.reason,
ELIGIBILITY_REASONS.SCHEDULED_ENDED,
);
assert.equal(
evaluateEntryEligibility(enabledEntry(), asset, NOW).reason,
ELIGIBILITY_REASONS.ASSET_NOT_READY,
);
});
test("mapped camel-case domain values are supported", () => {
assert.deepEqual(
evaluateEntryEligibility(
{ enabled: true, scheduleStartAt: NOW, scheduleEndAt: NOW + 1 },
{ status: "ready", expirationAt: NOW + 1 },
NOW,
),
{ playable: true, reason: null },
);
});
test("eligibility rejects invalid evaluation clocks", () => {
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "2035-01-01"]) {
assert.throws(
() => evaluateEntryEligibility(enabledEntry(), readyAsset(), invalid),
/nowMs must be a non-negative, safe epoch-millisecond integer/,
);
}
});
test("resolved settings inherit global duration and presentation", () => {
const settings = {
default_duration_ms: 7_000,
default_background_mode: "blurred",
default_solid_background_color: "#123456",
default_background_darkened: 1,
default_blur_px: 20,
};
assert.deepEqual(resolveEntrySettings({}, settings), {
durationMs: 7_000,
presentation: {
backgroundMode: "blurred",
backgroundColor: "#123456",
backgroundDarkened: true,
blurPx: 20,
},
});
});
test("entry overrides are independent and blur always remains global", () => {
const settings = {
defaultDurationMs: 7_000,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#123456",
defaultBackgroundDarkened: true,
defaultBlurPx: 20,
};
const entry = {
durationOverrideMs: 9_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#abcdef",
backgroundDarkenedOverride: false,
blurPx: 999,
};
assert.deepEqual(resolveEntrySettings(entry, settings), {
durationMs: 9_000,
presentation: {
backgroundMode: "solid",
backgroundColor: "#abcdef",
backgroundDarkened: false,
blurPx: 20,
},
});
});
+304
View File
@@ -0,0 +1,304 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const sharp = require("sharp");
const {
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
ImageProcessingError,
createImageProcessor,
} = require("../src/image-processing");
const MAX_INPUT_PIXELS = 12_000_000;
const CONTENT_TYPES = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
function createProcessor(overrides = {}) {
return createImageProcessor({
maxInputPixels: MAX_INPUT_PIXELS,
...overrides,
});
}
function hash(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function solidImage(format, width, height, background = "#4a76b8") {
return sharp({
create: {
width,
height,
channels: 4,
background,
},
})
.toFormat(format)
.toBuffer();
}
async function twoFrameImage(format) {
const width = 20;
const frameHeight = 10;
const channels = 4;
const firstFrame = Buffer.alloc(width * frameHeight * channels);
const secondFrame = Buffer.alloc(width * frameHeight * channels);
for (let offset = 0; offset < firstFrame.length; offset += channels) {
firstFrame.set([255, 0, 0, 255], offset);
secondFrame.set([0, 0, 255, 255], offset);
}
const image = sharp(Buffer.concat([firstFrame, secondFrame]), {
raw: {
width,
height: frameHeight * 2,
channels,
pageHeight: frameHeight,
},
});
if (format === "tiff") {
return image.tiff().toBuffer();
}
return image[format]({ delay: [100, 100], loop: 0 }).toBuffer();
}
test("accepts decoded still JPEG, PNG, and WebP content", async (t) => {
const processor = createProcessor();
for (const format of ["jpeg", "png", "webp"]) {
await t.test(format, async () => {
const source = await solidImage(format, 640, 360);
const result = await processor.process(source);
assert.equal(result.original.format, format);
assert.equal(result.original.contentType, CONTENT_TYPES[format]);
assert.equal(result.original.byteSize, source.length);
assert.equal(result.original.sha256, hash(source));
assert.equal(result.original.width, 640);
assert.equal(result.original.height, 360);
assert.equal(result.original.pages, 1);
for (const derivative of [result.display, result.thumbnail]) {
assert.ok(Buffer.isBuffer(derivative.buffer));
assert.equal(derivative.format, "webp");
assert.equal(derivative.contentType, "image/webp");
assert.equal(derivative.byteSize, derivative.buffer.length);
assert.equal(derivative.sha256, hash(derivative.buffer));
assert.match(derivative.sha256, /^[0-9a-f]{64}$/);
}
});
}
});
test("uses bounding boxes without cropping or changing source aspect ratio", async () => {
const source = await solidImage("png", 3000, 1000);
const result = await createProcessor().process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 1920, height: 640 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 480, height: 160 },
);
assert.equal(result.display.width / result.display.height, 3);
assert.equal(result.thumbnail.width / result.thumbnail.height, 3);
});
test("does not enlarge a source that fits within both derivative bounds", async () => {
const source = await solidImage("jpeg", 320, 180);
const result = await createProcessor().process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 320, height: 180 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 320, height: 180 },
);
});
test("applies EXIF orientation and strips it from derivative metadata", async () => {
const source = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: "#cb7235",
},
})
.jpeg()
.withMetadata({ orientation: 6 })
.toBuffer();
const result = await createProcessor().process(source);
const outputMetadata = await sharp(result.display.buffer).metadata();
assert.equal(result.original.orientation, 6);
assert.equal(result.original.encodedWidth, 120);
assert.equal(result.original.encodedHeight, 80);
assert.equal(result.original.width, 80);
assert.equal(result.original.height, 120);
assert.equal(result.display.width, 80);
assert.equal(result.display.height, 120);
assert.equal(outputMetadata.orientation, undefined);
assert.equal(outputMetadata.exif, undefined);
});
test("flattens transparent pixels against black in both derivatives", async () => {
const source = await solidImage("png", 40, 30, {
r: 255,
g: 0,
b: 0,
alpha: 0,
});
const result = await createProcessor().process(source);
assert.equal(result.original.hasAlpha, true);
for (const derivative of [result.display, result.thumbnail]) {
const metadata = await sharp(derivative.buffer).metadata();
const pixels = await sharp(derivative.buffer).raw().toBuffer();
assert.equal(metadata.hasAlpha, false);
assert.ok(
pixels.every((channel) => channel <= 2),
"fully transparent source pixels should become black",
);
}
});
test("supports a temporary file path without changing source identity", async (t) => {
const temporaryDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "announcements-image-processing-"),
);
t.after(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true }));
const source = await solidImage("webp", 800, 600);
const filename = path.join(temporaryDirectory, "upload.tmp");
fs.writeFileSync(filename, source);
const result = await createProcessor().process(filename);
assert.equal(result.original.sha256, hash(source));
assert.equal(result.original.byteSize, source.length);
assert.equal(result.original.format, "webp");
});
test("rejects malformed and unsupported decoded content with typed errors", async () => {
const processor = createProcessor();
const gif = await solidImage("gif", 64, 64);
await assert.rejects(
processor.process(Buffer.from("not an image")),
(error) =>
error instanceof ImageProcessingError &&
error.code === "MALFORMED_IMAGE" &&
error.statusCode === 422,
);
await assert.rejects(
processor.process(gif),
(error) =>
error instanceof ImageProcessingError &&
error.code === "UNSUPPORTED_IMAGE_FORMAT" &&
error.statusCode === 415 &&
error.details.detectedFormat === "gif",
);
});
test("rejects animated WebP and GIF instead of selecting the first frame", async (t) => {
for (const format of ["webp", "gif"]) {
await t.test(format, async () => {
const animated = await twoFrameImage(format);
const metadata = await sharp(animated, { animated: true }).metadata();
assert.equal(metadata.pages, 2, "test fixture must contain two frames");
await assert.rejects(
createProcessor().process(animated),
(error) =>
error instanceof ImageProcessingError &&
error.code === "ANIMATED_IMAGE_NOT_SUPPORTED" &&
error.details.pages === 2,
);
});
}
});
test("rejects unexpected multipage inputs with a distinct typed error", async () => {
const multipageTiff = await twoFrameImage("tiff");
const metadata = await sharp(multipageTiff, { animated: true }).metadata();
assert.equal(metadata.pages, 2, "test fixture must contain two pages");
await assert.rejects(
createProcessor().process(multipageTiff),
(error) =>
error instanceof ImageProcessingError &&
error.code === "MULTIPAGE_IMAGE_NOT_SUPPORTED" &&
error.details.pages === 2,
);
});
test("enforces the injected decoded-pixel limit", async () => {
const source = await solidImage("png", 100, 100);
const processor = createImageProcessor({ maxInputPixels: 9_999 });
await assert.rejects(
processor.process(source),
(error) =>
error instanceof ImageProcessingError &&
error.code === "IMAGE_PIXEL_LIMIT_EXCEEDED" &&
error.statusCode === 413 &&
error.details.maxInputPixels === 9_999,
);
});
test("uses documented defaults while allowing validated target overrides", async () => {
assert.deepEqual(DEFAULT_IMAGE_DERIVATIVE_OPTIONS, {
display: { maxWidth: 1920, maxHeight: 1080, quality: 82 },
thumbnail: { maxWidth: 480, maxHeight: 270, quality: 72 },
});
const source = await solidImage("png", 900, 600);
const processor = createProcessor({
display: { maxWidth: 300, maxHeight: 300, quality: 90 },
thumbnail: { maxWidth: 100, maxHeight: 50, quality: 65 },
});
const result = await processor.process(source);
assert.deepEqual(
{ width: result.display.width, height: result.display.height },
{ width: 300, height: 200 },
);
assert.deepEqual(
{ width: result.thumbnail.width, height: result.thumbnail.height },
{ width: 75, height: 50 },
);
assert.throws(
() => createImageProcessor({ maxInputPixels: 0 }),
/maxInputPixels must be at least 1/,
);
assert.throws(
() =>
createImageProcessor({
maxInputPixels: 10_000,
display: { quality: 101 },
}),
/display\.quality must be at most 100/,
);
});
test("rejects invalid input interfaces with structured errors", async () => {
await assert.rejects(
createProcessor().process({ path: "/tmp/not-an-interface" }),
(error) =>
error instanceof ImageProcessingError &&
error.code === "INVALID_IMAGE_INPUT" &&
error.statusCode === 400,
);
});
+249
View File
@@ -0,0 +1,249 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createApp } = require("../src/app");
const { loadConfig } = require("../src/config");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function createPlaybackStub({ domainError = null } = {}) {
const calls = [];
let revision = 10;
const snapshot = (action = "get") => ({
protocolVersion: 1,
outputId: "global",
playbackRevision: revision,
action,
});
const action = (name) => () => {
calls.push([name]);
revision += 1;
return snapshot(name);
};
return {
calls,
domainChanged(change) {
calls.push(["domainChanged", change]);
if (domainError) throw domainError;
return snapshot("domainChanged");
},
getSnapshot() {
calls.push(["getSnapshot"]);
return snapshot();
},
pause: action("pause"),
reconcile(reason) {
calls.push(["reconcile", reason]);
return snapshot("reconcile");
},
resume: action("resume"),
next: action("next"),
previous: action("previous"),
reload: action("reload"),
enterStatic(assetId) {
calls.push(["enterStatic", assetId]);
revision += 1;
return snapshot("static");
},
setMode(mode) {
calls.push(["setMode", mode]);
revision += 1;
return snapshot("mode");
},
activatePlaylist(playlistId) {
calls.push(["activatePlaylist", playlistId]);
revision += 1;
const current = snapshot("activate");
return {
activation: "active",
playlistId,
playback: { revision: current.playbackRevision },
snapshot: current,
};
},
};
}
async function createHarness(t, { domainError = null } = {}) {
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
const playbackService = createPlaybackStub({ domainError });
const invalidations = [];
const app = createApp({
config: testConfig(),
database,
imageProcessor: {},
lifecycle: { ready: true, stopping: false },
logger: { info() {}, warn() {}, error() {} },
migrations,
onPlaylistsChanged: (event) => invalidations.push(event),
playbackService,
storage: {
health() {
return { status: "healthy", checkedAt: null, lastSuccessAt: null };
},
},
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
database.close();
});
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
database,
invalidations,
playbackService,
};
}
async function requestJson(baseUrl, path, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
test("playback REST routes delegate every command to the one playback service", async (t) => {
const harness = await createHarness(t);
const read = await requestJson(harness.baseUrl, "/api/playback");
assert.equal(read.response.status, 200);
assert.equal(read.body.playbackRevision, 10);
const commands = [
["POST", "/api/playback/pause", undefined, "pause"],
["POST", "/api/playback/resume", undefined, "resume"],
["POST", "/api/playback/next", undefined, "next"],
["POST", "/api/playback/previous", undefined, "previous"],
["POST", "/api/playback/reload", undefined, "reload"],
["POST", "/api/playback/static", { assetId: "asset-static" }, "static"],
["PUT", "/api/playback/mode", { mode: "off" }, "mode"],
];
for (const [method, path, body, action] of commands) {
const result = await requestJson(harness.baseUrl, path, { method, body });
assert.equal(result.response.status, 200);
assert.equal(result.body.action, action);
}
assert.deepEqual(harness.playbackService.calls, [
["getSnapshot"],
["pause"],
["resume"],
["next"],
["previous"],
["reload"],
["enterStatic", "asset-static"],
["setMode", "off"],
]);
});
test("playlist activation delegates directly to the playback authority", async (t) => {
const harness = await createHarness(t);
const result = await requestJson(
harness.baseUrl,
"/api/playlists/playlist-a/activate",
{ method: "POST" },
);
assert.equal(result.response.status, 200);
assert.equal(result.body.activation, "active");
assert.equal(result.body.playlistId, "playlist-a");
assert.deepEqual(harness.playbackService.calls, [
["activatePlaylist", "playlist-a"],
]);
assert.deepEqual(harness.invalidations, []);
});
test("semantic domain edits reconcile playback independently of list invalidation", async (t) => {
const harness = await createHarness(t);
const timestamp = Date.now();
harness.database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
)
.run(
"unreferenced-asset",
"a".repeat(64),
"Unreferenced",
"unreferenced.png",
timestamp,
timestamp,
);
const settings = await requestJson(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultBlurPx: 21 },
});
assert.equal(settings.response.status, 200);
const asset = await requestJson(
harness.baseUrl,
"/api/assets/unreferenced-asset",
{ method: "PATCH", body: { displayName: "Renamed unreferenced" } },
);
assert.equal(asset.response.status, 200);
assert.deepEqual(harness.playbackService.calls, [
["reconcile", "before_domain_change"],
[
"domainChanged",
{ reason: "settings_updated", playlistIds: null, settingsRevision: 2 },
],
["reconcile", "before_domain_change"],
[
"domainChanged",
{
reason: "asset_updated",
playlistIds: [],
assetId: "unreferenced-asset",
},
],
]);
assert.deepEqual(harness.invalidations, [
{ reason: "settings_updated", playlistIds: null, settingsRevision: 2 },
]);
});
test("a playback reconciliation failure is surfaced after the domain commit", async (t) => {
const harness = await createHarness(t, {
domainError: new Error("reconciliation failed"),
});
const result = await requestJson(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Committed before reconciliation" },
});
assert.equal(result.response.status, 500);
assert.equal(result.body.error.code, "INTERNAL_ERROR");
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get()
.count,
1,
);
assert.equal(harness.invalidations.length, 0);
assert.equal(harness.playbackService.calls[0][0], "domainChanged");
});
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createApp } = require("../src/app");
const { loadConfig } = require("../src/config");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const NOW = Date.parse("2032-04-05T12:00:00.000Z");
const silentLogger = {
info() {},
warn() {},
error() {},
};
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function testStorage() {
return {
health() {
return {
status: "healthy",
checkedAt: new Date(NOW).toISOString(),
lastSuccessAt: new Date(NOW).toISOString(),
};
},
};
}
async function createHarness(t, { onPlaylistsChanged = () => {} } = {}) {
const config = testConfig();
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
const app = createApp({
config,
database,
imageProcessor: {},
lifecycle: { ready: true, stopping: false },
logger: silentLogger,
migrations,
now: () => NOW,
onPlaylistsChanged,
storage: testStorage(),
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
database.close();
});
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
database,
};
}
function insertAsset(database, { id, name, status = "ready", expirationAt = null }) {
const hashCharacter = id.slice(-1).toLowerCase().replace(/[^0-9a-f]/g, "a");
database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
expiration_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
hashCharacter.repeat(64),
name,
`${id}.png`,
status,
expirationAt,
NOW,
NOW,
);
}
async function jsonRequest(baseUrl, route, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${route}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
test("playlist REST routes expose resolved entries, complete ordering, and structured errors", async (t) => {
const changes = [];
const harness = await createHarness(t, {
onPlaylistsChanged: (event) => changes.push(event),
});
insertAsset(harness.database, { id: "asset-a", name: "Welcome" });
insertAsset(harness.database, { id: "asset-b", name: "Events" });
insertAsset(harness.database, {
id: "asset-f",
name: "Failed upload",
status: "failed",
});
const created = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(created.response.status, 201);
assert.match(created.body.id, /^[0-9a-f-]{36}$/);
assert.equal(created.body.revision, 1);
const playlistId = created.body.id;
const listed = await jsonRequest(harness.baseUrl, "/api/playlists");
assert.equal(listed.response.status, 200);
assert.deepEqual(listed.body.playlists.map((playlist) => playlist.id), [
playlistId,
]);
assert.equal(changes.length, 1, "GET must not publish invalidation events");
const first = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{
method: "POST",
body: {
assetId: "asset-a",
durationOverrideMs: 9_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#123abc",
backgroundDarkenedOverride: true,
},
},
);
assert.equal(first.response.status, 201);
assert.equal(first.body.position, 0);
assert.equal(first.body.effectiveDurationMs, 9_000);
assert.equal(first.body.backgroundColorOverride, "#123ABC");
assert.deepEqual(first.body.eligibility, { playable: true, reason: null });
const second = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-b" } },
);
assert.equal(second.response.status, 201);
assert.equal(second.body.position, 1);
assert.equal(second.body.effectiveDurationMs, 7_000);
const ordered = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{
method: "PUT",
body: { entryIds: [second.body.id, first.body.id] },
},
);
assert.equal(ordered.response.status, 200);
assert.deepEqual(
ordered.body.entries.map((entry) => [entry.id, entry.position]),
[
[second.body.id, 0],
[first.body.id, 1],
],
);
const incomplete = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{ method: "PUT", body: { entryIds: [first.body.id] } },
);
assert.equal(incomplete.response.status, 400);
assert.equal(incomplete.body.error.code, "PLAYLIST_ORDER_INCOMPLETE");
const unexpectedOrderField = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{
method: "PUT",
body: {
entryIds: [second.body.id, first.body.id],
appendMissing: false,
},
},
);
assert.equal(unexpectedOrderField.response.status, 400);
assert.equal(
unexpectedOrderField.body.error.code,
"INVALID_PLAYLIST_ORDER",
);
const missingOrderField = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/order`,
{ method: "PUT", body: {} },
);
assert.equal(missingOrderField.response.status, 400);
assert.equal(missingOrderField.body.error.code, "INVALID_PLAYLIST_ORDER");
const revisionAfterOrder = ordered.body.revision;
const duplicate = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-a" } },
);
assert.equal(duplicate.response.status, 409);
assert.equal(duplicate.body.error.code, "PLAYLIST_ENTRY_EXISTS");
const failed = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-f" } },
);
assert.equal(failed.response.status, 409);
assert.equal(failed.body.error.code, "ASSET_NOT_READY");
const missing = await jsonRequest(
harness.baseUrl,
"/api/playlists/not-a-playlist",
);
assert.equal(missing.response.status, 404);
assert.equal(missing.body.error.code, "PLAYLIST_NOT_FOUND");
const detail = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}`,
);
assert.equal(detail.body.revision, revisionAfterOrder);
assert.equal(detail.body.entries.length, 2);
const patchedEntry = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/${first.body.id}`,
{
method: "PATCH",
body: { enabled: false, durationOverrideMs: null },
},
);
assert.equal(patchedEntry.response.status, 200);
assert.equal(patchedEntry.body.enabled, false);
assert.equal(patchedEntry.body.effectiveDurationMs, 7_000);
assert.equal(patchedEntry.body.notPlayableReason, "disabled");
const removedEntry = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries/${second.body.id}`,
{ method: "DELETE" },
);
assert.equal(removedEntry.response.status, 200);
assert.deepEqual(removedEntry.body, { id: second.body.id, deleted: true });
const compacted = await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}`,
);
assert.deepEqual(
compacted.body.entries.map((entry) => entry.position),
[0],
);
const temporary = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Temporary" },
});
const deletedPlaylist = await jsonRequest(
harness.baseUrl,
`/api/playlists/${temporary.body.id}`,
{ method: "DELETE" },
);
assert.equal(deletedPlaylist.response.status, 200);
assert.deepEqual(deletedPlaylist.body, {
id: temporary.body.id,
deleted: true,
});
});
test("playlist data invalidation fires once for semantic changes and not for reads, failures, or no-ops", async (t) => {
const changes = [];
const harness = await createHarness(t, {
onPlaylistsChanged: (event) => changes.push(event),
});
insertAsset(harness.database, { id: "asset-a", name: "Welcome" });
insertAsset(harness.database, { id: "asset-b", name: "Unused" });
const created = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
const playlistId = created.body.id;
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, "created");
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`);
await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "sUNDAY" },
});
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, {
method: "PATCH",
body: { name: "Sunday" },
});
assert.equal(changes.length, 1);
await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, {
method: "PATCH",
body: { name: "Weekend" },
});
assert.equal(changes.length, 2);
assert.equal(changes[1].reason, "renamed");
await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 7_000 },
});
assert.equal(changes.length, 2);
await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
assert.equal(changes.length, 3);
assert.deepEqual(changes[2], {
reason: "settings_updated",
playlistIds: null,
settingsRevision: 2,
});
await jsonRequest(harness.baseUrl, "/api/assets/asset-b", {
method: "PATCH",
body: { displayName: "Still unused" },
});
assert.equal(changes.length, 3);
await jsonRequest(
harness.baseUrl,
`/api/playlists/${playlistId}/entries`,
{ method: "POST", body: { assetId: "asset-a" } },
);
assert.equal(changes.length, 4);
await jsonRequest(harness.baseUrl, "/api/assets/asset-a", {
method: "PATCH",
body: { expirationAt: NOW + 60_000 },
});
assert.deepEqual(changes.at(-1), {
reason: "asset_updated",
playlistIds: [playlistId],
assetId: "asset-a",
});
const countAfterAssetUpdate = changes.length;
await jsonRequest(harness.baseUrl, "/api/assets/asset-a", {
method: "PATCH",
body: { expirationAt: NOW + 60_000 },
});
assert.equal(changes.length, countAfterAssetUpdate);
});
test("a failing invalidation listener cannot turn a committed mutation into HTTP 500", async (t) => {
const harness = await createHarness(t, {
onPlaylistsChanged() {
throw new Error("listener unavailable");
},
});
const settings = await jsonRequest(harness.baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
assert.equal(settings.response.status, 200);
assert.equal(settings.body.revision, 2);
const playlist = await jsonRequest(harness.baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(playlist.response.status, 201);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get()
.count,
1,
);
});
+706
View File
@@ -0,0 +1,706 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createAssetService } = require("../src/assets/service");
const { createPlaybackService } = require("../src/playback");
const {
MAX_DURATION_MS,
MIN_DURATION_MS,
createPlaylistService,
} = require("../src/playlists/service");
const BASE_TIME = 2_000_000_000_000;
function createHarness() {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
let clock = BASE_TIME;
let idCounter = 0;
let assetCounter = 0;
const changes = [];
let playbackService;
const service = createPlaylistService({
database,
now: () => clock,
createId: () =>
`00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`,
onBeforeChange: () => {
playbackService?.reconcile("before_domain_change");
},
onChanged: (change) => {
changes.push(change);
playbackService?.domainChanged(change);
},
});
playbackService = createPlaybackService({ database, now: () => clock });
playbackService.initialize();
function setNow(value) {
clock = value;
}
function seedAsset(options = {}) {
const sequence = ++assetCounter;
const id = options.id || `asset-${sequence}`;
const name = options.name || id;
const status = options.status || "ready";
const expirationAt = options.expirationAt ?? null;
const hash = crypto
.createHash("sha256")
.update(`${sequence}:${id}`)
.digest("hex");
database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
expiration_at, display_sha256, display_width, display_height,
thumbnail_sha256, thumbnail_width, thumbnail_height,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`,
)
.run(
id,
hash,
name,
`${name}.png`,
status,
expirationAt,
hash,
hash,
clock,
clock,
);
return id;
}
function playback() {
return database
.prepare("SELECT * FROM playback_state WHERE output_id = 'global'")
.get();
}
return {
changes,
database,
playback,
playbackService,
seedAsset,
service,
setNow,
};
}
function expectError(action, code, statusCode) {
assert.throws(action, (error) => {
assert.equal(error.code, code);
if (statusCode !== undefined) assert.equal(error.statusCode, statusCode);
return true;
});
}
test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const sunday = harness.service.create({ name: " Sunday Service " });
const midweek = harness.service.create({ name: "Midweek" });
assert.match(sunday.id, /^[0-9a-f-]{36}$/);
assert.equal(sunday.name, "Sunday Service");
assert.equal(sunday.revision, 1);
assert.deepEqual(
harness.service.list().map((playlist) => playlist.name),
["Midweek", "Sunday Service"],
);
const revisionBeforeRead = harness.service.list()[1].revision;
const changesBeforeRead = harness.changes.length;
assert.equal(harness.service.get(sunday.id).entries.length, 0);
assert.equal(harness.service.list()[1].revision, revisionBeforeRead);
assert.equal(harness.changes.length, changesBeforeRead);
const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" });
assert.equal(renamed.revision, 2);
assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2);
expectError(
() => harness.service.create({ name: "sUnDaY wOrShIp" }),
"PLAYLIST_NAME_CONFLICT",
409,
);
expectError(
() => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }),
"PLAYLIST_NAME_CONFLICT",
409,
);
assert.equal(harness.service.get(midweek.id).revision, 1);
assert.deepEqual(harness.service.remove(midweek.id), {
id: midweek.id,
deleted: true,
});
expectError(
() => harness.service.get(midweek.id),
"PLAYLIST_NOT_FOUND",
404,
);
assert.deepEqual(
harness.changes.map((change) => change.reason),
["created", "created", "renamed", "deleted"],
);
});
test("active and pending playlists are conservatively protected from deletion", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const active = harness.service.create({ name: "Active" });
const pending = harness.service.create({ name: "Pending" });
harness.service.addEntry(active.id, {
assetId: harness.seedAsset({ id: "held", name: "Held" }),
});
harness.service.addEntry(pending.id, {
assetId: harness.seedAsset({ id: "queued", name: "Queued" }),
});
// A mid-duration normal slide is what keeps an activation genuinely pending.
// With no playable normal target the authority promotes the request straight
// away, so an activation cannot be left queued behind an empty playlist.
harness.playbackService.activatePlaylist(active.id);
assert.equal(
harness.playbackService.activatePlaylist(pending.id).activation,
"pending",
);
expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409);
expectError(
() => harness.service.remove(pending.id),
"PLAYLIST_PENDING_ACTIVATION",
409,
);
assert.equal(harness.playback().active_playlist_id, active.id);
assert.equal(harness.playback().pending_playlist_id, pending.id);
assert.equal(harness.service.list().length, 2);
});
test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const first = harness.service.create({ name: "First" });
const second = harness.service.create({ name: "Second" });
const ready = harness.seedAsset({ id: "ready", name: "Ready" });
for (const status of ["processing", "failed", "deleting"]) {
harness.seedAsset({ id: status, name: status, status });
}
harness.seedAsset({
id: "expired",
name: "Expired",
expirationAt: BASE_TIME,
});
const firstEntry = harness.service.addEntry(first.id, { assetId: ready });
const secondEntry = harness.service.addEntry(second.id, { assetId: ready });
assert.equal(firstEntry.position, 0);
assert.equal(secondEntry.position, 0);
assert.notEqual(firstEntry.id, secondEntry.id);
expectError(
() => harness.service.addEntry(first.id, { assetId: ready }),
"PLAYLIST_ENTRY_EXISTS",
409,
);
for (const status of ["processing", "failed", "deleting"]) {
expectError(
() => harness.service.addEntry(first.id, { assetId: status }),
"ASSET_NOT_READY",
409,
);
}
expectError(
() => harness.service.addEntry(first.id, { assetId: "missing" }),
"ASSET_NOT_FOUND",
404,
);
expectError(
() => harness.service.addEntry(first.id, { assetId: "expired" }),
"ASSET_EXPIRED",
409,
);
assert.equal(harness.service.get(first.id).revision, 2);
});
test("entry enablement, duration, and presentation overrides are playlist-local", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const a = harness.service.create({ name: "A" });
const b = harness.service.create({ name: "B" });
const assetId = harness.seedAsset({ id: "shared", name: "Shared" });
const entryA = harness.service.addEntry(a.id, { assetId });
const entryB = harness.service.addEntry(b.id, { assetId });
assert.equal(entryA.durationOverrideMs, null);
assert.equal(entryA.effectiveDurationMs, 7000);
assert.deepEqual(entryA.effectivePresentation, {
backgroundMode: "blurred",
backgroundColor: "#000000",
backgroundDarkened: false,
blurPx: 20,
});
const patched = harness.service.updateEntry(a.id, entryA.id, {
enabled: false,
durationOverrideMs: 4000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#a1b2c3",
backgroundDarkenedOverride: true,
});
assert.equal(patched.enabled, false);
assert.equal(patched.effectiveDurationMs, 4000);
assert.deepEqual(patched.effectivePresentation, {
backgroundMode: "solid",
backgroundColor: "#A1B2C3",
backgroundDarkened: true,
blurPx: 20,
});
assert.equal(harness.service.get(b.id).entries[0].enabled, true);
assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000);
assert.equal(
harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId)
.status,
"ready",
);
const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, {
durationOverrideMs: null,
backgroundModeOverride: null,
backgroundColorOverride: null,
backgroundDarkenedOverride: null,
});
assert.equal(inheritedAgain.effectiveDurationMs, 7000);
assert.equal(inheritedAgain.backgroundDarkenedOverride, null);
for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) {
expectError(
() =>
harness.service.updateEntry(a.id, entryA.id, {
durationOverrideMs: invalid,
}),
"INVALID_PLAYLIST_ENTRY",
400,
);
}
for (const patch of [
{ backgroundModeOverride: "gradient" },
{ backgroundColorOverride: "red" },
{ backgroundDarkenedOverride: 1 },
]) {
expectError(
() => harness.service.updateEntry(a.id, entryA.id, patch),
"INVALID_PLAYLIST_ENTRY",
400,
);
}
assert.equal(harness.service.get(a.id).revision, 4);
assert.equal(harness.service.get(b.id).revision, 2);
assert.equal(entryB.enabled, true);
});
test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Scheduled" });
const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" });
const futureExpiry = harness.seedAsset({
id: "future-expiry",
name: "Future expiry",
expirationAt: BASE_TIME + 20_000,
});
const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent });
const bounded = harness.service.addEntry(playlist.id, {
assetId: futureExpiry,
scheduleStartAt: BASE_TIME + 10_000,
scheduleEndAt: BASE_TIME + 30_000,
});
let detail = harness.service.get(playlist.id);
assert.equal(detail.evaluatedAt, BASE_TIME);
assert.equal(detail.settingsRevision, 1);
assert.deepEqual(
detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]),
[
[noSchedule.id, true, null],
[bounded.id, false, "scheduled_future"],
],
);
harness.setNow(BASE_TIME + 10_000);
assert.equal(harness.service.get(playlist.id).entries[1].playable, true);
harness.setNow(BASE_TIME + 20_000);
assert.equal(
harness.service.get(playlist.id).entries[1].notPlayableReason,
"asset_expired",
);
harness.setNow(BASE_TIME + 30_000);
assert.equal(
harness.service.get(playlist.id).entries[1].notPlayableReason,
"scheduled_ended",
);
harness.setNow(BASE_TIME);
const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" });
const endOnly = harness.service.addEntry(playlist.id, {
assetId: endOnlyAsset,
scheduleEndAt: BASE_TIME + 1,
});
assert.equal(endOnly.playable, true);
harness.setNow(BASE_TIME + 1);
assert.equal(
harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id)
.notPlayableReason,
"scheduled_ended",
);
expectError(
() =>
harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: BASE_TIME + 2,
scheduleEndAt: BASE_TIME + 1,
}),
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
400,
);
expectError(
() =>
harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: "2033-05-18T03:33:20",
}),
"INVALID_PLAYLIST_ENTRY",
400,
);
const iso = harness.service.updateEntry(playlist.id, noSchedule.id, {
scheduleStartAt: "2033-05-18T03:33:20Z",
});
assert.equal(iso.scheduleStartAt, 2_000_000_000_000);
});
test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Ordered" });
const other = harness.service.create({ name: "Other" });
const entries = ["one", "two", "three"].map((id) =>
harness.service.addEntry(playlist.id, {
assetId: harness.seedAsset({ id, name: id }),
}),
);
const foreign = harness.service.addEntry(other.id, {
assetId: harness.seedAsset({ id: "foreign", name: "foreign" }),
});
const revision = harness.service.get(playlist.id).revision;
const reordered = harness.service.reorderEntries(
playlist.id,
[entries[2].id, entries[0].id, entries[1].id],
);
assert.deepEqual(
reordered.entries.map((entry) => [entry.id, entry.position]),
[
[entries[2].id, 0],
[entries[0].id, 1],
[entries[1].id, 2],
],
);
assert.equal(reordered.revision, revision + 1);
const failedRevision = reordered.revision;
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[2].id,
entries[1].id,
]),
"PLAYLIST_ORDER_DUPLICATE_IDS",
);
expectError(
() => harness.service.reorderEntries(playlist.id, [entries[2].id]),
"PLAYLIST_ORDER_INCOMPLETE",
);
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[0].id,
foreign.id,
]),
"PLAYLIST_ORDER_FOREIGN_ENTRY",
);
expectError(
() =>
harness.service.reorderEntries(playlist.id, [
entries[2].id,
entries[0].id,
"missing-entry",
]),
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
);
assert.equal(harness.service.get(playlist.id).revision, failedRevision);
harness.service.removeEntry(playlist.id, entries[0].id);
assert.deepEqual(
harness.service.get(playlist.id).entries.map((entry) => entry.position),
[0, 1],
);
});
test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "Stable" });
const entry = harness.service.addEntry(playlist.id, {
assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }),
});
const baseline = harness.service.get(playlist.id).revision;
const eventCount = harness.changes.length;
harness.service.update(playlist.id, { name: "Stable" });
harness.service.updateEntry(playlist.id, entry.id, { enabled: true });
harness.service.reorderEntries(playlist.id, [entry.id]);
expectError(
() => harness.service.updateEntry(playlist.id, "missing", { enabled: false }),
"PLAYLIST_ENTRY_NOT_FOUND",
404,
);
assert.equal(harness.service.get(playlist.id).revision, baseline);
assert.equal(harness.changes.length, eventCount);
});
test("entry references protect assets and final removal restores delete eligibility", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playlist = harness.service.create({ name: "References" });
const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" });
const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false });
const assetService = createAssetService({
database: harness.database,
storage: {},
imageProcessor: {},
});
const referenced = assetService.references(assetId);
assert.equal(referenced.canDelete, false);
assert.equal(referenced.playlists[0].playlistEntryId, entry.id);
harness.service.removeEntry(playlist.id, entry.id);
assert.equal(assetService.references(assetId).canDelete, true);
});
test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const oldPlaylist = harness.service.create({ name: "Old" });
const newPlaylist = harness.service.create({ name: "New" });
const oldEntry = harness.service.addEntry(oldPlaylist.id, {
assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }),
});
const newEntry = harness.service.addEntry(newPlaylist.id, {
assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }),
durationOverrideMs: 4000,
});
const initial = harness.playbackService.activatePlaylist(oldPlaylist.id);
assert.equal(initial.activation, "active");
assert.equal(initial.playback.currentEntryId, oldEntry.id);
assert.equal(initial.playback.slideStartedAt, BASE_TIME);
assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000);
harness.database
.prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'")
.run();
harness.setNow(BASE_TIME + 1000);
const pending = harness.playbackService.activatePlaylist(newPlaylist.id);
assert.equal(pending.activation, "pending");
assert.equal(pending.playback.mode, "off");
assert.equal(pending.playback.activePlaylistId, oldPlaylist.id);
assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id);
assert.equal(pending.playback.currentEntryId, oldEntry.id);
const pendingRevision = pending.playback.revision;
const requestedAt = pending.playback.pendingPlaylistRequestedAt;
const repeated = harness.playbackService.activatePlaylist(newPlaylist.id);
assert.equal(repeated.activation, "pending");
assert.equal(repeated.playback.revision, pendingRevision);
assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt);
const cancel = harness.playbackService.activatePlaylist(oldPlaylist.id);
assert.equal(cancel.activation, "already_active");
assert.equal(cancel.playback.pendingPlaylistId, null);
assert.equal(cancel.playback.currentEntryId, oldEntry.id);
harness.setNow(BASE_TIME + 7000);
const immediate = harness.playbackService.activatePlaylist(newPlaylist.id);
assert.equal(immediate.activation, "active");
assert.equal(immediate.playback.activePlaylistId, newPlaylist.id);
assert.equal(immediate.playback.pendingPlaylistId, null);
assert.equal(immediate.playback.currentEntryId, newEntry.id);
assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000);
assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000);
assert.equal(immediate.playback.mode, "off");
});
test("paused normal playback queues and can replace a pending activation", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const current = harness.service.create({ name: "Paused current" });
const firstTarget = harness.service.create({ name: "First target" });
const secondTarget = harness.service.create({ name: "Second target" });
harness.service.addEntry(current.id, {
assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }),
});
harness.service.addEntry(firstTarget.id, {
assetId: harness.seedAsset({ id: "first-target", name: "First target" }),
});
harness.service.addEntry(secondTarget.id, {
assetId: harness.seedAsset({ id: "second-target", name: "Second target" }),
});
harness.playbackService.activatePlaylist(current.id);
harness.database
.prepare(
`UPDATE playback_state
SET rotation_paused = 1, paused_remaining_ms = 5000,
slide_started_at = NULL, next_slide_at = NULL
WHERE output_id = 'global'`,
)
.run();
harness.setNow(BASE_TIME + 1000);
const first = harness.playbackService.activatePlaylist(firstTarget.id);
assert.equal(first.activation, "pending");
assert.equal(first.playback.activePlaylistId, current.id);
assert.equal(first.playback.pendingPlaylistId, firstTarget.id);
assert.equal(first.playback.pausedRemainingMs, 5000);
harness.setNow(BASE_TIME + 2000);
const replacement = harness.playbackService.activatePlaylist(secondTarget.id);
assert.equal(replacement.activation, "pending");
assert.equal(replacement.playback.activePlaylistId, current.id);
assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id);
assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000);
assert.equal(replacement.playback.revision, first.playback.revision + 1);
assert.equal(replacement.playback.pausedRemainingMs, 5000);
});
test("static and paused activation prepare the first playable normal target without breaking hold", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const oldPlaylist = harness.service.create({ name: "Old static" });
const target = harness.service.create({ name: "Target static" });
const staticAssetId = harness.seedAsset({ id: "static", name: "Static" });
harness.service.addEntry(oldPlaylist.id, {
assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }),
});
const skipped = harness.service.addEntry(target.id, {
assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }),
enabled: false,
});
const firstPlayable = harness.service.addEntry(target.id, {
assetId: harness.seedAsset({ id: "playable", name: "Playable" }),
durationOverrideMs: 9000,
});
assert.equal(skipped.position, 0);
harness.playbackService.activatePlaylist(oldPlaylist.id);
harness.database
.prepare(
`UPDATE playback_state
SET static_asset_id = ?, rotation_paused = 1,
paused_remaining_ms = 1234, slide_started_at = NULL,
next_slide_at = NULL
WHERE output_id = 'global'`,
)
.run(staticAssetId);
const result = harness.playbackService.activatePlaylist(target.id);
assert.equal(result.activation, "active");
assert.equal(result.playback.activePlaylistId, target.id);
assert.equal(result.playback.staticAssetId, staticAssetId);
assert.equal(result.playback.rotationPaused, true);
assert.equal(result.playback.currentEntryId, firstPlayable.id);
assert.equal(result.playback.pausedRemainingMs, 9000);
assert.equal(result.playback.slideStartedAt, null);
assert.equal(result.playback.nextSlideAt, null);
});
test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const populated = harness.service.create({ name: "Populated" });
const empty = harness.service.create({ name: "Empty" });
const entries = ["first", "second"].map((id) =>
harness.service.addEntry(populated.id, {
assetId: harness.seedAsset({ id: `asset-${id}`, name: id }),
}),
);
harness.playbackService.activatePlaylist(populated.id);
const futurePlaybackTimestamp = BASE_TIME + 100_000;
// Static and pause suspend the normal timeline. Persist the same shape the
// playback authority itself writes, so the pre-change reconciliation has no
// malformed timeline to repair and only the deletion moves the revision.
harness.database
.prepare(
`UPDATE playback_state
SET updated_at = ?, mode = 'off', static_asset_id = ?,
rotation_paused = 1, paused_remaining_ms = 7000,
slide_started_at = NULL, next_slide_at = NULL
WHERE output_id = 'global'`,
)
.run(futurePlaybackTimestamp, "asset-second");
const beforeDelete = harness.playback();
harness.service.removeEntry(populated.id, entries[0].id);
const afterDelete = harness.playback();
assert.equal(afterDelete.current_entry_id, entries[1].id);
assert.equal(afterDelete.slide_started_at, null);
assert.equal(afterDelete.next_slide_at, null);
assert.equal(afterDelete.paused_remaining_ms, 7000);
assert.equal(afterDelete.active_playlist_id, populated.id);
assert.equal(afterDelete.mode, "off");
assert.equal(afterDelete.static_asset_id, "asset-second");
assert.equal(afterDelete.rotation_paused, 1);
assert.equal(afterDelete.revision, beforeDelete.revision + 1);
assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1);
assert.deepEqual(
harness.service.get(populated.id).entries.map((entry) => entry.position),
[0],
);
harness.database
.prepare(
`UPDATE playback_state
SET static_asset_id = NULL, rotation_paused = 0
WHERE output_id = 'global'`,
)
.run();
const result = harness.playbackService.activatePlaylist(empty.id);
assert.equal(result.activation, "active");
assert.equal(result.playback.activePlaylistId, empty.id);
assert.equal(result.playback.currentEntryId, null);
assert.equal(result.playback.slideStartedAt, null);
assert.equal(result.playback.nextSlideAt, null);
});
test("inactive playlist edits do not mutate authoritative playback state", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const active = harness.service.create({ name: "Live" });
const inactive = harness.service.create({ name: "Draft" });
harness.service.addEntry(active.id, {
assetId: harness.seedAsset({ id: "live", name: "Live asset" }),
});
const draftEntry = harness.service.addEntry(inactive.id, {
assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }),
});
harness.playbackService.activatePlaylist(active.id);
const before = harness.playback();
harness.service.update(inactive.id, { name: "Draft edited" });
harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false });
harness.service.reorderEntries(inactive.id, [draftEntry.id]);
assert.deepEqual(harness.playback(), before);
});
+200
View File
@@ -0,0 +1,200 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
function testConfig(overrides = {}) {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "access-key-that-must-not-leak",
S3_SECRET_ACCESS_KEY: "secret-key-that-must-not-leak",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_PASSWORD: "mqtt-password-that-must-not-leak",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id",
...overrides,
});
}
const silentLogger = {
info() {},
warn() {},
error() {},
};
function testStorage() {
return {
async start() {},
stop() {},
health() {
return {
status: "degraded",
checkedAt: "2026-08-16T00:00:00.000Z",
lastSuccessAt: null,
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
},
};
}
async function responseJson(baseUrl, path, init) {
const response = await fetch(`${baseUrl}${path}`, init);
return { response, body: await response.json() };
}
test("HTTP foundation exposes live, ready, detailed health, and persisted settings", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const live = await responseJson(baseUrl, "/health/live");
assert.equal(live.response.status, 200);
assert.deepEqual(live.body, { status: "ok" });
const ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 200);
assert.equal(ready.body.status, "ready");
const status = await responseJson(baseUrl, "/api/health");
assert.equal(status.response.status, 200);
assert.equal(status.body.status, "degraded");
assert.equal(status.body.services.database.currentSchemaVersion, 1);
assert.equal(status.body.services.storage.status, "degraded");
const statusText = JSON.stringify(status.body);
assert.doesNotMatch(statusText, /access-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /secret-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /mqtt-password-that-must-not-leak/);
assert.doesNotMatch(statusText, /private-id/);
const statusAlias = await responseJson(baseUrl, "/api/status");
assert.equal(statusAlias.response.status, 200);
assert.deepEqual(statusAlias.body, status.body);
const initial = await responseJson(baseUrl, "/api/settings");
assert.equal(initial.body.defaultBackgroundDarkened, false);
const changed = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
defaultBackgroundDarkened: true,
defaultDurationMs: 9000,
}),
});
assert.equal(changed.response.status, 200);
assert.equal(changed.body.revision, 2);
assert.equal(changed.body.defaultBackgroundDarkened, true);
const reread = await responseJson(baseUrl, "/api/settings");
assert.equal(reread.body.defaultDurationMs, 9000);
});
test("invalid settings receive a structured client error", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const result = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ defaultBackgroundDarkened: 0.25 }),
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.code, "INVALID_SETTINGS");
});
test("liveness stays up while critical readiness failures return 503", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
runtime.lifecycle.ready = false;
let live = await responseJson(baseUrl, "/health/live");
let ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.status, "not_ready");
runtime.lifecycle.ready = true;
runtime.migrations.pendingVersions.push(2);
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.migrations, "pending");
runtime.migrations.pendingVersions.length = 0;
runtime.database.close();
live = await responseJson(baseUrl, "/health/live");
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.database, "error");
});
test("runtime startup and shutdown are idempotent", async () => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
const [firstAddress, secondAddress] = await Promise.all([
runtime.start(),
runtime.start(),
]);
assert.deepEqual(firstAddress, secondAddress);
assert.equal(runtime.lifecycle.ready, true);
await Promise.all([runtime.stop("test"), runtime.stop("test")]);
assert.equal(runtime.lifecycle.ready, false);
assert.equal(runtime.database.open, false);
});
test("a listen failure stops playback and closes startup resources", async (t) => {
const occupied = net.createServer();
occupied.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
occupied.once("listening", resolve);
occupied.once("error", reject);
});
t.after(
() =>
new Promise((resolve) => {
occupied.close(resolve);
}),
);
const runtime = createRuntime({
config: testConfig({ PORT: String(occupied.address().port) }),
logger: silentLogger,
storage: testStorage(),
});
await assert.rejects(runtime.start(), (error) => error.code === "EADDRINUSE");
assert.equal(runtime.lifecycle.ready, false);
assert.equal(runtime.database.open, false);
});
+81
View File
@@ -0,0 +1,81 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
SettingsValidationError,
createSettingsService,
} = require("../src/settings/service");
function createService() {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
return {
database,
service: createSettingsService(database, { now: () => 2_000_000_000_000 }),
};
}
test("settings are persisted with the approved defaults", (t) => {
const { database, service } = createService();
t.after(() => database.close());
assert.deepEqual(service.get(), {
revision: 1,
defaultDurationMs: 7000,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#000000",
defaultBackgroundDarkened: false,
defaultBlurPx: 20,
crossfadeDurationMs: 1000,
updatedAt: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
});
test("settings updates persist boolean darkening and increment revision", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const updated = service.update({
defaultBackgroundDarkened: true,
defaultBlurPx: 24,
});
assert.equal(updated.revision, 2);
assert.equal(updated.defaultBackgroundDarkened, true);
assert.equal(updated.defaultBlurPx, 24);
assert.equal(
database.prepare("SELECT default_background_darkened FROM app_settings").get()
.default_background_darkened,
1,
);
});
test("settings reject strength values, unknown fields, and invalid values", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const original = service.get();
assert.throws(
() => service.update({ defaultBlurPx: 24, darkeningOverride: 0.25 }),
(error) => error instanceof SettingsValidationError,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultBlurPx: 24, defaultBackgroundDarkened: 1 }),
/must be a boolean/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultDurationMs: 0 }),
/positive integer/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ toString: "not a setting" }),
/unknown fields/,
);
assert.deepEqual(service.get(), original);
});
+383
View File
@@ -0,0 +1,383 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
const silentLogger = {
info() {},
warn() {},
error() {},
};
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function testStorage() {
return {
async start() {},
stop() {},
health() {
return {
status: "healthy",
checkedAt: "2032-04-05T12:00:00.000Z",
lastSuccessAt: "2032-04-05T12:00:00.000Z",
};
},
};
}
async function jsonRequest(baseUrl, route, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${route}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
function splitEnginePackets(payload) {
return payload.split("\x1e").filter(Boolean);
}
function socketEvents(payload) {
return splitEnginePackets(payload)
.filter((packet) => packet.startsWith("42"))
.map((packet) => JSON.parse(packet.slice(2)));
}
async function connectPollingSocket(baseUrl) {
let requestNumber = 0;
const endpoint = (sid) => {
const parameters = new URLSearchParams({
EIO: "4",
transport: "polling",
t: String(requestNumber++),
});
if (sid) parameters.set("sid", sid);
return `${baseUrl}/socket.io/?${parameters}`;
};
const openingResponse = await fetch(endpoint());
assert.equal(openingResponse.status, 200);
const openingPackets = splitEnginePackets(await openingResponse.text());
const openPacket = openingPackets.find((packet) => packet.startsWith("0"));
assert.ok(openPacket, "Engine.IO must return an open packet");
const { sid } = JSON.parse(openPacket.slice(1));
const connectResponse = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "40",
});
assert.equal(connectResponse.status, 200);
await connectResponse.text();
const connectedResponse = await fetch(endpoint(sid));
assert.equal(connectedResponse.status, 200);
const connectedPayload = await connectedResponse.text();
assert.ok(
splitEnginePackets(connectedPayload).some((packet) =>
packet.startsWith("40"),
),
"Socket.IO must acknowledge the namespace connection",
);
return {
initialEvents: socketEvents(connectedPayload),
async poll() {
const response = await fetch(endpoint(sid), {
signal: AbortSignal.timeout(3_000),
});
assert.equal(response.status, 200);
return response.text();
},
async close() {
const response = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "41",
});
await response.text();
},
};
}
test("Socket.IO sends an authoritative playback snapshot on connect and after mutations", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const socket = await connectPollingSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
let initial = socket.initialEvents.find(
([eventName]) => eventName === "playback:state",
);
if (!initial) {
initial = socketEvents(await socket.poll()).find(
([eventName]) => eventName === "playback:state",
);
}
assert.ok(initial, "a full playback snapshot must be sent on connection");
assert.equal(initial[1].protocolVersion, 1);
assert.equal(initial[1].outputId, "global");
const initialRevision = initial[1].playbackRevision;
const read = await jsonRequest(baseUrl, "/api/playback");
assert.equal(read.response.status, 200);
assert.equal(read.body.playbackRevision, initialRevision);
const reread = await jsonRequest(baseUrl, "/api/playback");
assert.equal(reread.body.playbackRevision, initialRevision);
const changed = await jsonRequest(baseUrl, "/api/playback/mode", {
method: "PUT",
body: { mode: "off" },
});
assert.equal(changed.response.status, 200);
assert.equal(changed.body.mode, "off");
assert.equal(changed.body.playbackRevision, initialRevision + 1);
const events = socketEvents(await socket.poll());
const playbackEvents = events.filter(
([eventName]) => eventName === "playback:state",
);
assert.equal(playbackEvents.length, 1);
assert.deepEqual(playbackEvents[0][1], changed.body);
const unchanged = await jsonRequest(baseUrl, "/api/playback/mode", {
method: "PUT",
body: { mode: "off" },
});
assert.equal(unchanged.body.playbackRevision, changed.body.playbackRevision);
assert.equal(
runtime.database
.prepare("SELECT revision FROM playback_state WHERE output_id = 'global'")
.get().revision,
changed.body.playbackRevision,
);
});
test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const socket = await connectPollingSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
await jsonRequest(baseUrl, "/api/playlists");
const invalid = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: {},
});
assert.equal(invalid.response.status, 400);
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 7_000 },
});
const created = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(created.response.status, 201);
let events = socketEvents(await socket.poll());
let playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(playlistEvents.length, 1);
assert.equal(playlistEvents[0][1].reason, "created");
assert.equal(playlistEvents[0][1].playlistId, created.body.id);
const duplicate = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "SUNDAY" },
});
assert.equal(duplicate.response.status, 409);
await jsonRequest(baseUrl, `/api/playlists/${created.body.id}`, {
method: "PATCH",
body: { name: "Sunday" },
});
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
events = socketEvents(await socket.poll());
playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.deepEqual(playlistEvents, [
[
"playlists:changed",
{
reason: "settings_updated",
playlistIds: null,
settingsRevision: 2,
},
],
]);
const second = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Midweek" },
});
assert.equal(second.response.status, 201);
events = socketEvents(await socket.poll());
playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(playlistEvents.length, 1);
assert.equal(playlistEvents[0][1].reason, "created");
const activated = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}/activate`,
{ method: "POST" },
);
assert.equal(activated.response.status, 200);
assert.equal(activated.body.activation, "active");
events = socketEvents(await socket.poll());
playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(playlistEvents.length, 1);
assert.equal(playlistEvents[0][1].reason, "activated");
assert.equal(playlistEvents[0][1].playlistId, created.body.id);
assert.equal(
playlistEvents[0][1].playbackRevision,
activated.body.playback.revision,
);
assert.equal(
events.filter(([eventName]) => eventName === "playback:state").length,
1,
);
const activeDelete = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}`,
{ method: "DELETE" },
);
assert.equal(activeDelete.response.status, 409);
assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE");
});
test("activation invalidations come from authoritative pending-state transitions", async (t) => {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const timestamp = Date.now();
const insertAsset = runtime.database.prepare(`
INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
display_sha256, display_content_type, display_byte_size,
display_width, display_height, created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', ?, 'image/webp', 1, 1920, 1080, ?, ?)
`);
const insertPlaylist = runtime.database.prepare(`
INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)
`);
const insertEntry = runtime.database.prepare(`
INSERT INTO playlist_entries (
id, playlist_id, asset_id, position, created_at, updated_at
) VALUES (?, ?, ?, 0, ?, ?)
`);
insertAsset.run(
"asset-old",
"a".repeat(64),
"Old asset",
"old.png",
"c".repeat(64),
timestamp,
timestamp,
);
insertAsset.run(
"asset-new",
"b".repeat(64),
"New asset",
"new.png",
"d".repeat(64),
timestamp,
timestamp,
);
insertPlaylist.run("playlist-old", "Old", timestamp, timestamp);
insertPlaylist.run("playlist-new", "New", timestamp, timestamp);
insertEntry.run(
"entry-old",
"playlist-old",
"asset-old",
timestamp,
timestamp,
);
insertEntry.run(
"entry-new",
"playlist-new",
"asset-new",
timestamp,
timestamp,
);
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const socket = await connectPollingSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
const activateOld = await jsonRequest(
baseUrl,
"/api/playlists/playlist-old/activate",
{ method: "POST" },
);
assert.equal(activateOld.body.activation, "active");
await socket.poll();
const requestNew = await jsonRequest(
baseUrl,
"/api/playlists/playlist-new/activate",
{ method: "POST" },
);
assert.equal(requestNew.body.activation, "pending");
let invalidations = socketEvents(await socket.poll()).filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(invalidations.length, 1);
assert.equal(invalidations[0][1].reason, "activation_requested");
assert.equal(invalidations[0][1].pendingPlaylistId, "playlist-new");
const cancel = await jsonRequest(
baseUrl,
"/api/playlists/playlist-old/activate",
{ method: "POST" },
);
assert.equal(cancel.body.activation, "already_active");
invalidations = socketEvents(await socket.poll()).filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(invalidations.length, 1);
assert.equal(invalidations[0][1].reason, "activation_canceled");
assert.equal(invalidations[0][1].pendingPlaylistId, null);
});
+105
View File
@@ -0,0 +1,105 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const express = require("express");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
createDetailedHealthRouter,
createHealthRouter,
} = require("../src/health/routes");
function config() {
return {
environment: "test",
host: "127.0.0.1",
port: 0,
timeZone: "America/Chicago",
trustProxy: true,
database: { path: ":memory:" },
storage: {
endpoint: "http://private-storage:8333",
region: "us-east-1",
bucket: "announcements-v2",
accessKeyId: "must-not-leak",
secretAccessKey: "must-not-leak-either",
forcePathStyle: true,
},
processing: {
tempDirectory: "/tmp/announcements-uploads",
maxFileBytes: 1024,
maxFiles: 2,
maxInputPixels: 1000,
},
mqtt: { url: null },
notifications: { homeAssistantWebhookUrl: null },
};
}
async function withServer(storage, callback) {
const database = openDatabase({ path: ":memory:", busyTimeoutMs: 1000 });
const migrations = runMigrations(database);
const lifecycle = { ready: true };
const app = express();
const options = { config: config(), database, lifecycle, migrations, storage };
app.use("/health", createHealthRouter(options));
app.use("/api/health", createDetailedHealthRouter(options));
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
try {
await callback(`http://127.0.0.1:${server.address().port}`);
} finally {
await new Promise((resolve) => server.close(resolve));
database.close();
}
}
test("storage degradation is reported but does not fail API readiness", async () => {
const storage = {
health() {
return {
status: "degraded",
checkedAt: "2026-08-16T12:00:00.000Z",
lastSuccessAt: "2026-08-16T11:59:00.000Z",
code: "STORAGE_UNAVAILABLE",
};
},
};
await withServer(storage, async (baseUrl) => {
const ready = await fetch(`${baseUrl}/health/ready`);
const readyBody = await ready.json();
assert.equal(ready.status, 200);
assert.equal(readyBody.status, "ready");
assert.equal(readyBody.checks.storage, "degraded");
const detailed = await fetch(`${baseUrl}/api/health`);
const detailedBody = await detailed.json();
assert.equal(detailed.status, 200);
assert.equal(detailedBody.status, "degraded");
assert.equal(detailedBody.services.storage.status, "degraded");
assert.equal(
detailedBody.services.storage.lastSuccessAt,
"2026-08-16T11:59:00.000Z",
);
assert.equal(detailedBody.services.storage.code, "STORAGE_UNAVAILABLE");
assert.doesNotMatch(JSON.stringify(detailedBody), /must-not-leak/);
assert.doesNotMatch(JSON.stringify(detailedBody), /private-storage/);
});
});
test("healthy storage produces healthy detailed status", async () => {
const storage = {
health() {
return { status: "healthy", checkedAt: "2026-08-16T12:00:00.000Z" };
},
};
await withServer(storage, async (baseUrl) => {
const detailed = await fetch(`${baseUrl}/api/health`);
const body = await detailed.json();
assert.equal(detailed.status, 200);
assert.equal(body.status, "ok");
assert.equal(body.services.storage.status, "healthy");
});
});
+397
View File
@@ -0,0 +1,397 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const { PassThrough, Readable } = require("node:stream");
const {
StorageObjectNotFoundError,
StorageUnavailableError,
createStorageService,
} = require("../src/storage");
const storageConfig = {
endpoint: "http://seaweedfs:8333",
region: "us-east-1",
bucket: "announcements-v2",
accessKeyId: "test-key",
secretAccessKey: "test-secret",
forcePathStyle: true,
};
function fakeClient(handler) {
const commands = [];
return {
commands,
async send(command) {
commands.push(command);
return handler(command, commands.length - 1);
},
};
}
function commandName(command) {
return command.constructor.name;
}
test("storage initialization probes an existing bucket without creating it", async () => {
const client = fakeClient(async () => ({}));
const service = createStorageService(storageConfig, { client });
const health = await service.initialize();
assert.equal(health.status, "healthy");
assert.match(health.checkedAt, /T/);
assert.equal(health.lastSuccessAt, health.checkedAt);
assert.deepEqual(client.commands.map(commandName), ["HeadBucketCommand"]);
});
test("storage initialization creates only a missing bucket", async () => {
let exists = false;
const missing = Object.assign(new Error("missing"), {
name: "NoSuchBucket",
$metadata: { httpStatusCode: 404 },
});
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand" && !exists) throw missing;
if (commandName(command) === "CreateBucketCommand") exists = true;
return {};
});
const service = createStorageService(storageConfig, { client });
assert.deepEqual(await service.ensureBucket(), { created: true });
assert.deepEqual(client.commands.map(commandName), [
"HeadBucketCommand",
"CreateBucketCommand",
]);
assert.equal(client.commands[1].input.Bucket, storageConfig.bucket);
assert.deepEqual(await service.ensureBucket(), { created: false });
assert.deepEqual(client.commands.map(commandName), [
"HeadBucketCommand",
"CreateBucketCommand",
"HeadBucketCommand",
]);
});
test("storage start performs a nonfatal immediate check and stop releases its client", async () => {
let destroyed = false;
const client = fakeClient(async () => ({}));
client.destroy = () => {
destroyed = true;
};
const service = createStorageService(
{ ...storageConfig, healthProbeIntervalMs: 1_000 },
{ client },
);
const health = await service.start();
assert.equal(health.status, "healthy");
assert.deepEqual(client.commands.map(commandName), ["HeadBucketCommand"]);
service.stop();
assert.equal(destroyed, true);
});
test("a create-bucket race is healthy only after the bucket can be verified", async () => {
const missing = Object.assign(new Error("missing"), {
name: "NoSuchBucket",
$metadata: { httpStatusCode: 404 },
});
const conflict = Object.assign(new Error("race"), {
name: "BucketAlreadyOwnedByYou",
$metadata: { httpStatusCode: 409 },
});
let headCalls = 0;
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand") {
headCalls += 1;
if (headCalls === 1) throw missing;
return {};
}
if (commandName(command) === "CreateBucketCommand") throw conflict;
throw new Error(`Unexpected ${commandName(command)}`);
});
const service = createStorageService(storageConfig, { client });
assert.deepEqual(await service.ensureBucket(), { created: false });
assert.equal(service.health().status, "healthy");
assert.deepEqual(client.commands.map(commandName), [
"HeadBucketCommand",
"CreateBucketCommand",
"HeadBucketCommand",
]);
});
test("an older failed probe cannot overwrite a newer successful operation", async () => {
let rejectProbe;
let notifyProbeStarted;
const probeStarted = new Promise((resolve) => {
notifyProbeStarted = resolve;
});
let headCalls = 0;
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand") {
headCalls += 1;
if (headCalls === 1) return {};
notifyProbeStarted();
return new Promise((resolve, reject) => {
rejectProbe = reject;
});
}
if (commandName(command) === "PutObjectCommand") return {};
throw new Error(`Unexpected ${commandName(command)}`);
});
const service = createStorageService(storageConfig, { client });
await service.initialize();
const staleProbe = service.probe();
await probeStarted;
await service.putObject({
key: "assets/id/original/hash",
body: Buffer.from("bytes"),
contentLength: 5,
});
const successfulState = service.health();
rejectProbe(new Error("older request failed"));
await staleProbe;
assert.equal(successfulState.status, "healthy");
assert.deepEqual(service.health(), successfulState);
});
test("temporary storage failure degrades without throwing during initialization and recovers", async () => {
let available = false;
const warnings = [];
const client = fakeClient(async () => {
if (!available) throw new Error("connection refused at a private address");
return {};
});
const service = createStorageService(storageConfig, {
client,
logger: { warn(message) { warnings.push(message); } },
});
const initial = await service.initialize();
assert.equal(initial.status, "degraded");
assert.equal(initial.code, "STORAGE_UNAVAILABLE");
assert.equal(initial.lastSuccessAt, null);
assert.deepEqual(warnings, ["Object storage is unavailable during initialization"]);
available = true;
const recovered = await service.probe();
assert.equal(recovered.status, "healthy");
assert.equal(recovered.lastSuccessAt, recovered.checkedAt);
assert.equal(service.health().status, "healthy");
available = false;
const degradedAgain = await service.probe();
assert.equal(degradedAgain.status, "degraded");
assert.equal(degradedAgain.lastSuccessAt, recovered.lastSuccessAt);
});
test("put, get, head, existence, and delete use the configured private bucket", async () => {
const body = Readable.from([Buffer.from("image bytes")]);
const client = fakeClient(async (command) => {
switch (commandName(command)) {
case "PutObjectCommand":
return { ETag: '"etag"' };
case "GetObjectCommand":
return {
Body: body,
ContentType: "image/webp",
ContentLength: 11,
ETag: '"etag"',
};
case "HeadObjectCommand":
return {
ContentType: "image/webp",
ContentLength: 11,
ETag: '"etag"',
};
case "DeleteObjectCommand":
return {};
case "HeadBucketCommand":
return {};
default:
throw new Error(`Unexpected ${commandName(command)}`);
}
});
const service = createStorageService(storageConfig, { client });
const key = "assets/id/display/hash.webp";
await service.initialize();
client.commands.length = 0;
const put = await service.putObject({
key,
body: Buffer.from("image bytes"),
contentType: "image/webp",
contentLength: 11,
});
assert.deepEqual(put, { key, etag: '"etag"', versionId: null });
const fetched = await service.getObject(key);
assert.equal(fetched.body, body);
assert.equal(fetched.contentType, "image/webp");
assert.equal(fetched.contentLength, 11);
assert.equal((await service.headObject(key)).etag, '"etag"');
assert.equal(await service.objectExists(key), true);
await service.deleteObject(key);
for (const command of client.commands) {
assert.equal(command.input.Bucket, storageConfig.bucket);
assert.equal(command.input.Key, key);
}
});
test("missing objects are distinct from unavailable storage", async () => {
let missing = true;
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand") return {};
if (missing) {
throw Object.assign(new Error("missing"), {
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
}
throw new Error("network offline");
});
const service = createStorageService(storageConfig, { client });
await service.initialize();
client.commands.length = 0;
assert.equal(await service.headObject("assets/id/original/hash"), null);
assert.equal(await service.objectExists("assets/id/original/hash"), false);
await assert.rejects(
service.getObject("assets/id/original/hash"),
StorageObjectNotFoundError,
);
assert.equal(service.health().status, "healthy");
missing = false;
await assert.rejects(
service.putObject({ key: "assets/id/original/hash", body: Buffer.alloc(0) }),
(error) =>
error instanceof StorageUnavailableError &&
error.code === "STORAGE_UNAVAILABLE" &&
error.statusCode === 503,
);
assert.equal(service.health().status, "degraded");
});
test("a missing bucket during object access is a storage outage, not a missing object", async () => {
let initialized = false;
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand" && !initialized) {
initialized = true;
return {};
}
throw Object.assign(new Error("bucket missing"), {
name: "NoSuchBucket",
$metadata: { httpStatusCode: 404 },
});
});
const service = createStorageService(storageConfig, { client });
await service.initialize();
await assert.rejects(
service.getObject("assets/id/original/hash"),
StorageUnavailableError,
);
assert.equal(service.health().status, "degraded");
});
test("storage rejects unsafe internal object keys before contacting S3", async () => {
const client = fakeClient(async () => ({}));
const service = createStorageService(storageConfig, { client });
await assert.rejects(
service.getObject("../private"),
/Storage object key is invalid/,
);
await assert.rejects(
service.deleteObject("/absolute"),
/Storage object key is invalid/,
);
assert.equal(client.commands.length, 0);
});
test("real HTTP requests time out instead of hanging indefinitely", async (t) => {
const server = http.createServer(() => {
// Intentionally accept the connection without sending response headers.
});
server.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const service = createStorageService({
...storageConfig,
endpoint: `http://127.0.0.1:${server.address().port}`,
connectionTimeoutMs: 100,
requestTimeoutMs: 100,
maxAttempts: 1,
});
t.after(() => service.stop());
const startedAt = Date.now();
const health = await service.probe();
assert.equal(health.status, "degraded");
assert.equal(health.code, "STORAGE_UNAVAILABLE");
assert.ok(Date.now() - startedAt < 2_000);
});
test("consumer stream cancellation does not falsely degrade storage", async () => {
let body = new PassThrough();
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand") return {};
if (commandName(command) === "GetObjectCommand") {
return { Body: body, ContentLength: 10, ContentType: "image/webp" };
}
throw new Error(`Unexpected ${commandName(command)}`);
});
const service = createStorageService(storageConfig, { client });
await service.initialize();
const cancelled = await service.getObject("assets/id/display/hash.webp");
cancelled.body.destroy(
Object.assign(new Error("consumer closed"), {
code: "ERR_STREAM_PREMATURE_CLOSE",
}),
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(service.health().status, "healthy");
body = new PassThrough();
const failed = await service.getObject("assets/id/display/hash.webp");
failed.body.destroy(Object.assign(new Error("upstream reset"), { code: "ECONNRESET" }));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(service.health().status, "degraded");
});
test("delete treats an already-missing object as an idempotent success", async () => {
let missingBucket = false;
const client = fakeClient(async (command) => {
if (commandName(command) === "HeadBucketCommand") return {};
if (commandName(command) === "DeleteObjectCommand") {
throw Object.assign(new Error("missing"), {
name: missingBucket ? "NoSuchBucket" : "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
}
throw new Error(`Unexpected ${commandName(command)}`);
});
const service = createStorageService(storageConfig, { client });
await service.initialize();
await assert.doesNotReject(
service.deleteObject("assets/id/original/hash"),
);
assert.equal(service.health().status, "healthy");
missingBucket = true;
await assert.rejects(
service.deleteObject("assets/id/original/hash"),
StorageUnavailableError,
);
});