From 111618bd7f31cf1360d71cb67bfb2eeb73a401aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 07:50:51 +0100 Subject: [PATCH] a plugin can claim a file type the platform cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the file browser's thumbnailer read what sharp and ffmpeg read, and that list was a constant in core. HEIC forced the question: sharp's bundled libheif has no HEVC decoder, and teaching core to shell out to ImageMagick means the PLATFORM acquiring a system dependency for one format. that is a plugin's job. so a manifest may declare `fileTypes` — extensions, plus a thumbnail path and a render path on its own sidecar. `ls` unions those with core's, so a file only promises a thumbnail when something can actually produce one. `/thumb` forwards a claimed type to whoever claimed it. CORE DOES NOT KNOW av-club EXISTS. it reads a claim at runtime from whatever is installed and enabled, calls a path by name, and falls back to a file icon when nothing claims the type. no import, no name, no special case. PROVEN BY DISABLING IT: heic reports thumb=image with av-club enabled, thumb=none disabled, image again re-enabled. that is the whole requirement — the file browser is what it was without the plugin — and it is a test rather than a claim. the plugin's bytes are cached by CORE, under the same key everything else uses, so a HEIC thumbnail is produced once and then served from disk like any other. that is why `buildThumbnail` takes a producer rather than there being a second path beside it: the cache key with its mtime and size, the concurrency cap and the remembered failures all live in that one function, and a parallel path would be a second place to forget the negative caching. TWO THINGS FOUND BY WIRING IT UP. a claimed-but-not-running sidecar is skipped rather than recorded, or `ls` promises a thumbnail that cannot be fetched while a plugin restarts. and the sidecar authenticates: it accepts `x-officer-user` from anything on loopback, by its own design, so core sends the account the request is FOR — passing anything else would be lying to a plugin that scopes per user. verified: HEIC 2 MB in, 12 KB out, 5.4s cold and 101ms cached; png and mp4 unchanged; 1665 tests; docs:check green. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../api/file-browser/plugin-file-types.ts | 99 +++++++++++++++++++ src/servers/api/file-browser/router.ts | 58 ++++++++++- src/servers/api/file-browser/thumbnails.ts | 16 ++- src/servers/plugins/manifest.ts | 32 ++++++ 4 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 src/servers/api/file-browser/plugin-file-types.ts diff --git a/src/servers/api/file-browser/plugin-file-types.ts b/src/servers/api/file-browser/plugin-file-types.ts new file mode 100644 index 00000000..f41462ed --- /dev/null +++ b/src/servers/api/file-browser/plugin-file-types.ts @@ -0,0 +1,99 @@ +import { snapshotPlugins } from '../../plugins/mount'; +import { sidecarPort } from '../../sidecar-registry'; + +/** + * File types an installed plugin can show and the platform cannot. + * + * ── One place, so three consumers cannot disagree ── + * + * `ls` decides whether an entry promises a thumbnail, `/thumb` decides where to get one, and the viewer + * decides whether a full-size render exists. Those must be the same answer: a listing that promises a + * thumbnail nothing will produce is a request that 404s on every scroll, and a viewer that offers a + * render for a type nothing claims is a spinner that never resolves. + * + * ── Core does not know which plugin this is ── + * + * It reads `fileTypes` from whatever is installed AND ENABLED, and calls a path by name on that + * plugin's own sidecar. Nothing here imports plugin code and nothing names av-club. Disable the plugin + * and the claims vanish with it, which is what makes the file browser degrade to exactly what it was + * rather than to something broken. + * + * ── Why the sidecar directly, and not our own HTTP surface ── + * + * A loopback request back through `/api` would need a credential to satisfy the platform's own auth, + * and minting one so the server can call itself is a worse thing to have than this. `sidecarPort` is + * the port the plugin announced when it started; the proxy in front of it exists for browsers, and the + * server is already inside. + * + * ── `x-officer-user` is the whole authentication, and that is the sidecar's own design ── + * + * A plugin sidecar binds loopback and accepts that header from anything already on the machine — the + * platform is the thing that decides who may call, and by the time this runs the account gate and the + * permission gate have both passed. `sidecarFetch` below sends it because the proxy in front of the + * sidecar sends it, and a caller that skips it gets a 401 that looks like the plugin being broken. + */ + +export type FileTypeClaim = { + plugin: string; + /** Absolute URL on the plugin's sidecar, or null when it did not offer that half. */ + thumbnail: string | null; + render: string | null; +}; + +/** + * Extension → who claims it. Lowercase keys, no leading dot. + * + * Not cached: `snapshotPlugins` reads the database, and this is called on a listing that is already + * doing database work. A cache would need invalidating on install, uninstall, enable and disable — + * four events to get wrong in exchange for a lookup that is already cheap. + */ +export async function pluginFileTypes(): Promise> { + const claims = new Map(); + + let states: Awaited>['states'] = []; + try { + ({ states } = await snapshotPlugins()); + } catch { + // The file browser still works. A database that cannot be read is a much louder problem elsewhere, + // and losing every thumbnail to it would only obscure that. + return claims; + } + + for (const state of states) { + if (!state.install?.enabled) continue; + + const declared = state.plugin.manifest?.fileTypes; + if (!declared?.extensions?.length) continue; + + const port = sidecarPort(state.plugin.appName); + // Claimed but not running. Skipped rather than recorded, so `ls` does not promise a thumbnail that + // cannot be fetched while the sidecar is restarting. + if (!port) continue; + + const base = `http://127.0.0.1:${port}`; + for (const raw of declared.extensions) { + const ext = String(raw).toLowerCase().replace(/^\./, ''); + // First claim wins. Two plugins claiming one extension is a conflict nobody has yet, and picking + // arbitrarily-but-stably beats picking differently on each request. + if (!ext || claims.has(ext)) continue; + + claims.set(ext, { + plugin: state.plugin.appName, + thumbnail: declared.thumbnail ? `${base}${declared.thumbnail}` : null, + render: declared.render ? `${base}${declared.render}` : null, + }); + } + } + + return claims; +} + +/** + * Call a plugin's sidecar the way its proxy would. + * + * The email is the account the request is being made FOR, not the server — a plugin scoping anything + * per user reads this, and passing something else would be lying to it about who is asking. + */ +export async function sidecarFetch(url: string, email: string): Promise { + return fetch(url, { headers: { 'x-officer-user': email } }); +} diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index a3a03534..971d0399 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -10,6 +10,7 @@ import { readTtsConfig } from '@@/api/server-settings/tts'; import { readOcrConfig } from '@@/api/server-settings/ocr'; import { getUserSettings } from 'officerdb'; import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails'; +import { pluginFileTypes, sidecarFetch } from './plugin-file-types'; async function getUserTtsVoice(userId: number): Promise { try { @@ -204,6 +205,12 @@ router.get('/ls', async (ctx) => { } return ctx.json({ path: '/', entries: [], reset: true }); } + /* + What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the + database, and a folder of two hundred files would otherwise ask two hundred times for one answer. + */ + const claimed = await pluginFileTypes(); + const entries = await Promise.all( names.map(async (name) => { const fullPath = resolve(absPath, name); @@ -228,7 +235,18 @@ router.get('/ls', async (ctx) => { Costs nothing: this entry has just been stat'd and the answer is a lookup on its name. */ - thumb: isDirectory ? null : thumbnailKind(name), + /* + Core's own answer first, then a plugin's claim. + + A file only says `thumb` when something can actually produce one: core for the formats sharp + and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic` + promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core + stopped claiming it. + */ + thumb: isDirectory + ? null + : (thumbnailKind(name) ?? + (claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)), }; }), ); @@ -375,12 +393,42 @@ router.get('/thumb', async (ctx) => { if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); + const size = clampSize(ctx.req.query('size')); const kind = thumbnailKind(relPath); - // 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The - // client does not ask for these, so this is the answer to a stale tab rather than to normal use. - if (!kind) return ctx.text('no thumbnail for this file type', 415); - const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('size'))); + /* + A type core cannot read, but an installed plugin can. + + Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses — + so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is + not asked again on every scroll. + + `buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the + remembered failures are all in that one function, and a second copy of them here for this one case + is how the two would come to behave differently. + */ + if (!kind) { + const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase()); + if (claimed?.thumbnail) { + const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => { + const res = await sidecarFetch( + `${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`, + user.email, + ); + return res.ok ? Buffer.from(await res.arrayBuffer()) : null; + }); + if (!fetched.ok) return ctx.text(fetched.reason, 422); + return new Response(Bun.file(fetched.path), { + headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' }, + }); + } + + // 415 rather than 404: the file is there, it is simply not something anything here can read. The + // client does not ask for these, so this answers a stale tab rather than normal use. + return ctx.text('no thumbnail for this file type', 415); + } + + const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size); // 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about // the file, and logging it as a server error would bury the ones that are. if (!result.ok) return ctx.text(result.reason, 422); diff --git a/src/servers/api/file-browser/thumbnails.ts b/src/servers/api/file-browser/thumbnails.ts index e4b8c75d..2d5780ba 100644 --- a/src/servers/api/file-browser/thumbnails.ts +++ b/src/servers/api/file-browser/thumbnails.ts @@ -216,6 +216,16 @@ export async function buildThumbnail( userDataDir: string, kind: 'image' | 'video', size: number, + /** + * Produce the bytes yourself instead of letting sharp or ffmpeg try. + * + * For a type core cannot read but an installed plugin can — see `plugin-file-types.ts`. It is passed + * IN rather than handled by a second function so that everything around it is shared: the cache key + * with its mtime and size, the concurrency limit, and the remembered failures. A parallel path for + * plugin thumbnails would be a second cache to keep correct and a second place to forget the + * negative caching, which is the part that stops one bad file spawning work on every scroll. + */ + produce?: () => Promise, ): Promise { const info = await stat(absPath).catch(() => null); if (!info || !info.isFile()) return { ok: false, reason: 'not a file' }; @@ -247,7 +257,11 @@ export async function buildThumbnail( const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath]; let ok: boolean; - if (kind === 'image') { + if (produce) { + const bytes = await produce().catch(() => null); + if (bytes?.length) await Bun.write(cachePath, bytes); + ok = !!bytes?.length; + } else if (kind === 'image') { ok = await resizeImage(absPath, cachePath, size); } else { // `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first. diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index c190f348..f6ea7e8e 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -477,6 +477,38 @@ export type PluginManifest = { settings?: PluginSetting[]; websocket?: { sidecar: string }; + /** + * File types this plugin can show that the platform cannot. + * + * ── Why this exists ── + * + * The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in + * core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and + * teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for + * one format — which is a plugin's job, not core's. + * + * So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a + * thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin + * that claimed it; and the viewer asks for `render` when opening one full size. + * + * ── The dependency still points the right way ── + * + * Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from + * whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing + * claims the type. Uninstall the plugin and the file browser is exactly what it was. + * + * `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=` — + * plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes. + */ + fileTypes?: { + /** Lowercase, no leading dot. `['heic', 'heif']`. */ + extensions: string[]; + /** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */ + thumbnail?: string; + /** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */ + render?: string; + }; + osDependencies?: OsDependency[]; /**