Files
pastilhasandClaude Opus 5 7e8353a100 serve the page at the address that was asked for
try_files listed $uri/ before any index, so nginx answered /gallery/aldi/65 with
a 301 appending the slash. Every internal link on this site is the bare form —
the thumbnails, the lightbox hrefs, the chevrons — so every navigation paid a
redirect before anything on the page could start. Lighthouse measured it at 78 ms
on the critical path of the first request.

Naming index.html first serves the file directly. Both forms still work, neither
redirects, and $uri/ stays after it for a directory with no index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:32:27 +01:00

359 lines
16 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.
#
# NO BACKTICKS IN HERE. This whole config is a template literal, so a pair of them around a path ends
# the string and makes the next word an identifier. That shipped once: the plugin failed to mount
# with "crops is not defined", every /fofos-admin call 404'd, and useReady's catch locked all nine
# sections behind "set a root location" — a build error wearing the costume of a setup problem.
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.
#
# $uri/index.html BEFORE $uri/, and the order is the whole point. A $uri/ match is a directory
# and nginx answers it with a 301 that appends the slash — so /gallery/aldi/65 cost a redirect
# before anything on the page could start. Every internal link on this site is the bare form (the
# thumbnails, the lightbox, the chevrons), so every navigation paid it: 78 ms on the critical path,
# measured, on the first request of every page.
#
# Naming index.html first serves the file at the address that was asked for. Both forms still work
# and neither redirects; $uri/ stays after it for a directory that has no index.
try_files $uri $uri/index.html $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}` };
}