import { createRouter } from '../../create-router'; import * as errors from '../../custom-errors'; import { isSuperAdmin } from '../../super-admin'; import { mountPrefix } from '../../plugins/manifest'; import { snapshotPlugins } from '../../plugins/mount'; import { manualInstallHint, reportDependencies } from '../../plugins/os-deps'; import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace'; import { findOrphanTables } from '../../plugins/orphans'; import { iconUrl } from '../../app-store/assets'; import { installPlugin, pluginProcessStatus, setPluginRunning, purgePlugin, uninstallPlugin, type PluginActionResult, } from '../../plugins/install'; // /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // // Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is // an administrative act however many members share the server. The permission layer covers it too; this // is the belt to that braces, the same shape `/api/app-store` uses. // // ── This is not the app store ── // // The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the // user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the // schema, mount the routes. The two coexist until the app store is rebuilt on this. export const pluginsRouter = createRouter(); pluginsRouter.use(async (ctx, next) => { if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only'); return next(); }); /** * GET /api/plugins — every plugin in the tree, with what the database knows about each. * * Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to * show the owner, and refusing the whole list because one plugin is malformed would hide the nine that * are fine. */ // GET /plugins/catalogue/:appName/readme — the store's own description of a plugin, for the detail // pane of something not installed yet. // // Under `/plugins` so the existing `plugins` permission covers it: reading the store is the same // privilege as installing from it, and a second key would be a second thing to grant for no separate // decision. pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => { const readme = await fetchReadme(ctx.req.param('appName')); return ctx.json({ readme }); }); pluginsRouter.get('/', async (ctx) => { const { states, broken } = await snapshotPlugins(); // Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to // ask about. A plugin that is installed and enabled but whose process is not online is the state worth // rendering differently — it is the difference between "off" and "broken". const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin))); // Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one // genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing. const dependencyReports = states.map(({ plugin }) => { const report = reportDependencies(plugin); return { manager: report.manager, items: report.dependencies, satisfied: report.missing.length === 0, canInstall: report.canInstall, blockedReason: report.blockedReason, packagesToInstall: report.packagesToInstall, manualHint: manualInstallHint(report.manager, report.packagesToInstall), }; }); // What the marketplace offers that this machine does not have. // // Merged here rather than in `snapshotPlugins`, which answers "what is on disk joined to what is // installed" and should keep answering exactly that — the catalogue is a third, remote source and // giving discovery a network dependency would make the plugins page fail when the store is down. // // A catalogue entry whose plugin is already on disk is DROPPED: the directory is the truth once it // exists, and showing both would offer to install something already installed. const onDisk = new Set(states.map((s) => s.plugin.appName)); const catalogue = (await fetchCatalogue().catch(() => [])).filter((e) => !onDisk.has(e.appName)); // Tables nothing on this machine claims. Cheap — one pg_class query and one import per plugin — and // computed here rather than behind its own route so the screen cannot show a plugin list without it. // Never fatal: a database that will not answer this must not take the plugins page down with it. const orphans = await findOrphanTables(catalogue.map((e) => e.appName)).catch((err) => { console.error('[plugins] orphan scan failed —', err instanceof Error ? err.message : err); return []; }); return ctx.json({ orphans, available: catalogue.map((e) => ({ appName: e.appName, prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`, label: e.label, summary: e.summary, image: e.iconUrl, // Same precedence the installed list uses: artwork wins, the glyph name is the fallback. ...(e.iconUrl ? {} : { icon: e.icon }), tile: e.tile ?? 'badge', color: e.color, publisher: e.publisher, version: e.version, platform: e.platform, permissions: e.permissions, has: e.has, source: e.source, // The same live PATH probe a local plugin gets, so ffmpeg/ffprobe are visible BEFORE the clone // rather than after. `reportDependencies` takes a DiscoveredPlugin, so the entry is adapted to // that shape — the only field it reads is `manifest.osDependencies`. dependencies: (() => { const r = reportDependencies({ manifest: { osDependencies: e.osDependencies } } as never); return { manager: r.manager, items: r.dependencies, satisfied: r.missing.length === 0, canInstall: r.canInstall, blockedReason: r.blockedReason, packagesToInstall: r.packagesToInstall, manualHint: manualInstallHint(r.manager, r.packagesToInstall), }; })(), })), plugins: states.map(({ plugin, install, outdated }, i) => ({ appName: plugin.appName, prefix: mountPrefix(plugin), label: plugin.manifest.label, summary: plugin.manifest.summary, icon: plugin.manifest.icon, // The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies // `assets/` into `public/plugins//` at install and `unpublishAssets` removes it, so a // plugin that has never been installed has nothing at that URL and an would 404. The list // falls back to a generic glyph, which is what it drew for everything until now. // // `[open]` That means you cannot see a plugin's icon BEFORE installing it, which is the one place // an app store most wants to. Fixing it means either publishing on discovery rather than install — // which breaks "assets are a property of the install" — or an authenticated icon route, which an // cannot use because it sends no Authorization header. ...(plugin.icon && install ? { image: iconUrl(plugin.appName) } : {}), color: plugin.manifest.color, publisher: plugin.manifest.publisher, version: plugin.manifest.version, platform: plugin.manifest.platform, permissions: plugin.manifest.permissions, // What the tree declared. The UI shows these so "installed but does nothing" is legible. has: { api: !!plugin.api, schema: !!plugin.schema, sidecar: !!plugin.sidecar, web: !!plugin.web, }, installed: !!install, enabled: install?.enabled ?? false, installedVersion: install?.version ?? null, outdated, processStatus: statuses[i] ?? null, // Probed live, against this machine's PATH, on every read. // // Deliberately here rather than only inside the installer: the owner decides whether to let the // server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not // in the log afterwards. It also lets them install the packages by hand first and watch this flip // to present, which is the escape hatch for a machine with no passwordless sudo. dependencies: dependencyReports[i]!, })), broken, }); }); /** * The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` — * "installed" and "installed but the sidecar would not start" are different outcomes and the second is * the one worth reading. See `plugins/install.ts` for why the order inside each is what it is. */ pluginsRouter.post('/:appName/install', async (ctx) => { const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id); return ctx.json(result, result.ok ? 200 : 400); }); // Destructive, and the only route here that is. Named `purge` rather than being a flag on uninstall so // nothing can reach it by passing a body field to the verb people already use. pluginsRouter.post('/:appName/purge', async (ctx) => { return ctx.json(await purgePlugin(ctx.req.param('appName'))); }); pluginsRouter.post('/:appName/uninstall', async (ctx) => { const result = await uninstallPlugin(ctx.req.param('appName')); return ctx.json(result, result.ok ? 200 : 400); }); pluginsRouter.post('/:appName/enable', async (ctx) => { const result = await setPluginRunning(ctx.req.param('appName'), true); return ctx.json(result, result.ok ? 200 : 400); }); pluginsRouter.post('/:appName/disable', async (ctx) => { const result = await setPluginRunning(ctx.req.param('appName'), false); return ctx.json(result, result.ok ? 200 : 400); }); // ── The same four verbs, streamed ── // // An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that // and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could // not". Streaming each step as it completes turns the same work into something you can watch, and — more // usefully — leaves the log on screen naming the step that failed. // // POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these // routes are owner-only. The client reads the body and parses frames itself, which is exactly what // `useCompanionLogStream` already does for the headscale container logs. // `userId` is the caller, threaded through so install can pin the new tile to THEIR dock rather than // guessing at the owner. Only install uses it; the others take it and ignore it so the map stays one shape. type VerbFn = ( appName: string, onStep: (s: string) => Promise, userId: number, onLog: (line: string) => Promise, ) => Promise; const VERBS = { install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog), uninstall: (appName, onStep, _userId, onLog) => uninstallPlugin(appName, onStep, onLog), enable: (appName, onStep) => setPluginRunning(appName, true, onStep), disable: (appName, onStep) => setPluginRunning(appName, false, onStep), purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog), // `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns // `isVerb` into a guard that proves nothing and lets an unknown verb index the map. } satisfies Record; type Verb = keyof typeof VERBS; const isVerb = (v: string): v is Verb => v in VERBS; pluginsRouter.post('/:appName/:verb/stream', async (ctx) => { const appName = ctx.req.param('appName'); const verb = ctx.req.param('verb'); if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`); // Read before the stream opens: once we are inside ReadableStream.start the request context is gone. const userId = ctx.get('user').id; const encoder = new TextEncoder(); const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); const stream = new ReadableStream({ async start(controller) { const send = (event: string, data: unknown) => { // The client may have navigated away mid-install. The work continues — it is the server's job // now — but writing to a closed stream would throw and abort it halfway. try { controller.enqueue(frame(event, data)); } catch { /* client gone */ } }; let result: PluginActionResult; try { result = await VERBS[verb]( appName, async (step) => send('step', { step }), userId, // Raw output from whatever the step is running — `git clone`, `db:push`, a package manager. // A separate event so the client can render it as what it is: the command talking, not the // platform reporting. async (line) => send('log', { line }), ); } catch (err) { result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) }; } send('done', result); try { controller.close(); } catch { /* already closed */ } }, }); return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', // Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until // the response ends, which is precisely the behaviour this endpoint exists to avoid. 'x-accel-buffering': 'no', }, }); });