The cropper needs an encoder, and only the build was told where one is. That made development the one place the old behaviour survived: the framing approved in the admin, the picture dev showed, and the file that shipped would have been three different things — which is the class of bug this whole change exists to remove. The port is ephemeral by design, so it is read at start rather than written to a file. av-club moving leaves dev pointing at a port that has gone; restarting dev from the panel fixes it, and startServer already deletes and re-creates precisely so a changed port is picked up. /images/ gets a year and immutable in the nginx config. Earned rather than assumed: the filename is a hash of the original, the framing, the slot and the width, so the bytes at a URL cannot legitimately change. That is the difference from /derived/, which is rewritten in place and therefore capped at thirty days. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
395 lines
17 KiB
TypeScript
395 lines
17 KiB
TypeScript
import { join } from 'node:path';
|
|
import { db } from 'officerdb/db';
|
|
import { eq } from 'drizzle-orm';
|
|
import { fofosAdminSettings } from '../db/schema';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
|
import { readDatabases } from './databases';
|
|
import { closeDevPort, openDevPort } from './firewall';
|
|
import { sidecarPort } from '@@/sidecar-registry';
|
|
import { getOwnerUser } from 'officerdb';
|
|
|
|
/**
|
|
* 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 ──
|
|
*
|
|
* A `bun dev` started from a terminal dies with the terminal, and nobody can tell whether it is running
|
|
* without finding the window it was started in. Under pm2 both are the same kind of thing: a named
|
|
* process with a status, a log and an uptime, startable from here and inspectable from anywhere. The
|
|
* cost is that development no longer prints to a console somebody is watching, which is what
|
|
* `pm2 logs` is for.
|
|
*
|
|
* ── This plugin owns EXACTLY two process names, and touches nothing else ──
|
|
*
|
|
* `fofos-dev` and `fofos-prod`. Every command here is built from that pair and from nothing a caller
|
|
* supplies, so there is no request that can stop, restart or delete another process on the machine.
|
|
* That matters more than usual: this runs on a host where Officer's own processes and other people's
|
|
* production services share one pm2 daemon, and a plugin that could name any of them would be a way to
|
|
* take them down through a web form.
|
|
*
|
|
* ── Absolute paths for pm2 and bun ──
|
|
*
|
|
* The platform's PATH is whatever pm2 was started with, which is not a login shell's. `website.ts`
|
|
* already learned this about bun; the same applies to pm2 itself.
|
|
*/
|
|
|
|
const PM2 = '/usr/local/bin/pm2';
|
|
|
|
export type ServerName = 'development' | 'production';
|
|
|
|
/** The one place these names exist. Nothing else in this module may build one. */
|
|
const PROCESS = { development: 'fofos-dev', production: 'fofos-prod' } as const;
|
|
|
|
export type ServerStatus = {
|
|
name: ServerName;
|
|
/** The pm2 process name, so somebody can go and look at it with `pm2 logs`. */
|
|
process: string;
|
|
port: number | null;
|
|
/** `online`, `stopped`, `errored` — pm2's own word, or null when it has never been started. */
|
|
status: string | null;
|
|
uptimeMs: number | null;
|
|
restarts: number | null;
|
|
/** How the site would be reached, once it is up. */
|
|
url: string | null;
|
|
/**
|
|
* Does the port actually answer?
|
|
*
|
|
* Asked separately from pm2's status because the two disagree, and the disagreement is the whole
|
|
* point. `bun --hot` survives a crash at startup: the process stays alive, pm2 reports `online`, and
|
|
* nothing is listening. That is exactly what development did — reported up, served nothing, and the
|
|
* panel repeated it confidently.
|
|
*
|
|
* Null when there is no port to try.
|
|
*/
|
|
reachable: boolean | null;
|
|
};
|
|
|
|
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
|
|
|
|
const PURPOSE = 'fofos-admin';
|
|
|
|
/**
|
|
* The site's boot secrets, made once and kept.
|
|
*
|
|
* The site refuses to serve a request without them, and neither is a decision worth putting in front of
|
|
* anybody: they are long random strings, and asking a person to invent one is asking for a worse one.
|
|
* Regenerating on every start would be worse still — it would invalidate every session and every token
|
|
* anybody had written down.
|
|
*/
|
|
export async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> {
|
|
const row = await readRow();
|
|
const existing = {
|
|
apiToken: row?.siteApiToken ? decryptSecret(PURPOSE, row.siteApiToken) : null,
|
|
sessionSecret: row?.siteSessionSecret ? decryptSecret(PURPOSE, row.siteSessionSecret) : null,
|
|
};
|
|
if (existing.apiToken && existing.sessionSecret) {
|
|
return { apiToken: existing.apiToken, sessionSecret: existing.sessionSecret };
|
|
}
|
|
|
|
// 64 hex characters. The site requires at least 32 and there is no reason to be near the limit.
|
|
const apiToken = existing.apiToken ?? randomBytes(32).toString('hex');
|
|
const sessionSecret = existing.sessionSecret ?? randomBytes(32).toString('hex');
|
|
|
|
const values = {
|
|
siteApiToken: encryptSecret(PURPOSE, apiToken),
|
|
siteSessionSecret: encryptSecret(PURPOSE, sessionSecret),
|
|
updatedAt: new Date(),
|
|
};
|
|
if (row) await db.update(fofosAdminSettings).set(values).where(eq(fofosAdminSettings.id, row.id));
|
|
else await db.insert(fofosAdminSettings).values(values);
|
|
|
|
return { apiToken, sessionSecret };
|
|
}
|
|
|
|
const run = async (
|
|
argv: string[],
|
|
timeoutMs = 60_000,
|
|
env?: Record<string, string>,
|
|
): Promise<{ ok: boolean; output: string }> => {
|
|
/*
|
|
pm2 hands the environment it was INVOKED with to the process it starts, so the way to give the site
|
|
its port and its database is to put them in this command's own environment. There is no flag for
|
|
it — `--env` names a block in an ecosystem file, not a variable.
|
|
*/
|
|
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);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Persist pm2's process list, so a reboot brings back what is actually running.
|
|
*
|
|
* Without this, pm2 resurrects the list as it was the last time somebody saved — which, after starting
|
|
* a server here, means the machine comes back up without the site and with no indication that anything
|
|
* was meant to be there. The systemd unit is enabled on this host, so the resurrect definitely happens;
|
|
* the only question is what it restores.
|
|
*
|
|
* Run after every start AND every stop, because the dump has to match reality in both directions: a
|
|
* server stopped here and left in the dump comes back on its own at the next reboot, which is the same
|
|
* bug pointing the other way.
|
|
*
|
|
* A failure to save does not fail the action. The process is already started or stopped by then, and
|
|
* reporting "could not start" about something that did start is worse than a line saying the list was
|
|
* not persisted.
|
|
*/
|
|
async function persist(): Promise<string> {
|
|
const saved = await run([PM2, 'save'], 30_000);
|
|
return saved.ok ? 'pm2 list saved' : `pm2 save failed: ${saved.output}`;
|
|
}
|
|
|
|
export function checkPort(value: unknown, field: string): number {
|
|
const port = Number(value);
|
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
throw new Error(`The ${field} port must be a whole number between 1024 and 65535.`);
|
|
}
|
|
return port;
|
|
}
|
|
|
|
/** What pm2 says about our two, and nothing about anybody else's. */
|
|
export async function readServers(): Promise<ServerStatus[]> {
|
|
const row = await readRow();
|
|
const ports: Record<ServerName, number | null> = {
|
|
development: row?.devPort ?? null,
|
|
production: row?.prodPort ?? null,
|
|
};
|
|
|
|
const listed = await run([PM2, 'jlist'], 20_000);
|
|
let processes: Array<{ name?: string; pm2_env?: { status?: string; pm_uptime?: number; restart_time?: number } }> = [];
|
|
if (listed.ok) {
|
|
try {
|
|
processes = JSON.parse(listed.output);
|
|
} catch {
|
|
// pm2 printed something that is not JSON — a daemon starting, usually. Treated as "no answer"
|
|
// rather than as "not running", because those are different and only one of them is alarming.
|
|
processes = [];
|
|
}
|
|
}
|
|
|
|
return Promise.all(
|
|
(['development', 'production'] as const).map(async (name) => {
|
|
const found = processes.find((entry) => entry.name === PROCESS[name]);
|
|
const port = ports[name];
|
|
return {
|
|
name,
|
|
process: PROCESS[name],
|
|
port,
|
|
status: found?.pm2_env?.status ?? null,
|
|
uptimeMs:
|
|
found?.pm2_env?.pm_uptime && found.pm2_env.status === 'online' ? Date.now() - found.pm2_env.pm_uptime : null,
|
|
restarts: found?.pm2_env?.restart_time ?? null,
|
|
url: port ? `http://127.0.0.1:${port}` : null,
|
|
reachable: port === null ? null : await answers(port),
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function savePorts(dev: unknown, prod: unknown): Promise<ServerStatus[]> {
|
|
const devPort = checkPort(dev, 'development');
|
|
const prodPort = checkPort(prod, 'production');
|
|
if (devPort === prodPort) throw new Error('The two servers cannot share a port.');
|
|
|
|
const row = await readRow();
|
|
|
|
/*
|
|
A changed port leaves the old rule behind, and this is the only place that knows both values.
|
|
|
|
Closing it on STOP would be wrong — a stopped development server is usually about to start again,
|
|
and a rule for a port nothing listens on grants nothing. Leaving it on every change is how a
|
|
firewall accumulates holes nobody remembers opening.
|
|
*/
|
|
if (row?.devPort && row.devPort !== devPort) await closeDevPort(row.devPort);
|
|
|
|
if (row) {
|
|
await db
|
|
.update(fofosAdminSettings)
|
|
.set({ devPort, prodPort, updatedAt: new Date() })
|
|
.where(eq(fofosAdminSettings.id, row.id));
|
|
} else {
|
|
await db.insert(fofosAdminSettings).values({ devPort, prodPort });
|
|
}
|
|
return readServers();
|
|
}
|
|
|
|
/**
|
|
* Start one of them.
|
|
*
|
|
* `pm2 delete` first, then `start`. Not `restart`: restart on a process that has never existed fails,
|
|
* and restart on one that exists reuses the env it was started with — so a port or database changed
|
|
* here would be saved, reported, and not actually in effect until somebody worked out why.
|
|
*
|
|
* The environment is assembled here rather than left to the checkout's `.env`, because the plugin is
|
|
* what knows the ports and the database names. `src/env-file.ts` reads files first and the environment
|
|
* last, so anything written down in the checkout still wins — this fills what is absent, it does not
|
|
* override what somebody chose.
|
|
*/
|
|
export async function startServer(which: ServerName): Promise<{ ok: boolean; output: string }> {
|
|
/*
|
|
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 = row.devPort;
|
|
if (!port) throw new Error(`No ${which} port is set.`);
|
|
|
|
const websiteDir = join(row.rootPath, 'website');
|
|
const databases = await readDatabases();
|
|
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;
|
|
if (!raw) throw new Error('The platform has no Postgres connection configured.');
|
|
const url = new URL(raw);
|
|
url.pathname = `/${target.name}`;
|
|
|
|
/*
|
|
Everything the site refuses to boot without.
|
|
|
|
It has no `.env` in the checkout, and it should not need one: every value here is either known to
|
|
this plugin already (the port, the media directory, the database) or a random secret nobody should
|
|
be asked to invent. `src/env-file.ts` reads files first and the environment last, so a checkout that
|
|
DOES write one still wins — this fills absence, it does not override a choice.
|
|
*/
|
|
const secrets = await siteSecrets();
|
|
|
|
const env: Record<string, string> = {
|
|
BUN_PORT: String(port),
|
|
FOFOS_API_TOKEN: secrets.apiToken,
|
|
ADMIN_SESSION_SECRET: secrets.sessionSecret,
|
|
// Loopback. Production warns about it and serves anyway; a real domain belongs in the checkout's
|
|
// own env file, where it is a deployment fact rather than something this panel invented.
|
|
PUBLIC_URL: `http://127.0.0.1:${port}`,
|
|
API_URL: `http://127.0.0.1:${port}`,
|
|
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
|
|
// Both names, because the site's guard compares them and one alone leaves the other falling back
|
|
// 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 } : {}),
|
|
// Only development reaches here — production is refused above — so this is always the dev URL.
|
|
POSTGRES_URL_DEV: url.toString(),
|
|
};
|
|
|
|
/*
|
|
How the development server reaches av-club — the same two values the BUILD gets, for the same
|
|
reason and by the same route.
|
|
|
|
The site 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. Given only to the build, that would make
|
|
development the one place the old behaviour survived: the crop you approved in the admin, the
|
|
picture dev showed you, and the file that actually shipped would be three different things. That is
|
|
precisely the class of bug this whole change exists to remove, so dev runs the same path.
|
|
|
|
The port is EPHEMERAL by design — av-club binds whatever is free and reports it — so this is read
|
|
at start rather than written into a file. A restart of av-club therefore leaves dev pointing at a
|
|
port that has moved, and the fix is to restart dev from the panel, which is where its environment
|
|
is assembled anyway. Worth knowing rather than worth engineering around: `startServer` deletes and
|
|
re-creates the process precisely so a changed port is picked up.
|
|
*/
|
|
const avClubPort = sidecarPort('av-club');
|
|
// av-club resolves every path against a user's own media directory, so it has to be told who this is
|
|
// working for. The owner, because that is whose root location this plugin administers.
|
|
const owner = await getOwnerUser();
|
|
if (avClubPort && owner?.email) {
|
|
env.AV_CLUB_URL = `http://127.0.0.1:${avClubPort}`;
|
|
env.AV_CLUB_USER = owner.email;
|
|
}
|
|
|
|
/*
|
|
Open the firewall to the proxy BEFORE starting, so the port is reachable the moment it binds.
|
|
|
|
NPM runs in a container and crosses the docker bridge to reach host services; ufw allows that subnet
|
|
to named ports only. A development server on a port nobody opened answers `curl 127.0.0.1` and is
|
|
invisible to the proxy — which presents as a browser timing out with a valid certificate, a correct
|
|
proxy host and a healthy process, and is written down only in nginx's own error log.
|
|
|
|
Never fatal. A rule that could not be added is a line in the output; the server still starts.
|
|
*/
|
|
const firewall = await openDevPort(port);
|
|
|
|
// Removed first so a stale process cannot survive with the old environment. Failure is expected and
|
|
// ignored: the usual case is that it was never there.
|
|
await run([PM2, 'delete', PROCESS[which]], 20_000);
|
|
|
|
const started = await run(
|
|
[
|
|
PM2,
|
|
'start',
|
|
process.execPath,
|
|
'--name',
|
|
PROCESS[which],
|
|
'--cwd',
|
|
websiteDir,
|
|
'--',
|
|
'run',
|
|
'dev',
|
|
],
|
|
120_000,
|
|
env,
|
|
);
|
|
|
|
return {
|
|
ok: started.ok,
|
|
output: [`$ pm2 start ${PROCESS[which]} (${which}, port ${port})`, firewall, started.output, await persist()]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
};
|
|
}
|
|
|
|
/** Stop one. `delete`, not `stop`, so a stopped entry does not linger in the list looking like a fault. */
|
|
export async function stopServer(which: ServerName): Promise<{ ok: boolean; output: string }> {
|
|
const result = await run([PM2, 'delete', PROCESS[which]], 30_000);
|
|
return {
|
|
ok: result.ok,
|
|
output: [`$ pm2 delete ${PROCESS[which]}`, result.output, await persist()].filter(Boolean).join('\n'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Does anything answer on this port?
|
|
*
|
|
* ANY response counts, including a 404 or a 500: the question is whether something is listening, not
|
|
* whether it is happy. A short timeout, because a port nobody holds refuses immediately and one that is
|
|
* firewalled would otherwise hang this request.
|
|
*/
|
|
async function answers(port: number): Promise<boolean> {
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1500), redirect: 'manual' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|