a plugin can claim a file type the platform cannot read

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 07:50:51 +01:00
co-authored by Claude Opus 5
parent 9cb875ca79
commit 111618bd7f
4 changed files with 199 additions and 6 deletions
@@ -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<Map<string, FileTypeClaim>> {
const claims = new Map<string, FileTypeClaim>();
let states: Awaited<ReturnType<typeof snapshotPlugins>>['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<Response> {
return fetch(url, { headers: { 'x-officer-user': email } });
}
+53 -5
View File
@@ -10,6 +10,7 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
import { readOcrConfig } from '@@/api/server-settings/ocr'; import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails'; import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
async function getUserTtsVoice(userId: number): Promise<string | null> { async function getUserTtsVoice(userId: number): Promise<string | null> {
try { try {
@@ -204,6 +205,12 @@ router.get('/ls', async (ctx) => {
} }
return ctx.json({ path: '/', entries: [], reset: true }); 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( const entries = await Promise.all(
names.map(async (name) => { names.map(async (name) => {
const fullPath = resolve(absPath, 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. 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'); if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath); const absPath = resolveUserPath(rootDir, relPath);
const size = clampSize(ctx.req.query('size'));
const kind = thumbnailKind(relPath); 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 // 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. // the file, and logging it as a server error would bury the ones that are.
if (!result.ok) return ctx.text(result.reason, 422); if (!result.ok) return ctx.text(result.reason, 422);
+15 -1
View File
@@ -216,6 +216,16 @@ export async function buildThumbnail(
userDataDir: string, userDataDir: string,
kind: 'image' | 'video', kind: 'image' | 'video',
size: number, 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<Buffer | null>,
): Promise<ThumbnailResult> { ): Promise<ThumbnailResult> {
const info = await stat(absPath).catch(() => null); const info = await stat(absPath).catch(() => null);
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' }; 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]; const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
let ok: boolean; 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); ok = await resizeImage(absPath, cachePath, size);
} else { } else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first. // `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
+32
View File
@@ -477,6 +477,38 @@ export type PluginManifest = {
settings?: PluginSetting[]; settings?: PluginSetting[];
websocket?: { sidecar: string }; 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=<absolute>` —
* 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[]; osDependencies?: OsDependency[];
/** /**