UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

235 lines (234 loc) 9.56 kB
import { a as openRootFile, c as readFileDescriptorBounded } from "./boundary-file-read-uaJcf6X6.js"; import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js"; import { t as normalizeHostname } from "./hostname-_16721Le.js"; import { c as isBlockedHostnameOrIp } from "./ssrf-0QyXWOVG.js"; import { r as readRemoteMediaBuffer } from "./fetch-BiZ7bEBv.js"; import { d as readImageMetadataFromHeader, s as createImageProcessor } from "./image-ops-DU1SIRgh.js"; import { a as parseControlUiResourcePath } from "./control-ui-contract-zYW4RcpK.js"; import { r as authorizeControlUiReadRequestOrReply } from "./http-auth-utils-C3lb4QXY.js"; import { c as sendMethodNotAllowed, v as respondNotFound } from "./http-common-BaZaosnr.js"; import "./http-utils-BHgXp7Zb.js"; import { a as resolveHttpImageMimeType, c as startsWithSvgRootElement, i as createHttpImageRepresentation, s as sendHttpImageResponse } from "./http-image-response-DUkYrvGx.js"; import { a as resolveManagedPluginIconSource, o as resolveManagedSetupCatalogIconUrl } from "./management-service-C_33JbZN.js"; import { closeSync } from "node:fs"; import { isIP } from "node:net"; import { fileTypeFromBuffer } from "file-type"; import pLimit from "p-limit"; //#region src/gateway/plugin-icon-http.ts const PLUGIN_ID_RE = /^(?:[a-z0-9][a-z0-9._-]{0,127}|@[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127})$/iu; const SVG_MIME_TYPE = "image/svg+xml"; const PLUGIN_ICON_CACHE_MAX_ENTRIES = 128; const LINK_FAVICON_MAX_BYTES = 65536; const LINK_FAVICON_NEGATIVE_CACHE_TTL_MS = 3e5; const LINK_FAVICON_MAX_OUTSTANDING_FETCHES = 32; const linkFaviconFetchLimit = pLimit(4); const PLUGIN_ICON_MAX_BYTES = 262144; const PLUGIN_ICON_MAX_REDIRECTS = 3; const PLUGIN_ICON_REQUEST_TIMEOUT_MS = 5e3; const PLUGIN_ICON_CACHE_TTL_MS = 36e5; let pluginIconCache = /* @__PURE__ */ new Map(); const pluginIconImageProcessor = createImageProcessor(); function normalizeLinkFaviconHostname(value) { if (value.length > 253) return null; const normalized = normalizeHostname(value); if (!normalized || isIP(normalized) !== 0 || isBlockedHostnameOrIp(normalized)) return null; try { return new URL(`https://${normalized}/`).hostname === normalized ? normalized : null; } catch { return null; } } async function validateImageMime(body, contentType) { if (contentType === SVG_MIME_TYPE) { const text = body.toString("utf8"); return !text.includes("\0") && !/<!doctype|<!entity/iu.test(text) && startsWithSvgRootElement(text); } const detected = await fileTypeFromBuffer(body); return resolveHttpImageMimeType(detected?.mime) === contentType; } function rememberIcon(cache, cacheKey, entry) { cache.delete(cacheKey); cache.set(cacheKey, entry); pruneMapToMaxSize(cache, PLUGIN_ICON_CACHE_MAX_ENTRIES); return entry; } async function normalizeIconPayload(params) { const contentType = resolveHttpImageMimeType(params.contentType); if (!contentType || !await validateImageMime(params.body, contentType)) return null; if (contentType === SVG_MIME_TYPE || contentType === "image/x-icon") return createHttpImageRepresentation(params.body, contentType); const metadata = readImageMetadataFromHeader(params.body); if (!metadata || !Number.isInteger(metadata.width) || !Number.isInteger(metadata.height) || metadata.width <= 0 || metadata.height <= 0 || metadata.width > 25e6 / metadata.height) return null; const normalized = await pluginIconImageProcessor.encode(params.body, { format: "png", compressionLevel: 9, resize: { fit: "inside", maxSide: 256, enlarge: false } }); if (normalized.data.byteLength > params.maxBytes) return null; return createHttpImageRepresentation(normalized.data, "image/png"); } async function loadPackageIcon(params) { const cacheKey = `${params.cacheScope}\0file:${params.rootPath}\0${params.iconPath}`; const now = Date.now(); const cached = pluginIconCache.get(cacheKey); if (cached && cached.expiresAt > now) { pluginIconCache.delete(cacheKey); pluginIconCache.set(cacheKey, cached); return await cached.promise; } if (cached) pluginIconCache.delete(cacheKey); const pending = (async () => { const opened = await openRootFile({ absolutePath: params.iconPath, rootPath: params.rootPath, boundaryLabel: "plugin package directory", maxBytes: PLUGIN_ICON_MAX_BYTES, rejectHardlinks: true }); if (!opened.ok) return null; try { const body = await readFileDescriptorBounded(opened.fd, PLUGIN_ICON_MAX_BYTES); if (body.byteLength < 1) return null; return await normalizeIconPayload({ body, contentType: "image/png", maxBytes: PLUGIN_ICON_MAX_BYTES }); } catch { return null; } finally { closeSync(opened.fd); } })(); const entry = rememberIcon(pluginIconCache, cacheKey, { expiresAt: now + PLUGIN_ICON_CACHE_TTL_MS, promise: pending }); const result = await pending; if (!result && pluginIconCache.get(cacheKey) === entry) pluginIconCache.delete(cacheKey); return result; } async function loadCatalogIcon(params) { let parsed; try { parsed = new URL(params.iconUrl); } catch { return null; } if (parsed.protocol !== "https:" || parsed.username || parsed.password || !parsed.hostname || parsed.hash) return null; const cacheKey = `${params.cacheScope}\0${parsed.href}`; const now = Date.now(); const cached = pluginIconCache.get(cacheKey); if (cached && cached.expiresAt > now) { pluginIconCache.delete(cacheKey); pluginIconCache.set(cacheKey, cached); return await cached.promise; } if (cached) pluginIconCache.delete(cacheKey); const load = async () => { try { const loaded = await readRemoteMediaBuffer({ url: parsed.href, maxBytes: params.maxBytes ?? 262144, maxRedirects: 3, requireHttps: params.requireHttps, timeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, responseHeaderTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, readIdleTimeoutMs: PLUGIN_ICON_REQUEST_TIMEOUT_MS, requestInit: { headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml" } } }); return await normalizeIconPayload({ body: loaded.buffer, contentType: loaded.contentType, maxBytes: params.maxBytes ?? 262144 }); } catch { return null; } }; if (params.limitConcurrency && linkFaviconFetchLimit.activeCount + linkFaviconFetchLimit.pendingCount >= LINK_FAVICON_MAX_OUTSTANDING_FETCHES) return null; const pending = params.limitConcurrency ? linkFaviconFetchLimit(load) : load(); const entry = rememberIcon(pluginIconCache, cacheKey, { expiresAt: now + PLUGIN_ICON_CACHE_TTL_MS, promise: pending }); const result = await pending; if (!result && pluginIconCache.get(cacheKey) === entry) { if (params.retainFailureForMs) entry.expiresAt = Date.now() + params.retainFailureForMs; else pluginIconCache.delete(cacheKey); } return result; } function clearPluginIconCacheForTest() { pluginIconCache = /* @__PURE__ */ new Map(); } async function handlePluginIconHttpRequest(req, res, opts) { const pathname = req.url ? new URL(req.url, "http://localhost").pathname : void 0; const pluginRequest = parseControlUiResourcePath("pluginIcon", pathname, opts.basePath); const catalogRequest = parseControlUiResourcePath("catalogIcon", pathname, opts.basePath); const faviconRequest = parseControlUiResourcePath("linkFavicon", pathname, opts.basePath); if (!pluginRequest.matched && !catalogRequest.matched && !faviconRequest.matched) return false; const pluginId = pluginRequest.matched && pluginRequest.value && PLUGIN_ID_RE.test(pluginRequest.value) ? pluginRequest.value : null; const catalogIconUrl = catalogRequest.matched ? catalogRequest.value : null; const faviconHostname = faviconRequest.matched ? faviconRequest.value ? normalizeLinkFaviconHostname(faviconRequest.value) : null : null; const method = req.method; if (method !== "GET" && method !== "HEAD") { sendMethodNotAllowed(res, "GET, HEAD"); return true; } if (!await authorizeControlUiReadRequestOrReply({ req, res, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter })) return true; if (faviconRequest.matched && opts.config.gateway?.controlUi?.automaticallyFetchFavicons === false) { respondNotFound(res); return true; } const pluginIcon = pluginId ? await resolveManagedPluginIconSource({ config: opts.config, pluginId }) : void 0; const remoteIconUrl = catalogIconUrl ? resolveManagedSetupCatalogIconUrl({ config: opts.config, iconUrl: catalogIconUrl }) : faviconHostname ? `https://${faviconHostname}/favicon.ico` : void 0; if (!pluginIcon && !remoteIconUrl) { respondNotFound(res); return true; } const cacheScope = pluginId ? `plugin:${pluginId}` : faviconHostname ? "favicon" : "catalog"; const icon = pluginIcon ? await loadPackageIcon({ cacheScope, iconPath: pluginIcon.path, rootPath: pluginIcon.rootPath }) : await loadCatalogIcon({ cacheScope, iconUrl: remoteIconUrl, ...faviconHostname ? { maxBytes: LINK_FAVICON_MAX_BYTES, requireHttps: true, retainFailureForMs: LINK_FAVICON_NEGATIVE_CACHE_TTL_MS, limitConcurrency: true } : {} }); if (!icon) { respondNotFound(res); return true; } sendHttpImageResponse({ req, res, image: icon, filename: faviconHostname ? "link-favicon" : "plugin-icon" }); return true; } //#endregion export { LINK_FAVICON_MAX_BYTES, PLUGIN_ICON_CACHE_TTL_MS, PLUGIN_ICON_MAX_BYTES, PLUGIN_ICON_MAX_REDIRECTS, PLUGIN_ICON_REQUEST_TIMEOUT_MS, clearPluginIconCacheForTest, handlePluginIconHttpRequest };