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 = () => { <>
{
+ event.currentTarget.style.visibility = 'hidden';
+ }}
+ />
+
+
+ {item.url}
+ {/*
+ Saved on blur rather than on every keystroke: a caption is a sentence somebody
+ is in the middle of, and a request per character would be both wasteful and a
+ race with itself.
+ */}
+ {
+ if (event.target.value.trim() !== (item.alt ?? '')) {
+ void send(`alt:${item.id}`, () =>
+ clientRef.current.patch(`/fofos-admin/galleries/${gallery.id}/items/${item.id}`, {
+ alt: event.target.value.trim() || null,
+ }),
+ );
+ }
+ }}
+ placeholder="Caption — describe it, for screen readers"
+ className="h-7 text-xs"
+ />