Add plugin storage, install API, and directory watcher (#71)
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
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 `<PLUGINS_DIR>/<id>/<version>/`; 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 `<PLUGINS_DIR>/_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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
ec6ca80c4d
commit
621aa47244
@ -16,6 +16,7 @@
|
|||||||
"search:reindex": "tsx src/search/reindex.cli.ts"
|
"search:reindex": "tsx src/search/reindex.cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dorfteich/plugin-sdk": "workspace:*",
|
||||||
"@dorfteich/shared": "workspace:*",
|
"@dorfteich/shared": "workspace:*",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
@ -25,6 +26,7 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dompurify": "^3.4.11",
|
"dompurify": "^3.4.11",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"fractional-indexing": "^4.0.0",
|
"fractional-indexing": "^4.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
@ -55,7 +57,6 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@ -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;
|
||||||
@ -82,6 +82,7 @@ model Pond {
|
|||||||
labels Label[]
|
labels Label[]
|
||||||
grants RoleGrant[]
|
grants RoleGrant[]
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
|
pondPlugins PondPlugin[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
@ -571,3 +572,53 @@ model ConversionJob {
|
|||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("conversion_jobs")
|
@@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 `<PLUGINS_DIR>/<id>/<version>/`. 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")
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { MailModule } from './mail/mail.module';
|
|||||||
import { MembersModule } from './members/members.module';
|
import { MembersModule } from './members/members.module';
|
||||||
import { PagesModule } from './pages/pages.module';
|
import { PagesModule } from './pages/pages.module';
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
|
import { PluginsModule } from './plugins/plugins.module';
|
||||||
import { PondsModule } from './ponds/ponds.module';
|
import { PondsModule } from './ponds/ponds.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
@ -50,6 +51,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
MembersModule,
|
MembersModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
|
PluginsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
83
apps/api/src/plugins/plugin-admin.controller.ts
Normal file
83
apps/api/src/plugins/plugin-admin.controller.ts
Normal file
@ -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<PluginView> {
|
||||||
|
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<PluginView[]> {
|
||||||
|
return this.plugins.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uninstall a plugin (refused while `required`). */
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async uninstall(@Param('id') id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.plugins.uninstall(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PluginPackageError) throw toHttpException(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
apps/api/src/plugins/plugin-assets.controller.ts
Normal file
76
apps/api/src/plugins/plugin-assets.controller.ts
Normal file
@ -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<string, string> = {
|
||||||
|
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<StreamableFile> {
|
||||||
|
// 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<string, unknown>).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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/api/src/plugins/plugin-css.ts
Normal file
57
apps/api/src/plugins/plugin-css.ts
Normal file
@ -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-<pluginId>-<styleId>` 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()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
apps/api/src/plugins/plugin-package.service.test.ts
Normal file
134
apps/api/src/plugins/plugin-package.service.test.ts
Normal file
@ -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<string, Uint8Array>): Buffer {
|
||||||
|
return Buffer.from(zipSync(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestZip(manifest: unknown, extra: Record<string, Uint8Array> = {}): 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/api/src/plugins/plugin-package.service.ts
Normal file
124
apps/api/src/plugins/plugin-package.service.ts
Normal file
@ -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<string, Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, Uint8Array> {
|
||||||
|
let entries: Record<string, Uint8Array>;
|
||||||
|
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<string, Uint8Array>();
|
||||||
|
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<string, Uint8Array>): 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<string, string[]> = {};
|
||||||
|
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<string, Uint8Array>): 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/api/src/plugins/plugin-storage.service.ts
Normal file
107
apps/api/src/plugins/plugin-storage.service.ts
Normal file
@ -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):
|
||||||
|
* `<PLUGINS_DIR>/<id>/<version>/<files…>`. A Site Admin drops ZIPs into
|
||||||
|
* `<PLUGINS_DIR>/_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<void> {
|
||||||
|
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 `/<id>/<version>/…` never sees a
|
||||||
|
* 404 for a file that is about to appear (acceptance: atomic update).
|
||||||
|
*/
|
||||||
|
async writeVersion(id: string, version: string, files: Map<string, Uint8Array>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await rm(this.versionDir(id, version), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a plugin's entire tree (uninstall). Idempotent. */
|
||||||
|
async removePlugin(id: string): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
await access(fullPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAssetReadStream(fullPath: string): Readable {
|
||||||
|
return createReadStream(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
apps/api/src/plugins/plugin-watcher.service.ts
Normal file
106
apps/api/src/plugins/plugin-watcher.service.ts
Normal file
@ -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 `<PLUGINS_DIR>/_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<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/api/src/plugins/plugin.constants.ts
Normal file
29
apps/api/src/plugins/plugin.constants.ts
Normal file
@ -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<string, string[]>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PluginPackageError';
|
||||||
|
}
|
||||||
|
}
|
||||||
215
apps/api/src/plugins/plugins.e2e.db.test.ts
Normal file
215
apps/api/src/plugins/plugins.e2e.db.test.ts
Normal file
@ -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<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
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<string, unknown>, 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<string> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/api/src/plugins/plugins.module.ts
Normal file
24
apps/api/src/plugins/plugins.module.ts
Normal file
@ -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 {}
|
||||||
150
apps/api/src/plugins/plugins.service.ts
Normal file
150
apps/api/src/plugins/plugins.service.ts
Normal file
@ -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<DbPluginMode, PluginInstanceMode> = {
|
||||||
|
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<PluginView> {
|
||||||
|
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<void> {
|
||||||
|
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<PluginView[]> {
|
||||||
|
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<PluginView | null> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ export async function createTestApp(
|
|||||||
// Fresh scratch directory per test file so upload tests never touch the
|
// Fresh scratch directory per test file so upload tests never touch the
|
||||||
// repository or collide with each other.
|
// repository or collide with each other.
|
||||||
process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-'));
|
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 base = Test.createTestingModule({ imports: [AppModule] });
|
||||||
const moduleRef = await (customize ? customize(base) : base).compile();
|
const moduleRef = await (customize ? customize(base) : base).compile();
|
||||||
|
|||||||
@ -60,6 +60,9 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
||||||
# Matches the `uploads` volume mount below (ADR 0011).
|
# Matches the `uploads` volume mount below (ADR 0011).
|
||||||
UPLOADS_DIR: /data/uploads
|
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).
|
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
|
||||||
PANDOC_URL: http://pandoc:3030
|
PANDOC_URL: http://pandoc:3030
|
||||||
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
|
||||||
@ -69,6 +72,7 @@ services:
|
|||||||
networks: [frontend, internal]
|
networks: [frontend, internal]
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
|
- plugins:/data/plugins
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -161,3 +165,4 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
uploads:
|
uploads:
|
||||||
|
plugins:
|
||||||
|
|||||||
@ -138,14 +138,21 @@ slug resolves those rows.
|
|||||||
|
|
||||||
### `plugins`
|
### `plugins`
|
||||||
|
|
||||||
`id` (manifest id), `version`, `manifest` (jsonb), `storage_path`,
|
`id` (manifest id, primary key), `name`, `version`, `api_version`,
|
||||||
`kind` (`code` / `section_style`), `instance_mode`
|
`kind` (`code` / `section_style`), `mode`
|
||||||
(`disabled` / `optional` / `required`), `installed_by`, `installed_at`.
|
(`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 `<PLUGINS_DIR>/<id>/<version>/`; the path is derived from id+version,
|
||||||
|
not stored (issue #71).
|
||||||
|
|
||||||
### `pond_plugins`
|
### `pond_plugins`
|
||||||
|
|
||||||
(`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional`
|
(`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
|
## Quotas and settings
|
||||||
|
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export * from './host';
|
|||||||
export * from './manifest';
|
export * from './manifest';
|
||||||
export * from './plugin';
|
export * from './plugin';
|
||||||
export * from './rpc';
|
export * from './rpc';
|
||||||
|
export * from './version';
|
||||||
|
|||||||
20
packages/plugin-sdk/src/version.test.ts
Normal file
20
packages/plugin-sdk/src/version.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
packages/plugin-sdk/src/version.ts
Normal file
31
packages/plugin-sdk/src/version.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -39,6 +39,17 @@
|
|||||||
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
|
"import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
"grant_exists": "Diese Berechtigung existiert bereits.",
|
"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_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_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.",
|
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
|
||||||
|
|||||||
@ -39,6 +39,17 @@
|
|||||||
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
|
"import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.",
|
||||||
"network": "The server could not be reached.",
|
"network": "The server could not be reached.",
|
||||||
"grant_exists": "This grant already exists.",
|
"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_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_pond_admin_personal_pond": "A personal pond's only administrator is its owner.",
|
||||||
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
||||||
|
|||||||
@ -82,6 +82,14 @@ export const apiEnvSchema = z.object({
|
|||||||
* dev/test runs point this at the web app's built `public/fonts`.
|
* dev/test runs point this at the web app's built `public/fonts`.
|
||||||
*/
|
*/
|
||||||
FONTS_DIR: z.string().min(1).default('./fonts'),
|
FONTS_DIR: z.string().min(1).default('./fonts'),
|
||||||
|
/**
|
||||||
|
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
||||||
|
* `<PLUGINS_DIR>/<id>/<version>/…` 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<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export * from './links';
|
|||||||
export * from './members';
|
export * from './members';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
|
export * from './plugins';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
|
|||||||
59
packages/shared/src/plugins.ts
Normal file
59
packages/shared/src/plugins.ts
Normal file
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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/<id>/<version>/`. */
|
||||||
|
assetBasePath: string;
|
||||||
|
license: string;
|
||||||
|
homepage?: string;
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine-readable rejection codes for an install/uninstall attempt. Each is
|
||||||
|
* also an `errors.<code>` 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];
|
||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dorfteich/plugin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/plugin-sdk
|
||||||
'@dorfteich/shared':
|
'@dorfteich/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
@ -56,6 +59,9 @@ importers:
|
|||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.4.11
|
specifier: ^3.4.11
|
||||||
version: 3.4.11
|
version: 3.4.11
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
fractional-indexing:
|
fractional-indexing:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
@ -141,9 +147,6 @@ importers:
|
|||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
fflate:
|
|
||||||
specifier: ^0.8.3
|
|
||||||
version: 0.8.3
|
|
||||||
pdf-parse:
|
pdf-parse:
|
||||||
specifier: ^2.4.5
|
specifier: ^2.4.5
|
||||||
version: 2.4.5
|
version: 2.4.5
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user