mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-02 02:57:51 +00:00
fix(tlon): cap inbound image downloads (#58223)
This commit is contained in:
101
extensions/tlon/src/monitor/media.test.ts
Normal file
101
extensions/tlon/src/monitor/media.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("tlon monitor media", () => {
|
||||
async function loadMediaModule() {
|
||||
const mediaRuntime = await import("openclaw/plugin-sdk/media-runtime");
|
||||
const mediaModule = await import("./media.js");
|
||||
return {
|
||||
fetchRemoteMedia: vi.mocked(mediaRuntime.fetchRemoteMedia),
|
||||
saveMediaBuffer: vi.mocked(mediaRuntime.saveMediaBuffer),
|
||||
...mediaModule,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caps extracted images at eight per message", async () => {
|
||||
const { extractImageBlocks } = await loadMediaModule();
|
||||
const content = Array.from({ length: 10 }, (_, index) => ({
|
||||
block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } },
|
||||
}));
|
||||
|
||||
const images = extractImageBlocks(content);
|
||||
|
||||
expect(images).toHaveLength(8);
|
||||
expect(images.map((image) => image.url)).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => `https://example.com/${index}.png`),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores fetched media through the shared inbound media store with the image cap", async () => {
|
||||
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
|
||||
|
||||
fetchRemoteMedia.mockResolvedValue({
|
||||
buffer: Buffer.from("image-data"),
|
||||
contentType: "image/png",
|
||||
fileName: "photo.png",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValue({
|
||||
id: "photo---uuid.png",
|
||||
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
||||
size: "image-data".length,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const result = await downloadMedia("https://example.com/photo.png");
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/photo.png",
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
readIdleTimeoutMs: 30_000,
|
||||
requestInit: { method: "GET" },
|
||||
}),
|
||||
);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("image-data"),
|
||||
"image/png",
|
||||
"inbound",
|
||||
MAX_IMAGE_BYTES,
|
||||
"photo.png",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
localPath: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
||||
contentType: "image/png",
|
||||
originalUrl: "https://example.com/photo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the fetch exceeds the image cap", async () => {
|
||||
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
|
||||
|
||||
fetchRemoteMedia.mockRejectedValue(
|
||||
new Error(
|
||||
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await downloadMedia("https://example.com/photo.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fetchWithSsrFGuard } from "../../runtime-api.js";
|
||||
import {
|
||||
fetchRemoteMedia,
|
||||
MAX_IMAGE_BYTES,
|
||||
saveMediaBuffer,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getDefaultSsrFPolicy } from "../urbit/context.js";
|
||||
|
||||
// Default to OpenClaw workspace media directory
|
||||
const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound");
|
||||
const MAX_IMAGES_PER_MESSAGE = 8;
|
||||
const TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface ExtractedImage {
|
||||
url: string;
|
||||
@@ -39,6 +39,9 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] {
|
||||
url: verse.block.image.src,
|
||||
alt: verse.block.image.alt,
|
||||
});
|
||||
if (images.length >= MAX_IMAGES_PER_MESSAGE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +54,7 @@ export function extractImageBlocks(content: unknown): ExtractedImage[] {
|
||||
*/
|
||||
export async function downloadMedia(
|
||||
url: string,
|
||||
mediaDir: string = DEFAULT_MEDIA_DIR,
|
||||
mediaDir?: string,
|
||||
): Promise<DownloadedMedia | null> {
|
||||
try {
|
||||
// Validate URL is http/https before fetching
|
||||
@@ -61,56 +64,59 @@ export async function downloadMedia(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure media directory exists
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
|
||||
// Fetch with SSRF protection
|
||||
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url,
|
||||
init: { method: "GET" },
|
||||
policy: getDefaultSsrFPolicy(),
|
||||
auditContext: "tlon-media-download",
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
readIdleTimeoutMs: TLON_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
ssrfPolicy: getDefaultSsrFPolicy(),
|
||||
requestInit: { method: "GET" },
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine content type and extension
|
||||
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
||||
const ext = getExtensionFromContentType(contentType) || getExtensionFromUrl(url) || "bin";
|
||||
|
||||
// Generate unique filename
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const localPath = path.join(mediaDir, filename);
|
||||
|
||||
// Stream to file
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
console.error(`[tlon-media] No response body for ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const writeStream = createWriteStream(localPath);
|
||||
await pipeline(Readable.fromWeb(body as any), writeStream);
|
||||
|
||||
if (!mediaDir) {
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
MAX_IMAGE_BYTES,
|
||||
fetched.fileName,
|
||||
);
|
||||
return {
|
||||
localPath,
|
||||
contentType,
|
||||
localPath: saved.path,
|
||||
contentType: saved.contentType ?? fetched.contentType ?? "application/octet-stream",
|
||||
originalUrl: url,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[tlon-media] Error downloading ${url}: ${error?.message ?? String(error)}`);
|
||||
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
const ext =
|
||||
getExtensionFromFileName(fetched.fileName) ||
|
||||
getExtensionFromContentType(fetched.contentType ?? "") ||
|
||||
getExtensionFromUrl(url) ||
|
||||
"bin";
|
||||
const localPath = path.join(mediaDir, `${randomUUID()}.${ext}`);
|
||||
await writeFile(localPath, fetched.buffer);
|
||||
|
||||
return {
|
||||
localPath,
|
||||
contentType: fetched.contentType ?? "application/octet-stream",
|
||||
originalUrl: url,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[tlon-media] Error downloading ${url}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getExtensionFromFileName(fileName?: string): string | null {
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
const ext = path.extname(fileName).replace(/^\./, "");
|
||||
return ext || null;
|
||||
}
|
||||
|
||||
function getExtensionFromContentType(contentType: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
|
||||
Reference in New Issue
Block a user