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), }); } }