diff --git a/scripts/generate-settings-search-index.cjs b/scripts/generate-settings-search-index.cjs index beeefc3dd..3e4f91234 100644 --- a/scripts/generate-settings-search-index.cjs +++ b/scripts/generate-settings-search-index.cjs @@ -85,9 +85,12 @@ function adminSections() { const settings = read("AdminSettingsSections.tsx"); const management = read("AdminManagementSections.tsx"); return { - "admin-general": keysIn( - functionBody(settings, "AdminGeneralSettingsSection"), - ), + "admin-general": [ + ...new Set([ + ...keysIn(functionBody(settings, "AdminGeneralSettingsSection")), + ...keysIn(read("AdminPluginDeveloperMode.tsx")), + ]), + ], "admin-users": [ ...new Set([ ...keysIn(functionBody(management, "AdminUsersSection")), diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 7b8b6c508..c38931820 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -18,6 +18,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js"; import { HostDefaultsRepository } from "./host-defaults-repository.js"; import { PluginMigrationRepository } from "./plugin-migration-repository.js"; +import { PluginInstallCountRepository } from "./plugin-install-count-repository.js"; import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js"; import { UserAuthRepository } from "./user-auth-repository.js"; import { RbacAccessRepository } from "./rbac-access-repository.js"; @@ -240,6 +241,13 @@ export function createCurrentPluginSettingsRepository(): PluginSettingsRepositor ); } +export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository { + return new PluginInstallCountRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("plugin_install_count_repository_write"), + ); +} + export function createCurrentPluginMigrationRepository(): PluginMigrationRepository { return new PluginMigrationRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/plugin-install-count-repository.ts b/src/backend/database/repositories/plugin-install-count-repository.ts new file mode 100644 index 000000000..a270cc93d --- /dev/null +++ b/src/backend/database/repositories/plugin-install-count-repository.ts @@ -0,0 +1,68 @@ +import { and, eq } from "drizzle-orm"; +import { pluginInstallCounts } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect; + +export interface PluginInstallCountInput { + pluginId: string; + count: number; + source: string; +} + +/** Popularity numbers for the store, one row per plugin and registry. */ +export class PluginInstallCountRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByRegistry( + registryId: string, + ): Promise { + return this.context.drizzle + .select() + .from(pluginInstallCounts) + .where(eq(pluginInstallCounts.registryId, registryId)); + } + + /** Writes only the rows that changed, so an unchanged sync costs no save. */ + async upsertMany( + registryId: string, + entries: PluginInstallCountInput[], + now = new Date().toISOString(), + ): Promise { + const existing = new Map( + (await this.listByRegistry(registryId)).map((row) => [row.pluginId, row]), + ); + let written = 0; + for (const entry of entries) { + const row = existing.get(entry.pluginId); + if (row && row.count === entry.count && row.source === entry.source) { + continue; + } + if (row) { + await this.context.drizzle + .update(pluginInstallCounts) + .set({ count: entry.count, source: entry.source, updatedAt: now }) + .where( + and( + eq(pluginInstallCounts.pluginId, entry.pluginId), + eq(pluginInstallCounts.registryId, registryId), + ), + ); + } else { + await this.context.drizzle.insert(pluginInstallCounts).values({ + pluginId: entry.pluginId, + registryId, + count: entry.count, + source: entry.source, + updatedAt: now, + }); + } + written += 1; + } + if (written > 0) await this.onWrite?.(); + return written; + } +} diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 1bdfeaf1d..1dad7a26e 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -1399,6 +1399,20 @@ router.put( const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +/** The consented capability list, or null when the body is malformed. */ +function readCapabilities(body: unknown): string[] | undefined | null { + const value = (body as { capabilities?: unknown } | undefined)?.capabilities; + if (value === undefined) return undefined; + if ( + !Array.isArray(value) || + value.length > 200 || + !value.every((c) => typeof c === "string" && c.length <= 128) + ) { + return null; + } + return value as string[]; +} + function readVersion(body: unknown): string | undefined | null { const version = (body as { version?: unknown } | undefined)?.version; if (version === undefined || version === null || version === "") { @@ -1503,7 +1517,9 @@ router.post( * starts it along with any installed dependency that is off. Without a * version the newest compatible release is used; naming an older one * pins the plugin there. A bundled plugin at its shipped version needs - * no download. + * no download. capabilities must list exactly what the consent prompt + * showed; when it differs from what the release asks for, nothing is + * downloaded and the answer is 409 CONSENT_MISMATCH with the real list. * tags: * - Plugins * parameters: @@ -1513,24 +1529,31 @@ router.post( * schema: * type: string * requestBody: + * required: true * content: * application/json: * schema: * type: object + * required: + * - capabilities * properties: * version: * type: string + * capabilities: + * type: array + * items: + * type: string * responses: * 200: * description: The installed version and the plugin's state. * 400: - * description: Invalid id or version. + * description: Invalid id, version or capability list, or no capability list. * 403: * description: The caller lacks admin.plugins.manage. * 404: * description: Not in the official registry. * 409: - * description: Already installed, incompatible, or managed by a linked server. + * description: Already installed, incompatible, the consent does not match, or managed by a linked server. * 502: * description: The registry could not be reached. */ @@ -1541,17 +1564,29 @@ router.post( async (req: Request, res: Response) => { const pluginId = String(req.params.id); const version = readVersion(req.body); - if (!ID_PATTERN.test(pluginId) || version === null) { - res.status(400).json({ error: "Invalid plugin id or version" }); + const capabilities = readCapabilities(req.body); + if ( + !ID_PATTERN.test(pluginId) || + version === null || + capabilities === null + ) { + res + .status(400) + .json({ error: "Invalid plugin id, version or capabilities" }); return; } if (await refuseOnLinkedDesktop(res)) return; try { const { installPlugin } = await import("../../plugins/manage.js"); const userId = (req as AuthenticatedRequest).userId as string; - const result = await installPlugin(pluginId, { version, userId }); + const result = await installPlugin(pluginId, { + version, + userId, + capabilities, + }); await auditPluginAction(req, "install_plugin", pluginId, { version: result.version, + capabilities, }); res.json(result); } catch (error) { @@ -1573,8 +1608,9 @@ router.post( * description: > * Swaps in another release from the official registry, newer or older. * If the release declares capabilities the installed one does not, the - * request must set acceptCapabilities, otherwise it answers 409 with - * code CAPABILITIES_ADDED and the list. A running plugin is started + * request must set acceptCapabilities and list exactly those added + * capabilities in capabilities, otherwise it answers 409 with code + * CAPABILITIES_ADDED (or CONSENT_MISMATCH) and the list. A running plugin is started * again on the new version. Naming a version that is not the newest * pins the plugin to it. * tags: @@ -1595,6 +1631,10 @@ router.post( * type: string * acceptCapabilities: * type: boolean + * capabilities: + * type: array + * items: + * type: string * responses: * 200: * description: The new version and the plugin's state. @@ -1614,8 +1654,15 @@ router.post( async (req: Request, res: Response) => { const pluginId = String(req.params.id); const version = readVersion(req.body); - if (!ID_PATTERN.test(pluginId) || version === null) { - res.status(400).json({ error: "Invalid plugin id or version" }); + const capabilities = readCapabilities(req.body); + if ( + !ID_PATTERN.test(pluginId) || + version === null || + capabilities === null + ) { + res + .status(400) + .json({ error: "Invalid plugin id, version or capabilities" }); return; } if (await refuseOnLinkedDesktop(res)) return; @@ -1626,6 +1673,7 @@ router.post( version, userId, acceptCapabilities: req.body?.acceptCapabilities === true, + capabilities, }); await auditPluginAction(req, "update_plugin", pluginId, { version: result.version, @@ -1807,4 +1855,231 @@ router.get( }, ); +/** + * @openapi + * /plugins/developer-mode: + * get: + * summary: Read whether plugin developer mode is on + * description: Developer mode lets admins install a .tmxplug from a file, without the registry's signature check. The UI shows a banner while it is on. + * tags: + * - Plugins + * responses: + * 200: + * description: Whether developer mode is on, and whether TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs anyway. + * 403: + * description: The caller lacks admin.plugins.manage. + */ +router.get( + "/developer-mode", + authenticateJWT, + requireManagePlugins, + async (_req: Request, res: Response) => { + try { + const { getDeveloperMode } = await import("../../plugins/manage.js"); + const { requireSignedPlugins } = await import("../../plugins/trust.js"); + res.json({ + enabled: await getDeveloperMode(), + signedOnly: requireSignedPlugins(), + }); + } catch (error) { + sendManageError( + res, + error, + "Failed to read developer mode", + "plugin_developer_mode", + ); + } + }, +); + +/** + * @openapi + * /plugins/developer-mode: + * put: + * summary: Turn plugin developer mode on or off + * description: Turning it off drops any file that was uploaded but not installed yet. Plugins already installed from a file keep running and stay marked unverified. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - enabled + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: The saved value. + * 400: + * description: enabled is not a boolean. + * 403: + * description: The caller lacks admin.plugins.manage. + */ +router.put( + "/developer-mode", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const enabled = req.body?.enabled; + if (typeof enabled !== "boolean") { + res.status(400).json({ error: "enabled must be a boolean" }); + return; + } + try { + const { setDeveloperMode } = await import("../../plugins/manage.js"); + await setDeveloperMode(enabled); + await auditPluginAction(req, "plugin_developer_mode", "*", { enabled }); + res.json({ enabled }); + } catch (error) { + sendManageError( + res, + error, + "Failed to save developer mode", + "plugin_developer_mode", + ); + } + }, +); + +/** + * @openapi + * /plugins/upload: + * post: + * summary: Upload a .tmxplug to install from a file + * description: > + * Developer mode only. The body is the raw .tmxplug (at most 50 MB). + * The file is checked (archive, manifest, plugin API) and held for 30 + * minutes, but nothing is loaded. The answer lists what it asks for so + * the admin can agree, then POST /plugins/upload/{token}/install + * installs it. A file can replace an earlier upload with the same id, + * never a bundled or registry plugin. + * tags: + * - Plugins + * requestBody: + * required: true + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * responses: + * 200: + * description: A token plus the plugin's id, name, version and capabilities. + * 400: + * description: Not a valid .tmxplug or manifest. + * 403: + * description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage. + * 409: + * description: The id belongs to a bundled or registry plugin, the plugin API does not match, or managed by a linked server. + * 413: + * description: The file is larger than 50 MB. + */ +router.post( + "/upload", + authenticateJWT, + requireManagePlugins, + express.raw({ type: "application/octet-stream", limit: "50mb" }), + async (req: Request, res: Response) => { + if (await refuseOnLinkedDesktop(res)) return; + if (!Buffer.isBuffer(req.body) || req.body.length === 0) { + res.status(400).json({ + error: "Send the .tmxplug as application/octet-stream", + code: "INVALID_ARTIFACT", + }); + return; + } + try { + const { stageUpload } = await import("../../plugins/manage.js"); + res.json(await stageUpload(req.body)); + } catch (error) { + sendManageError( + res, + error, + "Failed to read the uploaded plugin", + "plugin_upload", + ); + } + }, +); + +/** + * @openapi + * /plugins/upload/{token}/install: + * post: + * summary: Install a plugin uploaded from a file + * description: > + * Developer mode only. capabilities must list exactly what the upload + * asks for, as the consent prompt showed it. The plugin is installed, + * granted those capabilities and started, and is marked unverified for + * as long as it stays installed. + * tags: + * - Plugins + * parameters: + * - in: path + * name: token + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - capabilities + * properties: + * capabilities: + * type: array + * items: + * type: string + * responses: + * 200: + * description: The installed version and the plugin's state. + * 400: + * description: Invalid token or capability list. + * 403: + * description: Developer mode is off, signed plugins are required, or the caller lacks admin.plugins.manage. + * 404: + * description: The upload expired or never existed. + * 409: + * description: The consent does not match, the id is taken, or managed by a linked server. + */ +router.post( + "/upload/:token/install", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const token = String(req.params.token); + const capabilities = readCapabilities(req.body); + if (!/^[a-f0-9]{32}$/.test(token) || capabilities === null) { + res.status(400).json({ error: "Invalid upload token or capabilities" }); + return; + } + if (await refuseOnLinkedDesktop(res)) return; + try { + const { installUpload } = await import("../../plugins/manage.js"); + const userId = (req as AuthenticatedRequest).userId as string; + const result = await installUpload(token, { userId, capabilities }); + await auditPluginAction(req, "install_plugin_from_file", result.id, { + version: result.version, + capabilities, + verified: false, + }); + res.json(result); + } catch (error) { + sendManageError( + res, + error, + "Failed to install the plugin", + "plugin_install", + ); + } + }, +); + export default router; diff --git a/src/backend/plugins/artifact-manifest.ts b/src/backend/plugins/artifact-manifest.ts new file mode 100644 index 000000000..b1065db4f --- /dev/null +++ b/src/backend/plugins/artifact-manifest.ts @@ -0,0 +1,67 @@ +import zlib from "node:zlib"; + +const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; + +/** + * Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to + * disk. Returns null when the archive has no such file. + */ +export function readArtifactFile( + buffer: Buffer, + wanted: string, +): Buffer | null { + const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES }); + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const field = (start: number, length: number) => + header + .subarray(start, start + length) + .toString("utf8") + .replace(/\0.*$/s, ""); + const prefix = field(345, 155); + const name = ( + prefix ? `${prefix}/${field(0, 100)}` : field(0, 100) + ).replace(/^\.\//, ""); + const size = Number.parseInt(field(124, 12).trim() || "0", 8); + if (!Number.isFinite(size) || size < 0) break; + const start = offset + 512; + if (name === wanted) return tar.subarray(start, start + size); + offset = start + Math.ceil(size / 512) * 512; + } + return null; +} + +/** The manifest.json packed in a .tmxplug, parsed but not validated. */ +export function readArtifactManifest(buffer: Buffer): Record { + let raw: Buffer | null; + try { + raw = readArtifactFile(buffer, "manifest.json"); + } catch { + throw new Error("the file is not a valid .tmxplug archive"); + } + if (!raw) throw new Error("the archive has no manifest.json"); + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString("utf8")); + } catch { + throw new Error("manifest.json is not valid JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("manifest.json is not an object"); + } + return parsed as Record; +} + +/** Whether two capability lists name the same set, order ignored. */ +export function sameCapabilities( + a: readonly string[], + b: readonly string[], +): boolean { + const left = new Set(a); + const right = new Set(b); + if (left.size !== right.size) return false; + for (const capability of left) if (!right.has(capability)) return false; + return true; +} diff --git a/src/backend/plugins/auto-update.ts b/src/backend/plugins/auto-update.ts index f04dfcfb5..37bf55967 100644 --- a/src/backend/plugins/auto-update.ts +++ b/src/backend/plugins/auto-update.ts @@ -1,7 +1,8 @@ /** - * Applies plugin updates on a timer for plugins with auto-update on. An - * update that asks for a new capability is never applied here; admins get - * an alert and review it in the Plugins tab. + * The registry timer: refreshes the store's install counts and applies + * plugin updates for plugins with auto-update on. An update that asks for a + * new capability is never applied here; admins get an alert and review it in + * the Plugins tab. Nothing is ever installed from here. */ import { pluginLogger } from "../utils/logger.js"; @@ -12,6 +13,9 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000; let timer: NodeJS.Timeout | null = null; export async function runPluginAutoUpdate(): Promise { + const { syncInstallCounts } = await import("./install-counts.js"); + await syncInstallCounts(); + const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js"); if (await isLinkedDesktop()) return; diff --git a/src/backend/plugins/install-counts.ts b/src/backend/plugins/install-counts.ts new file mode 100644 index 000000000..a0dd5d62b --- /dev/null +++ b/src/backend/plugins/install-counts.ts @@ -0,0 +1,79 @@ +/** + * Popularity numbers for the plugin store, copied from the registry's + * stats.json into plugin_install_counts. + * + * Two sources, and the store prefers the first: + * - "aggregate-telemetry": instances that reported the plugin enabled in the + * last 7 days through the Usage Statistics plugin. It undercounts by every + * instance that turned the report off or never installed that plugin. + * - "github-releases": downloads of the plugin's .tmxplug release assets. + * This counts downloads, not active installs: an instance that updates + * three times counts three times, and Docker images that bundle the plugin + * count once per build, not once per install. + * + * Treat both as a rough ranking, never as a user count. + */ + +import { pluginLogger } from "../utils/logger.js"; +import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js"; + +export const TELEMETRY_SOURCE = "aggregate-telemetry"; +export const DOWNLOADS_SOURCE = "github-releases"; + +export interface InstallCount { + count: number; + source: string; +} + +async function repository() { + const { createCurrentPluginInstallCountRepository } = + await import("../database/repositories/factory.js"); + return createCurrentPluginInstallCountRepository(); +} + +/** Picks the number to show: active installs when known, downloads otherwise. */ +export function preferredCount(entry: { + downloads: number; + activeInstalls: number | null; +}): InstallCount { + return entry.activeInstalls !== null && entry.activeInstalls > 0 + ? { count: entry.activeInstalls, source: TELEMETRY_SOURCE } + : { count: entry.downloads, source: DOWNLOADS_SOURCE }; +} + +/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */ +export async function syncInstallCounts(): Promise { + try { + const stats = await fetchRegistryStats(); + if (!stats) return 0; + const entries = [...stats.plugins].map(([pluginId, entry]) => ({ + pluginId, + ...preferredCount(entry), + })); + return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries); + } catch (error) { + pluginLogger.warn( + `Could not sync plugin install counts: ${ + error instanceof Error ? error.message : String(error) + }`, + { operation: "plugin_registry" }, + ); + return 0; + } +} + +export async function readInstallCounts( + registryId: string = OFFICIAL_REGISTRY_ID, +): Promise> { + try { + const rows = await (await repository()).listByRegistry(registryId); + return new Map( + rows.map((row) => [ + row.pluginId, + { count: row.count, source: row.source }, + ]), + ); + } catch { + return new Map(); + } +} diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 278a5c126..23ff51ae0 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -1,12 +1,15 @@ /** * Installing, updating, uninstalling and switching plugins at runtime. * - * Only the official registry is a source for now. Bundled plugins ship in the + * The official registry is the one real source. With developer mode on, an + * admin can also install a .tmxplug from a file; that skips the download, + * sha256 and signature checks and stays marked unverified. Bundled plugins ship in the * image and can still be uninstalled: their files are deleted and the id is * recorded in the settings table, so the shipped copy is ignored from then * on and a reinstall downloads the plugin like any other. */ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import semver from "semver"; @@ -38,8 +41,15 @@ import { } from "./registry-index.js"; import { invalidatePluginPermissionCache } from "./permissions.js"; import { moveWithRetry } from "./fs-retry.js"; +import { readInstallCounts } from "./install-counts.js"; +import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js"; +import { parseManifest, type PluginManifest } from "./manifest.js"; +import { requireSignedPlugins } from "./trust.js"; const UNINSTALLED_KEY = "plugins_uninstalled"; +const DEVELOPER_MODE_KEY = "plugins_developer_mode"; +/** Installed from a file with no registry signature behind it. Never cleared. */ +export const UNVERIFIED_TIER = "unverified"; export class PluginManageError extends Error { constructor( @@ -212,6 +222,9 @@ export interface RegistryListing { pinnedVersion: string | null; autoUpdate: boolean; bundled: boolean; + /** See install-counts.ts for what this number does and does not mean. */ + installCount: number | null; + installCountSource: string | null; }>; } @@ -223,6 +236,7 @@ export async function listRegistry( const records = new Map( (await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]), ); + const counts = await readInstallCounts(); const { loader } = getPluginRuntime(); const shipped = loader.bundledIds(); @@ -271,6 +285,8 @@ export async function listRegistry( pinnedVersion: record?.pinnedVersion ?? null, autoUpdate: Boolean(record?.autoUpdate), bundled: shipped.has(plugin.id), + installCount: counts.get(plugin.id)?.count ?? null, + installCountSource: counts.get(plugin.id)?.source ?? null, }; }), }; @@ -313,24 +329,31 @@ async function grantDeclared( invalidatePluginPermissionCache(plugin.id); } +type SwapSource = { release: RegistryVersion } | { upload: string }; + /** - * Puts `release` in place of whatever copy of the plugin is loaded. On a - * failed load the previous copy comes back. + * Puts a registry release, or an uploaded file, in place of whatever copy of + * the plugin is loaded. On a failed load the previous copy comes back. */ async function swapIn( pluginId: string, - release: RegistryVersion, + source: SwapSource, ): Promise { const { loader } = getPluginRuntime(); const previous = loader.get(pluginId); const bundledVersion = loader.bundledVersion(pluginId); const useBundled = + "release" in source && bundledVersion !== undefined && - bundledVersion === release.version && + bundledVersion === source.release.version && isBundledOnDisk(pluginId); // Fetched before anything stops, so a bad download changes nothing. - const staged = useBundled ? null : await downloadRelease(pluginId, release); + const staged = useBundled + ? null + : "release" in source + ? await downloadRelease(pluginId, source.release) + : { file: source.upload, signatureFile: `${source.upload}.sig` }; const finalFile = path.join(getPluginsDir(), `${pluginId}.tmxplug`); const backupDir = path.join(getDownloadsDir(), `${pluginId}.previous`); @@ -561,6 +584,32 @@ export interface InstallOptions { version?: string; userId: string | null; acceptCapabilities?: boolean; + /** + * The capabilities the consent prompt showed: every one for an install, + * the added ones for an update. Must match what the release asks for. + */ + capabilities?: string[]; +} + +function assertConsent( + consented: string[] | undefined, + asked: readonly string[], +): void { + if (consented === undefined) { + throw new PluginManageError( + "The capabilities agreed to were not sent", + 400, + "CONSENT_REQUIRED", + ); + } + if (!sameCapabilities(consented, asked)) { + throw new PluginManageError( + "The capabilities agreed to do not match what this release asks for", + 409, + "CONSENT_MISMATCH", + { capabilities: [...asked] }, + ); + } } async function requireRelease( @@ -641,12 +690,13 @@ export function installPlugin( } const { release, latest } = await requireRelease(pluginId, options.version); + assertConsent(options.capabilities, release.capabilities); const pin = pinFor(release, latest, options.version); await applyPin(pluginId, pin); let plugin: LoadedPlugin; try { - plugin = await swapIn(pluginId, release); + plugin = await swapIn(pluginId, { release }); } catch (error) { await applyPin(pluginId, null); throw new PluginManageError( @@ -716,13 +766,16 @@ async function updateUnlocked( live.manifest.capabilities, release.capabilities, ); - if (added.length > 0 && !options.acceptCapabilities) { - throw new PluginManageError( - "This version asks for new capabilities", - 409, - "CAPABILITIES_ADDED", - { capabilities: added }, - ); + if (added.length > 0) { + if (!options.acceptCapabilities) { + throw new PluginManageError( + "This version asks for new capabilities", + 409, + "CAPABILITIES_ADDED", + { capabilities: added }, + ); + } + assertConsent(options.capabilities, added); } const wasEnabled = record.state !== "disabled"; @@ -731,7 +784,7 @@ async function updateUnlocked( let plugin: LoadedPlugin; try { - plugin = await swapIn(pluginId, release); + plugin = await swapIn(pluginId, { release }); } catch (error) { await applyPin(pluginId, record.pinnedVersion ?? null); if (wasEnabled && loader.get(pluginId)) await startAndRecord(pluginId); @@ -1003,3 +1056,241 @@ export function deletePluginData( return removed; }); } + +export async function getDeveloperMode(): Promise { + const { createCurrentSettingsRepository } = await repos(); + return ( + (await createCurrentSettingsRepository().get(DEVELOPER_MODE_KEY)) === "true" + ); +} + +export async function setDeveloperMode(enabled: boolean): Promise { + const { createCurrentSettingsRepository } = await repos(); + await createCurrentSettingsRepository().set( + DEVELOPER_MODE_KEY, + enabled ? "true" : "false", + ); + if (!enabled) await clearUploads(); + return enabled; +} + +/** 50 MB, the most nginx lets through to /plugins. */ +export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; +const UPLOAD_TTL_MS = 30 * 60_000; + +interface StagedUpload { + file: string; + manifest: PluginManifest; + expiresAt: number; +} + +const uploads = new Map(); + +async function clearUploads(now = Infinity): Promise { + for (const [token, upload] of uploads) { + if (upload.expiresAt > now) continue; + uploads.delete(token); + await fs.promises.rm(upload.file, { force: true }); + } +} + +export interface UploadPreview { + token: string; + id: string; + name: string; + version: string; + description: string; + author: string; + capabilities: string[]; + /** The version of an earlier upload this one replaces. */ + replaces: string | null; +} + +async function assertUploadsAllowed(): Promise { + if (!(await getDeveloperMode())) { + throw new PluginManageError( + "Installing from a file needs developer mode", + 403, + "DEVELOPER_MODE_OFF", + ); + } + if (requireSignedPlugins()) { + throw new PluginManageError( + "TERMIX_REQUIRE_SIGNED_PLUGINS is on, so unsigned files cannot be installed", + 403, + "SIGNED_ONLY", + ); + } +} + +function validateUpload(buffer: Buffer): PluginManifest { + let raw: Record; + try { + raw = readArtifactManifest(buffer); + } catch (error) { + throw new PluginManageError( + error instanceof Error ? error.message : String(error), + 400, + "INVALID_ARTIFACT", + ); + } + const { manifest, errors } = parseManifest(raw); + if (!manifest) { + throw new PluginManageError( + `The manifest is not valid: ${errors.join("; ")}`, + 400, + "INVALID_MANIFEST", + ); + } + if (!isApiCompatible(String(manifest.engine.api))) { + throw new PluginManageError( + `${manifest.id} ${manifest.version} does not run on this version of Termix`, + 409, + "INCOMPATIBLE", + ); + } + return manifest; +} + +/** Only another upload of the same id can be replaced from a file. */ +async function assertReplaceable(pluginId: string): Promise { + const { loader } = getPluginRuntime(); + if (loader.bundledIds().has(pluginId)) { + throw new PluginManageError( + `${pluginId} ships with Termix and can only be replaced by a signed release`, + 409, + "BUNDLED_ID", + ); + } + const { createCurrentPluginRepository } = await repos(); + const record = await createCurrentPluginRepository().findById(pluginId); + if (!record) return null; + if (record.tier !== UNVERIFIED_TIER) { + throw new PluginManageError( + "A plugin with this id is already installed from another source", + 409, + "ALREADY_INSTALLED", + ); + } + return record.version; +} + +/** + * Checks an uploaded .tmxplug and holds it until the admin agrees to what + * it asks for. Nothing is loaded yet. + */ +export async function stageUpload(buffer: Buffer): Promise { + await assertUploadsAllowed(); + await clearUploads(Date.now()); + const manifest = validateUpload(buffer); + const replaces = await assertReplaceable(manifest.id); + + const token = crypto.randomBytes(16).toString("hex"); + const dir = getDownloadsDir(); + await fs.promises.mkdir(dir, { recursive: true }); + const file = path.join(dir, `upload-${token}.tmxplug`); + await fs.promises.writeFile(file, buffer); + uploads.set(token, { + file, + manifest, + expiresAt: Date.now() + UPLOAD_TTL_MS, + }); + + return { + token, + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description ?? "", + author: manifest.author?.name ?? "", + capabilities: [...manifest.capabilities], + replaces, + }; +} + +/** Installs a staged upload. It stays marked unverified for good. */ +export function installUpload( + token: string, + options: { userId: string | null; capabilities?: string[] }, +): Promise<{ id: string; version: string; state: string }> { + return serialized(async () => { + await assertUploadsAllowed(); + const upload = uploads.get(token); + if (!upload || upload.expiresAt < Date.now()) { + throw new PluginManageError( + "The uploaded file has expired, upload it again", + 404, + "UPLOAD_EXPIRED", + ); + } + const pluginId = upload.manifest.id; + // Read again: the staged file is what gets loaded, not the first look. + const manifest = validateUpload(await fs.promises.readFile(upload.file)); + if (manifest.id !== pluginId) { + throw new PluginManageError( + "The staged file changed", + 409, + "UPLOAD_CHANGED", + ); + } + assertConsent(options.capabilities, manifest.capabilities); + await assertReplaceable(pluginId); + + const { createCurrentPluginRepository } = await repos(); + const repository = createCurrentPluginRepository(); + const before = await repository.findById(pluginId); + const wasEnabled = !before || before.state !== "disabled"; + + uploads.delete(token); + let plugin: LoadedPlugin; + try { + plugin = await swapIn(pluginId, { upload: upload.file }); + } catch (error) { + await fs.promises.rm(upload.file, { force: true }); + const { loader } = getPluginRuntime(); + if (before && wasEnabled && loader.get(pluginId)) { + await startAndRecord(pluginId); + } + throw new PluginManageError( + error instanceof Error ? error.message : String(error), + 500, + "INSTALL_FAILED", + ); + } + + await repository.update(pluginId, { + tier: UNVERIFIED_TIER, + registryId: null, + pinnedVersion: null, + autoUpdate: false, + }); + await grantDeclared(plugin, options.userId); + + if (!wasEnabled) { + return { id: pluginId, version: manifest.version, state: "stopped" }; + } + const { missing } = dependencyChain(pluginId); + if (missing.length > 0) { + await recordState( + pluginId, + "blocked", + `requires ${missing.map((id) => `"${id}"`).join(", ")}, which is not installed`, + ); + return { id: pluginId, version: manifest.version, state: "blocked" }; + } + for (const id of (await planStateChange(pluginId, true)).enable) { + await startAndRecord(id); + } + const state = await startAndRecord(pluginId); + pluginLogger.warn( + `Installed unverified plugin ${pluginId}@${manifest.version} from a file`, + { operation: "plugin_install" }, + ); + return { id: pluginId, version: manifest.version, state }; + }); +} + +/** Test seam. */ +export function resetUploads(): void { + uploads.clear(); +} diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index a708a85f7..1976f18e4 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -16,6 +16,7 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest"; import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; import { pluginLogger } from "../utils/logger.js"; import { verifyPluginArtifact } from "./trust.js"; +import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js"; import { getPluginsDir } from "./paths.js"; export const OFFICIAL_REGISTRY_ID = "official"; @@ -245,6 +246,55 @@ export async function fetchRegistryIndex( return inflight; } +export interface RegistryStats { + plugins: Map; +} + +/** stats.json sits next to index.json and is rebuilt daily by the registry. */ +export function getStatsUrl(): string | null { + if (process.env.TERMIX_PLUGIN_STATS_URL) { + return process.env.TERMIX_PLUGIN_STATS_URL; + } + const url = getRegistryUrl(); + return /\/index\.json$/.test(url) + ? url.replace(/\/index\.json$/, "/stats.json") + : null; +} + +function asCount(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : null; +} + +export function parseRegistryStats(raw: unknown): RegistryStats { + const plugins = new Map< + string, + { downloads: number; activeInstalls: number | null } + >(); + const entries = (raw as { plugins?: unknown } | null)?.plugins; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + return { plugins }; + } + for (const [id, value] of Object.entries(entries)) { + if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue; + const entry = value as Record; + plugins.set(id, { + downloads: asCount(entry.downloads) ?? 0, + activeInstalls: asCount(entry.activeInstalls), + }); + } + return { plugins }; +} + +export async function fetchRegistryStats(): Promise { + const url = getStatsUrl(); + if (!url) return null; + const response = await fetchFollowing(url); + const body = await readLimited(response, MAX_INDEX_BYTES); + return parseRegistryStats(JSON.parse(body.toString("utf8"))); +} + /** Test seam. */ export function resetRegistryCache(): void { cache = null; @@ -266,6 +316,35 @@ export function findRelease( return { plugin, release }; } +/** + * The signed file is the truth and the index only describes it, so the + * consent the admin gave (built from the index) must match what the packed + * manifest asks for. + */ +export function assertArtifactMatches( + pluginId: string, + release: RegistryVersion, + manifest: Record, +): void { + const where = `${pluginId} ${release.version}`; + if (manifest.id !== pluginId) { + throw new Error(`${where}: the archive is for "${String(manifest.id)}"`); + } + if (manifest.version !== release.version) { + throw new Error( + `${where}: the archive is version ${String(manifest.version)}`, + ); + } + const declared = Array.isArray(manifest.capabilities) + ? manifest.capabilities.filter((c): c is string => typeof c === "string") + : []; + if (!sameCapabilities(declared, release.capabilities)) { + throw new Error( + `${where}: the archive asks for different capabilities than the registry lists`, + ); + } +} + /** * Downloads a release, verifies it, and leaves it in a staging folder the * loader never scans. The caller moves it into place. @@ -296,6 +375,7 @@ export async function downloadRelease( if (verified.ok === false) { throw new Error(`${pluginId} ${release.version}: ${verified.reason}`); } + assertArtifactMatches(pluginId, release, readArtifactManifest(buffer)); const staging = getDownloadsDir(); await fs.promises.mkdir(staging, { recursive: true }); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 4bebd101f..a6fe7d27d 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -173,6 +173,10 @@ const manageMock = vi.hoisted(() => ({ deletePluginData: vi.fn(), planStateChange: vi.fn(), setPluginState: vi.fn(), + getDeveloperMode: vi.fn(), + setDeveloperMode: vi.fn(), + stageUpload: vi.fn(), + installUpload: vi.fn(), isLinkedDesktop: vi.fn(async () => false), })); const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) })); @@ -630,11 +634,15 @@ describe("plugins route", () => { version: "1.0.0", state: "active", }); - const res = await post("/plugins/docker/install", { version: "1.0.0" }); + const res = await post("/plugins/docker/install", { + version: "1.0.0", + capabilities: ["kv:own"], + }); expect(res.status).toBe(200); expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", { version: "1.0.0", userId: "admin-1", + capabilities: ["kv:own"], }); expect(auditMock.logAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -649,6 +657,13 @@ describe("plugins route", () => { expect( (await post("/plugins/docker/install", { version: 7 })).status, ).toBe(400); + expect( + (await post("/plugins/docker/install", { capabilities: "kv:own" })) + .status, + ).toBe(400); + expect( + (await post("/plugins/docker/install", { capabilities: [1] })).status, + ).toBe(400); expect(manageMock.installPlugin).not.toHaveBeenCalled(); }); @@ -668,6 +683,25 @@ describe("plugins route", () => { version: undefined, userId: "admin-1", acceptCapabilities: false, + capabilities: undefined, + }); + }); + + it("passes the consented list on an accepted update", async () => { + manageMock.updatePlugin.mockResolvedValue({ + id: "docker", + version: "1.1.0", + state: "active", + }); + await post("/plugins/docker/update", { + acceptCapabilities: true, + capabilities: ["notify:send"], + }); + expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", { + version: undefined, + userId: "admin-1", + acceptCapabilities: true, + capabilities: ["notify:send"], }); }); @@ -734,6 +768,21 @@ describe("plugins route", () => { body: JSON.stringify({ autoUpdate: true }), }), fetch(`${baseUrl}/plugins/docker/data`, { headers }), + fetch(`${baseUrl}/plugins/developer-mode`, { headers }), + fetch(`${baseUrl}/plugins/developer-mode`, { + method: "PUT", + headers, + body: JSON.stringify({ enabled: true }), + }), + fetch(`${baseUrl}/plugins/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-test-user-id": user, + }, + body: Buffer.from("x"), + }), + post(`/plugins/upload/${"a".repeat(32)}/install`, {}, user), ]; for (const res of await Promise.all(calls)) { expect(res.status).toBe(403); @@ -745,4 +794,106 @@ describe("plugins route", () => { } }); }); + + describe("developer mode and file installs", () => { + beforeEach(() => { + for (const fn of Object.values(manageMock)) fn.mockReset(); + manageMock.isLinkedDesktop.mockResolvedValue(false); + auditMock.logAudit.mockClear(); + }); + + it("reads and saves developer mode, auditing the change", async () => { + manageMock.getDeveloperMode.mockResolvedValue(true); + const read = await fetch(`${baseUrl}/plugins/developer-mode`); + expect(await read.json()).toEqual({ enabled: true, signedOnly: false }); + + const bad = await fetch(`${baseUrl}/plugins/developer-mode`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: "yes" }), + }); + expect(bad.status).toBe(400); + + const saved = await fetch(`${baseUrl}/plugins/developer-mode`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + expect(saved.status).toBe(200); + expect(manageMock.setDeveloperMode).toHaveBeenCalledWith(false); + expect(auditMock.logAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: "plugin_developer_mode" }), + ); + }); + + it("hands the raw upload to the stager", async () => { + manageMock.stageUpload.mockResolvedValue({ token: "t", id: "notes" }); + const res = await fetch(`${baseUrl}/plugins/upload`, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: Buffer.from("archive"), + }); + expect(res.status).toBe(200); + const [buffer] = manageMock.stageUpload.mock.calls[0]; + expect(Buffer.from(buffer).toString()).toBe("archive"); + }); + + it("refuses an upload that is not octet-stream", async () => { + const res = await fetch(`${baseUrl}/plugins/upload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(manageMock.stageUpload).not.toHaveBeenCalled(); + }); + + it("passes the developer mode refusal through", async () => { + manageMock.stageUpload.mockRejectedValue( + new PluginManageError("off", 403, "DEVELOPER_MODE_OFF"), + ); + const res = await fetch(`${baseUrl}/plugins/upload`, { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: Buffer.from("archive"), + }); + expect(res.status).toBe(403); + expect((await res.json()).code).toBe("DEVELOPER_MODE_OFF"); + }); + + it("installs a staged upload and audits it as unverified", async () => { + manageMock.installUpload.mockResolvedValue({ + id: "notes", + version: "0.1.0", + state: "active", + }); + const token = "b".repeat(32); + const res = await fetch(`${baseUrl}/plugins/upload/${token}/install`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capabilities: ["kv:own"] }), + }); + expect(res.status).toBe(200); + expect(manageMock.installUpload).toHaveBeenCalledWith(token, { + userId: "admin-1", + capabilities: ["kv:own"], + }); + expect(auditMock.logAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "install_plugin_from_file", + resourceId: "notes", + }), + ); + }); + + it("rejects a malformed upload token", async () => { + const res = await fetch(`${baseUrl}/plugins/upload/nope/install`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capabilities: [] }), + }); + expect(res.status).toBe(400); + expect(manageMock.installUpload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/backend/tests/plugins/artifact-manifest.test.ts b/src/backend/tests/plugins/artifact-manifest.test.ts new file mode 100644 index 000000000..0d11677a3 --- /dev/null +++ b/src/backend/tests/plugins/artifact-manifest.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { + readArtifactFile, + readArtifactManifest, + sameCapabilities, +} from "../../plugins/artifact-manifest.js"; + +function pack(files: Record, prefix = ""): Buffer { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-artifact-")); + try { + for (const [name, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); + fs.writeFileSync(path.join(dir, name), content); + } + const file = path.join(dir, "out.tmxplug"); + tar.c( + { gzip: true, sync: true, file, cwd: dir }, + Object.keys(files).map((name) => `${prefix}${name}`), + ); + return fs.readFileSync(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe("readArtifactFile", () => { + it("finds a file by name, with or without a ./ prefix", () => { + const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" }); + expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x"); + const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./"); + expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe( + '{"id":"a"}', + ); + }); + + it("returns null for a missing file", () => { + expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe( + null, + ); + }); +}); + +describe("readArtifactManifest", () => { + it("parses the manifest", () => { + expect( + readArtifactManifest(pack({ "manifest.json": '{"id":"notes"}' })), + ).toEqual({ id: "notes" }); + }); + + it("refuses a file that is not an archive", () => { + expect(() => readArtifactManifest(Buffer.from("nope"))).toThrow( + /not a valid .tmxplug/, + ); + }); + + it("refuses an archive with no manifest or a broken one", () => { + expect(() => readArtifactManifest(pack({ "a.txt": "a" }))).toThrow( + /no manifest.json/, + ); + expect(() => + readArtifactManifest(pack({ "manifest.json": "{oops" })), + ).toThrow(/not valid JSON/); + expect(() => + readArtifactManifest(pack({ "manifest.json": "[1]" })), + ).toThrow(/not an object/); + }); +}); + +describe("sameCapabilities", () => { + it("compares as sets", () => { + expect(sameCapabilities(["a", "b"], ["b", "a"])).toBe(true); + expect(sameCapabilities(["a"], ["a", "b"])).toBe(false); + expect(sameCapabilities(["a", "c"], ["a", "b"])).toBe(false); + expect(sameCapabilities([], [])).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/install-counts.test.ts b/src/backend/tests/plugins/install-counts.test.ts new file mode 100644 index 000000000..a3ad0e962 --- /dev/null +++ b/src/backend/tests/plugins/install-counts.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + stats: null as null | { + plugins: Map; + }, + fail: false, + upserted: [] as Array<{ registryId: string; entries: unknown[] }>, + rows: [] as Array<{ pluginId: string; count: number; source: string }>, +})); + +vi.mock("../../plugins/registry-index.js", () => ({ + OFFICIAL_REGISTRY_ID: "official", + fetchRegistryStats: async () => { + if (state.fail) throw new Error("offline"); + return state.stats; + }, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginInstallCountRepository: () => ({ + upsertMany: async (registryId: string, entries: unknown[]) => { + state.upserted.push({ registryId, entries }); + return entries.length; + }, + listByRegistry: async () => state.rows, + }), +})); + +const { + DOWNLOADS_SOURCE, + TELEMETRY_SOURCE, + preferredCount, + readInstallCounts, + syncInstallCounts, +} = await import("../../plugins/install-counts.js"); + +beforeEach(() => { + state.stats = null; + state.fail = false; + state.upserted = []; + state.rows = []; +}); + +describe("preferredCount", () => { + it("prefers active installs and falls back to downloads", () => { + expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({ + count: 12, + source: TELEMETRY_SOURCE, + }); + expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({ + count: 90, + source: DOWNLOADS_SOURCE, + }); + expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({ + count: 90, + source: DOWNLOADS_SOURCE, + }); + }); +}); + +describe("syncInstallCounts", () => { + it("writes one row per plugin with its preferred source", async () => { + state.stats = { + plugins: new Map([ + ["docker", { downloads: 50, activeInstalls: 20 }], + ["tunnels", { downloads: 8, activeInstalls: null }], + ]), + }; + expect(await syncInstallCounts()).toBe(2); + expect(state.upserted).toEqual([ + { + registryId: "official", + entries: [ + { pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }, + { pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE }, + ], + }, + ]); + }); + + it("keeps the old numbers when the registry is unreachable", async () => { + state.fail = true; + expect(await syncInstallCounts()).toBe(0); + expect(state.upserted).toEqual([]); + }); + + it("does nothing for a registry with no stats file", async () => { + expect(await syncInstallCounts()).toBe(0); + expect(state.upserted).toEqual([]); + }); +}); + +describe("readInstallCounts", () => { + it("maps the stored rows by plugin id", async () => { + state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }]; + const counts = await readInstallCounts(); + expect(counts.get("docker")).toEqual({ + count: 20, + source: TELEMETRY_SOURCE, + }); + }); +}); diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index b2bb9f5c7..fb461cfde 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -155,6 +155,8 @@ function tempRoot(prefix: string): string { return root; } +const DEFAULT_CAPS = ["hosts:read", "kv:own"]; + function registryEntry( id: string, versions: Array<{ version: string; capabilities?: string[] }>, @@ -248,7 +250,10 @@ describe("uninstall and reinstall of a bundled plugin", () => { expect(loader.get("docker")).toBeUndefined(); registry.download.mockImplementation(() => stageArtifact("docker")); - const result = await manage.installPlugin("docker", { userId: "admin" }); + const result = await manage.installPlugin("docker", { + userId: "admin", + capabilities: DEFAULT_CAPS, + }); expect(result.state).toBe("active"); expect(registry.download).toHaveBeenCalledOnce(); expect(loader.get("docker")?.source).toBe("user"); @@ -299,7 +304,10 @@ describe("installing from the registry", () => { ); const loader = await boot(); - const result = await manage.installPlugin("notes", { userId: "admin" }); + const result = await manage.installPlugin("notes", { + userId: "admin", + capabilities: DEFAULT_CAPS, + }); expect(result).toMatchObject({ id: "notes", state: "active" }); expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe( @@ -326,7 +334,11 @@ describe("installing from the registry", () => { registry.download.mockImplementation(() => stageArtifact("notes")); await boot(); - await manage.installPlugin("notes", { userId: "admin", version: "1.0.0" }); + await manage.installPlugin("notes", { + userId: "admin", + version: "1.0.0", + capabilities: DEFAULT_CAPS, + }); expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0"); }); @@ -334,7 +346,10 @@ describe("installing from the registry", () => { registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])]; registry.download.mockImplementation(() => stageArtifact("notes")); await boot(); - await manage.installPlugin("notes", { userId: "admin" }); + await manage.installPlugin("notes", { + userId: "admin", + capabilities: DEFAULT_CAPS, + }); await manage.uninstallPlugin("notes"); expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe( @@ -429,3 +444,199 @@ describe("dependencies", () => { }); }); }); + +describe("consent", () => { + it("refuses an install without the consented list, before any download", async () => { + registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])]; + await boot(); + await expect( + manage.installPlugin("notes", { userId: "admin" }), + ).rejects.toMatchObject({ code: "CONSENT_REQUIRED" }); + expect(registry.download).not.toHaveBeenCalled(); + }); + + it("refuses when the consent does not match what the release asks for", async () => { + registry.index.plugins = [ + registryEntry("notes", [ + { version: "1.0.0", capabilities: ["kv:own", "credentials:read"] }, + ]), + ]; + await boot(); + await expect( + manage.installPlugin("notes", { + userId: "admin", + capabilities: ["kv:own"], + }), + ).rejects.toMatchObject({ + code: "CONSENT_MISMATCH", + details: { capabilities: ["kv:own", "credentials:read"] }, + }); + expect(registry.download).not.toHaveBeenCalled(); + expect(db.rows.has("notes")).toBe(false); + }); + + it("accepts the same list in another order", async () => { + registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])]; + registry.download.mockImplementation(() => stageArtifact("notes")); + await boot(); + const result = await manage.installPlugin("notes", { + userId: "admin", + capabilities: ["kv:own", "hosts:read"], + }); + expect(result.state).toBe("active"); + }); + + it("needs the added capabilities listed to accept an update", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + registry.index.plugins = [ + registryEntry("docker", [ + { + version: "1.1.0", + capabilities: ["hosts:read", "kv:own", "notify:send"], + }, + { version: "1.0.0" }, + ]), + ]; + await boot(); + + await expect( + manage.updatePlugin("docker", { + userId: "admin", + acceptCapabilities: true, + capabilities: ["ssh:connect"], + }), + ).rejects.toMatchObject({ code: "CONSENT_MISMATCH" }); + await expect( + manage.updatePlugin("docker", { + userId: "admin", + acceptCapabilities: true, + }), + ).rejects.toMatchObject({ code: "CONSENT_REQUIRED" }); + expect(registry.download).not.toHaveBeenCalled(); + }); +}); + +describe("installing from a file", () => { + async function artifactBuffer( + id: string, + manifestOverrides: Record = {}, + ) { + const staged = await stageArtifact(id, manifestOverrides); + const buffer = fs.readFileSync(staged.file); + fs.rmSync(staged.file); + return buffer; + } + + afterEach(() => { + manage.resetUploads(); + delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS; + }); + + it("is refused while developer mode is off", async () => { + await boot(); + await expect( + manage.stageUpload(await artifactBuffer("notes")), + ).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" }); + }); + + it("is refused when signed plugins are required", async () => { + await boot(); + await manage.setDeveloperMode(true); + process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true"; + await expect( + manage.stageUpload(await artifactBuffer("notes")), + ).rejects.toMatchObject({ code: "SIGNED_ONLY" }); + }); + + it("stages, asks for consent, then installs it marked unverified", async () => { + const loader = await boot(); + await manage.setDeveloperMode(true); + + const preview = await manage.stageUpload( + await artifactBuffer("notes", { capabilities: ["kv:own"] }), + ); + expect(preview).toMatchObject({ + id: "notes", + capabilities: ["kv:own"], + replaces: null, + }); + expect(loader.get("notes")).toBeUndefined(); + + await expect( + manage.installUpload(preview.token, { + userId: "admin", + capabilities: ["kv:own", "hosts:read"], + }), + ).rejects.toMatchObject({ code: "CONSENT_MISMATCH" }); + + const result = await manage.installUpload(preview.token, { + userId: "admin", + capabilities: ["kv:own"], + }); + expect(result.state).toBe("active"); + expect(db.rows.get("notes")).toMatchObject({ + tier: manage.UNVERIFIED_TIER, + registryId: null, + }); + expect(loader.get("notes")?.signedBy).toBeUndefined(); + expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe( + true, + ); + + // A token is used once. + await expect( + manage.installUpload(preview.token, { + userId: "admin", + capabilities: ["kv:own"], + }), + ).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" }); + }); + + it("replaces an earlier upload but never a bundled plugin", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + const loader = await boot(); + await manage.setDeveloperMode(true); + + await expect( + manage.stageUpload(await artifactBuffer("docker")), + ).rejects.toMatchObject({ code: "BUNDLED_ID" }); + + const first = await manage.stageUpload(await artifactBuffer("notes")); + await manage.installUpload(first.token, { + userId: "admin", + capabilities: first.capabilities, + }); + const second = await manage.stageUpload( + await artifactBuffer("notes", { version: "1.1.0" }), + ); + expect(second.replaces).toBe("1.0.0"); + await manage.installUpload(second.token, { + userId: "admin", + capabilities: second.capabilities, + }); + expect(loader.get("notes")?.manifest.version).toBe("1.1.0"); + expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER); + }); + + it("refuses a file that is not a plugin archive", async () => { + await boot(); + await manage.setDeveloperMode(true); + await expect( + manage.stageUpload(Buffer.from("not a tarball")), + ).rejects.toMatchObject({ code: "INVALID_ARTIFACT" }); + }); + + it("drops staged files when developer mode is turned off", async () => { + await boot(); + await manage.setDeveloperMode(true); + const preview = await manage.stageUpload(await artifactBuffer("notes")); + await manage.setDeveloperMode(false); + await manage.setDeveloperMode(true); + await expect( + manage.installUpload(preview.token, { + userId: "admin", + capabilities: preview.capabilities, + }), + ).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" }); + }); +}); diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index 74cde0db0..d4e33712e 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import * as tar from "tar"; const fetchMock = vi.hoisted(() => vi.fn()); const testKey = vi.hoisted(() => ({ value: null as null | object })); @@ -26,9 +27,12 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => { import { downloadRelease, fetchRegistryIndex, + fetchRegistryStats, findRelease, + getStatsUrl, isApiCompatible, parseRegistryIndex, + parseRegistryStats, resetRegistryCache, type RegistryVersion, } from "../../plugins/registry-index.js"; @@ -186,8 +190,25 @@ describe("fetchRegistryIndex", () => { }); }); +/** A real .tmxplug holding only a manifest, enough for the checks. */ +function pack(manifest: Record): Buffer { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-")); + try { + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest)); + const file = path.join(dir, "out.tmxplug"); + tar.c({ gzip: true, sync: true, file, cwd: dir }, ["manifest.json"]); + return fs.readFileSync(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + describe("downloadRelease", () => { - const artifact = Buffer.from("plugin archive bytes"); + const artifact = pack({ + id: "docker", + version: "1.0.0", + capabilities: ["kv:own"], + }); it("writes a verified artifact and its signature to staging", async () => { const signed = sign(artifact); @@ -206,6 +227,47 @@ describe("downloadRelease", () => { ).toBe(true); }); + it("refuses a file signed by a key this build does not trust", async () => { + const other = crypto.generateKeyPairSync("ed25519").privateKey; + const digest = crypto.createHash("sha256").update(artifact).digest(); + fetchMock.mockResolvedValueOnce(response(artifact)); + await expect( + downloadRelease( + "docker", + version({ + sha256: digest.toString("hex"), + signature: crypto.sign(null, digest, other).toString("base64"), + size: artifact.length, + }), + ), + ).rejects.toThrow(/trusted key/); + }); + + it.each([ + ["another plugin id", { id: "evil" }, /archive is for/], + ["another version", { version: "9.9.9" }, /archive is version/], + [ + "more capabilities than the index lists", + { capabilities: ["kv:own", "credentials:read"] }, + /different capabilities/, + ], + ])("refuses a signed file with %s", async (_label, override, error) => { + const tampered = pack({ + id: "docker", + version: "1.0.0", + capabilities: ["kv:own"], + ...override, + }); + const signed = sign(tampered); + fetchMock.mockResolvedValueOnce(response(tampered)); + await expect( + downloadRelease("docker", version({ ...signed, size: tampered.length })), + ).rejects.toThrow(error); + expect(fs.existsSync(path.join(dataDir, "plugins", ".downloads"))).toBe( + false, + ); + }); + it("refuses a file that does not match its signature", async () => { const signed = sign(artifact); fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!"))); @@ -229,3 +291,38 @@ describe("downloadRelease", () => { ).rejects.toThrow(/larger than allowed/); }); }); + +describe("registry stats", () => { + it("sits next to the index", () => { + expect(getStatsUrl()).toBe( + "https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/stats.json", + ); + }); + + it("keeps valid counts and drops the rest", () => { + const stats = parseRegistryStats({ + plugins: { + docker: { downloads: 120, activeInstalls: 40 }, + tunnels: { downloads: 7 }, + "../bad": { downloads: 1 }, + broken: { downloads: -3, activeInstalls: "many" }, + }, + }); + expect(Object.fromEntries(stats.plugins)).toEqual({ + docker: { downloads: 120, activeInstalls: 40 }, + tunnels: { downloads: 7, activeInstalls: null }, + broken: { downloads: 0, activeInstalls: null }, + }); + }); + + it("fetches and parses stats.json", async () => { + fetchMock.mockResolvedValueOnce( + response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })), + ); + const stats = await fetchRegistryStats(); + expect(stats?.plugins.get("docker")).toEqual({ + downloads: 3, + activeInstalls: null, + }); + }); +}); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 9531c1a48..c7c21463f 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -284,6 +284,9 @@ export interface RegistryPluginEntry { pinnedVersion: string | null; autoUpdate: boolean; bundled: boolean; + /** Active installs when known, release downloads otherwise. */ + installCount: number | null; + installCountSource: string | null; } export interface RegistryListing { @@ -338,14 +341,16 @@ export async function getPluginRegistry( return response.data; } +/** `capabilities` is exactly what the consent prompt showed. */ export async function installPlugin( pluginId: string, - version?: string, + options: { version?: string; capabilities: string[] }, ): Promise<{ id: string; version: string; state: string }> { try { - const response = await rbacApi.post(`${pluginPath(pluginId)}/install`, { - version, - }); + const response = await rbacApi.post( + `${pluginPath(pluginId)}/install`, + options, + ); return response.data; } finally { announcePluginsChanged(); @@ -354,7 +359,11 @@ export async function installPlugin( export async function updatePlugin( pluginId: string, - options: { version?: string; acceptCapabilities?: boolean } = {}, + options: { + version?: string; + acceptCapabilities?: boolean; + capabilities?: string[]; + } = {}, ): Promise<{ id: string; version: string; state: string }> { try { const response = await rbacApi.post( @@ -443,3 +452,57 @@ export async function deletePluginData(pluginId: string): Promise { announcePluginsChanged(); } } + +export interface DeveloperModeState { + enabled: boolean; + /** TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs even in developer mode. */ + signedOnly: boolean; +} + +export async function getDeveloperMode(): Promise { + const response = await rbacApi.get("/plugins/developer-mode"); + return response.data; +} + +/** Fired after developer mode changes; detail is the new value. */ +export const DEVELOPER_MODE_CHANGED_EVENT = "termix:plugin-developer-mode"; + +export async function setDeveloperMode(enabled: boolean): Promise { + await rbacApi.put("/plugins/developer-mode", { enabled }); + window.dispatchEvent( + new CustomEvent(DEVELOPER_MODE_CHANGED_EVENT, { detail: enabled }), + ); +} + +export interface PluginUploadPreview { + token: string; + id: string; + name: string; + version: string; + description: string; + author: string; + capabilities: string[]; + replaces: string | null; +} + +export async function uploadPlugin(file: Blob): Promise { + const response = await rbacApi.post("/plugins/upload", file, { + headers: { "Content-Type": "application/octet-stream" }, + }); + return response.data; +} + +export async function installUploadedPlugin( + token: string, + capabilities: string[], +): Promise<{ id: string; version: string; state: string }> { + try { + const response = await rbacApi.post( + `/plugins/upload/${encodeURIComponent(token)}/install`, + { capabilities }, + ); + return response.data; + } finally { + announcePluginsChanged(); + } +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 962f0932f..312afc829 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1976,6 +1976,7 @@ "notRunning": "Not running" }, "sort": { + "popular": "Most popular", "name": "Name", "updated": "Recently updated", "category": "Category" @@ -2017,7 +2018,9 @@ "updateExplain": "This version asks for something the one you have does not, so it will not install on its own.", "newInVersion": "New in this version", "minor": "It also adds its own screens and stores its own data, kept separate from other plugins.", - "none": "It asks for no special access." + "none": "It asks for no special access.", + "uploadTitle": "Install {{name}} from a file", + "uploadWarning": "This file was not checked or signed by the Termix registry. Only install it if you built it yourself or trust whoever did." }, "confirm": { "uninstallTitle": "Uninstall {{name}}?", @@ -2054,7 +2057,10 @@ "deleteData": "Could not delete data for {{name}}: {{error}}", "managedByServer": "This desktop is linked to a server, which decides its plugins.", "hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.", - "missingDependencies": "{{name}} needs {{list}}, which is not installed." + "missingDependencies": "{{name}} needs {{list}}, which is not installed.", + "consentChanged": "What this plugin asks for changed since you looked. Check the list again.", + "upload": "Could not read {{name}}: {{error}}", + "developerMode": "Could not change developer mode: {{error}}" }, "data": { "title": "Stored data", @@ -2122,7 +2128,29 @@ "registryUnreachable": "Could not reach the plugin registry ({{error}}). Installed plugins still work.", "managedByServer": "This desktop is linked to a server, which decides its plugins. Changes are made on the server.", "browsePlugins": "Browse plugins", - "loading": "Loading" + "loading": "Loading", + "developer": { + "title": "Plugin developer mode", + "hint": "Lets admins install plugins from a .tmxplug file in the Plugins tab, without the registry checks. Use it to build and test your own plugins.", + "enableTitle": "Turn on developer mode?", + "enableBody": "Admins will be able to install plugins from files that Termix has not checked. A plugin can do anything its capabilities allow, so only turn this on while you are building or testing plugins.", + "enable": "Turn on", + "installFromFile": "Install from file", + "signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked." + }, + "count": { + "active_one": "{{formatted}} active install", + "active_other": "{{formatted}} active installs", + "downloads_one": "{{formatted}} download", + "downloads_other": "{{formatted}} downloads", + "activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.", + "downloadsHint": "How many times a release was downloaded. This is not the number of installs." + }, + "unverified": "Unverified", + "unverifiedHint": "Installed from a file. Termix did not check or sign it.", + "reportIssue": "Report an issue", + "reportTo": "Report to {{author}}", + "openSettings": "Open its settings" } }, "settings": { diff --git a/src/ui/plugins/PluginConsentPrompt.tsx b/src/ui/plugins/PluginConsentPrompt.tsx index fbd5e7e54..fa54bac7d 100644 --- a/src/ui/plugins/PluginConsentPrompt.tsx +++ b/src/ui/plugins/PluginConsentPrompt.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { TriangleAlert } from "lucide-react"; import { Button } from "@/components/button"; import { Facts } from "@/components/panel-layout"; import { PanePrompt } from "@/components/surface/surface-scope"; @@ -7,10 +8,12 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model"; export interface ConsentRequest { plugin: PluginEntry; - mode: "install" | "update"; + mode: "install" | "update" | "upload"; version: string; /** What the prompt lists: all of them to install, only the new ones to update. */ capabilities: string[]; + /** Set for a file uploaded in developer mode. */ + uploadToken?: string; } /** @@ -44,13 +47,15 @@ export function PluginConsentPrompt({ title={ mode === "update" ? t("plugins.manager.consent.updateTitle", { name: plugin.name }) - : t("plugins.manager.consent.installTitle", { name: plugin.name }) + : mode === "upload" + ? t("plugins.manager.consent.uploadTitle", { name: plugin.name }) + : t("plugins.manager.consent.installTitle", { name: plugin.name }) } description={ {plugin.author && {plugin.author}} - {mode === "update" && plugin.version + {mode !== "install" && plugin.version ? t("plugins.manager.versionChange", { from: plugin.version, to: version, @@ -78,6 +83,14 @@ export function PluginConsentPrompt({ } >
+ {mode === "upload" && ( +
+ + + {t("plugins.manager.consent.uploadWarning")} + +
+ )} {mode === "update" && (

{t("plugins.manager.consent.updateExplain")} diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index eff764512..ce29e5cc9 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + Bug, Database, Download, ExternalLink, Pin, RotateCcw, + Settings, Trash2, TriangleAlert, } from "lucide-react"; @@ -21,15 +23,25 @@ import { SelectValue, } from "@/components/select"; import { getPluginData, type PluginDataSummary } from "@/api/plugins-api"; -import { CapabilityRow, PluginIconBox } from "./plugin-bits"; +import { + CapabilityRow, + InstallCountFact, + PluginIconBox, + UnverifiedBadge, +} from "./plugin-bits"; import { describeContributions, formatBytes, orderByRisk, + reportIssueUrl, type PluginEntry, } from "./plugin-model"; +import { openPluginSettings } from "./open-plugins"; import type { PluginsManager } from "./use-plugins-manager"; +const LINK_ROW = + "flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"; + function formatDate(value: string | null | undefined): string { if (!value) return ""; const date = new Date(value); @@ -60,6 +72,15 @@ export function PluginDetail({ }, [plugin.version, plugin.latestVersion]); const contributions = describeContributions(plugin.contributes); + const settings = plugin.contributes?.settings as + { admin?: unknown[]; user?: unknown[] } | undefined; + const hasSettings = + plugin.installed && + ((settings?.admin?.length ?? 0) > 0 || (settings?.user?.length ?? 0) > 0); + const issueUrl = reportIssueUrl( + plugin, + import.meta.env.VITE_APP_VERSION || undefined, + ); return (

+ {plugin.unverified && } {plugin.author && {plugin.author}} {plugin.version ? ( v{plugin.version} @@ -91,6 +113,10 @@ export function PluginDetail({ })} )} +
@@ -305,6 +331,7 @@ export function PluginDetail({ + )} + + {issueUrl && ( + + + + {plugin.source === "official" || + plugin.source === "bundled" || + !plugin.author + ? t("plugins.manager.reportIssue") + : t("plugins.manager.reportTo", { author: plugin.author })} + + + )} + {plugin.repository && ( diff --git a/src/ui/plugins/PluginsTab.tsx b/src/ui/plugins/PluginsTab.tsx index b25ba6b01..033f54c41 100644 --- a/src/ui/plugins/PluginsTab.tsx +++ b/src/ui/plugins/PluginsTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDownWideNarrow, @@ -11,6 +11,7 @@ import { Search, Trash2, TriangleAlert, + Upload, } from "lucide-react"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; @@ -33,7 +34,11 @@ import { } from "@/components/panel-layout"; import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; import { cn } from "@/lib/utils"; -import { PluginIconBox } from "./plugin-bits"; +import { + InstallCountFact, + PluginIconBox, + UnverifiedBadge, +} from "./plugin-bits"; import { PluginConsentPrompt } from "./PluginConsentPrompt"; import { PluginDetail } from "./PluginDetail"; import { @@ -210,7 +215,7 @@ function SortSelect({ onChange: (next: SortKey) => void; }) { const { t } = useTranslation(); - const keys: SortKey[] = ["name", "updated", "category"]; + const keys: SortKey[] = ["popular", "name", "updated", "category"]; return ( { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void manager.requestUpload(file); + }} + /> + + + ); +} + function CardHead({ plugin, muted, @@ -431,6 +468,7 @@ function InstalledCard({ onOpen={onOpen} facts={ <> + {plugin.unverified && } {plugin.author && {plugin.author}} v{plugin.version} {adds.length > 0 && {adds.join(", ")}} @@ -540,7 +578,7 @@ function BrowseSection({ }) { const { t } = useTranslation(); const [category, setCategory] = useState(null); - const [sort, setSort] = useState("name"); + const [sort, setSort] = useState("popular"); const categories = useMemo(() => categoriesOf(plugins), [plugins]); const rows = sortPlugins( @@ -633,6 +671,10 @@ function BrowseCard({ {plugin.author && {plugin.author}} {plugin.latestVersion && v{plugin.latestVersion}} {plugin.category && {plugin.category}} + } /> diff --git a/src/ui/plugins/open-plugins.ts b/src/ui/plugins/open-plugins.ts index 89591ee83..3bec49aa2 100644 --- a/src/ui/plugins/open-plugins.ts +++ b/src/ui/plugins/open-plugins.ts @@ -1,2 +1,11 @@ /** Asks the shell to open the Plugins tab. */ export const OPEN_PLUGINS_EVENT = "termix:open-plugins"; + +/** Asks the shell to open Settings at a plugin's page; detail is the plugin id. */ +export const OPEN_PLUGIN_SETTINGS_EVENT = "termix:open-plugin-settings"; + +export function openPluginSettings(pluginId: string): void { + window.dispatchEvent( + new CustomEvent(OPEN_PLUGIN_SETTINGS_EVENT, { detail: pluginId }), + ); +} diff --git a/src/ui/plugins/plugin-bits.tsx b/src/ui/plugins/plugin-bits.tsx index 0d9484d67..cb7b7fa2b 100644 --- a/src/ui/plugins/plugin-bits.tsx +++ b/src/ui/plugins/plugin-bits.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { PluginIcon } from "@/lib/plugin-icon"; import { cn } from "@/lib/utils"; -import { capabilityRisk, isSevere } from "./plugin-model"; +import { capabilityRisk, formatCount, isSevere } from "./plugin-model"; export function PluginIconBox({ name, @@ -74,3 +74,45 @@ export function CapabilityRow({
); } + +/** Permanent mark on anything installed without a registry signature. */ +export function UnverifiedBadge() { + const { t } = useTranslation(); + return ( + + {t("plugins.manager.unverified")} + + ); +} + +/** Active installs when the registry has them, release downloads otherwise. */ +export function InstallCountFact({ + count, + source, +}: { + count: number | null; + source: string | null; +}) { + const { t } = useTranslation(); + if (count === null || count <= 0) return null; + const active = source === "aggregate-telemetry"; + return ( + + {t( + active + ? "plugins.manager.count.active" + : "plugins.manager.count.downloads", + { count, formatted: formatCount(count) }, + )} + + ); +} diff --git a/src/ui/plugins/plugin-model.ts b/src/ui/plugins/plugin-model.ts index 92f2f7d9a..accca9abf 100644 --- a/src/ui/plugins/plugin-model.ts +++ b/src/ui/plugins/plugin-model.ts @@ -5,6 +5,7 @@ import { } from "@termix-ssh/plugin-sdk/capabilities"; import type { PluginSummary, + PluginUploadPreview, RegistryPluginEntry, RegistryPluginVersion, } from "@/api/plugins-api"; @@ -36,6 +37,22 @@ export interface PluginEntry { inRegistry: boolean; contributes: Record | null; publishedAt: string | null; + installCount: number | null; + installCountSource: string | null; + source: PluginSource; + /** Installed from a file or folder with no registry signature behind it. */ + unverified: boolean; +} + +export type PluginSource = "bundled" | "official" | "unverified"; + +/** Where an installed copy came from, as the issue report names it. */ +export function pluginSource(summary: PluginSummary): PluginSource { + if (summary.tier === "unverified") return "unverified"; + if (summary.source === "bundled" || summary.tier === "bundled") { + return "bundled"; + } + return summary.signedBy ? "official" : "unverified"; } export function pluginStatus(summary: PluginSummary): PluginStatus { @@ -79,6 +96,10 @@ export function mergePlugins( inRegistry: Boolean(entry), contributes: (summary.contributes as Record) ?? null, publishedAt: latestPublished(entry?.versions ?? []), + installCount: entry?.installCount ?? null, + installCountSource: entry?.installCountSource ?? null, + source: pluginSource(summary), + unverified: pluginSource(summary) === "unverified", }); } @@ -110,12 +131,47 @@ export function mergePlugins( inRegistry: true, contributes: null, publishedAt: latestPublished(entry.versions), + installCount: entry.installCount ?? null, + installCountSource: entry.installCountSource ?? null, + source: "official", + unverified: false, }); } return rows; } +/** A row for a file that was uploaded but not installed yet. */ +export function uploadEntry(preview: PluginUploadPreview): PluginEntry { + return { + id: preview.id, + name: preview.name, + description: preview.description, + author: preview.author, + category: "", + installed: preview.replaces !== null, + version: preview.replaces, + latestVersion: preview.version, + updateAvailable: false, + addedCapabilities: [], + versions: [], + capabilities: preview.capabilities, + status: null, + enabled: false, + lastError: null, + autoUpdate: false, + pinnedVersion: null, + bundled: false, + inRegistry: false, + contributes: null, + publishedAt: null, + installCount: null, + installCountSource: null, + source: "unverified", + unverified: true, + }; +} + function latestPublished(versions: RegistryPluginVersion[]): string | null { const dates = versions .map((v) => v.publishedAt) @@ -124,11 +180,17 @@ function latestPublished(versions: RegistryPluginVersion[]): string | null { return dates.at(-1) ?? null; } -export type SortKey = "name" | "updated" | "category"; +export type SortKey = "popular" | "name" | "updated" | "category"; export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] { const sorted = [...rows]; - if (key === "updated") { + if (key === "popular") { + sorted.sort( + (a, b) => + (b.installCount ?? -1) - (a.installCount ?? -1) || + a.name.localeCompare(b.name), + ); + } else if (key === "updated") { sorted.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""), ); @@ -230,3 +292,49 @@ export function formatBytes(bytes: number): string { } return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; } + +/** 1234 reads as 1.2k, so the number stays a rough ranking. */ +export function formatCount(count: number): string { + if (count < 1000) return String(count); + if (count < 1_000_000) { + return `${(count / 1000).toFixed(count < 10_000 ? 1 : 0)}k`; + } + return `${(count / 1_000_000).toFixed(1)}M`; +} + +/** + * A new GitHub issue on the plugin's own repo with what a maintainer asks + * first already filled in. Null when the repository is not on GitHub. + */ +export function reportIssueUrl( + plugin: Pick< + PluginEntry, + "id" | "repository" | "version" | "latestVersion" | "status" | "source" + >, + termixVersion?: string, +): string | null { + const repo = plugin.repository + ?.trim() + .replace(/\.git$/, "") + .replace(/\/+$/, ""); + if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) { + return null; + } + const lines = [ + `**Plugin:** ${plugin.id}`, + `**Version:** ${plugin.version ?? plugin.latestVersion ?? "not installed"}`, + `**Source:** ${plugin.source}`, + `**State:** ${plugin.status ?? "not installed"}`, + ...(termixVersion ? [`**Termix:** ${termixVersion}`] : []), + "", + "**What happened:**", + "", + "**What you expected:**", + "", + ]; + const params = new URLSearchParams({ + title: `[${plugin.id}] `, + body: lines.join("\n"), + }); + return `${repo}/issues/new?${params.toString()}`; +} diff --git a/src/ui/plugins/use-plugins-manager.ts b/src/ui/plugins/use-plugins-manager.ts index b26468be5..db5880365 100644 --- a/src/ui/plugins/use-plugins-manager.ts +++ b/src/ui/plugins/use-plugins-manager.ts @@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import semver from "semver"; import { + DEVELOPER_MODE_CHANGED_EVENT, deletePluginData, + getDeveloperMode, getPluginRegistry, getPlugins, installPlugin, + installUploadedPlugin, previewPluginState, retryPlugin, setPluginOptions, @@ -14,11 +17,13 @@ import { uninstallPlugin, updateAllPlugins, updatePlugin, + uploadPlugin, + type DeveloperModeState, type PluginSummary, type RegistryListing, } from "@/api/plugins-api"; import { useConfirm } from "@/components/surface/surface-scope"; -import { mergePlugins, type PluginEntry } from "./plugin-model"; +import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model"; import type { ConsentRequest } from "./PluginConsentPrompt"; interface ApiError { @@ -53,6 +58,10 @@ export function usePluginsManager() { const [refreshing, setRefreshing] = useState(false); const [busy, setBusy] = useState>(new Set()); const [consent, setConsent] = useState(null); + const [developer, setDeveloper] = useState({ + enabled: false, + signedOnly: false, + }); const mounted = useRef(true); useEffect(() => { @@ -64,12 +73,14 @@ export function usePluginsManager() { const load = useCallback(async (refresh = false) => { if (refresh) setRefreshing(true); - const [plugins, listing] = await Promise.allSettled([ + const [plugins, listing, devMode] = await Promise.allSettled([ getPlugins(), getPluginRegistry(refresh), + getDeveloperMode(), ]); if (!mounted.current) return; if (plugins.status === "fulfilled") setInstalled(plugins.value); + if (devMode.status === "fulfilled") setDeveloper(devMode.value); if (listing.status === "fulfilled") { setRegistry(listing.value); setRegistryError(listing.value.registry.error); @@ -84,6 +95,18 @@ export function usePluginsManager() { void load(); }, [load]); + // The switch lives in admin settings, which may change while this is open. + useEffect(() => { + const onChange = (event: Event) => { + const enabled = (event as CustomEvent).detail; + if (typeof enabled !== "boolean") return; + setDeveloper((current) => ({ ...current, enabled })); + }; + window.addEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange); + return () => + window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange); + }, []); + const plugins = useMemo( () => mergePlugins(installed, registry?.plugins ?? null), [installed, registry], @@ -152,13 +175,13 @@ export function usePluginsManager() { ); const doInstall = useCallback( - (plugin: PluginEntry, version: string) => + (plugin: PluginEntry, version: string, capabilities: string[]) => run(plugin.id, async () => { try { - const result = await installPlugin( - plugin.id, - version === plugin.latestVersion ? undefined : version, - ); + const result = await installPlugin(plugin.id, { + version: version === plugin.latestVersion ? undefined : version, + capabilities, + }); if (result.state === "failed" || result.state === "blocked") { toast.error( t("plugins.manager.toast.installedNotRunning", { @@ -171,6 +194,17 @@ export function usePluginsManager() { ); } } catch (error) { + const info = readError(error, ""); + if (info.code === "CONSENT_MISMATCH") { + toast.error(t("plugins.manager.errors.consentChanged")); + setConsent({ + plugin, + mode: "install", + version, + capabilities: (info.data.capabilities as string[]) ?? [], + }); + return; + } failToast(error, "plugins.manager.errors.install", plugin.name); } }), @@ -178,12 +212,13 @@ export function usePluginsManager() { ); const doUpdate = useCallback( - (plugin: PluginEntry, version: string, acceptCapabilities: boolean) => + (plugin: PluginEntry, version: string, accepted: string[] | null) => run(plugin.id, async () => { try { await updatePlugin(plugin.id, { version: version === plugin.latestVersion ? undefined : version, - acceptCapabilities, + acceptCapabilities: accepted !== null, + capabilities: accepted ?? undefined, }); toast.success( t("plugins.manager.toast.updated", { @@ -193,7 +228,13 @@ export function usePluginsManager() { ); } catch (error) { const info = readError(error, ""); - if (info.code === "CAPABILITIES_ADDED") { + if ( + info.code === "CAPABILITIES_ADDED" || + info.code === "CONSENT_MISMATCH" + ) { + if (info.code === "CONSENT_MISMATCH") { + toast.error(t("plugins.manager.errors.consentChanged")); + } setConsent({ plugin, mode: "update", @@ -242,21 +283,74 @@ export function usePluginsManager() { }); return; } - await doUpdate(plugin, target, false); + await doUpdate(plugin, target, null); }, [confirm, t, doUpdate], ); + const doInstallUpload = useCallback( + (request: ConsentRequest) => + run(request.plugin.id, async () => { + try { + const result = await installUploadedPlugin( + request.uploadToken!, + request.capabilities, + ); + if (result.state === "failed" || result.state === "blocked") { + toast.error( + t("plugins.manager.toast.installedNotRunning", { + name: request.plugin.name, + }), + ); + } else { + toast.success( + t("plugins.manager.toast.installed", { + name: request.plugin.name, + }), + ); + } + } catch (error) { + failToast( + error, + "plugins.manager.errors.install", + request.plugin.name, + ); + } + }), + [run, t, failToast], + ); + + /** Reads a picked .tmxplug and asks for consent before anything loads. */ + const requestUpload = useCallback( + async (file: File) => { + try { + const preview = await uploadPlugin(file); + setConsent({ + plugin: uploadEntry(preview), + mode: "upload", + version: preview.version, + capabilities: preview.capabilities, + uploadToken: preview.token, + }); + } catch (error) { + failToast(error, "plugins.manager.errors.upload", file.name); + } + }, + [failToast], + ); + const confirmConsent = useCallback(async () => { if (!consent) return; const request = consent; setConsent(null); - if (request.mode === "install") { - await doInstall(request.plugin, request.version); + if (request.mode === "upload" && request.uploadToken) { + await doInstallUpload(request); + } else if (request.mode === "install") { + await doInstall(request.plugin, request.version, request.capabilities); } else { - await doUpdate(request.plugin, request.version, true); + await doUpdate(request.plugin, request.version, request.capabilities); } - }, [consent, doInstall, doUpdate]); + }, [consent, doInstall, doUpdate, doInstallUpload]); const nameOf = useCallback( (id: string) => plugins.find((p) => p.id === id)?.name ?? id, @@ -439,6 +533,9 @@ export function usePluginsManager() { busy, consent, setConsent, + developerMode: developer.enabled, + signedOnly: developer.signedOnly, + requestUpload, refresh: () => load(true), requestInstall, requestUpdate, diff --git a/src/ui/settings/settings-search-index.ts b/src/ui/settings/settings-search-index.ts index d2eeec50e..f3cbf8a4a 100644 --- a/src/ui/settings/settings-search-index.ts +++ b/src/ui/settings/settings-search-index.ts @@ -132,6 +132,13 @@ export const SETTINGS_SEARCH_INDEX: Record = { "admin.sessionTimeoutRange", "admin.statusCheck", "common.save", + "plugins.manager.developer.enable", + "plugins.manager.developer.enableBody", + "plugins.manager.developer.enableTitle", + "plugins.manager.developer.hint", + "plugins.manager.developer.signedOnly", + "plugins.manager.developer.title", + "plugins.manager.errors.developerMode", ], "admin-host-defaults": [ "admin.hostTags", diff --git a/src/ui/sidebar/AdminPluginDeveloperMode.tsx b/src/ui/sidebar/AdminPluginDeveloperMode.tsx new file mode 100644 index 000000000..da2499f84 --- /dev/null +++ b/src/ui/sidebar/AdminPluginDeveloperMode.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { SettingRow } from "@/components/section-card"; +import { useConfirm } from "@/components/surface/surface-scope"; +import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api"; +import { AdminToggle } from "./AdminSettingsShared"; + +/** Lets admins install plugins from a file. Asks before turning it on. */ +export function AdminPluginDeveloperMode() { + const { t } = useTranslation(); + const confirm = useConfirm(); + const [enabled, setEnabled] = useState(false); + const [signedOnly, setSignedOnly] = useState(false); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + getDeveloperMode() + .then((state) => { + if (cancelled) return; + setEnabled(state.enabled); + setSignedOnly(state.signedOnly); + setLoaded(true); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const toggle = async () => { + const next = !enabled; + if (next) { + const ok = await confirm({ + title: t("plugins.manager.developer.enableTitle"), + description: t("plugins.manager.developer.enableBody"), + confirmLabel: t("plugins.manager.developer.enable"), + }); + if (!ok) return; + } + setSaving(true); + try { + await setDeveloperMode(next); + setEnabled(next); + } catch (error) { + toast.error( + t("plugins.manager.errors.developerMode", { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setSaving(false); + } + }; + + return ( + <> + + void toggle()} + disabled={!loaded || saving} + /> + + {enabled && signedOnly && ( +

+ {t("plugins.manager.developer.signedOnly")} +

+ )} + + ); +} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 725f280f3..2c6393af7 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -6,6 +6,7 @@ import { SettingRow } from "@/components/section-card"; import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react"; import { AccordionSection, AdminToggle } from "./AdminSettingsShared"; import { AdminHostTags } from "./AdminHostTags"; +import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode"; import type { TlsStatus } from "@/api/tls-api"; type GeneralSettingsSectionProps = { @@ -142,6 +143,7 @@ export function AdminGeneralSettingsSection({ onToggle={handleTogglePasswordReset} /> +
{t("admin.sessionTimeout")} diff --git a/src/ui/tests/plugins/PluginConsentPrompt.test.tsx b/src/ui/tests/plugins/PluginConsentPrompt.test.tsx index 9d363923d..5a1885170 100644 --- a/src/ui/tests/plugins/PluginConsentPrompt.test.tsx +++ b/src/ui/tests/plugins/PluginConsentPrompt.test.tsx @@ -28,6 +28,8 @@ const [plugin] = mergePlugins( pinnedVersion: null, autoUpdate: false, bundled: false, + installCount: null, + installCountSource: null, }, ], ); diff --git a/src/ui/tests/plugins/plugin-model.test.ts b/src/ui/tests/plugins/plugin-model.test.ts index 7c533d851..98dfafd02 100644 --- a/src/ui/tests/plugins/plugin-model.test.ts +++ b/src/ui/tests/plugins/plugin-model.test.ts @@ -4,11 +4,15 @@ import { categoriesOf, describeContributions, formatBytes, + formatCount, matchesQuery, mergePlugins, orderByRisk, + pluginSource, pluginStatus, + reportIssueUrl, sortPlugins, + uploadEntry, } from "@/plugins/plugin-model"; function summary(overrides: Partial = {}): PluginSummary { @@ -50,6 +54,8 @@ function entry( pinnedVersion: null, autoUpdate: false, bundled: true, + installCount: null, + installCountSource: null, ...overrides, }; } @@ -172,3 +178,108 @@ describe("helpers", () => { expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB"); }); }); + +describe("popularity", () => { + it("sorts by install count, unknown counts last", () => { + const rows = mergePlugins( + [], + [ + entry({ id: "a", name: "A", installed: false, installCount: 5 }), + entry({ id: "b", name: "B", installed: false, installCount: null }), + entry({ id: "c", name: "C", installed: false, installCount: 90 }), + ], + ); + expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("formats counts roughly", () => { + expect(formatCount(950)).toBe("950"); + expect(formatCount(1234)).toBe("1.2k"); + expect(formatCount(45_000)).toBe("45k"); + expect(formatCount(2_500_000)).toBe("2.5M"); + }); +}); + +describe("source and trust", () => { + it("tells bundled, signed and unverified copies apart", () => { + expect(pluginSource(summary({ source: "bundled", tier: "bundled" }))).toBe( + "bundled", + ); + expect( + pluginSource( + summary({ source: "user", tier: "official", signedBy: "k" }), + ), + ).toBe("official"); + expect(pluginSource(summary({ source: "user", tier: "community" }))).toBe( + "unverified", + ); + expect( + pluginSource( + summary({ source: "user", tier: "unverified", signedBy: "k" }), + ), + ).toBe("unverified"); + }); + + it("marks an upload as unverified", () => { + const row = uploadEntry({ + token: "t", + id: "notes", + name: "Notes", + version: "0.2.0", + description: "", + author: "me", + capabilities: ["kv:own"], + replaces: "0.1.0", + }); + expect(row).toMatchObject({ + unverified: true, + installed: true, + version: "0.1.0", + latestVersion: "0.2.0", + }); + }); +}); + +describe("reportIssueUrl", () => { + it("prefills id, version, source and state on the plugin repo", () => { + const url = reportIssueUrl( + { + id: "docker", + repository: "https://github.com/Termix-SSH/Plugin-Docker.git", + version: "1.0.0", + latestVersion: "1.1.0", + status: "failed", + source: "official", + }, + "26.10.0", + )!; + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe( + "https://github.com/Termix-SSH/Plugin-Docker/issues/new", + ); + expect(parsed.searchParams.get("title")).toBe("[docker] "); + const body = parsed.searchParams.get("body")!; + expect(body).toContain("**Plugin:** docker"); + expect(body).toContain("**Version:** 1.0.0"); + expect(body).toContain("**Source:** official"); + expect(body).toContain("**State:** failed"); + expect(body).toContain("**Termix:** 26.10.0"); + }); + + it("gives nothing for a repo that is not on GitHub", () => { + expect( + reportIssueUrl({ + id: "x", + repository: "https://gitlab.com/a/b", + version: null, + latestVersion: null, + status: null, + source: "official", + }), + ).toBeNull(); + }); +});