/** * @fileoverview Plugin UI static file serving route * * Serves plugin UI bundles from the plugin's dist/ui/ directory under the * `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md * §19.0.3 (Bundle Serving). * * Plugin UI bundles are pre-built ESM that the host serves as static assets. * The host dynamically imports the plugin's UI entry module from this path, * resolves the named export declared in `ui.slots[].exportName`, and mounts * it into the extension slot. * * Security: * - Path traversal is prevented by resolving the requested path and verifying * it stays within the plugin's UI directory. * - Only plugins in 'ready' status have their UI served. * - Only plugins that declare `entrypoints.ui` serve UI bundles. * * Cache Headers: * - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`) * receive `Cache-Control: public, max-age=31536000, immutable`. * - Other files receive `Cache-Control: public, max-age=0, must-revalidate` * with ETag-based conditional request support. * * @module server/routes/plugin-ui-static * @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving * @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation */ import { Router } from "express"; import path from "node:path"; import fs from "node:fs"; import crypto from "node:crypto"; import type { Db } from "@paperclipai/db"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { logger } from "../middleware/logger.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** * Regex to detect content-hashed filenames. * * Matches patterns like: * - `index-a1b2c3d4.js` * - `styles.abc123def.css` * - `chunk-ABCDEF01.mjs` * * The hash portion must be at least 8 hex characters to avoid false positives. */ const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/; /** * Cache-Control header for content-hashed files. * These files are immutable by definition (the hash changes when content changes). */ /** 1 year in seconds — standard for content-hashed immutable resources. */ const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000 const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`; /** * Cache-Control header for non-hashed files. * These files must be revalidated on each request (ETag-based). */ const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate"; /** * MIME types for common plugin UI bundle file extensions. */ const MIME_TYPES: Record = { ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "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", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject", ".ico": "image/x-icon", ".txt": "text/plain; charset=utf-8", }; // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- /** * Resolve a plugin's UI directory from its package location. * * The plugin's `packageName` is stored in the DB. We resolve the package path * from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in * `node_modules`. If the plugin was installed from a local path, the manifest * `entrypoints.ui` path is resolved relative to the package directory. * * @param localPluginDir - The plugin installation directory * @param packageName - The npm package name * @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/") * @returns Absolute path to the UI directory, or null if not found */ export function resolvePluginUiDir( localPluginDir: string, packageName: string, entrypointsUi: string, packagePath?: string | null, ): string | null { // For local-path installs, prefer the persisted package path. if (packagePath) { const resolvedPackagePath = path.resolve(packagePath); if (fs.existsSync(resolvedPackagePath)) { const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi); if ( uiDirFromPackagePath.startsWith(resolvedPackagePath) && fs.existsSync(uiDirFromPackagePath) ) { return uiDirFromPackagePath; } } } // Resolve the package root within the local plugin directory's node_modules. // npm installs go to /node_modules// let packageRoot: string; if (packageName.startsWith("@")) { // Scoped package: @scope/name -> node_modules/@scope/name packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/")); } else { packageRoot = path.join(localPluginDir, "node_modules", packageName); } // If the standard location doesn't exist, the plugin may have been installed // from a local path. Try to check if the package.json is accessible at the // computed path or if the package is found elsewhere. if (!fs.existsSync(packageRoot)) { // For local-path installs, the packageName may be a directory that doesn't // live inside node_modules. Check if the package exists directly at the // localPluginDir level. const directPath = path.join(localPluginDir, packageName); if (fs.existsSync(directPath)) { packageRoot = directPath; } else { return null; } } // Resolve the UI directory relative to the package root const uiDir = path.resolve(packageRoot, entrypointsUi); // Verify the resolved UI directory exists and is actually inside the package if (!fs.existsSync(uiDir)) { return null; } return uiDir; } /** * Compute an ETag from file stat (size + mtime). * This is a lightweight approach that avoids reading the file content. */ function computeETag(size: number, mtimeMs: number): string { const ETAG_VERSION = "v2"; const hash = crypto .createHash("md5") .update(`${ETAG_VERSION}:${size}-${mtimeMs}`) .digest("hex") .slice(0, 16); return `"${hash}"`; } // --------------------------------------------------------------------------- // Route factory // --------------------------------------------------------------------------- /** * Options for the plugin UI static route. */ export interface PluginUiStaticRouteOptions { /** * The local plugin installation directory. * This is where plugins are installed via `npm install --prefix`. * Defaults to the standard `~/.paperclip/plugins/` location. */ localPluginDir: string; } /** * Create an Express router that serves plugin UI static files. * * This route handles `GET /_plugins/:pluginId/ui/*` requests by: * 1. Looking up the plugin in the registry by ID or key * 2. Verifying the plugin is in 'ready' status with UI declared * 3. Resolving the file path within the plugin's dist/ui/ directory * 4. Serving the file with appropriate cache headers * * @param db - Database connection for plugin registry lookups * @param options - Configuration options * @returns Express router */ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) { const router = Router(); const registry = pluginRegistryService(db); const log = logger.child({ service: "plugin-ui-static" }); /** * GET /_plugins/:pluginId/ui/* * * Serve a static file from a plugin's UI bundle directory. * * The :pluginId parameter accepts either: * - Database UUID * - Plugin key (e.g., "acme.linear") * * The wildcard captures the relative file path within the UI directory. * * Cache strategy: * - Content-hashed filenames → immutable, 1-year max-age * - Other files → must-revalidate with ETag */ router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => { const { pluginId } = req.params; // Extract the relative file path from the named wildcard. // In Express 5 with path-to-regexp v8, named wildcards may return // an array of path segments or a single string. const rawParam = req.params.filePath; const rawFilePath = Array.isArray(rawParam) ? rawParam.join("/") : rawParam as string | undefined; if (!rawFilePath || rawFilePath.length === 0) { res.status(400).json({ error: "File path is required" }); return; } // Step 1: Look up the plugin let plugin = null; try { plugin = await registry.getById(pluginId); } catch (error) { const maybeCode = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (maybeCode !== "22P02") { throw error; } } if (!plugin) { plugin = await registry.getByKey(pluginId); } if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Step 2: Verify the plugin is ready and has UI declared if (plugin.status !== "ready") { res.status(403).json({ error: `Plugin UI is not available (status: ${plugin.status})`, }); return; } const manifest = plugin.manifestJson; if (!manifest?.entrypoints?.ui) { res.status(404).json({ error: "Plugin does not declare a UI bundle" }); return; } // Step 2b: Check for devUiUrl in plugin config — proxy to local dev server // when a plugin author has configured a dev server URL for hot-reload. // See PLUGIN_SPEC.md §27.2 — Local Development Workflow try { const configRow = await registry.getConfig(plugin.id); const devUiUrl = configRow && typeof configRow === "object" && "configJson" in configRow && (configRow as { configJson: Record }).configJson?.devUiUrl; if (typeof devUiUrl === "string" && devUiUrl.length > 0) { // Dev proxy is only available in development mode if (process.env.NODE_ENV === "production") { log.warn( { pluginId: plugin.id }, "plugin-ui-static: devUiUrl ignored in production", ); // Fall through to static file serving below } else { // Guard against rawFilePath overriding the base URL via protocol // scheme (e.g. "https://evil.com/x") or protocol-relative paths // (e.g. "//evil.com/x") which cause `new URL(path, base)` to // ignore the base entirely. // Normalize percent-encoding so encoded slashes (%2F) can't bypass // the protocol/path checks below. let decodedPath: string; try { decodedPath = decodeURIComponent(rawFilePath); } catch { res.status(400).json({ error: "Invalid file path" }); return; } if ( decodedPath.includes("://") || decodedPath.startsWith("//") || decodedPath.startsWith("\\\\") ) { res.status(400).json({ error: "Invalid file path" }); return; } // Proxy the request to the dev server const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/"); // SSRF protection: only allow http/https and localhost targets for dev proxy if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { res.status(400).json({ error: "devUiUrl must use http or https protocol" }); return; } // Dev proxy is restricted to loopback addresses only. // Validate the *constructed* targetUrl hostname (not the base) to // catch any path-based override that slipped past the checks above. const devHost = targetUrl.hostname; const isLoopback = devHost === "localhost" || devHost === "127.0.0.1" || devHost === "::1" || devHost === "[::1]"; if (!isLoopback) { log.warn( { pluginId: plugin.id, devUiUrl, host: devHost }, "plugin-ui-static: devUiUrl must target localhost, rejecting proxy", ); res.status(400).json({ error: "devUiUrl must target localhost" }); return; } log.debug( { pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href }, "plugin-ui-static: proxying to devUiUrl", ); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const upstream = await fetch(targetUrl.href, { signal: controller.signal }); if (!upstream.ok) { res.status(upstream.status).json({ error: `Dev server returned ${upstream.status}`, }); return; } const contentType = upstream.headers.get("content-type"); if (contentType) res.set("Content-Type", contentType); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); const body = await upstream.arrayBuffer(); res.send(Buffer.from(body)); return; } finally { clearTimeout(timeout); } } catch (proxyErr) { log.warn( { pluginId: plugin.id, devUiUrl, err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr), }, "plugin-ui-static: failed to proxy to devUiUrl, falling back to static", ); // Fall through to static serving below } } } } catch { // Config lookup failure is non-fatal — fall through to static serving } // Step 3: Resolve the plugin's UI directory const uiDir = resolvePluginUiDir( options.localPluginDir, plugin.packageName, manifest.entrypoints.ui, plugin.packagePath, ); if (!uiDir) { log.warn( { pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName }, "plugin-ui-static: UI directory not found on disk", ); res.status(404).json({ error: "Plugin UI directory not found" }); return; } // Step 4: Resolve the requested file path and prevent traversal (including symlinks) const resolvedFilePath = path.resolve(uiDir, rawFilePath); // Step 5: Check that the file exists and is a regular file let fileStat: fs.Stats; try { fileStat = fs.statSync(resolvedFilePath); } catch { res.status(404).json({ error: "File not found" }); return; } // Security: resolve symlinks via realpathSync and verify containment. // This prevents symlink-based traversal that string-based startsWith misses. let realFilePath: string; let realUiDir: string; try { realFilePath = fs.realpathSync(resolvedFilePath); realUiDir = fs.realpathSync(uiDir); } catch { res.status(404).json({ error: "File not found" }); return; } const relative = path.relative(realUiDir, realFilePath); if (relative.startsWith("..") || path.isAbsolute(relative)) { res.status(403).json({ error: "Access denied" }); return; } if (!fileStat.isFile()) { res.status(404).json({ error: "File not found" }); return; } // Step 6: Determine cache strategy based on filename const basename = path.basename(resolvedFilePath); const isContentHashed = CONTENT_HASH_PATTERN.test(basename); // Step 7: Set cache headers if (isContentHashed) { res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE); } else { res.set("Cache-Control", CACHE_CONTROL_REVALIDATE); // Compute and set ETag for conditional request support const etag = computeETag(fileStat.size, fileStat.mtimeMs); res.set("ETag", etag); // Check If-None-Match for 304 Not Modified const ifNoneMatch = req.headers["if-none-match"]; if (ifNoneMatch === etag) { res.status(304).end(); return; } } // Step 8: Set Content-Type const ext = path.extname(resolvedFilePath).toLowerCase(); const contentType = MIME_TYPES[ext]; if (contentType) { res.set("Content-Type", contentType); } // Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev) res.set("Access-Control-Allow-Origin", "*"); // Step 10: Send the file // The plugin source can live in Git worktrees (e.g. ".worktrees/..."). // `send` defaults to dotfiles:"ignore", which treats dot-directories as // not found. We already enforce traversal safety above, so allow dot paths. res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => { if (err) { log.error( { err, pluginId: plugin.id, filePath: resolvedFilePath }, "plugin-ui-static: error sending file", ); // Only send error if headers haven't been sent yet if (!res.headersSent) { res.status(500).json({ error: "Failed to serve file" }); } } }); }); return router; }