| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/; |
|
|
| |
| |
| |
| |
| |
| const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; |
| const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`; |
|
|
| |
| |
| |
| |
| const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate"; |
|
|
| |
| |
| |
| 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", |
| }; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function resolvePluginUiDir( |
| localPluginDir: string, |
| packageName: string, |
| entrypointsUi: string, |
| packagePath?: string | null, |
| ): string | null { |
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| |
| let packageRoot: string; |
| if (packageName.startsWith("@")) { |
| |
| packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/")); |
| } else { |
| packageRoot = path.join(localPluginDir, "node_modules", packageName); |
| } |
|
|
| |
| |
| |
| if (!fs.existsSync(packageRoot)) { |
| |
| |
| |
| const directPath = path.join(localPluginDir, packageName); |
| if (fs.existsSync(directPath)) { |
| packageRoot = directPath; |
| } else { |
| return null; |
| } |
| } |
|
|
| |
| const uiDir = path.resolve(packageRoot, entrypointsUi); |
|
|
| |
| if (!fs.existsSync(uiDir)) { |
| return null; |
| } |
|
|
| return uiDir; |
| } |
|
|
| |
| |
| |
| |
| 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}"`; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface PluginUiStaticRouteOptions { |
| |
| |
| |
| |
| |
| localPluginDir: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) { |
| const router = Router(); |
| const registry = pluginRegistryService(db); |
| const log = logger.child({ service: "plugin-ui-static" }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => { |
| const { pluginId } = req.params; |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| if (process.env.NODE_ENV === "production") { |
| log.warn( |
| { pluginId: plugin.id }, |
| "plugin-ui-static: devUiUrl ignored in production", |
| ); |
| |
| } else { |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/"); |
|
|
| |
| if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { |
| res.status(400).json({ error: "devUiUrl must use http or https protocol" }); |
| return; |
| } |
|
|
| |
| |
| |
| 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", |
| ); |
| |
| } |
| } |
| } |
| } catch { |
| |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| const resolvedFilePath = path.resolve(uiDir, rawFilePath); |
|
|
| |
| let fileStat: fs.Stats; |
| try { |
| fileStat = fs.statSync(resolvedFilePath); |
| } catch { |
| res.status(404).json({ error: "File not found" }); |
| return; |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| const basename = path.basename(resolvedFilePath); |
| const isContentHashed = CONTENT_HASH_PATTERN.test(basename); |
|
|
| |
| if (isContentHashed) { |
| res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE); |
| } else { |
| res.set("Cache-Control", CACHE_CONTROL_REVALIDATE); |
|
|
| |
| const etag = computeETag(fileStat.size, fileStat.mtimeMs); |
| res.set("ETag", etag); |
|
|
| |
| const ifNoneMatch = req.headers["if-none-match"]; |
| if (ifNoneMatch === etag) { |
| res.status(304).end(); |
| return; |
| } |
| } |
|
|
| |
| const ext = path.extname(resolvedFilePath).toLowerCase(); |
| const contentType = MIME_TYPES[ext]; |
| if (contentType) { |
| res.set("Content-Type", contentType); |
| } |
|
|
| |
| res.set("Access-Control-Allow-Origin", "*"); |
|
|
| |
| |
| |
| |
| res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => { |
| if (err) { |
| log.error( |
| { err, pluginId: plugin.id, filePath: resolvedFilePath }, |
| "plugin-ui-static: error sending file", |
| ); |
| |
| if (!res.headersSent) { |
| res.status(500).json({ error: "Failed to serve file" }); |
| } |
| } |
| }); |
| }); |
|
|
| return router; |
| } |
|
|