mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
295 lines
9.6 KiB
JavaScript
295 lines
9.6 KiB
JavaScript
export const TEXT_OUTPUT_MAX_BYTES = 64 * 1024;
|
|
export const TEXT_OUTPUT_MAX_CHARS = 4096;
|
|
export const TEXT_OUTPUT_TIMEOUT_MS = 5000;
|
|
|
|
const ALLOWED_QUERY_KEYS = new Set(["filename", "subfolder", "type"]);
|
|
const ALLOWED_FILE_TYPES = new Set(["input", "output", "temp"]);
|
|
const ALLOWED_TEXT_EXTENSIONS = new Set([
|
|
"txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log",
|
|
]);
|
|
const MAX_FILE_FIELD_CHARS = 1024;
|
|
const ACTIVE_MIME_TYPES = new Set([
|
|
"text/html",
|
|
"text/javascript",
|
|
"text/css",
|
|
"image/svg+xml",
|
|
"application/xhtml+xml",
|
|
]);
|
|
const APPLICATION_TEXT_MIME_TYPES = new Set([
|
|
"application/json",
|
|
"application/xml",
|
|
"application/yaml",
|
|
"application/x-yaml",
|
|
]);
|
|
|
|
function fixedResult(status, reason = "", content = "", truncated = false) {
|
|
return { status, content, truncated, reason };
|
|
}
|
|
|
|
function resolveLocationHref() {
|
|
return globalThis.location?.href || "http://127.0.0.1/";
|
|
}
|
|
|
|
function codePointLength(value = "") {
|
|
return Array.from(String(value)).length;
|
|
}
|
|
|
|
function hasUnsafeCharacters(value = "") {
|
|
return Array.from(String(value)).some((char) => {
|
|
const code = char.charCodeAt(0);
|
|
return code < 32 || code === 127;
|
|
});
|
|
}
|
|
|
|
function isSafeFilename(filename) {
|
|
if (
|
|
!filename
|
|
|| codePointLength(filename) > MAX_FILE_FIELD_CHARS
|
|
|| hasUnsafeCharacters(filename)
|
|
|| filename === "."
|
|
|| filename === ".."
|
|
|| filename.includes("/")
|
|
|| filename.includes("\\")
|
|
) {
|
|
return false;
|
|
}
|
|
const dotIndex = filename.lastIndexOf(".");
|
|
const suffix = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : "";
|
|
return ALLOWED_TEXT_EXTENSIONS.has(suffix);
|
|
}
|
|
|
|
function isSafeSubfolder(subfolder) {
|
|
return codePointLength(subfolder) <= MAX_FILE_FIELD_CHARS
|
|
&& !hasUnsafeCharacters(subfolder)
|
|
&& !subfolder.includes("\\")
|
|
&& !subfolder.startsWith("/")
|
|
&& !subfolder.split("/").some((part) => part === "." || part === "..");
|
|
}
|
|
|
|
function validateViewUrl(input, baseHref = resolveLocationHref()) {
|
|
if (typeof input !== "string" || !input) {
|
|
return null;
|
|
}
|
|
try {
|
|
const base = new URL(baseHref);
|
|
const resolved = new URL(input, base);
|
|
if (
|
|
resolved.origin !== base.origin
|
|
|| !["http:", "https:"].includes(resolved.protocol)
|
|
|| resolved.username
|
|
|| resolved.password
|
|
|| resolved.hash
|
|
|| !resolved.pathname.endsWith("/view")
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const keys = Array.from(resolved.searchParams.keys());
|
|
if (keys.some((key) => !ALLOWED_QUERY_KEYS.has(key))) {
|
|
return null;
|
|
}
|
|
if (
|
|
resolved.searchParams.getAll("filename").length !== 1
|
|
|| !isSafeFilename(resolved.searchParams.get("filename") || "")
|
|
|| resolved.searchParams.getAll("type").length !== 1
|
|
|| !ALLOWED_FILE_TYPES.has(resolved.searchParams.get("type") || "")
|
|
|| resolved.searchParams.getAll("subfolder").length > 1
|
|
|| !isSafeSubfolder(resolved.searchParams.get("subfolder") || "")
|
|
) {
|
|
return null;
|
|
}
|
|
return resolved;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveTextOutputViewUrl(input) {
|
|
return validateViewUrl(input)?.href || "";
|
|
}
|
|
|
|
function isAllowedTextMime(value) {
|
|
const parts = String(value || "").split(";");
|
|
const mime = parts.shift()?.trim().toLowerCase() || "";
|
|
if (!mime || ACTIVE_MIME_TYPES.has(mime)) {
|
|
return false;
|
|
}
|
|
for (const parameter of parts) {
|
|
const separator = parameter.indexOf("=");
|
|
if (separator < 0) {
|
|
continue;
|
|
}
|
|
const key = parameter.slice(0, separator).trim().toLowerCase();
|
|
const rawValue = parameter.slice(separator + 1).trim();
|
|
const parameterValue = rawValue.replace(/^"|"$/g, "").toLowerCase();
|
|
if (key === "charset" && !["utf-8", "utf8"].includes(parameterValue)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (mime.startsWith("text/")) {
|
|
return true;
|
|
}
|
|
return APPLICATION_TEXT_MIME_TYPES.has(mime);
|
|
}
|
|
|
|
function parseDeclaredLength(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!/^\d+$/.test(raw)) {
|
|
return null;
|
|
}
|
|
const parsed = Number(raw);
|
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
async function cancelReader(reader) {
|
|
try {
|
|
await reader?.cancel?.();
|
|
} catch {
|
|
// Best-effort release after fail-closed size rejection.
|
|
}
|
|
}
|
|
|
|
function getByteChunkLength(value) {
|
|
const isByteView = ArrayBuffer.isView(value) && value?.BYTES_PER_ELEMENT === 1;
|
|
const hasByteTag = Object.prototype.toString.call(value) === "[object Uint8Array]";
|
|
if (!isByteView && !hasByteTag) {
|
|
return null;
|
|
}
|
|
const byteLength = Number(value?.byteLength);
|
|
return Number.isSafeInteger(byteLength) && byteLength >= 0 ? byteLength : null;
|
|
}
|
|
|
|
function copyByteChunk(value) {
|
|
try {
|
|
return Uint8Array.from(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function loadBoundedTextOutput(viewUrl, { fetchFn = globalThis.fetch, signal } = {}) {
|
|
const validatedHref = resolveTextOutputViewUrl(viewUrl);
|
|
const validatedUrl = validatedHref ? new URL(validatedHref) : null;
|
|
if (!validatedUrl || typeof fetchFn !== "function") {
|
|
return fixedResult("unavailable", "invalid_url");
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
let cancelled = false;
|
|
const onCallerAbort = () => {
|
|
cancelled = true;
|
|
controller.abort();
|
|
};
|
|
if (signal?.aborted) {
|
|
return fixedResult("unavailable", "cancelled");
|
|
}
|
|
signal?.addEventListener?.("abort", onCallerAbort, { once: true });
|
|
const timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, TEXT_OUTPUT_TIMEOUT_MS);
|
|
|
|
try {
|
|
// SECURITY: do not route through the generic JSON/text wrapper; it uses an
|
|
// unbounded body reader. This loader owns every byte and never adds tokens.
|
|
const response = await fetchFn(validatedUrl.href, {
|
|
method: "GET",
|
|
credentials: "same-origin",
|
|
redirect: "error",
|
|
headers: {
|
|
Accept: "text/plain, text/markdown, text/csv, application/json, application/xml, application/yaml",
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response || !response.ok) {
|
|
return fixedResult("unavailable", "http_error");
|
|
}
|
|
if (response.redirected) {
|
|
return fixedResult("unavailable", "redirected");
|
|
}
|
|
if (response.url) {
|
|
const responseUrl = validateViewUrl(response.url);
|
|
if (!responseUrl || responseUrl.href !== validatedUrl.href) {
|
|
return fixedResult("unavailable", "redirected");
|
|
}
|
|
}
|
|
if (!isAllowedTextMime(response.headers?.get?.("content-type"))) {
|
|
return fixedResult("unavailable", "mime_rejected");
|
|
}
|
|
|
|
const declaredLength = parseDeclaredLength(response.headers?.get?.("content-length"));
|
|
if (declaredLength !== null && declaredLength > TEXT_OUTPUT_MAX_BYTES) {
|
|
controller.abort();
|
|
return fixedResult("unavailable", "oversized");
|
|
}
|
|
|
|
if (!response.body || typeof response.body.getReader !== "function") {
|
|
return fixedResult("link_only", "stream_unavailable");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
while (true) {
|
|
const step = await reader.read();
|
|
if (step.done) {
|
|
break;
|
|
}
|
|
const chunkLength = getByteChunkLength(step.value);
|
|
if (chunkLength === null) {
|
|
await cancelReader(reader);
|
|
return fixedResult("link_only", "stream_unavailable");
|
|
}
|
|
if (chunkLength > TEXT_OUTPUT_MAX_BYTES - totalBytes) {
|
|
controller.abort();
|
|
await cancelReader(reader);
|
|
return fixedResult("unavailable", "oversized");
|
|
}
|
|
const chunk = copyByteChunk(step.value);
|
|
if (!chunk || chunk.byteLength !== chunkLength) {
|
|
await cancelReader(reader);
|
|
return fixedResult("link_only", "stream_unavailable");
|
|
}
|
|
totalBytes += chunkLength;
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
const bytes = new Uint8Array(totalBytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
|
|
let content;
|
|
try {
|
|
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
} catch {
|
|
return fixedResult("unavailable", "invalid_utf8");
|
|
}
|
|
const contentCodePoints = Array.from(content);
|
|
if (contentCodePoints.length > TEXT_OUTPUT_MAX_CHARS) {
|
|
return fixedResult(
|
|
"truncated",
|
|
"display_limit",
|
|
contentCodePoints.slice(0, TEXT_OUTPUT_MAX_CHARS).join(""),
|
|
true
|
|
);
|
|
}
|
|
return fixedResult("success", "", content, false);
|
|
} catch {
|
|
if (cancelled || signal?.aborted) {
|
|
return fixedResult("unavailable", "cancelled");
|
|
}
|
|
if (timedOut) {
|
|
return fixedResult("unavailable", "timeout");
|
|
}
|
|
return fixedResult("unavailable", "network_error");
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
signal?.removeEventListener?.("abort", onCallerAbort);
|
|
}
|
|
}
|