diff --git a/TODO.md b/TODO.md index baff0324..410089b7 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Themes -**107 open, 32 closed**, across 34 themes. +**106 open, 33 closed**, across 34 themes. | Theme | Open | Closed | | --- | --- | --- | @@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide | [Mobile apps](todo/mobile-apps/) | 5 | 0 | | [Multi-user](todo/multi-user/) | 17 | 5 | | [Notifications](todo/notifications/) | 1 | 0 | -| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 | +| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 | | [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 | | [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index c01660f3..37f89e08 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -224,6 +224,58 @@ pluginsRouter.post('/update-all', async (ctx) => { }); }); +/* + POST /plugins/install-from-url — a plugin that is in no registry. + + The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" + had no route at all. The first real case is a plugin that must never be listed: fofos-admin is one + website's admin panel, useful to exactly one person, and putting it in a public registry to install it + would be the wrong shape. + + ── The name comes from the URL, because the name IS the directory ── + + `appName` is taken from the directory a plugin sits in and never declared in its manifest, so the id + cannot disagree with where the code is. That decision is made for us here: the repository name becomes + the directory, and therefore the id, the route segment, the table prefix and the sidecar suffix. A + repository called something unusable as an id is refused before anything is cloned rather than after — + a half-clone under a bad name is a plugin the platform reports as broken instead of absent. + + ── Owner-only, and it is not the same owner-only as the rest of this router ── + + Every route here is owner-gated because installing a plugin mounts routes and starts a process. This + one also takes an ADDRESS from the caller, so the owner is choosing what code runs, not choosing from + a catalogue somebody curated. The URL is validated in `fetch.ts` — https, no credentials, no shell — + but validation is not review: nothing here reads what it cloned. +*/ +pluginsRouter.post('/install-from-url', async (ctx) => { + type FromUrl = { url?: string; appName?: string }; + const body = await ctx.req.json().catch(() => ({}) as FromUrl); + const url = body.url?.trim(); + if (!url) throw errors.BAD_REQUEST('url is required'); + + // `https://host/owner/repo` or `…/repo.git` → `repo`. Derived rather than asked for, so the name and + // the address cannot disagree; `appName` in the body only OVERRIDES it, for a repository whose name is + // not the id you want. + const derived = url + .replace(/\.git$/, '') + .replace(/\/+$/, '') + .split('/') + .pop(); + const appName = body.appName?.trim() || derived; + + // The same shape every other plugin id has. Checked here because everything downstream — the route + // segment, the table prefix, the pm2 process name — inherits it, and each of those fails differently. + if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) { + throw errors.BAD_REQUEST( + `"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` + + 'it becomes the directory, the route and the table prefix.', + ); + } + + const result = await installPlugin(appName, undefined, ctx.get('user').id, undefined, url); + return ctx.json(result, result.ok ? 200 : 400); +}); + 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); diff --git a/src/servers/plugins/fetch.ts b/src/servers/plugins/fetch.ts index e91306a9..a337125e 100644 --- a/src/servers/plugins/fetch.ts +++ b/src/servers/plugins/fetch.ts @@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git'); * verb with different questions — what happens to uncommitted changes, does the version go backwards — * and it does not exist yet. */ -export async function fetchPlugin(appName: string, onLog?: OnLog): Promise { +export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise { const dir = join(PLUGINS_DIR, appName); if (existsSync(dir)) { @@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise { const steps: string[] = []; @@ -160,7 +168,7 @@ export async function installPlugin( // as it was. let plugin = await findPlugin(appName); if (!plugin) { - const fetched = await fetchPlugin(appName, onLog); + const fetched = await fetchPlugin(appName, onLog, source); if (!fetched.ok) { return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` }; } diff --git a/todo/plugin-ecosystem/no-install-from-git-url.md b/todo/plugin-ecosystem/no-install-from-git-url.md index 4fe9dad4..44117e4e 100644 --- a/todo/plugin-ecosystem/no-install-from-git-url.md +++ b/todo/plugin-ecosystem/no-install-from-git-url.md @@ -2,7 +2,7 @@ id: plugin-ecosystem/no-install-from-git-url title: "No install-from-git-URL" theme: plugin-ecosystem -status: open +status: done created: 2026-08-18 date_source: section order: 3 @@ -11,3 +11,18 @@ order: 3 **No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the "build your own, give it to a friend" path has no affordance. Small, and needed before the unofficial channel is real. + +**Done 2026-08-28.** `POST /api/plugins/install-from-url`, owner-only, taking `{ url }` and optionally +`{ appName }`. + +Smaller than this note assumed: `fetchPlugin` already cloned, validated the URL, refused to overwrite an +existing directory, checked for a `manifest.ts` and cleaned up a failed clone. One line tied it to the +catalogue — where the URL came from. So the change is a source parameter threaded through, plus a route. + +The name comes from the repository, because `appName` IS the directory name and is never declared in a +manifest. That is checked against `^[a-z0-9]+(-[a-z0-9]+)*$` BEFORE cloning: it becomes the route +segment, the table prefix and the pm2 process name, and a half-clone under an unusable name reads as a +broken plugin rather than an absent one. + +The first real user is `fofos/fofos-admin` — one website's admin panel, which should never be in a +registry. Verified end to end against it and then purged, since this is not that website's machine.