Files
fofos-admin/api/static-site.ts
T
pastilhasandClaude Opus 5 3650a3c2b9 nginx serves /crops/, not /images/
The site's crop store was renamed — see its own commit for why the name was wrong. This is the fourth
place that had to move with it: the constant, the development route, the build's copy target, and this
cache rule.

The rule itself is unchanged. A year and immutable is still EARNED here rather than assumed: the
filename is a hash of the original, its stamp, the framing, the slot and the width, so the bytes at a
given URL can never legitimately change.

A site published before the rename carries /images/ in its HTML and no longer has the files behind it.
The fix is a publish, which rewrites every one of those URLs anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:23:01 +01:00

345 lines
15 KiB
TypeScript

import { stat, readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { fofosAdminSettings } from '../db/schema';
import { sidecarPort } from '@@/sidecar-registry';
import { getOwnerUser } from 'officerdb';
import { readDatabases } from './databases';
/**
* Production: a directory of files behind nginx, not a running server.
*
* ── Why production is not a process ──
*
* The site's own build script says it plainly: *"The public site is not served by Bun in production —
* it is a directory of files behind nginx."* `bun run build:site` walks every route, renders it through
* the same Hono app that serves development, and writes `dist/`. So production has no port, nothing to
* restart, and nothing that can crash at three in the morning — it is a build that either succeeded or
* did not, and a web server with no application in it.
*
* Development stays a pm2 process, because that is what `bun --hot` is for.
*
* ── The container exists because nginx could not see the files ──
*
* Nginx Proxy Manager mounts only its own `/data` and `/etc/letsencrypt`. It cannot read `dist`, and
* there is no other nginx on the machine. The alternatives were putting site files inside NPM's own
* volume, or editing NPM's compose and restarting it — which takes every site on the box down for a
* moment.
*
* So: one `nginx:alpine`, `dist` mounted READ-ONLY, on the same docker network as NPM and with **no
* published port**. NPM reaches it by container name. Nothing about the site is exposed except through
* the proxy, which is the same property every other part of this plugin depends on.
*
* ── This plugin owns exactly one container name ──
*
* `fofos-web`. Every command here is built from that constant and from nothing a caller supplies, so no
* request can stop or remove another container on this machine — the same rule the pm2 side follows,
* and it matters for the same reason: Officer's own services share this daemon.
*/
const CONTAINER = 'fofos-web';
const IMAGE = 'nginx:alpine';
/** The network NPM is on, so it can reach the container by name without a published port. */
const NETWORK = 'fofos';
const DOCKER = '/usr/bin/docker';
export type StaticStatus = {
/** `<root>/website/dist`. */
path: string;
/** The build has produced something. */
built: boolean;
/** Pages written — top-level entries, which is close enough to be useful and cheap to count. */
entries: number | null;
/** When the build last wrote. */
builtAt: string | null;
container: {
name: string;
/** docker's own word: `running`, `exited`, or null when it does not exist. */
state: string | null;
/** What NPM should forward to. Only meaningful while it is running. */
forward: string;
};
};
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
export const distPath = (root: string): string => join(root, 'website', 'dist');
const run = async (
argv: string[],
timeoutMs = 60_000,
options: { env?: Record<string, string>; cwd?: string } = {},
) => {
/*
`cwd` matters and its absence was silent.
`bun run build:site` resolves the script from the package.json in the CURRENT directory. Without a
cwd it ran in the platform's own, which has no such script, and the error — "Script not found" —
reads like the site is missing one rather than like it was asked in the wrong place.
*/
const proc = Bun.spawn(argv, {
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
...(options.cwd ? { cwd: options.cwd } : {}),
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
});
const timer = setTimeout(() => proc.kill(), timeoutMs);
try {
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { ok: code === 0, output: `${stdout}${stderr}`.trim() };
} finally {
clearTimeout(timer);
}
};
export async function readStatic(): Promise<StaticStatus> {
const row = await readRow();
const path = row?.rootPath ? distPath(row.rootPath) : '';
const info = path ? await stat(path).catch(() => null) : null;
const entries = info?.isDirectory() ? await readdir(path).catch(() => null) : null;
const inspected = await run([DOCKER, 'inspect', '-f', '{{.State.Status}}', CONTAINER], 20_000);
return {
path,
built: Boolean(info?.isDirectory() && entries?.length),
entries: entries?.length ?? null,
builtAt: info?.mtime.toISOString() ?? null,
container: {
name: CONTAINER,
// `docker inspect` fails when there is no such container, which is "not created" rather than an
// error — a state to report, not a fault to raise.
state: inspected.ok ? inspected.output.trim() : null,
forward: `${CONTAINER}:80`,
},
};
}
/**
* Build the site.
*
* `FOFOS_ENV=production`, so the site's own guard reads the production database and refuses if the
* name disagrees — the same check the schema push goes through, and for the same reason. The build
* reads content, so pointing it at the wrong database would publish the wrong site rather than fail.
*
* It renders every route through the real app, so it needs everything the server needs: the database,
* the media directory, and the secrets. Assembled here for the same reason the dev server's are.
*/
export async function buildSite(secrets: {
apiToken: string;
sessionSecret: string;
}): Promise<{ ok: boolean; output: string }> {
const row = await readRow();
if (!row?.rootPath) throw new Error('No root location is set.');
const websiteDir = join(row.rootPath, 'website');
// Absent when av-club is not installed. The build is told, rather than left to fail on a fetch to a
// port that is not there — and it can then say so plainly instead of timing out.
const avClubPort = sidecarPort('av-club');
// av-club resolves every path against a user's own media directory, so the build has to say who it
// is working for. The owner, because that is whose root location this plugin administers.
const owner = await getOwnerUser();
const databases = await readDatabases();
if (!databases.production.name || !databases.production.exists) {
throw new Error('The production database does not exist yet.');
}
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!raw) throw new Error('The platform has no Postgres connection configured.');
const url = new URL(raw);
url.pathname = `/${databases.production.name}`;
const result = await run(['bun', 'run', 'build:site'], 15 * 60 * 1000, {
cwd: websiteDir,
env: {
NODE_ENV: 'production',
FOFOS_ENV: 'production',
POSTGRES_URL: url.toString(),
...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}),
...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}),
FOFOS_API_TOKEN: secrets.apiToken,
ADMIN_SESSION_SECRET: secrets.sessionSecret,
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
/*
How the build reaches av-club.
The build crops each placement to the exact shape and size its slot renders at, rather than
shipping a whole photograph and asking CSS to hide most of it. That needs an encoder, and the
rule is that every encoder is av-club — so the plugin, which knows the sidecar registry, hands
the site a URL and the site knows nothing else about the platform.
`x-officer-user` is av-club's whole authentication, and the build has to send it: the sidecar
resolves paths against that user's own media directory.
*/
...(avClubPort && owner?.email ? { AV_CLUB_URL: `http://127.0.0.1:${avClubPort}`, AV_CLUB_USER: owner?.email ?? '' } : {}),
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
API_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
},
});
return { ok: result.ok, output: `$ bun run build:site (FOFOS_ENV=production)\n${result.output}` };
}
/**
* Create or replace the web server.
*
* Removed and recreated rather than restarted, for the same reason the pm2 side does it: a container
* keeps the mounts and the network it was created with, so a root location changed here would be saved,
* shown, and still serving the old directory.
*
* `--restart unless-stopped`, so it comes back with the machine. No published port: NPM reaches it on
* the shared network by name, and a port on the host would be the site available unauthenticated beside
* the proxy that exists to gate it.
*/
/**
* The nginx configuration the container runs.
*
* Written out on every start rather than kept as a file somebody edits, so what nginx does is decided
* here and cannot drift. The stock `nginx:alpine` config serves the directory and nothing else: no
* compression, and no cache headers at all, so every asset carried a TTL of zero and a returning
* visitor re-downloaded the whole site.
*
* ── Four lifetimes, because there are four kinds of file ──
*
* `images/` are placements cropped to their slot, named by a hash of everything that decides their
* bytes. A year, immutable, and earned: the file at that URL cannot change, because a change produces
* a different name. This is exactly what `derived/` cannot have.
*
* `derived/` renditions are rebuilt IN PLACE — av-club replaces the contents of `1280.webp` rather
* than writing a new name. So they cannot be `immutable`, however much they look like they could be:
* a re-crop would be invisible to everyone who had seen the old one, for as long as the header said.
* Thirty days is the compromise, and it is the reason this is spelled out rather than copied from the
* usual recipe.
*
* Bundled assets — the stylesheet, the scripts, the fonts — change only when a build runs, and a build
* is also the only thing that changes the HTML that references them. A week, revalidated after.
*
* HTML is `no-cache`: revalidate every time. Not `no-store` — the file is still cached, it is just
* never used without asking. Anything longer and a publish is invisible to whoever already visited,
* which is the one failure that makes the whole panel look broken.
*
* ── Why `^~` on the prefixes ──
*
* A regex location beats a prefix location in nginx, so the extension rule below would otherwise
* capture `/derived/…/1280.webp` and hand it the wrong lifetime. `^~` stops the regex from being
* considered at all. This is the kind of thing that is correct or silently wrong with no middle.
*/
const NGINX_CONF = `server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Redirects stay relative, so they keep the scheme and host the visitor arrived on.
#
# nginx sits behind the proxy that terminates TLS, so it believes it is serving plain HTTP. Asked for
# /home/gallery/16 it correctly redirects to the directory — and wrote the Location as
# http://host/home/gallery/16/, downgrading an HTTPS visitor to HTTP for a round trip before the
# proxy sent them back. Off emits just the path, and the browser keeps the scheme it had.
absolute_redirect off;
# Text compresses; the images and video here are already compressed formats and would only burn CPU.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css text/plain application/javascript application/json image/svg+xml;
# Rebuilt in place by av-club, so a long life would pin a stale crop. See the note above.
location ^~ /derived/ {
expires 30d;
add_header Cache-Control "public";
}
# Placements cropped to their slot. A year and immutable, and this one is EARNED rather than assumed:
# the filename is a hash of the original, the framing, the slot and the width, so the bytes at a given
# URL can never legitimately change. A re-crop is a different name, and the HTML that carries it is
# revalidated every visit. This is the difference from /derived/ above, which is rewritten in place.
#
# `/crops/`, and it was `/images/` until 2026-08-31 — a name taken from the URL rather than from what
# is behind it, beside two other directories equally full of images. A site published before the
# rename has `/images/` in its HTML and no longer has the files; the fix is a publish, which rewrites
# every one of these URLs anyway.
location ^~ /crops/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /media/ {
expires 30d;
add_header Cache-Control "public";
}
# The typefaces. A year, because a face is not edited — it is replaced, and a replacement is a new
# name. This is the one directory here where a long life carries no risk of pinning something stale.
location ^~ /fonts/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Everything a build produces. A year, and immutable — earned, not assumed: every asset the layout
# links carries a ?v= stamp of the build id, so a build that changes a file changes its URL, and
# the HTML carrying the new URLs is no-cache and revalidated every visit. They cannot disagree.
#
# It was seven days while the URLs were bare, which was correct then: a stable name with a long life
# means somebody running last week's script against this week's markup.
location ~* \\.(css|js|webp|png|jpg|jpeg|gif|svg|ico|mp4)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
# The build writes about/index.html, so a directory match is what serves /about.
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
}
`;
export async function startStatic(): Promise<{ ok: boolean; output: string }> {
const row = await readRow();
if (!row?.rootPath) throw new Error('No root location is set.');
const dist = distPath(row.rootPath);
if (!(await stat(dist).catch(() => null))) throw new Error('There is no dist directory yet — build first.');
// Beside the site rather than inside dist/, which the build empties and replaces.
const conf = join(row.rootPath, 'nginx.conf');
await writeFile(conf, NGINX_CONF);
// Expected to fail the first time; the usual case is that it was never there.
await run([DOCKER, 'rm', '-f', CONTAINER], 30_000);
const created = await run(
[
DOCKER, 'run', '-d',
'--name', CONTAINER,
'--restart', 'unless-stopped',
'--network', NETWORK,
// Read-only: nginx serves this and has no business writing to somebody's build output.
'-v', `${dist}:/usr/share/nginx/html:ro`,
'-v', `${conf}:/etc/nginx/conf.d/default.conf:ro`,
IMAGE,
],
120_000,
);
return { ok: created.ok, output: `$ docker run ${CONTAINER} (${dist} -> /usr/share/nginx/html, network ${NETWORK})\n${created.output}` };
}
export async function stopStatic(): Promise<{ ok: boolean; output: string }> {
const result = await run([DOCKER, 'rm', '-f', CONTAINER], 60_000);
return { ok: result.ok, output: `$ docker rm -f ${CONTAINER}\n${result.output}` };
}