diff --git a/api/router.ts b/api/router.ts index d726955..380a265 100644 --- a/api/router.ts +++ b/api/router.ts @@ -14,7 +14,7 @@ import { watchMailbox } from './messages'; import { watchOriginals } from './originals-watch'; import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers'; import { buildSite, readStatic, startStatic, stopStatic } from './static-site'; -import { join } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; /** @@ -261,6 +261,50 @@ router.post('/_config/media/create', async (ctx) => { * Recursive, deliberately: a folder of folders is still "these pictures", and refusing to descend would * make the action silently miss most of what somebody pointed at. */ +/** + * Serve a file out of `derived/`. + * + * ── Why this exists when the site serves them too ── + * + * The site has its own `/derived/*`, and the admin cannot use it: the panel talks to this plugin, and + * the catch-all below forwards everything to the site's `/api/admin`, which is a different route. More + * importantly, an admin thumbnail should not require the development server to be running — looking at + * a gallery is not editing the site. + * + * So this reads the disk directly. The plugin knows the root; the file is right there. + * + * ── Not under `/_config` ── + * + * That prefix is owner-only, and a thumbnail is not a credential. A member who can open the section can + * see the pictures in it, which is the whole point of the section. + * + * Registered BEFORE the catch-all, or the catch-all would forward it to fofos. + */ +router.get('/derived/*', async (ctx) => { + const user = ctx.get('user'); + const settings = await readSettings(user.email); + + const url = new URL(ctx.req.url); + const after = url.pathname.slice(url.pathname.indexOf('/derived/') + '/derived/'.length); + const relative = decodeURIComponent(after); + + const base = join(settings.rootPath, 'media', 'derived'); + const full = resolve(join(base, relative)); + // `resolve` first, then compare, so `..` cannot climb out — and against the directory plus a separator + // as well as itself, since a bare startsWith accepts `derived-old` for a root of `derived`. + if (full !== base && !full.startsWith(base + sep)) return ctx.json({ error: 'Not found' }, 404); + + const file = Bun.file(full); + if (!(await file.exists())) return ctx.json({ error: 'Not found' }, 404); + + return new Response(file, { + // A rebuild replaces the contents of a rendition rather than its name, so unlike a content-addressed + // store these can go stale. A few minutes is enough for a page of thumbnails and short enough that a + // reconverted image appears while somebody is still looking at it. + headers: { 'Cache-Control': 'private, max-age=300' }, + }); +}); + router.get('/_config/media/files', async (ctx) => { const user = ctx.get('user'); const scope = ctx.req.query('path') ?? ''; diff --git a/web/sections/GalleriesPanel.tsx b/web/sections/GalleriesPanel.tsx index aa724bd..9c18566 100644 --- a/web/sections/GalleriesPanel.tsx +++ b/web/sections/GalleriesPanel.tsx @@ -192,12 +192,50 @@ export const GalleriesPanel = () => { <>