497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
/**
|
|
* @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<string, string> = {
|
|
".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 <localPluginDir>/node_modules/<packageName>/
|
|
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<string, unknown> }).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;
|
|
}
|