/**
 * @fileoverview Plugin UI static file serving route
 *
 * Serves plugin UI bundles from the plugin's dist/ui/ directory under the
 * `/_plugins/:pluginId/ui/*` namespace described in
 * doc/engineering/PLUGIN_RUNTIME_CONTRACT.md.
 *
 * 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/engineering/PLUGIN_RUNTIME_CONTRACT.md
 * @see doc/engineering/PLUGIN_RUNTIME_CONTRACT.md
 */
import { Router } from "express";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { logger } from "../middleware/logger.js";
import { isPathInsideDir, resolvePluginPackageCandidateDirs, } from "../services/plugin-loader.worker-paths.js";
import { pluginRegistryService } from "../services/plugin-registry.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 = {
    ".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, packageName, entrypointsUi, packagePath, options = {}) {
    for (const packageRoot of resolvePluginPackageCandidateDirs(localPluginDir, packageName, packagePath, options)) {
        const uiDir = path.resolve(packageRoot, entrypointsUi);
        if (isPathInsideDir(uiDir, packageRoot)
            && fs.existsSync(uiDir)) {
            return uiDir;
        }
    }
    return null;
}
/**
 * Compute an ETag from file stat (size + mtime).
 * This is a lightweight approach that avoids reading the file content.
 */
function computeETag(size, mtimeMs) {
    const ETAG_VERSION = "v2";
    const hash = crypto
        .createHash("md5")
        .update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
        .digest("hex")
        .slice(0, 16);
    return `"${hash}"`;
}
/**
 * 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, options) {
    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;
        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.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 doc/engineering/PLUGIN_RUNTIME_CONTRACT.md — Local Development Workflow
        try {
            const configRow = await registry.getConfig(plugin.id);
            const devUiUrl = configRow &&
                typeof configRow === "object" &&
                "configJson" in configRow &&
                configRow.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;
                    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;
        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;
        let realUiDir;
        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;
}
//# sourceMappingURL=plugin-ui-static.js.map