import { stat, readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { db } from 'officerdb/db'; import { fofosAdminSettings } from '../db/schema'; 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 = { /** `/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; 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 { 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'); 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'), // TEMPORARY — passed through so a bisect can rebuild one page instead of all 48. The site's // build script leaves every other page alone when this is set. Unset, nothing changes. ...(process.env.FOFOS_BUILD_ONLY ? { FOFOS_BUILD_ONLY: process.env.FOFOS_BUILD_ONLY } : {}), // 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. * * ── Three lifetimes, because there are three kinds of file ── * * `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; # 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"; } 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. Seven days and no more: these names are stable across builds and the # HTML that references them is revalidated every time, so a longer life means a visitor running last # week's stylesheet against this week's markup. Fingerprinted filenames are what would earn a year # here, and the build does not write them. location ~* \\.(css|js|webp|png|jpg|jpeg|gif|svg|ico|mp4)$ { expires 7d; add_header Cache-Control "public"; } 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}` }; }