import { Controller, Get, NotFoundException, Param, Req, Res, StreamableFile, UseGuards, } from '@nestjs/common'; import type { Request, Response } from 'express'; import type { PluginFallbackView } from '@dorfteich/shared'; import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame'; import { PluginStorageService } from './plugin-storage.service'; import { PluginsEnabledGuard } from './plugins-enabled.guard'; 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', // Bundled-app plugins (drawio): lazy-loaded stencils/resources/icons. xml: 'text/xml; charset=utf-8', txt: 'text/plain; charset=utf-8', ico: 'image/x-icon', }; 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, private readonly config: AppConfig, private readonly settings: InstanceSettingsService, ) {} /** * The manifest fallback a `plugin_block` shows while its plugin is inactive * (issue #76). Deliberately NOT `@Public`: it names installed plugins, which * is instance metadata for signed-in users, not sandbox-servable content. * 404 covers "never installed" — the client shows a neutral placeholder. * Deliberately NOT behind the kill switch either (issue #200): it serves no * plugin code, and with plugins disabled the existing blocks still need it * to render their declared fallback. An image fallback degrades to the * neutral placeholder then — its bytes live on the disabled asset surface. */ @Get(':id/fallback') @AuthenticatedOnly() async fallback(@Param('id') id: string): Promise { const view = await this.plugins.fallbackFor(id); if (!view) throw new NotFoundException(); if (view.fallback?.type === 'image' && !(await this.settings.get('plugins.enabled'))) { return { ...view, fallback: null }; } return view; } /** * The sandbox frame document (#73). Declared before the asset wildcard so * the static segment wins. The CSP pins every load to this plugin's asset * path and blocks all network access; see plugin-frame.ts for the rationale. */ @Get(':id/:version/frame') @Public() @UseGuards(PluginsEnabledGuard) async frame( @Param('id') id: string, @Param('version') version: string, @Res({ passthrough: true }) response: Response, ): Promise { const plugin = await this.plugins.get(id); if (!plugin || plugin.version !== version) throw new NotFoundException(); // Asset base is built from the configured public origin, not the request // Host header: a proxy that rewrites Host (the vite dev proxy does) must // not be able to produce a CSP that blocks the plugin's own bundle. const origin = new URL(this.config.env.APP_BASE_URL).origin; const assetBase = buildPluginAssetBase(origin, plugin.id, plugin.version); response.set('Content-Security-Policy', buildPluginFrameCsp(assetBase)); response.set('X-Content-Type-Options', 'nosniff'); response.set('Cache-Control', 'public, max-age=31536000, immutable'); response.type('text/html; charset=utf-8'); return buildPluginFrameHtml(plugin.name); } @Get(':id/:version/*rest') @Public() @UseGuards(PluginsEnabledGuard) 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'); // HTML assets can become documents (a plugin's own child frame, e.g. the // bundled drawio editor) — stamp the same restrictive CSP the frame // document carries, so no packaged page can widen the sandbox's network // or embedding rules. if (relPath.toLowerCase().endsWith('.html')) { response.set( 'Content-Security-Policy', buildPluginFrameCsp( buildPluginAssetBase(new URL(this.config.env.APP_BASE_URL).origin, id, version), ), ); } // The sandbox frame has a null (opaque) origin, so it fetches its own // bundle cross-origin; allow it. These are public, immutable client // assets, never user data — a wildcard is safe. response.set('Access-Control-Allow-Origin', '*'); return new StreamableFile(this.storage.createAssetReadStream(full), { type: contentTypeFor(relPath), }); } }