openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
311 lines (310 loc) • 15.4 kB
JavaScript
import { L as sanitizeUntrustedFileName } from "../fs-safe-B6pvPGnf.js";
import { d as normalizeMimeType } from "../mime-CVpcq9ju.js";
import { n as loadWebMedia } from "../web-media-C9Ri3Aft.js";
import { t as buildOutboundMediaLoadOptions } from "../load-options-VzbF4ozo.js";
import "../web-media-CbeT3HsJ.js";
import { randomBytes } from "node:crypto";
//#region src/plugin-sdk/outbound-media.ts
/** Load outbound media from a remote URL or approved local path using the shared web-media policy. */
async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
return await loadWebMedia(mediaUrl, buildOutboundMediaLoadOptions({
maxBytes: options.maxBytes,
mediaAccess: options.mediaAccess,
mediaLocalRoots: options.mediaLocalRoots,
mediaReadFile: options.mediaReadFile,
workspaceDir: options.workspaceDir,
proxyUrl: options.proxyUrl,
fetchImpl: options.fetchImpl,
requestInit: options.requestInit,
optimizeImages: options.optimizeImages,
trustExplicitProxyDns: options.trustExplicitProxyDns
}));
}
const DEFAULT_HOSTED_OUTBOUND_MEDIA_RAW_CHUNK_BYTES = 36864;
const DEFAULT_HOSTED_OUTBOUND_MEDIA_MAX_ENTRIES = 64;
const DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 512;
const HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS = 6e4;
function createHostedOutboundMediaId() {
return randomBytes(12).toString("hex");
}
function createHostedOutboundMediaToken() {
return randomBytes(24).toString("hex");
}
function buildHostedOutboundMediaMetaKey(id) {
return `media:${id}:meta`;
}
function buildHostedOutboundMediaChunkKey(id, index) {
return `media:${id}:chunk:${String(index).padStart(4, "0")}`;
}
function parseHostedOutboundMediaMetaKey(key) {
if (!key.startsWith("media:") || !key.endsWith(":meta")) return;
return key.slice(6, -5) || void 0;
}
function isFutureHostedOutboundMediaExpiry(expiresAt, nowMs) {
return typeof expiresAt === "number" && Number.isSafeInteger(expiresAt) && expiresAt > nowMs;
}
function isRetainedHostedOutboundMediaExpiry(expiresAt, nowMs, postExpiryRetentionMs) {
return typeof expiresAt === "number" && Number.isSafeInteger(expiresAt) && (expiresAt > nowMs || nowMs - expiresAt < postExpiryRetentionMs);
}
function createHostedOutboundMediaMetaRecord(params) {
return {
id: params.id,
routePath: params.routePath,
token: params.token,
...params.contentType ? { contentType: params.contentType } : {},
...params.fileName ? { fileName: params.fileName } : {},
expiresAt: params.expiresAt,
chunkCount: params.chunkCount,
byteLength: params.byteLength
};
}
function createHostedOutboundMediaMetadata(meta) {
return {
routePath: meta.routePath,
token: meta.token,
...meta.contentType ? { contentType: meta.contentType } : {},
...meta.fileName ? { fileName: meta.fileName } : {},
expiresAt: meta.expiresAt,
byteLength: meta.byteLength
};
}
async function deleteHostedOutboundMediaRows(id, metadataStore, chunkStore, knownChunkCount) {
const metaKey = buildHostedOutboundMediaMetaKey(id);
const chunkCount = (await metadataStore.lookup(metaKey))?.chunkCount ?? knownChunkCount;
if (chunkCount != null) for (let index = 0; index < chunkCount; index += 1) await chunkStore.delete(buildHostedOutboundMediaChunkKey(id, index));
await metadataStore.delete(metaKey);
}
function createHostedOutboundMediaStore(options) {
const rawChunkBytes = options.rawChunkBytes ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_RAW_CHUNK_BYTES;
const maxEntries = options.maxEntries ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_MAX_ENTRIES;
const chunkRowsPerEntryBudget = options.chunkRowsPerEntryBudget ?? DEFAULT_HOSTED_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
const maxChunkRows = options.maxChunkRows ?? maxEntries * chunkRowsPerEntryBudget;
const overflowPolicy = options.overflowPolicy ?? "evict-oldest";
const postExpiryRetentionMs = options.postExpiryRetentionMs ?? 0;
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new Error("hosted outbound media maxEntries must be a positive integer");
if (!Number.isSafeInteger(maxChunkRows) || maxChunkRows < 1) throw new Error("hosted outbound media maxChunkRows must be a positive integer");
if (!Number.isSafeInteger(postExpiryRetentionMs) || postExpiryRetentionMs < 0) throw new Error("hosted outbound media postExpiryRetentionMs must be a non-negative integer");
if (options.maxTotalBytes !== void 0 && (!Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 1)) throw new Error("hosted outbound media maxTotalBytes must be a positive integer");
if (overflowPolicy !== "evict-oldest" && overflowPolicy !== "reject-new") throw new Error("hosted outbound media overflowPolicy must be evict-oldest or reject-new");
const createId = options.createId ?? createHostedOutboundMediaId;
const createToken = options.createToken ?? createHostedOutboundMediaToken;
const chunkPhysicalTtlMs = options.ttlMs + postExpiryRetentionMs;
const metadataPhysicalTtlMs = options.ttlMs + Math.max(postExpiryRetentionMs, Math.min(options.ttlMs, HOSTED_OUTBOUND_MEDIA_METADATA_TTL_GRACE_MS));
if (!Number.isSafeInteger(chunkPhysicalTtlMs) || chunkPhysicalTtlMs < 1 || !Number.isSafeInteger(metadataPhysicalTtlMs) || metadataPhysicalTtlMs < 1) throw new Error("hosted outbound media physical TTL must be a positive safe integer");
let capacityMutation = Promise.resolve();
const activeReaders = /* @__PURE__ */ new Map();
const deferredDeletes = /* @__PURE__ */ new Set();
const deletingEntries = /* @__PURE__ */ new Set();
async function withCapacityMutation(operation) {
const result = capacityMutation.then(operation, operation);
capacityMutation = result.then(() => void 0, () => void 0);
return await result;
}
async function deleteEntry(id) {
deferredDeletes.add(id);
if ((activeReaders.get(id) ?? 0) > 0) return false;
deletingEntries.add(id);
try {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore);
deferredDeletes.delete(id);
return true;
} finally {
deletingEntries.delete(id);
}
}
async function deleteEntryRows(id, chunkCount) {
await deleteHostedOutboundMediaRows(id, options.metadataStore, options.chunkStore, chunkCount);
}
async function readMetadataRecord(id, nowMs) {
const meta = await options.metadataStore.lookup(buildHostedOutboundMediaMetaKey(id));
if (!meta) return null;
if (!isFutureHostedOutboundMediaExpiry(meta.expiresAt, nowMs)) {
if (!isRetainedHostedOutboundMediaExpiry(meta.expiresAt, nowMs, postExpiryRetentionMs)) await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
return meta;
}
async function deleteStoredRow(row) {
const id = parseHostedOutboundMediaMetaKey(row.key);
if (!id || !Number.isSafeInteger(row.value.chunkCount) || row.value.chunkCount < 1 || row.value.chunkCount > maxChunkRows) {
await options.metadataStore.delete(row.key);
return;
}
await deleteEntry(id);
}
async function cleanupExpired(nowMs = Date.now()) {
await withCapacityMutation(async () => {
for (const row of await options.metadataStore.entries()) if (!isRetainedHostedOutboundMediaExpiry(row.value.expiresAt, nowMs, postExpiryRetentionMs)) await deleteStoredRow(row);
});
}
async function acquireReader(id, nowMs) {
activeReaders.set(id, (activeReaders.get(id) ?? 0) + 1);
let closed = false;
const close = async () => {
if (closed) return;
closed = true;
const remaining = (activeReaders.get(id) ?? 1) - 1;
if (remaining > 0) {
activeReaders.set(id, remaining);
return;
}
activeReaders.delete(id);
if (deferredDeletes.has(id)) await withCapacityMutation(async () => await deleteEntry(id));
};
if (deferredDeletes.has(id) || deletingEntries.has(id)) {
await close();
return null;
}
const meta = await readMetadataRecord(id, nowMs);
if (!meta) {
await close();
return null;
}
return {
meta,
close
};
}
async function pruneForCapacity(incomingChunkCount, incomingByteLength, nowMs = Date.now()) {
if (options.maxTotalBytes !== void 0 && incomingByteLength > options.maxTotalBytes) throw new Error(`hosted outbound media payload exceeds aggregate byte capacity (${incomingByteLength}/${options.maxTotalBytes} bytes)`);
const rows = await options.metadataStore.entries();
const validRows = rows.filter((row) => {
const id = parseHostedOutboundMediaMetaKey(row.key);
return id !== void 0 && row.value.id === id && Number.isSafeInteger(row.value.chunkCount) && row.value.chunkCount > 0 && row.value.chunkCount <= maxChunkRows && Number.isSafeInteger(row.value.byteLength) && row.value.byteLength >= 0 && isRetainedHostedOutboundMediaExpiry(row.value.expiresAt, nowMs, postExpiryRetentionMs);
});
const validKeys = new Set(validRows.map((row) => row.key));
const orderedRows = validRows.toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key));
const invalidRows = rows.filter((row) => !validKeys.has(row.key));
for (const row of invalidRows) await deleteStoredRow(row);
let entryCount = orderedRows.length;
let chunkCount = orderedRows.reduce((total, row) => total + row.value.chunkCount, 0);
let totalBytes = orderedRows.reduce((total, row) => total + row.value.byteLength, 0);
if (overflowPolicy === "reject-new" && (entryCount >= maxEntries || chunkCount + incomingChunkCount > maxChunkRows || options.maxTotalBytes !== void 0 && totalBytes + incomingByteLength > options.maxTotalBytes)) throw new Error(`hosted outbound media capacity is full (${entryCount}/${maxEntries} entries, ${chunkCount + incomingChunkCount}/${maxChunkRows} chunk rows, ${totalBytes + incomingByteLength}/${options.maxTotalBytes ?? "unbounded"} bytes)`);
for (const row of orderedRows) {
if (entryCount < maxEntries && chunkCount + incomingChunkCount <= maxChunkRows && (options.maxTotalBytes === void 0 || totalBytes + incomingByteLength <= options.maxTotalBytes)) break;
const id = parseHostedOutboundMediaMetaKey(row.key);
if (!id) continue;
if ((activeReaders.get(id) ?? 0) > 0) continue;
if (await deleteEntry(id)) {
entryCount -= 1;
chunkCount -= row.value.chunkCount;
totalBytes -= row.value.byteLength;
}
}
if (entryCount >= maxEntries || chunkCount + incomingChunkCount > maxChunkRows || options.maxTotalBytes !== void 0 && totalBytes + incomingByteLength > options.maxTotalBytes) throw new Error("hosted outbound media capacity is full while active readers retain entries");
}
return {
async prepareUrl(params) {
const expiresAt = options.resolveExpiresAtMs(options.ttlMs);
if (expiresAt === void 0) throw new Error("hosted outbound media expiry could not be resolved");
const media = await loadOutboundMediaFromUrl(params.mediaUrl, {
maxBytes: params.maxBytes,
mediaAccess: params.mediaAccess,
...params.proxyUrl ? { proxyUrl: params.proxyUrl } : {},
...params.requestInit ? { requestInit: params.requestInit } : {}
});
await params.validateBeforePersist?.(media);
const id = createId();
const token = createToken();
const chunkCount = Math.max(1, Math.ceil(media.buffer.byteLength / rawChunkBytes));
if (chunkCount > maxChunkRows) throw new Error(`hosted outbound media exceeds SQLite chunk row limit (${chunkCount}/${maxChunkRows})`);
return await withCapacityMutation(async () => {
await pruneForCapacity(chunkCount, media.buffer.byteLength);
try {
for (let index = 0; index < chunkCount; index += 1) {
const chunk = media.buffer.subarray(index * rawChunkBytes, (index + 1) * rawChunkBytes);
await options.chunkStore.register(buildHostedOutboundMediaChunkKey(id, index), {
id,
index,
dataBase64: chunk.toString("base64")
}, { ttlMs: chunkPhysicalTtlMs });
}
await options.metadataStore.register(buildHostedOutboundMediaMetaKey(id), createHostedOutboundMediaMetaRecord({
id,
routePath: params.routePath,
token,
contentType: media.contentType,
fileName: media.fileName,
expiresAt,
chunkCount,
byteLength: media.buffer.byteLength
}), { ttlMs: metadataPhysicalTtlMs });
} catch (error) {
await deleteEntryRows(id, chunkCount);
throw error;
}
return `${params.publicBaseUrl}${params.routePath}${id}?token=${token}`;
});
},
async readMetadata(id, nowMs = Date.now()) {
const reader = await acquireReader(id, nowMs);
if (!reader) return null;
try {
return deferredDeletes.has(id) || deletingEntries.has(id) ? null : createHostedOutboundMediaMetadata(reader.meta);
} finally {
await reader.close();
}
},
async read(id, nowMs = Date.now()) {
const reader = await acquireReader(id, nowMs);
if (!reader) return null;
const { close, meta } = reader;
try {
const expectedChunkCount = Math.max(1, Math.ceil(meta.byteLength / rawChunkBytes));
if (!Number.isSafeInteger(meta.byteLength) || meta.byteLength < 0 || meta.chunkCount !== expectedChunkCount || meta.chunkCount > maxChunkRows) {
await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
const buffer = Buffer.allocUnsafe(meta.byteLength);
let offset = 0;
for (let index = 0; index < meta.chunkCount; index += 1) {
const chunk = await options.chunkStore.lookup(buildHostedOutboundMediaChunkKey(id, index));
if (!chunk || chunk.id !== id || chunk.index !== index) {
await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
const decoded = Buffer.from(chunk.dataBase64, "base64");
const expectedBytes = index === meta.chunkCount - 1 ? meta.byteLength - rawChunkBytes * (meta.chunkCount - 1) : rawChunkBytes;
if (decoded.byteLength !== expectedBytes) {
await withCapacityMutation(async () => await deleteEntry(id));
return null;
}
decoded.copy(buffer, offset);
offset += decoded.byteLength;
}
return {
metadata: createHostedOutboundMediaMetadata(meta),
buffer
};
} finally {
await close();
}
},
async delete(id) {
deferredDeletes.add(id);
await withCapacityMutation(async () => await deleteEntry(id));
},
cleanupExpired,
async clear() {
await withCapacityMutation(async () => await Promise.all([options.metadataStore.clear(), options.chunkStore.clear()]));
}
};
}
function encodeHostedOutboundMediaFileName(fileName) {
return encodeURIComponent(fileName).replace(/[\x27()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
}
/** Build download-only response headers for immutable hosted outbound media. */
function buildHostedOutboundMediaResponseHeaders(metadata, options = {}) {
const contentType = normalizeMimeType(metadata.contentType?.split(";", 1)[0]?.trim()) ?? "application/octet-stream";
const fileName = sanitizeUntrustedFileName(metadata.fileName ?? options.fallbackFileName ?? "attachment.bin", "attachment.bin");
const asciiFallback = fileName.replace(/[^\x20-\x7e]|[%"\\]/g, "_").trim() || "attachment.bin";
return {
"Content-Type": contentType,
"Content-Length": String(metadata.byteLength),
"Content-Disposition": `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeHostedOutboundMediaFileName(fileName)}`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff"
};
}
//#endregion
export { buildHostedOutboundMediaResponseHeaders, createHostedOutboundMediaStore, loadOutboundMediaFromUrl };