diff --git a/api/servers.ts b/api/servers.ts index 6d99464..01c3c08 100644 --- a/api/servers.ts +++ b/api/servers.ts @@ -6,6 +6,8 @@ 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. @@ -300,6 +302,31 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out 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. diff --git a/api/static-site.ts b/api/static-site.ts index faf67c8..5719e60 100644 --- a/api/static-site.ts +++ b/api/static-site.ts @@ -209,7 +209,11 @@ export async function buildSite(secrets: { * 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 ── + * ── 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: @@ -258,6 +262,15 @@ const NGINX_CONF = `server { 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. + location ^~ /images/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + location ^~ /media/ { expires 30d; add_header Cache-Control "public";