UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

254 lines (253 loc) • 9.25 kB
import { t as pruneMapToMaxSize } from "./map-size-BlrH_CnE.mjs"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs"; import { o as racePromiseWithAbortSignal } from "./src-D1BHGNTj.mjs"; import "./abort-signal-QqAmLWTl.mjs"; import { r as withResponseBodyTimeout } from "./http-response-body-timeout-DEQH4A8m.mjs"; import { i as readResponseWithLimit } from "./http-response-body-ljSrZsaY.mjs"; import { t as normalizeHostname } from "./hostname-Db1ZcSGB.mjs"; import { o as isBlockedHostnameOrIp } from "./ssrf-BUqrVNdU.mjs"; import { i as fetchWithSsrFGuard, o as withStrictGuardedFetchMode } from "./fetch-guard-C19WJ54-.mjs"; import { d as readImageMetadataFromHeader, m as createImageProcessor } from "./image-ops-C9MTaUYr.mjs"; import { isIP } from "node:net"; import pLimit from "p-limit"; import { Parser } from "htmlparser2"; import { fileTypeFromBuffer } from "file-type"; //#region src/gateway/control-ui-link-preview.ts const HTML_MAX_BYTES = 65536; const IMAGE_MAX_BYTES = 2097152; const ICON_MAX_BYTES = 65536; const IMAGE_OUTPUT_MAX_BYTES = 262144; const REQUEST_TIMEOUT_MS = 5e3; const PREVIEW_TIMEOUT_MS = 15e3; const CACHE_MAX_ENTRIES = 64; const SUCCESS_TTL_MS = 36e5; const FAILURE_TTL_MS = 3e5; const loads = pLimit(4); const processor = createImageProcessor(); const principals = /* @__PURE__ */ new WeakMap(); let nextPrincipal = 0; const cache = /* @__PURE__ */ new Map(); /** Public presentation only: never reuse browser credentials or private-network policy. */ function parseControlUiLinkPreviewUrl(value, base) { if (typeof value !== "string" || !value.trim() || value.length > 2048) return null; const url = URL.parse(value, base); if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password || isIP(normalizeHostname(url.hostname)) || isBlockedHostnameOrIp(url.hostname)) return null; url.hash = ""; return url; } async function readPageMetadata(response, finalUrl, signal) { const metadata = /* @__PURE__ */ new Map(); const links = []; let baseHref; let titleText; let inTitle = false; let finished = false; const parser = new Parser({ onopentag(name, attrs) { if (name === "body") { finished = true; parser.pause(); } else if (name === "base") baseHref ??= attrs.href; else if (name === "title" && titleText === void 0) { titleText = ""; inTitle = true; } else if (name === "meta") { const key = (attrs.property ?? attrs.name)?.toLowerCase(); const value = attrs.content?.trim(); if (key && value && !metadata.has(key)) metadata.set(key, value); } else if (name === "link") links.push(attrs); }, ontext(text) { if (inTitle) titleText += text; }, onclosetag(name) { if (name === "title") inTitle = false; else if (name === "head") { finished = true; parser.pause(); } } }); const reader = response.body?.getReader(); if (reader) try { await withResponseBodyTimeout({ signal, timeoutMs: REQUEST_TIMEOUT_MS, onTimeout: void 0, cancel: (error) => reader.cancel(error), read: async (refreshTimeout) => { const decoder = new TextDecoder(); let remaining = HTML_MAX_BYTES; while (remaining > 0) { refreshTimeout?.(); const { done, value } = await reader.read(); if (done) { parser.end(decoder.decode()); return; } const chunk = value.subarray(0, remaining); remaining -= chunk.byteLength; parser.write(decoder.decode(chunk, { stream: true })); if (finished) return; } parser.end(); } }); } finally { reader.cancel().catch(() => void 0); reader.releaseLock(); } const baseUrl = URL.parse(baseHref ?? "", finalUrl)?.href ?? finalUrl; const title = metadata.get("og:title") ?? metadata.get("twitter:title") ?? titleText; const description = metadata.get("og:description") ?? metadata.get("twitter:description") ?? metadata.get("description"); const image = [ "og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src" ].map((key) => parseControlUiLinkPreviewUrl(metadata.get(key), baseUrl)?.href).find(Boolean); const icons = []; for (const attrs of links) { const rel = attrs.rel?.toLowerCase().split(/\s+/u) ?? []; if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue; const url = parseControlUiLinkPreviewUrl(attrs.href, baseUrl)?.href; if (url && attrs.type !== "image/svg+xml" && !icons.includes(url)) { icons.push(url); if (icons.length === 2) break; } } return { title: title ? truncateUtf16Safe(title.replace(/\s+/gu, " ").trim(), 180) : void 0, description: description ? truncateUtf16Safe(description.replace(/\s+/gu, " ").trim(), 400) : void 0, image, icons }; } async function fetchPreviewResource(url, accept, signal, isEnabled, read) { try { const { response, finalUrl, release } = await fetchWithSsrFGuard(withStrictGuardedFetchMode({ url, maxRedirects: 3, timeoutMs: REQUEST_TIMEOUT_MS, signal, init: { headers: { Accept: accept }, credentials: "omit" }, beforeRequest: () => { signal.throwIfAborted(); if (!isEnabled()) throw new Error("Link previews disabled"); }, resolveDispatcherPolicy: (target) => { if (!parseControlUiLinkPreviewUrl(target.href)) throw new Error("Not a public preview URL"); } })); try { return response.ok ? await read(response, finalUrl) : void 0; } finally { await release(); } } catch { return; } } async function loadImage(bytes, icon, signal) { try { if (!bytes || bytes.length > (icon ? ICON_MAX_BYTES : IMAGE_MAX_BYTES)) return; const detected = await fileTypeFromBuffer(bytes); if (icon && detected?.mime === "image/x-icon") return `data:image/x-icon;base64,${bytes.toString("base64")}`; if (!detected || ![ "image/png", "image/apng", "image/jpeg", "image/webp", "image/gif", "image/avif" ].includes(detected.mime)) return; const size = readImageMetadataFromHeader(bytes); if (!size || size.width <= 0 || size.height <= 0 || size.width > 16e6 / size.height) return; const result = await processor.encode(bytes, { format: "png", resize: { maxSide: icon ? 64 : 640, fit: "inside", enlarge: false }, signal }); return result.data.length <= (icon ? ICON_MAX_BYTES : IMAGE_OUTPUT_MAX_BYTES) ? `data:image/png;base64,${result.data.toString("base64")}` : void 0; } catch { return; } } async function loadPreview(url, isEnabled, signal) { if (signal.aborted || !isEnabled()) return {}; const page = await fetchPreviewResource(url.href, "text/html,application/xhtml+xml", signal, isEnabled, async (response, finalUrl) => { const type = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); if (type !== "text/html" && type !== "application/xhtml+xml") return; return { ...await readPageMetadata(response, finalUrl, signal), finalUrl }; }); const iconUrls = /* @__PURE__ */ new Set([...page?.icons ?? [], new URL("/favicon.ico", page?.finalUrl ?? url.href).href]); const images = /* @__PURE__ */ new Map(); const imageBytes = (target) => { let pending = images.get(target); if (!pending) { pending = fetchPreviewResource(target, "image/*", signal, isEnabled, (response) => readResponseWithLimit(response, target === page?.image ? IMAGE_MAX_BYTES : ICON_MAX_BYTES, { signal, chunkTimeoutMs: REQUEST_TIMEOUT_MS })); images.set(target, pending); } return pending; }; const [imageDataUrl, faviconDataUrl] = await Promise.all([page?.image ? imageBytes(page.image).then((bytes) => loadImage(bytes, false, signal)) : void 0, (async () => { for (const iconUrl of iconUrls) { const icon = await loadImage(await imageBytes(iconUrl), true, signal); if (icon) return icon; } })()]); return isEnabled() ? { ...page?.title ? { title: page.title } : {}, ...page?.description ? { description: page.description } : {}, ...imageDataUrl ? { imageDataUrl } : {}, ...faviconDataUrl ? { faviconDataUrl } : {} } : {}; } /** Anonymous requests, isolated to the requesting principal and owner revision. */ async function loadControlUiLinkPreview(url, isEnabled, scope) { if (!isEnabled()) return {}; let principal = principals.get(scope.principal); if (principal === void 0) { principal = ++nextPrincipal; principals.set(scope.principal, principal); } const key = JSON.stringify([ principal, scope.revision, url.href ]); const now = Date.now(); const existing = cache.get(key); if (existing && existing.expiresAt > now) { const result = await existing.promise; return isEnabled() ? result : {}; } if (loads.activeCount + loads.pendingCount >= 32) return {}; const signal = AbortSignal.timeout(PREVIEW_TIMEOUT_MS); const entry = { expiresAt: Number.POSITIVE_INFINITY, promise: racePromiseWithAbortSignal(loads(() => loadPreview(url, isEnabled, signal)), signal).catch(() => ({})) }; cache.delete(key); cache.set(key, entry); pruneMapToMaxSize(cache, CACHE_MAX_ENTRIES); const result = await entry.promise; entry.expiresAt = Date.now() + (Object.keys(result).length ? SUCCESS_TTL_MS : FAILURE_TTL_MS); return isEnabled() ? result : {}; } //#endregion export { loadControlUiLinkPreview, parseControlUiLinkPreviewUrl };