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
77 lines
2.5 KiB
TypeScript
77 lines
2.5 KiB
TypeScript
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),
|
|
});
|
|
}
|
|
}
|