openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
235 lines (234 loc) • 9.56 kB
JavaScript
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 };