diff --git a/api/router.ts b/api/router.ts index 66458fe..f481fbb 100644 --- a/api/router.ts +++ b/api/router.ts @@ -11,7 +11,8 @@ import { reconcile } from './derive'; import { createDatabases, pushSchema, readDatabases } from './databases'; import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups'; import { watchMailbox } from './messages'; -import { readServers, savePorts, startServer, stopServer } from './servers'; +import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers'; +import { buildSite, readStatic, startStatic, stopStatic } from './static-site'; import { join } from 'node:path'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; @@ -422,6 +423,37 @@ router.put('/_config/servers', async (ctx) => { } }); +/** + * Production — a static build, and the nginx that serves it. + * + * Separate routes from the servers above because it is a different kind of thing: there is no process, + * no port and nothing to restart. A build either succeeded or did not, and a container is either + * serving the directory or is not. + */ +router.get('/_config/static', async (ctx) => ctx.json(await readStatic())); + +router.post('/_config/static/build', async (ctx) => { + try { + const result = await buildSite(await siteSecrets()); + return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + +router.post('/_config/static/:action', async (ctx) => { + const action = ctx.req.param('action'); + if (action !== 'start' && action !== 'stop') { + return ctx.json({ error: 'Action must be start or stop.' }, 400); + } + try { + const result = action === 'start' ? await startStatic() : await stopStatic(); + return ctx.json({ ...result, status: await readStatic() }, result.ok ? 200 : 400); + } catch (error) { + return ctx.json({ error: (error as Error).message }, 400); + } +}); + router.post('/_config/servers/:which/:action', async (ctx) => { const which = ctx.req.param('which'); const action = ctx.req.param('action'); diff --git a/api/servers.ts b/api/servers.ts index f8df6b1..2cb4864 100644 --- a/api/servers.ts +++ b/api/servers.ts @@ -7,7 +7,17 @@ import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { readDatabases } from './databases'; /** - * The site's two servers — development and production — run under pm2. + * The site's DEVELOPMENT server, under pm2. + * + * ── Production is not here, and that is the point ── + * + * The site's own build script says the public site is a directory of files behind nginx, not a Bun + * process. So production is a build and a web server with no application in it — see `static-site.ts`. + * This module is development only: `bun --hot`, which is exactly the case pm2 is right for. + * + * The two names remain, because a caller asking about `production` should get a real answer rather than + * a 400, and because the panel still shows two rows. `production` here reports the pm2 process that no + * longer exists — which is correct, and is why `startServer` refuses it. * * ── pm2 for BOTH, including development ── * @@ -74,7 +84,7 @@ const PURPOSE = 'fofos-admin'; * Regenerating on every start would be worse still — it would invalidate every session and every token * anybody had written down. */ -async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> { +export async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> { const row = await readRow(); const existing = { apiToken: row?.siteApiToken ? decryptSecret(PURPOSE, row.siteApiToken) : null, @@ -226,15 +236,25 @@ export async function savePorts(dev: unknown, prod: unknown): Promise { + /* + Production has no process to start. + + Refused here rather than in the route, so there is one place that knows it — a check in the panel + alone would be a rule the API did not have, and the API is what an agent reaches. + */ + if (which === 'production') { + throw new Error('Production is a static build served by nginx, not a process. Build it instead.'); + } + const row = await readRow(); if (!row?.rootPath) throw new Error('No root location is set.'); - const port = which === 'development' ? row.devPort : row.prodPort; + const port = row.devPort; if (!port) throw new Error(`No ${which} port is set.`); const websiteDir = join(row.rootPath, 'website'); const databases = await readDatabases(); - const target = which === 'development' ? databases.development : databases.production; + const target = databases.development; if (!target.name || !target.exists) throw new Error(`The ${which} database does not exist yet.`); const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; @@ -265,7 +285,8 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out // to whatever the repository shipped with. Same reasoning as the schema push. ...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}), ...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}), - ...(which === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }), + // Only development reaches here — production is refused above — so this is always the dev URL. + POSTGRES_URL_DEV: url.toString(), }; // Removed first so a stale process cannot survive with the old environment. Failure is expected and @@ -283,7 +304,7 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out websiteDir, '--', 'run', - which === 'development' ? 'dev' : 'start', + 'dev', ], 120_000, env, diff --git a/api/static-site.ts b/api/static-site.ts new file mode 100644 index 0000000..de38434 --- /dev/null +++ b/api/static-site.ts @@ -0,0 +1,202 @@ +import { stat, readdir } 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, env?: Record) => { + const proc = Bun.spawn(argv, { + stdout: 'pipe', + stderr: 'pipe', + stdin: 'ignore', + ...(env ? { env: { ...process.env, ...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, + { + 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'), + // 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. + */ +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.'); + + // 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`, + 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}` }; +} diff --git a/web/ProxiesBlock.tsx b/web/ProxiesBlock.tsx index 40eb0ad..690ee78 100644 --- a/web/ProxiesBlock.tsx +++ b/web/ProxiesBlock.tsx @@ -73,6 +73,14 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { clientRef.current = client; const [hosts, setHosts] = useState(null); + /* + Production is not a port on this machine. + + It is a container on the same docker network as NPM, so the forward target is a NAME — `fofos-web:80` + — and the gateway address that reaches a host process would reach nothing. Read here rather than + passed down, because the shape of production's target is this block's business. + */ + const [staticTarget, setStaticTarget] = useState<{ host: string; port: number } | null>(null); const [unavailable, setUnavailable] = useState(false); const [domains, setDomains] = useState>({}); const [forwardHost, setForwardHost] = useState(''); @@ -89,6 +97,11 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { for (const host of found) counts.set(host.forward_host, (counts.get(host.forward_host) ?? 0) + 1); const common = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; if (common) setForwardHost((typed) => typed || common); + + const web = await clientRef.current + .get<{ container: { name: string } }>('/fofos-admin/_config/static') + .catch(() => null); + if (web?.container?.name) setStaticTarget({ host: web.container.name, port: 80 }); } catch { // Not installed, not running, or not permitted. Reported as absent rather than as an error: this // block is an integration with another plugin, and its absence is a fact rather than a fault. @@ -108,14 +121,20 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { setFailed(false); setOutput(null); try { + // Production goes to the container by name; development to the host, at its port. + const target = + server.name === 'production' && staticTarget + ? { host: staticTarget.host, port: staticTarget.port } + : { host: forwardHost.trim(), port: server.port! }; + await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', { ...NEW_HOST, domain_names: [domain], forward_scheme: 'http', - forward_host: forwardHost.trim(), - forward_port: server.port, + forward_host: target.host, + forward_port: target.port, }); - setOutput(`${domain} → ${forwardHost.trim()}:${server.port}`); + setOutput(`${domain} → ${target.host}:${target.port}`); setDomains((current) => ({ ...current, [server.name]: '' })); await load(); } catch (failure) { @@ -246,13 +265,33 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { const row = (server: Server) => { const label = server.name === 'development' ? 'Development' : 'Production'; // Everything NPM already points at this server's port, whoever made it. - const mine = (hosts ?? []).filter((host) => host.forward_port === server.port); + /* + What already points at this server. + + Matched on the FULL target rather than the port alone: production forwards to a container on port + 80, and every other site behind this NPM that happens to use port 80 would otherwise be listed here + as though it belonged to fofos. + */ + const mine = (hosts ?? []).filter((host) => + server.name === 'production' && staticTarget + ? host.forward_host === staticTarget.host && host.forward_port === staticTarget.port + : host.forward_port === server.port && server.port !== null, + ); return (

{label} - {server.port ? · port {server.port} : ' · no port set'} + + {' · '} + {server.name === 'production' + ? staticTarget + ? `${staticTarget.host}:${staticTarget.port}` + : 'static, no server yet' + : server.port + ? `port ${server.port}` + : 'no port set'} +

{mine.map((host) => ( @@ -325,7 +364,10 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => { }; // Nothing left to point anywhere: every server already has a domain. - const anythingToCreate = servers.some((server) => !(hosts ?? []).some((host) => host.forward_port === server.port)); + // Development only: production's target is a container name, not the gateway address this field holds. + const anythingToCreate = servers.some( + (server) => server.name === 'development' && !(hosts ?? []).some((host) => host.forward_port === server.port), + ); return ( diff --git a/web/ServersBlock.tsx b/web/ServersBlock.tsx index 95bf2d9..290c61f 100644 --- a/web/ServersBlock.tsx +++ b/web/ServersBlock.tsx @@ -8,9 +8,19 @@ import { Block } from './Block'; import { Console } from './Console'; import { useFlash } from './useFlash'; import { ProxiesBlock } from './ProxiesBlock'; +import { StaticBlock } from './StaticBlock'; /** - * The two servers that serve the site, under pm2. + * How the site is served: a development process, and a production build. + * + * ── They are not two of the same thing, and this stopped pretending ── + * + * Development is `bun --hot` under pm2 — a process with a port, an uptime and a restart count. + * Production is a directory of files behind nginx, which the site's own build script says outright: it + * has no port and nothing to restart. It is built or stale, served or not served. + * + * So production gets Build and a container. Showing it as a process was showing something that does not + * exist, and its Start button could only ever have failed. * * A block inside Administration rather than a section of its own: it is the same KIND of thing as the * root location and the databases — this machine, not the site's content — and a nav row for one card @@ -88,7 +98,8 @@ export const ServersBlock = () => { setServers( await clientRef.current.put('/fofos-admin/_config/servers', { development: Number(dev), - production: Number(prod), + // Still sent because the API validates that the two differ. Nothing listens on it. + production: Number(prod || 3010), }), ); flash(); @@ -121,9 +132,8 @@ export const ServersBlock = () => { } }; - const dirty = - dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '') || - prod !== String(servers?.find((s) => s.name === 'production')?.port ?? ''); + // Only development has a port now. Production is a container on a docker network, publishing none. + const dirty = dev !== String(servers?.find((s) => s.name === 'development')?.port ?? ''); const row = (server: Server, value: string, onChange: (next: string) => void) => { // pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a @@ -214,16 +224,17 @@ export const ServersBlock = () => { > {!servers ? null : ( <> - {servers.map((server) => - server.name === 'development' ? row(server, dev, setDev) : row(server, prod, setProd), - )} + {servers.filter((server) => server.name === 'development').map((server) => row(server, dev, setDev))} + + {/* Production, which is a build rather than a process — see StaticBlock. */} + {error &&

{error}

} {dirty && (
- diff --git a/web/StaticBlock.tsx b/web/StaticBlock.tsx new file mode 100644 index 0000000..fa82317 --- /dev/null +++ b/web/StaticBlock.tsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { readableError } from './errors'; +import { Console } from './Console'; + +/** + * Production: the built site, and the nginx serving it. + * + * ── Two states, not one ── + * + * A build can succeed while nothing serves it, and a container can serve a directory built weeks ago. + * They fail independently and they are fixed differently, so they are reported separately rather than + * collapsed into "production is up" — which would be true in one of those cases and misleading in the + * other. + * + * ── Build takes minutes ── + * + * It renders every route through the real app and copies every referenced file. Said in the button, + * because a spinner sitting for three minutes with no explanation is indistinguishable from a hang. + */ + +type Static = { + path: string; + built: boolean; + entries: number | null; + builtAt: string | null; + container: { name: string; state: string | null; forward: string }; +}; + +const when = (iso: string) => + new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + +export const StaticBlock = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null); + const [output, setOutput] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async () => { + try { + setStatus(await clientRef.current.get('/fofos-admin/_config/static')); + } catch { + setStatus(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const act = async (what: 'build' | 'start' | 'stop') => { + setBusy(what); + setFailed(false); + setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null); + try { + const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`; + const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {}); + setStatus(result.status); + setOutput(result.output); + setFailed(!result.ok); + } catch (failure) { + setOutput(readableError(failure)); + setFailed(true); + void load(); + } finally { + setBusy(null); + } + }; + + if (!status) return null; + + const serving = status.container.state === 'running'; + + return ( +
+
+
+

+ + Production + + {status.built ? `${status.entries} files` : 'not built'} + {status.builtAt ? ` · built ${when(status.builtAt)}` : ''} + {' · '} + {status.container.state ?? 'no server'} + +

+ {/* The forward target, because it is what a domain has to point at and it is not a port. */} +

+ {status.container.name} · {serving ? status.container.forward : 'static files, no port'} +

+
+ +
+ + + +
+
+ + +
+ ); +};