From 9fd5f1b3ab8718fdcbacae2ab8b88ec1bcc82d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 21:23:40 +0100 Subject: [PATCH] gallery items get a thumbnail and a caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thumbnail comes from the DERIVED ladder, which is the point of the whole pipeline: most of these are HEIC and a browser cannot display the original at all. 320 is the smallest rung and still sharp at this size. Served by this plugin from disk rather than by the site. The panel talks to the plugin and the catch-all forwards everything to the site's /api/admin, which is a different route — and more importantly, looking at a gallery should not require the development server to be running. Editing the site is not the same as reading it. Not under /_config, because 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 what the section is. Registered before the catch-all, or the catch-all would forward it to fofos. Path traversal is refused by resolving first and comparing against the directory plus a separator — a bare startsWith accepts `derived-old` for a root of `derived`. An item whose conversion has not run has no rung, and the thumbnail hides rather than showing a broken-image glyph, which reads as corrupt rather than pending. Captions save on blur. A caption is a sentence somebody is in the middle of, and a request per keystroke would be wasteful and a race with itself. Co-Authored-By: Claude Opus 5 --- api/router.ts | 46 ++++++++++++++++++++++++++++++++- web/sections/GalleriesPanel.tsx | 44 ++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 4 deletions(-) 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 = () => { <>