From 621aa4724433242d491ac47b6aef84fc934d05fc Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 10 Jul 2026 16:55:26 +0200 Subject: [PATCH] Add plugin storage, install API, and directory watcher (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md §Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation. - Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) + `PluginInstanceMode` enum; migration 20260710130000_plugins. - `PluginPackageService`: pure, stateless ZIP → validated package via fflate — structure check, manifest validation (SDK), apiVersion gate, kind/bundle/styles rules, CSS sanitation (no @import / external url() / expression()), zip-slip and unpacked-size guards. Each failure carries a stable PluginErrorCode; manifest issues travel as ApiError details. - `PluginStorageService`: on-disk layout `///`; atomic writeVersion (staging dir + rename, no 404 window mid-update), removeVersion/removePlugin, traversal-safe asset resolution, dropzone + quarantine dirs. - `PluginsService`: install/update (update only to a strictly higher version, preserving the admin's instance mode; files land before the metadata pointer flips) / uninstall (refused while required; soft-delete + files removed + pond activations dropped) / list / get. - `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload), error→HTTP-status mapping. Public version-pinned static serving at `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for the installed current version. - `PluginWatcherService`: watches `/_dropzone/`, runs the same validation, installs valid drops and quarantines invalid ones with the error logged; inert under NODE_ENV=test (tests drive processDropped). - SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`, `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`. - Tests: package unit test (valid + each invalid class) and an e2e DB test (GUI install + immutable serving, non-admin 403, invalid-manifest details, dropzone install + quarantine, atomic higher-only update, required-guarded uninstall that removes files and tombstones metadata). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/package.json | 3 +- .../20260710130000_plugins/migration.sql | 35 +++ apps/api/prisma/schema.prisma | 51 +++++ apps/api/src/app.module.ts | 2 + .../src/plugins/plugin-admin.controller.ts | 83 +++++++ .../src/plugins/plugin-assets.controller.ts | 76 +++++++ apps/api/src/plugins/plugin-css.ts | 57 +++++ .../plugins/plugin-package.service.test.ts | 134 +++++++++++ .../api/src/plugins/plugin-package.service.ts | 124 ++++++++++ .../api/src/plugins/plugin-storage.service.ts | 107 +++++++++ .../api/src/plugins/plugin-watcher.service.ts | 106 +++++++++ apps/api/src/plugins/plugin.constants.ts | 29 +++ apps/api/src/plugins/plugins.e2e.db.test.ts | 215 ++++++++++++++++++ apps/api/src/plugins/plugins.module.ts | 24 ++ apps/api/src/plugins/plugins.service.ts | 150 ++++++++++++ apps/api/src/testing/test-app.ts | 1 + deploy/compose/docker-compose.yml | 5 + docs/architecture/data-model.md | 15 +- packages/plugin-sdk/src/index.ts | 1 + packages/plugin-sdk/src/version.test.ts | 20 ++ packages/plugin-sdk/src/version.ts | 31 +++ packages/shared/i18n/de/errors.json | 11 + packages/shared/i18n/en/errors.json | 11 + packages/shared/src/env.ts | 8 + packages/shared/src/index.ts | 1 + packages/shared/src/plugins.ts | 59 +++++ pnpm-lock.yaml | 9 +- 27 files changed, 1360 insertions(+), 8 deletions(-) create mode 100644 apps/api/prisma/migrations/20260710130000_plugins/migration.sql create mode 100644 apps/api/src/plugins/plugin-admin.controller.ts create mode 100644 apps/api/src/plugins/plugin-assets.controller.ts create mode 100644 apps/api/src/plugins/plugin-css.ts create mode 100644 apps/api/src/plugins/plugin-package.service.test.ts create mode 100644 apps/api/src/plugins/plugin-package.service.ts create mode 100644 apps/api/src/plugins/plugin-storage.service.ts create mode 100644 apps/api/src/plugins/plugin-watcher.service.ts create mode 100644 apps/api/src/plugins/plugin.constants.ts create mode 100644 apps/api/src/plugins/plugins.e2e.db.test.ts create mode 100644 apps/api/src/plugins/plugins.module.ts create mode 100644 apps/api/src/plugins/plugins.service.ts create mode 100644 packages/plugin-sdk/src/version.test.ts create mode 100644 packages/plugin-sdk/src/version.ts create mode 100644 packages/shared/src/plugins.ts diff --git a/apps/api/package.json b/apps/api/package.json index ac00f7f..d2e7d12 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "search:reindex": "tsx src/search/reindex.cli.ts" }, "dependencies": { + "@dorfteich/plugin-sdk": "workspace:*", "@dorfteich/shared": "workspace:*", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", @@ -25,6 +26,7 @@ "argon2": "^0.44.0", "cookie-parser": "^1.4.7", "dompurify": "^3.4.11", + "fflate": "^0.8.3", "fractional-indexing": "^4.0.0", "i18next": "^26.3.4", "jsdom": "^26.1.0", @@ -55,7 +57,6 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", - "fflate": "^0.8.3", "pdf-parse": "^2.4.5", "pino-pretty": "^13.0.0", "supertest": "^7.0.0", diff --git a/apps/api/prisma/migrations/20260710130000_plugins/migration.sql b/apps/api/prisma/migrations/20260710130000_plugins/migration.sql new file mode 100644 index 0000000..779524b --- /dev/null +++ b/apps/api/prisma/migrations/20260710130000_plugins/migration.sql @@ -0,0 +1,35 @@ +-- CreateEnum +CREATE TYPE "PluginInstanceMode" AS ENUM ('DISABLED', 'OPTIONAL', 'REQUIRED'); + +-- CreateTable +CREATE TABLE "plugins" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "version" TEXT NOT NULL, + "api_version" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "mode" "PluginInstanceMode" NOT NULL DEFAULT 'DISABLED', + "manifest" JSONB NOT NULL, + "installed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "removed_at" TIMESTAMP(3), + + CONSTRAINT "plugins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "pond_plugins" ( + "pond_id" TEXT NOT NULL, + "plugin_id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pond_plugins_pkey" PRIMARY KEY ("pond_id","plugin_id") +); + +-- AddForeignKey +ALTER TABLE "pond_plugins" ADD CONSTRAINT "pond_plugins_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pond_plugins" ADD CONSTRAINT "pond_plugins_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e1fec80..91c169b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -82,6 +82,7 @@ model Pond { labels Label[] grants RoleGrant[] conversionJobs ConversionJob[] + pondPlugins PondPlugin[] @@index([ownerId]) @@map("ponds") @@ -571,3 +572,53 @@ model ConversionJob { @@index([status, createdAt]) @@map("conversion_jobs") } + +/// Instance-level activation a Site Admin sets per installed plugin +/// (ADR 0008 lifecycle, issue #71). `optional` plugins are then toggled per +/// pond via PondPlugin; `required` plugins cannot be uninstalled. +enum PluginInstanceMode { + DISABLED + OPTIONAL + REQUIRED +} + +/// An installed plugin package (ADR 0008, issue #71). The validated manifest is +/// stored verbatim so serving and admin views never re-read disk; the unpacked +/// bundle lives under `///`. Uninstall is a soft +/// delete (`removedAt` set, files removed) so existing plugin_block nodes can +/// still resolve the manifest fallback. +model Plugin { + id String @id + name String + version String + apiVersion String @map("api_version") + kind String + mode PluginInstanceMode @default(DISABLED) + /// The full manifest as validated at install time (@dorfteich/plugin-sdk). + manifest Json + installedAt DateTime @default(now()) @map("installed_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Set when uninstalled; active queries filter `removedAt: null`. + removedAt DateTime? @map("removed_at") + + pondPlugins PondPlugin[] + + @@map("plugins") +} + +/// Per-pond activation of an `optional` plugin, toggled by a Pond Admin +/// (issue #71 model; the toggle UI/endpoint is #72). A row's presence with +/// `enabled = true` means the plugin is active in that pond. +model PondPlugin { + pondId String @map("pond_id") + pluginId String @map("plugin_id") + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade) + plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade) + + @@id([pondId, pluginId]) + @@map("pond_plugins") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2c0cf63..253bf3a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -18,6 +18,7 @@ import { MailModule } from './mail/mail.module'; import { MembersModule } from './members/members.module'; import { PagesModule } from './pages/pages.module'; import { PermissionsModule } from './permissions/permissions.module'; +import { PluginsModule } from './plugins/plugins.module'; import { PondsModule } from './ponds/ponds.module'; import { PrismaModule } from './prisma/prisma.module'; import { PublicModule } from './public/public.module'; @@ -50,6 +51,7 @@ import { VersionsModule } from './versions/versions.module'; MembersModule, PublicModule, ImportExportModule, + PluginsModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts new file mode 100644 index 0000000..2cfb2e0 --- /dev/null +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -0,0 +1,83 @@ +import { + BadRequestException, + ConflictException, + Controller, + Delete, + Get, + HttpCode, + HttpException, + NotFoundException, + Param, + PayloadTooLargeException, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { PluginView } from '@dorfteich/shared'; + +import { SiteAdminGuard } from '../admin/site-admin.guard'; + +import { PluginsService } from './plugins.service'; +import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants'; + +/** Maps a package/registry error to the HTTP status that fits its class. */ +function toHttpException(error: PluginPackageError): HttpException { + const body = error.details + ? { code: error.code, message: error.message, details: error.details } + : { code: error.code, message: error.message }; + switch (error.code) { + case 'plugin_not_found': + return new NotFoundException(body); + case 'plugin_required_cannot_uninstall': + case 'plugin_version_not_higher': + return new ConflictException(body); + case 'plugin_too_large': + return new PayloadTooLargeException(body); + default: + return new BadRequestException(body); + } +} + +/** + * Site Admin plugin administration (ADR 0008, issue #71). Installing is + * deliberately restricted to Site Admins (kickoff decision); the instance-mode + * and per-pond-activation writes arrive with the admin UI (#72). + */ +@Controller('admin/plugins') +@UseGuards(SiteAdminGuard) +export class PluginAdminController { + constructor(private readonly plugins: PluginsService) {} + + /** Upload and install (or update) a plugin ZIP. */ + @Post() + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } })) + async install(@UploadedFile() file: Express.Multer.File | undefined): Promise { + if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' }); + try { + return await this.plugins.install(file.buffer); + } catch (error) { + if (error instanceof PluginPackageError) throw toHttpException(error); + throw error; + } + } + + /** All installed plugins. */ + @Get() + list(): Promise { + return this.plugins.list(); + } + + /** Uninstall a plugin (refused while `required`). */ + @Delete(':id') + @HttpCode(204) + async uninstall(@Param('id') id: string): Promise { + try { + await this.plugins.uninstall(id); + } catch (error) { + if (error instanceof PluginPackageError) throw toHttpException(error); + throw error; + } + } +} diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts new file mode 100644 index 0000000..8e35543 --- /dev/null +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Get, + NotFoundException, + Param, + Req, + Res, + StreamableFile, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; + +import { Public } from '../auth/auth.guard'; + +import { PluginStorageService } from './plugin-storage.service'; +import { PluginsService } from './plugins.service'; + +/** Content types for the file kinds a plugin bundle ships. Unknown extensions + * are served as opaque bytes — the sandbox iframe's CSP decides what may run. */ +const CONTENT_TYPES: Record = { + js: 'text/javascript; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + css: 'text/css; charset=utf-8', + json: 'application/json; charset=utf-8', + html: 'text/html; charset=utf-8', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + woff2: 'font/woff2', +}; + +function contentTypeFor(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + return CONTENT_TYPES[ext] ?? 'application/octet-stream'; +} + +/** + * Serves the unpacked assets of an installed plugin version (ADR 0008). Public + * because the sandboxed, opaque-origin iframe (#73) loads them with no session, + * and plugin bundles are client code, not user data. Paths are version-pinned + * and content-addressed by version, so responses are immutable. + */ +@Controller('plugins') +export class PluginAssetsController { + constructor( + private readonly plugins: PluginsService, + private readonly storage: PluginStorageService, + ) {} + + @Get(':id/:version/*rest') + @Public() + async serve( + @Param('id') id: string, + @Param('version') version: string, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ): Promise { + // Only serve assets for an installed, current version — a removed plugin or + // a stale version pointer must not leak files. + const plugin = await this.plugins.get(id); + if (!plugin || plugin.version !== version) throw new NotFoundException(); + + const rest = (request.params as Record).rest; + const relPath = Array.isArray(rest) ? rest.join('/') : String(rest ?? ''); + const full = this.storage.assetPath(id, version, relPath); + if (!full || !(await this.storage.assetExists(full))) throw new NotFoundException(); + + response.set('X-Content-Type-Options', 'nosniff'); + response.set('Cache-Control', 'public, max-age=31536000, immutable'); + return new StreamableFile(this.storage.createAssetReadStream(full), { + type: contentTypeFor(relPath), + }); + } +} diff --git a/apps/api/src/plugins/plugin-css.ts b/apps/api/src/plugins/plugin-css.ts new file mode 100644 index 0000000..aea364a --- /dev/null +++ b/apps/api/src/plugins/plugin-css.ts @@ -0,0 +1,57 @@ +import { PluginPackageError } from './plugin.constants'; + +/** + * Style-plugin CSS sanitation (ADR 0008, security.md §Plugins). A style plugin + * ships CSS only — no JavaScript — so the risk is exfiltration/track-back via + * network-fetching CSS constructs, not code execution. We reject the constructs + * that can reach off-instance or execute: + * + * - `@import` — pulls in another stylesheet (possibly external); + * - `url(...)` pointing at an absolute/external/`javascript:` target — a font + * or background that beacons to a third party (relative and `data:` are ok); + * - `expression(...)` — legacy IE dynamic CSS (script execution). + * + * Selector scoping under `.dt-style--` is applied by the + * section-style renderer (#75); here we only gate what may be stored. + */ + +/** Strips CSS comments so their contents cannot hide a rejected construct. */ +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ' '); +} + +/** Whether a `url(...)` target reaches outside the plugin's own assets. A bare + * relative path or a `data:` URI stays inside the sandbox; anything with a + * scheme (`http:`, `https:`, `javascript:`, `file:`, …) or a protocol-relative + * `//host` prefix is external. */ +function isExternalUrl(target: string): boolean { + const value = target.trim().replace(/^['"]|['"]$/g, ''); + if (value.startsWith('//')) return true; + if (/^data:/i.test(value)) return false; + return /^[a-z][a-z0-9+.-]*:/i.test(value); +} + +/** + * Throws {@link PluginPackageError} `plugin_css_unsafe` if the stylesheet uses a + * forbidden construct; returns normally when it is safe to store and serve. + */ +export function assertSafeCss(css: string): void { + const source = stripComments(css); + + if (/@import\b/i.test(source)) { + throw new PluginPackageError('plugin_css_unsafe', 'CSS may not use @import'); + } + if (/\bexpression\s*\(/i.test(source)) { + throw new PluginPackageError('plugin_css_unsafe', 'CSS may not use expression()'); + } + + for (const match of source.matchAll(/\burl\(\s*([^)]*?)\s*\)/gi)) { + const target = match[1] ?? ''; + if (isExternalUrl(target)) { + throw new PluginPackageError( + 'plugin_css_unsafe', + `CSS may not reference an external URL: ${target.trim()}`, + ); + } + } +} diff --git a/apps/api/src/plugins/plugin-package.service.test.ts b/apps/api/src/plugins/plugin-package.service.test.ts new file mode 100644 index 0000000..2090d41 --- /dev/null +++ b/apps/api/src/plugins/plugin-package.service.test.ts @@ -0,0 +1,134 @@ +import { zipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; + +import { PluginPackageService } from './plugin-package.service'; +import { PluginPackageError } from './plugin.constants'; + +const service = new PluginPackageService(); +const enc = (text: string) => new TextEncoder().encode(text); + +const codeManifest = { + id: 'toc', + name: 'Table of Contents', + version: '1.0.0', + apiVersion: '1', + kind: 'code', + extensionPoints: [{ type: 'pageTool', id: 'toc', title: { de: 'Inhalt', en: 'Contents' } }], + permissions: ['readCurrentPage'], + license: 'MIT', +}; + +const styleManifest = { + id: 'callouts', + name: 'Callouts', + version: '1.0.0', + apiVersion: '1', + kind: 'section_style', + extensionPoints: [{ type: 'sectionStyle', id: 'note', title: { de: 'Hinweis', en: 'Note' } }], + license: 'MIT', +}; + +function zip(entries: Record): Buffer { + return Buffer.from(zipSync(entries)); +} + +function manifestZip(manifest: unknown, extra: Record = {}): Buffer { + return zip({ 'manifest.json': enc(JSON.stringify(manifest)), ...extra }); +} + +/** Asserts parsing throws a PluginPackageError with the expected code. */ +function expectReject(buffer: Buffer, code: string): PluginPackageError { + try { + service.parse(buffer); + } catch (error) { + expect(error).toBeInstanceOf(PluginPackageError); + expect((error as PluginPackageError).code).toBe(code); + return error as PluginPackageError; + } + throw new Error(`expected parse to reject with ${code}`); +} + +describe('PluginPackageService.parse — valid packages', () => { + it('accepts a code plugin with a bundle', () => { + const result = service.parse( + manifestZip(codeManifest, { 'plugin.js': enc('export default {}') }), + ); + expect(result.manifest.id).toBe('toc'); + expect(result.files.has('plugin.js')).toBe(true); + }); + + it('accepts a section-style plugin with a stylesheet', () => { + const css = '.note { background: #eef; } .icon { background: url("./icon.png"); }'; + const result = service.parse(manifestZip(styleManifest, { 'styles.css': enc(css) })); + expect(result.manifest.kind).toBe('section_style'); + expect(result.files.has('styles.css')).toBe(true); + }); +}); + +describe('PluginPackageService.parse — each invalid class', () => { + it('rejects a non-ZIP', () => { + expectReject(Buffer.from('not a zip at all'), 'plugin_invalid_zip'); + }); + + it('rejects a package without a manifest', () => { + expectReject(zip({ 'plugin.js': enc('x') }), 'plugin_bad_structure'); + }); + + it('rejects a manifest that is not valid JSON', () => { + expectReject(zip({ 'manifest.json': enc('{ not json') }), 'plugin_invalid_manifest'); + }); + + it('rejects a schema-invalid manifest with field details', () => { + const error = expectReject( + manifestZip({ ...codeManifest, id: 'Bad Id' }, { 'plugin.js': enc('x') }), + 'plugin_invalid_manifest', + ); + expect(error.details?.id).toBeTruthy(); + }); + + it('rejects an incompatible apiVersion', () => { + expectReject( + manifestZip({ ...codeManifest, apiVersion: '2' }, { 'plugin.js': enc('x') }), + 'plugin_api_incompatible', + ); + }); + + it('rejects a code plugin without a bundle', () => { + expectReject(manifestZip(codeManifest), 'plugin_missing_bundle'); + }); + + it('rejects a style plugin without a stylesheet', () => { + expectReject(manifestZip(styleManifest), 'plugin_missing_styles'); + }); + + it('rejects a stylesheet using @import', () => { + expectReject( + manifestZip(styleManifest, { 'styles.css': enc('@import url("http://evil.test/x.css");') }), + 'plugin_css_unsafe', + ); + }); + + it('rejects a stylesheet fetching an external url', () => { + expectReject( + manifestZip(styleManifest, { + 'styles.css': enc('.n { background: url(https://evil.test/a) }'), + }), + 'plugin_css_unsafe', + ); + }); + + it('rejects a zip-slip path', () => { + expectReject( + zip({ '../evil.js': enc('x'), 'manifest.json': enc(JSON.stringify(codeManifest)) }), + 'plugin_bad_structure', + ); + }); + + it('rejects a package that inflates beyond the unpacked limit', () => { + const huge = new Uint8Array(21 * 1024 * 1024); // zeros compress tiny, inflate large + expectReject( + zip({ 'manifest.json': enc(JSON.stringify(codeManifest)), 'plugin.js': huge }), + 'plugin_too_large', + ); + }); +}); diff --git a/apps/api/src/plugins/plugin-package.service.ts b/apps/api/src/plugins/plugin-package.service.ts new file mode 100644 index 0000000..8347e7d --- /dev/null +++ b/apps/api/src/plugins/plugin-package.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; +import { checkApiVersion, validateManifest, type PluginManifest } from '@dorfteich/plugin-sdk'; +import { unzipSync } from 'fflate'; + +import { assertSafeCss } from './plugin-css'; +import { + CODE_BUNDLE_FILE, + MANIFEST_FILE, + MAX_PLUGIN_UNPACKED_BYTES, + PluginPackageError, + STYLES_FILE, +} from './plugin.constants'; + +/** A validated, unpacked plugin package ready to be written to disk. */ +export interface ParsedPluginPackage { + manifest: PluginManifest; + /** Relative posix path → file bytes (directory entries removed). */ + files: Map; +} + +/** + * Turns an uploaded ZIP buffer into a validated {@link ParsedPluginPackage} or + * throws {@link PluginPackageError} (ADR 0008, plugin-architecture.md §Package + * format). Pure and stateless — no filesystem or database access — so both the + * GUI upload and the directory watcher run the exact same validation. + */ +@Injectable() +export class PluginPackageService { + parse(zip: Buffer): ParsedPluginPackage { + const files = this.unzip(zip); + const manifest = this.readManifest(files); + this.checkApiCompatibility(manifest); + this.checkKindFiles(manifest, files); + return { manifest, files }; + } + + private unzip(zip: Buffer): Map { + let entries: Record; + try { + entries = unzipSync(new Uint8Array(zip)); + } catch { + throw new PluginPackageError('plugin_invalid_zip', 'The file is not a valid ZIP archive'); + } + + const files = new Map(); + let unpacked = 0; + for (const [rawPath, bytes] of Object.entries(entries)) { + // Directory entries come back as zero-length keys ending in '/'. + if (rawPath.endsWith('/')) continue; + const path = this.safeRelativePath(rawPath); + unpacked += bytes.length; + if (unpacked > MAX_PLUGIN_UNPACKED_BYTES) { + throw new PluginPackageError('plugin_too_large', 'The unpacked package is too large'); + } + files.set(path, bytes); + } + if (files.size === 0) { + throw new PluginPackageError('plugin_bad_structure', 'The package is empty'); + } + return files; + } + + /** Rejects absolute paths and `..` traversal (zip-slip) before any write. */ + private safeRelativePath(rawPath: string): string { + const path = rawPath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (path.split('/').some((segment) => segment === '..')) { + throw new PluginPackageError('plugin_bad_structure', `Illegal path in package: ${rawPath}`); + } + return path; + } + + private readManifest(files: Map): PluginManifest { + const raw = files.get(MANIFEST_FILE); + if (!raw) { + throw new PluginPackageError('plugin_bad_structure', `Package is missing ${MANIFEST_FILE}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(raw)); + } catch { + throw new PluginPackageError('plugin_invalid_manifest', `${MANIFEST_FILE} is not valid JSON`); + } + const result = validateManifest(parsed); + if (!result.success || !result.manifest) { + const details: Record = {}; + for (const issue of result.issues) { + (details[issue.path] ??= []).push(issue.message); + } + throw new PluginPackageError('plugin_invalid_manifest', 'The manifest is invalid', details); + } + return result.manifest; + } + + private checkApiCompatibility(manifest: PluginManifest): void { + const check = checkApiVersion(manifest.apiVersion); + if (!check.compatible) { + throw new PluginPackageError( + 'plugin_api_incompatible', + check.reason ?? 'Unsupported plugin API version', + ); + } + } + + private checkKindFiles(manifest: PluginManifest, files: Map): void { + if (manifest.kind === 'code' && !files.has(CODE_BUNDLE_FILE)) { + throw new PluginPackageError( + 'plugin_missing_bundle', + `A code plugin must include ${CODE_BUNDLE_FILE}`, + ); + } + if (manifest.kind === 'section_style' && !files.has(STYLES_FILE)) { + throw new PluginPackageError( + 'plugin_missing_styles', + `A style plugin must include ${STYLES_FILE}`, + ); + } + // Whenever a stylesheet is present (required for style plugins, optional for + // code plugins), it must pass the sanitation gate. + const styles = files.get(STYLES_FILE); + if (styles) { + assertSafeCss(new TextDecoder().decode(styles)); + } + } +} diff --git a/apps/api/src/plugins/plugin-storage.service.ts b/apps/api/src/plugins/plugin-storage.service.ts new file mode 100644 index 0000000..ad2f2c7 --- /dev/null +++ b/apps/api/src/plugins/plugin-storage.service.ts @@ -0,0 +1,107 @@ +import { createReadStream } from 'node:fs'; +import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Readable } from 'node:stream'; + +import { Injectable } from '@nestjs/common'; + +import { AppConfig } from '../config/app-config.service'; + +/** Subfolders inside PLUGINS_DIR that are not plugin ids. */ +const DROPZONE_DIR = '_dropzone'; +const QUARANTINE_DIR = '_quarantine'; + +/** + * Filesystem layout for installed plugins (ADR 0008, issue #71): + * `///`. A Site Admin drops ZIPs into + * `/_dropzone/`; rejected drops move to `_quarantine/`. Kept behind + * this service so callers never build plugin paths themselves (and a future S3 + * binding stays possible). + */ +@Injectable() +export class PluginStorageService { + constructor(private readonly config: AppConfig) {} + + private get root(): string { + return resolve(this.config.env.PLUGINS_DIR); + } + + versionDir(id: string, version: string): string { + return join(this.root, id, version); + } + + get dropzoneDir(): string { + return join(this.root, DROPZONE_DIR); + } + + get quarantineDir(): string { + return join(this.root, QUARANTINE_DIR); + } + + /** Creates the dropzone/quarantine folders so the watcher can rely on them. */ + async ensureServiceDirs(): Promise { + await mkdir(this.dropzoneDir, { recursive: true }); + await mkdir(this.quarantineDir, { recursive: true }); + } + + /** + * Writes a version's files atomically: fully populate a temp directory, then + * rename it into place. The version directory therefore never exists in a + * half-written state, so a client serving `///…` never sees a + * 404 for a file that is about to appear (acceptance: atomic update). + */ + async writeVersion(id: string, version: string, files: Map): Promise { + const target = this.versionDir(id, version); + const staging = `${target}.tmp-${randomUUID()}`; + try { + for (const [relPath, bytes] of files) { + const dest = join(staging, relPath); + await mkdir(dirname(dest), { recursive: true }); + await writeFile(dest, bytes); + } + // Replace any existing dir for this exact version (re-upload of same + // version) so the rename target is free. + await rm(target, { recursive: true, force: true }); + await mkdir(dirname(target), { recursive: true }); + await rename(staging, target); + } finally { + await rm(staging, { recursive: true, force: true }); + } + } + + /** Removes one obsolete version directory after an update flips the pointer. */ + async removeVersion(id: string, version: string): Promise { + await rm(this.versionDir(id, version), { recursive: true, force: true }); + } + + /** Removes a plugin's entire tree (uninstall). Idempotent. */ + async removePlugin(id: string): Promise { + await rm(join(this.root, id), { recursive: true, force: true }); + } + + /** + * Resolves an asset path inside a version directory, guarding against + * traversal outside it. Returns `null` if the request escapes the directory. + */ + assetPath(id: string, version: string, relPath: string): string | null { + const base = this.versionDir(id, version); + const full = resolve(base, relPath); + const rel = relative(base, full); + if (rel.startsWith('..') || resolve(base, rel) !== full) return null; + return full; + } + + async assetExists(fullPath: string): Promise { + try { + await access(fullPath); + return true; + } catch { + return false; + } + } + + createAssetReadStream(fullPath: string): Readable { + return createReadStream(fullPath); + } +} diff --git a/apps/api/src/plugins/plugin-watcher.service.ts b/apps/api/src/plugins/plugin-watcher.service.ts new file mode 100644 index 0000000..1f12ef9 --- /dev/null +++ b/apps/api/src/plugins/plugin-watcher.service.ts @@ -0,0 +1,106 @@ +import { watch, type FSWatcher } from 'node:fs'; +import { readFile, rename, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PinoLogger } from 'nestjs-pino'; + +import { ClockService } from '../common/clock.service'; +import { AppConfig } from '../config/app-config.service'; + +import { PluginStorageService } from './plugin-storage.service'; +import { PluginPackageError } from './plugin.constants'; +import { PluginsService } from './plugins.service'; + +/** Debounce window: an editor/copy may fire several `rename` events per file. */ +const DEBOUNCE_MS = 300; + +/** + * Registers plugin ZIPs a Site Admin drops into `/_dropzone/` + * (ADR 0008 lifecycle). Each dropped file runs the exact same validation as the + * GUI upload; a valid package installs and the ZIP is consumed, an invalid one + * moves to `_quarantine/` with its error logged. The watcher is inert under + * `NODE_ENV=test` — tests call {@link processDropped} directly for determinism. + */ +@Injectable() +export class PluginWatcherService implements OnModuleInit, OnModuleDestroy { + private watcher: FSWatcher | undefined; + private readonly pendingTimers = new Map(); + + constructor( + private readonly plugins: PluginsService, + private readonly storage: PluginStorageService, + private readonly config: AppConfig, + private readonly clock: ClockService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(PluginWatcherService.name); + } + + async onModuleInit(): Promise { + if (this.config.env.NODE_ENV === 'test') return; + await this.storage.ensureServiceDirs(); + this.watcher = watch(this.storage.dropzoneDir, (_event, filename) => { + if (!filename || !filename.endsWith('.zip')) return; + this.schedule(filename.toString()); + }); + this.logger.info({ dir: this.storage.dropzoneDir }, 'watching plugin dropzone'); + } + + onModuleDestroy(): void { + this.watcher?.close(); + for (const timer of this.pendingTimers.values()) clearTimeout(timer); + this.pendingTimers.clear(); + } + + private schedule(filename: string): void { + const existing = this.pendingTimers.get(filename); + if (existing) clearTimeout(existing); + this.pendingTimers.set( + filename, + setTimeout(() => { + this.pendingTimers.delete(filename); + void this.processDropped(join(this.storage.dropzoneDir, filename)); + }, DEBOUNCE_MS), + ); + } + + /** + * Installs one dropped ZIP. On success the file is removed from the dropzone; + * on any validation failure it is quarantined and the error logged. Returns + * the outcome so tests can assert without relying on filesystem events. + */ + async processDropped( + filePath: string, + ): Promise<{ installed: true; id: string } | { installed: false; code: string }> { + let zip: Buffer; + try { + zip = await readFile(filePath); + } catch { + // The file vanished between the event and the read — nothing to do. + return { installed: false, code: 'plugin_invalid_zip' }; + } + + try { + const view = await this.plugins.install(zip); + // The package is now unpacked under PLUGINS_DIR; consume the dropped ZIP. + await rm(filePath, { force: true }); + this.logger.info({ id: view.id, version: view.version }, 'installed plugin from dropzone'); + return { installed: true, id: view.id }; + } catch (error) { + const code = error instanceof PluginPackageError ? error.code : 'plugin_bad_structure'; + await this.quarantine(filePath); + this.logger.warn({ code, file: filePath }, 'quarantined invalid plugin drop'); + return { installed: false, code }; + } + } + + private async quarantine(filePath: string): Promise { + const name = filePath.split('/').pop() ?? 'package.zip'; + const stamp = this.clock.now().toISOString().replace(/[:.]/g, '-'); + await this.storage.ensureServiceDirs(); + await rename(filePath, join(this.storage.quarantineDir, `${stamp}-${name}`)).catch( + () => undefined, + ); + } +} diff --git a/apps/api/src/plugins/plugin.constants.ts b/apps/api/src/plugins/plugin.constants.ts new file mode 100644 index 0000000..b5e6e33 --- /dev/null +++ b/apps/api/src/plugins/plugin.constants.ts @@ -0,0 +1,29 @@ +import type { PluginErrorCode } from '@dorfteich/shared'; + +/** Compressed upload ceiling for a plugin ZIP (multer rejects larger). */ +export const MAX_PLUGIN_ZIP_BYTES = 5 * 1024 * 1024; // 5 MiB + +/** Total decompressed ceiling — guards against a zip bomb inflating in memory. */ +export const MAX_PLUGIN_UNPACKED_BYTES = 20 * 1024 * 1024; // 20 MiB + +/** The two files the package structure hinges on. */ +export const MANIFEST_FILE = 'manifest.json'; +export const CODE_BUNDLE_FILE = 'plugin.js'; +export const STYLES_FILE = 'styles.css'; + +/** + * A rejected install/uninstall, carrying a stable {@link PluginErrorCode} and, + * for manifest failures, the field-level issues to surface in the API error + * body `details`. Thrown by the package/registry services; the controller maps + * it to the matching HTTP status. + */ +export class PluginPackageError extends Error { + constructor( + readonly code: PluginErrorCode, + message: string, + readonly details?: Record, + ) { + super(message); + this.name = 'PluginPackageError'; + } +} diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts new file mode 100644 index 0000000..7a1ec3b --- /dev/null +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -0,0 +1,215 @@ +import { readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { zipSync } from 'fflate'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { PluginStorageService } from './plugin-storage.service'; +import { PluginWatcherService } from './plugin-watcher.service'; + +const enc = (text: string) => new TextEncoder().encode(text); + +function codeManifest(overrides: Record = {}): Record { + return { + id: 'toc', + name: 'Table of Contents', + version: '1.0.0', + apiVersion: '1', + kind: 'code', + extensionPoints: [{ type: 'pageTool', id: 'toc', title: { de: 'Inhalt', en: 'Contents' } }], + permissions: ['readCurrentPage'], + license: 'MIT', + ...overrides, + }; +} + +function pluginZip(manifest: Record, bundle = 'export default {}'): Buffer { + return Buffer.from( + zipSync({ 'manifest.json': enc(JSON.stringify(manifest)), 'plugin.js': enc(bundle) }), + ); +} + +/** + * Plugin storage, install API, and directory watcher (issue #71). Exercises the + * GUI upload endpoint, the dropzone watcher, atomic updates, and the uninstall + * guards against a real database and filesystem. + */ +describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let storage: PluginStorageService; + let watcher: PluginWatcherService; + const suffix = uniqueSuffix(); + const password = 'plugins installieren macht spass 1'; + + const admin = { username: `pam-plugins-${suffix}`, displayName: `Pam Plugins ${suffix}` }; + const outsider = { username: `orin-plugins-${suffix}`, displayName: `Orin Outside ${suffix}` }; + let adminCookie: string; + let outsiderCookie: string; + + const api = () => request(app.getHttpServer()); + + async function loginOf(username: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + return sessionCookieOf(res); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + storage = app.get(PluginStorageService); + watcher = app.get(PluginWatcherService); + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + + const adminUser = await users.createUser({ + username: admin.username, + email: `${admin.username}@example.org`, + displayName: admin.displayName, + password, + locale: 'en', + }); + const verifyToken = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); + await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); + adminCookie = await loginOf(admin.username); + + const outsiderUser = await users.createUser({ + username: outsider.username, + email: `${outsider.username}@example.org`, + displayName: outsider.displayName, + password, + locale: 'en', + }); + await users.markEmailVerified(outsiderUser.id); + outsiderCookie = await loginOf(outsider.username); + }); + + afterAll(async () => { + await prisma.pondPlugin.deleteMany({}); + await prisma.plugin.deleteMany({}); + await app.close(); + }); + + it('installs via the GUI upload and serves the bundle immutably', async () => { + const res = await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest()), 'toc.zip') + .expect(201); + expect(res.body).toMatchObject({ id: 'toc', version: '1.0.0', kind: 'code', mode: 'disabled' }); + + const asset = await api().get('/api/v1/plugins/toc/1.0.0/plugin.js').expect(200); + expect(asset.headers['cache-control']).toContain('immutable'); + expect(asset.headers['content-type']).toContain('text/javascript'); + expect(asset.text).toContain('export default'); + + const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200); + expect(list.body.map((p: { id: string }) => p.id)).toContain('toc'); + }); + + it('forbids install for non-Site-Admins', async () => { + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', outsiderCookie) + .attach('file', pluginZip(codeManifest({ id: 'nope' })), 'nope.zip') + .expect(403); + }); + + it('rejects a schema-invalid manifest with field details', async () => { + const res = await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'Bad Id' })), 'bad.zip') + .expect(400); + expect(res.body.code).toBe('plugin_invalid_manifest'); + expect(res.body.details.id).toBeTruthy(); + }); + + it('installs a plugin dropped into the dropzone and quarantines an invalid one', async () => { + await storage.ensureServiceDirs(); + + const goodPath = join(storage.dropzoneDir, 'dropped-index.zip'); + await writeFile(goodPath, pluginZip(codeManifest({ id: 'page-index' }))); + const good = await watcher.processDropped(goodPath); + expect(good).toEqual({ installed: true, id: 'page-index' }); + expect( + await storage.assetExists(join(storage.versionDir('page-index', '1.0.0'), 'plugin.js')), + ).toBe(true); + + const badPath = join(storage.dropzoneDir, 'dropped-broken.zip'); + await writeFile(badPath, Buffer.from('not a zip')); + const bad = await watcher.processDropped(badPath); + expect(bad.installed).toBe(false); + const quarantined = await readdir(storage.quarantineDir); + expect(quarantined.some((name) => name.endsWith('dropped-broken.zip'))).toBe(true); + }); + + it('updates only to a higher version, atomically', async () => { + // Install a fresh plugin, then update it. + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip') + .expect(201); + + // A same/lower version is refused. + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.0.0' })), 'v1.zip') + .expect(409) + .expect((r) => expect(r.body.code).toBe('plugin_version_not_higher')); + + // A higher version installs; its assets exist and the old version is gone. + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'updatable', version: '1.1.0' })), 'v2.zip') + .expect(201); + + await api().get('/api/v1/plugins/updatable/1.1.0/plugin.js').expect(200); + // The pointer moved, so the old version no longer resolves. + await api().get('/api/v1/plugins/updatable/1.0.0/plugin.js').expect(404); + expect( + await storage.assetExists(join(storage.versionDir('updatable', '1.0.0'), 'plugin.js')), + ).toBe(false); + }); + + it('refuses uninstall while required, then removes files and marks it removed', async () => { + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'removable' })), 'r.zip') + .expect(201); + + // Required plugins cannot be uninstalled. + await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'REQUIRED' } }); + await api() + .delete('/api/v1/admin/plugins/removable') + .set('Cookie', adminCookie) + .expect(409) + .expect((r) => expect(r.body.code).toBe('plugin_required_cannot_uninstall')); + + // Made optional, it uninstalls: assets vanish and metadata is tombstoned. + await prisma.plugin.update({ where: { id: 'removable' }, data: { mode: 'OPTIONAL' } }); + await api().delete('/api/v1/admin/plugins/removable').set('Cookie', adminCookie).expect(204); + + await api().get('/api/v1/plugins/removable/1.0.0/plugin.js').expect(404); + const record = await prisma.plugin.findUnique({ where: { id: 'removable' } }); + expect(record?.removedAt).not.toBeNull(); + const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200); + expect(list.body.map((p: { id: string }) => p.id)).not.toContain('removable'); + }); +}); diff --git a/apps/api/src/plugins/plugins.module.ts b/apps/api/src/plugins/plugins.module.ts new file mode 100644 index 0000000..cc78fe4 --- /dev/null +++ b/apps/api/src/plugins/plugins.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; + +import { CommonModule } from '../common/common.module'; + +import { PluginAdminController } from './plugin-admin.controller'; +import { PluginAssetsController } from './plugin-assets.controller'; +import { PluginPackageService } from './plugin-package.service'; +import { PluginStorageService } from './plugin-storage.service'; +import { PluginWatcherService } from './plugin-watcher.service'; +import { PluginsService } from './plugins.service'; + +/** + * Plugin installation and serving (ADR 0008, issue #71): the ZIP package + * validator, the on-disk store under `PLUGINS_DIR`, the install registry, the + * Site Admin admin endpoints, static asset serving for the sandbox, and the + * dropzone directory watcher. + */ +@Module({ + imports: [CommonModule], + controllers: [PluginAdminController, PluginAssetsController], + providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService], + exports: [PluginsService], +}) +export class PluginsModule {} diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts new file mode 100644 index 0000000..9753897 --- /dev/null +++ b/apps/api/src/plugins/plugins.service.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; +import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; +import type { PluginInstanceMode, PluginView } from '@dorfteich/shared'; + +import { ClockService } from '../common/clock.service'; +import { PrismaService } from '../prisma/prisma.service'; + +import { PluginPackageService } from './plugin-package.service'; +import { PluginStorageService } from './plugin-storage.service'; +import { PluginPackageError } from './plugin.constants'; + +const DB_MODE_TO_VIEW: Record = { + DISABLED: 'disabled', + OPTIONAL: 'optional', + REQUIRED: 'required', +}; + +/** + * Install registry for plugin packages (ADR 0008, issue #71): validates and + * unpacks an uploaded ZIP, records/updates its metadata, and removes it on + * uninstall. The install path is shared by the admin upload endpoint and the + * directory watcher. + */ +@Injectable() +export class PluginsService { + constructor( + private readonly prisma: PrismaService, + private readonly packages: PluginPackageService, + private readonly storage: PluginStorageService, + private readonly clock: ClockService, + ) {} + + /** + * Validates and installs (or updates) a plugin from a ZIP buffer. An update + * (same id already installed and not removed) is accepted only when its + * version is strictly higher; the admin's chosen instance `mode` is preserved + * across updates. Files land atomically before the metadata pointer flips. + */ + async install(zip: Buffer): Promise { + const { manifest, files } = this.packages.parse(zip); + + const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); + const isActiveUpdate = existing !== null && existing.removedAt === null; + if (isActiveUpdate && !isHigherVersion(manifest.version, existing.version)) { + throw new PluginPackageError( + 'plugin_version_not_higher', + `Version ${manifest.version} does not exceed the installed ${existing.version}`, + ); + } + + // Write the new version's assets first — the metadata still points at the + // old version until the upsert below, so serving never 404s mid-update. + await this.storage.writeVersion(manifest.id, manifest.version, files); + + const record = await this.prisma.plugin.upsert({ + where: { id: manifest.id }, + create: { + id: manifest.id, + name: manifest.name, + version: manifest.version, + apiVersion: manifest.apiVersion, + kind: manifest.kind, + manifest: manifest as unknown as Prisma.InputJsonValue, + }, + update: { + name: manifest.name, + version: manifest.version, + apiVersion: manifest.apiVersion, + kind: manifest.kind, + manifest: manifest as unknown as Prisma.InputJsonValue, + // Reinstalling a previously removed plugin clears the tombstone. + removedAt: null, + }, + }); + + // Drop the superseded version's files once the pointer has moved. + if (existing && existing.version !== manifest.version) { + await this.storage.removeVersion(manifest.id, existing.version); + } + + return this.toView(record); + } + + /** + * Uninstalls a plugin: refused while `required`; otherwise the metadata is + * tombstoned (`removedAt` set, per-pond activations dropped) and every file is + * removed from disk. + */ + async uninstall(id: string): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id } }); + if (!plugin || plugin.removedAt !== null) { + throw new PluginPackageError('plugin_not_found', `Plugin ${id} is not installed`); + } + if (plugin.mode === 'REQUIRED') { + throw new PluginPackageError( + 'plugin_required_cannot_uninstall', + `Plugin ${id} is required and cannot be uninstalled`, + ); + } + + await this.prisma.$transaction([ + this.prisma.pondPlugin.deleteMany({ where: { pluginId: id } }), + this.prisma.plugin.update({ + where: { id }, + data: { removedAt: this.clock.now() }, + }), + ]); + await this.storage.removePlugin(id); + } + + /** All installed (non-removed) plugins, for the Site Admin list (#72). */ + async list(): Promise { + const plugins = await this.prisma.plugin.findMany({ + where: { removedAt: null }, + orderBy: { name: 'asc' }, + }); + return plugins.map((plugin) => this.toView(plugin)); + } + + /** One installed plugin, or `null` if absent/removed. */ + async get(id: string): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id } }); + if (!plugin || plugin.removedAt !== null) return null; + return this.toView(plugin); + } + + private toView(plugin: Plugin): PluginView { + const manifest = plugin.manifest as unknown as PluginManifest; + return { + id: plugin.id, + name: plugin.name, + version: plugin.version, + apiVersion: plugin.apiVersion, + kind: plugin.kind, + mode: DB_MODE_TO_VIEW[plugin.mode], + permissions: manifest.permissions ?? [], + extensionPoints: manifest.extensionPoints.map((point) => ({ + type: point.type, + id: point.id, + title: point.title, + })), + assetBasePath: `/api/v1/plugins/${plugin.id}/${plugin.version}/`, + license: manifest.license, + homepage: manifest.homepage, + installedAt: plugin.installedAt.toISOString(), + updatedAt: plugin.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index c7b0509..1684465 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -26,6 +26,7 @@ export async function createTestApp( // Fresh scratch directory per test file so upload tests never touch the // repository or collide with each other. process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-')); + process.env.PLUGINS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-plugins-')); const base = Test.createTestingModule({ imports: [AppModule] }); const moduleRef = await (customize ? customize(base) : base).compile(); diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 9a3f882..90bd371 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -60,6 +60,9 @@ services: SMTP_FROM: ${SMTP_FROM:-Dorfteich } # Matches the `uploads` volume mount below (ADR 0011). UPLOADS_DIR: /data/uploads + # Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site + # Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them. + PLUGINS_DIR: /data/plugins # Internal pandoc-server sidecar for import/export (ADR 0009, issue #62). PANDOC_URL: http://pandoc:3030 # Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67). @@ -69,6 +72,7 @@ services: networks: [frontend, internal] volumes: - uploads:/data/uploads + - plugins:/data/plugins depends_on: db: condition: service_healthy @@ -161,3 +165,4 @@ networks: volumes: db-data: uploads: + plugins: diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index a62b29c..80337aa 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -138,14 +138,21 @@ slug resolves those rows. ### `plugins` -`id` (manifest id), `version`, `manifest` (jsonb), `storage_path`, -`kind` (`code` / `section_style`), `instance_mode` -(`disabled` / `optional` / `required`), `installed_by`, `installed_at`. +`id` (manifest id, primary key), `name`, `version`, `api_version`, +`kind` (`code` / `section_style`), `mode` +(`DISABLED` / `OPTIONAL` / `REQUIRED`, the instance mode — issue #72 sets it), +`manifest` (jsonb — the full validated manifest, so serving/admin views never +re-read disk), `installed_at`, `updated_at`, `removed_at` (soft-delete +tombstone: set on uninstall, files removed, so existing `plugin_block` nodes can +still resolve the manifest fallback). Unpacked bundles live on the plugins +volume at `///`; the path is derived from id+version, +not stored (issue #71). ### `pond_plugins` (`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional` -plugins; `required` plugins are active everywhere. +plugins; `required` plugins are active everywhere. Rows cascade-delete with +their pond or plugin, and are dropped when a plugin is uninstalled. ## Quotas and settings diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 148a92b..a28cd59 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -4,3 +4,4 @@ export * from './host'; export * from './manifest'; export * from './plugin'; export * from './rpc'; +export * from './version'; diff --git a/packages/plugin-sdk/src/version.test.ts b/packages/plugin-sdk/src/version.test.ts new file mode 100644 index 0000000..fea686e --- /dev/null +++ b/packages/plugin-sdk/src/version.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { compareVersions, isHigherVersion } from './version'; + +describe('compareVersions', () => { + it('orders by major, then minor, then patch', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.2.0', '1.1.9')).toBe(1); + expect(compareVersions('1.0.10', '1.0.9')).toBe(1); + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + }); +}); + +describe('isHigherVersion', () => { + it('is true only for a strictly greater version', () => { + expect(isHigherVersion('1.0.1', '1.0.0')).toBe(true); + expect(isHigherVersion('1.0.0', '1.0.0')).toBe(false); + expect(isHigherVersion('0.9.0', '1.0.0')).toBe(false); + }); +}); diff --git a/packages/plugin-sdk/src/version.ts b/packages/plugin-sdk/src/version.ts new file mode 100644 index 0000000..54a731d --- /dev/null +++ b/packages/plugin-sdk/src/version.ts @@ -0,0 +1,31 @@ +/** + * Version comparison for plugin manifests. Versions are validated by the + * manifest schema as `MAJOR.MINOR.PATCH`, so a numeric three-part compare is + * exact — no pre-release/build metadata to reason about in v1. The install flow + * (#71) uses this to accept an update only when the uploaded version is higher + * than the installed one. + */ + +/** Splits a validated `x.y.z` string into its three numeric parts. */ +function parts(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version.split('.').map((n) => Number.parseInt(n, 10)); + return [major, minor, patch]; +} + +/** Returns -1 if `a` < `b`, 1 if `a` > `b`, 0 if equal. */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + const pa = parts(a); + const pb = parts(b); + for (let i = 0; i < 3; i += 1) { + const left = pa[i] ?? 0; + const right = pb[i] ?? 0; + if (left < right) return -1; + if (left > right) return 1; + } + return 0; +} + +/** Whether `candidate` is a strictly higher version than `current`. */ +export function isHigherVersion(candidate: string, current: string): boolean { + return compareVersions(candidate, current) > 0; +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 252de85..0e561e5 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -39,6 +39,17 @@ "import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.", "network": "Der Server war nicht erreichbar.", "grant_exists": "Diese Berechtigung existiert bereits.", + "plugin_invalid_zip": "Die hochgeladene Datei ist kein gültiges ZIP-Archiv.", + "plugin_bad_structure": "Das Plugin-Paket hat eine ungültige Struktur.", + "plugin_invalid_manifest": "Das Plugin-Manifest ist ungültig.", + "plugin_api_incompatible": "Dieses Plugin benötigt eine nicht unterstützte Plugin-API-Version.", + "plugin_too_large": "Das Plugin-Paket ist zu groß (Limit: {{limitBytes}} Bytes).", + "plugin_missing_bundle": "Ein Code-Plugin muss ein plugin.js-Bundle enthalten.", + "plugin_missing_styles": "Ein Style-Plugin muss eine styles.css-Datei enthalten.", + "plugin_css_unsafe": "Das Plugin-Stylesheet verwendet nicht erlaubte Konstrukte.", + "plugin_version_not_higher": "Ein Update muss eine höhere Version als das installierte haben.", + "plugin_required_cannot_uninstall": "Ein erforderliches Plugin kann nicht deinstalliert werden.", + "plugin_not_found": "Dieses Plugin ist nicht installiert.", "grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.", "grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.", "grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 5c99e40..0ddedf6 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -39,6 +39,17 @@ "import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.", "network": "The server could not be reached.", "grant_exists": "This grant already exists.", + "plugin_invalid_zip": "The uploaded file is not a valid ZIP archive.", + "plugin_bad_structure": "The plugin package has an invalid structure.", + "plugin_invalid_manifest": "The plugin manifest is invalid.", + "plugin_api_incompatible": "This plugin targets an unsupported plugin API version.", + "plugin_too_large": "The plugin package is too large (limit: {{limitBytes}} bytes).", + "plugin_missing_bundle": "A code plugin must include a plugin.js bundle.", + "plugin_missing_styles": "A style plugin must include a styles.css file.", + "plugin_css_unsafe": "The plugin stylesheet uses constructs that are not allowed.", + "plugin_version_not_higher": "An update must have a higher version than the installed one.", + "plugin_required_cannot_uninstall": "A required plugin cannot be uninstalled.", + "plugin_not_found": "This plugin is not installed.", "grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.", "grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.", "grant_subject_id_mismatch": "The grant's subject is inconsistent.", diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index d016b39..e01458c 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -82,6 +82,14 @@ export const apiEnvSchema = z.object({ * dev/test runs point this at the web app's built `public/fonts`. */ FONTS_DIR: z.string().min(1).default('./fonts'), + /** + * Directory holding installed plugin packages (ADR 0008, issue #71). Layout + * `///…` for unpacked bundles the sandbox iframe + * loads, plus a `_dropzone/` a Site Admin drops ZIPs into and a + * `_quarantine/` for rejected drops. In Docker a persistent volume mounts + * here; the relative default serves native dev/test runs. + */ + PLUGINS_DIR: z.string().min(1).default('./data/plugins'), }); export type ApiEnv = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 20d8040..2f8fb7e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,6 +14,7 @@ export * from './links'; export * from './members'; export * from './pages'; export * from './permissions'; +export * from './plugins'; export * from './search'; export * from './ponds'; export * from './quotas'; diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts new file mode 100644 index 0000000..fcec22e --- /dev/null +++ b/packages/shared/src/plugins.ts @@ -0,0 +1,59 @@ +/** + * Plugin administration types shared between api and web (ADR 0008, issue #71). + * The manifest itself lives in `@dorfteich/plugin-sdk`; these types describe an + * *installed* plugin as the instance stores and surfaces it. + */ + +/** + * Instance-level activation a Site Admin sets per plugin (ADR 0008 lifecycle): + * `disabled` — installed but inert; `optional` — available, Pond Admins toggle + * it per pond; `required` — always on everywhere and cannot be uninstalled. + */ +export const PLUGIN_INSTANCE_MODES = ['disabled', 'optional', 'required'] as const; +export type PluginInstanceMode = (typeof PLUGIN_INSTANCE_MODES)[number]; + +/** One extension point as surfaced to admins (mirrors the manifest entry). */ +export interface PluginExtensionPointView { + type: string; + id: string; + title: Record; +} + +/** An installed plugin as returned by the admin API. */ +export interface PluginView { + id: string; + name: string; + version: string; + apiVersion: string; + kind: string; + mode: PluginInstanceMode; + /** Capabilities the manifest declared, shown to the Site Admin at install. */ + permissions: string[]; + extensionPoints: PluginExtensionPointView[]; + /** Base path the sandbox loads assets from: `/plugins///`. */ + assetBasePath: string; + license: string; + homepage?: string; + installedAt: string; + updatedAt: string; +} + +/** + * Machine-readable rejection codes for an install/uninstall attempt. Each is + * also an `errors.` i18n key. `validateManifest` field issues travel in + * the ApiErrorBody `details`. + */ +export const PLUGIN_ERROR_CODES = [ + 'plugin_invalid_zip', + 'plugin_bad_structure', + 'plugin_invalid_manifest', + 'plugin_api_incompatible', + 'plugin_too_large', + 'plugin_missing_bundle', + 'plugin_missing_styles', + 'plugin_css_unsafe', + 'plugin_version_not_higher', + 'plugin_required_cannot_uninstall', + 'plugin_not_found', +] as const; +export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11ed3a3..e8b5070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: apps/api: dependencies: + '@dorfteich/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@dorfteich/shared': specifier: workspace:* version: link:../../packages/shared @@ -56,6 +59,9 @@ importers: dompurify: specifier: ^3.4.11 version: 3.4.11 + fflate: + specifier: ^0.8.3 + version: 0.8.3 fractional-indexing: specifier: ^4.0.0 version: 4.0.0 @@ -141,9 +147,6 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.3 - fflate: - specifier: ^0.8.3 - version: 0.8.3 pdf-parse: specifier: ^2.4.5 version: 2.4.5