From 0f078f2ea263d9a0017fee82aa21453687157156 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 09:54:41 +0100 Subject: [PATCH] chore: remove unused media host server --- src/media/host.test.ts | 146 ---------- src/media/host.ts | 68 ----- src/media/server.outside-workspace.test.ts | 76 ------ src/media/server.runtime.ts | 27 -- src/media/server.test-support.ts | 76 ------ src/media/server.test.ts | 296 --------------------- src/media/server.ts | 224 ---------------- 7 files changed, 913 deletions(-) delete mode 100644 src/media/host.test.ts delete mode 100644 src/media/host.ts delete mode 100644 src/media/server.outside-workspace.test.ts delete mode 100644 src/media/server.runtime.ts delete mode 100644 src/media/server.test-support.ts delete mode 100644 src/media/server.test.ts delete mode 100644 src/media/server.ts diff --git a/src/media/host.test.ts b/src/media/host.test.ts deleted file mode 100644 index ae4c8f49698..00000000000 --- a/src/media/host.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import fs from "node:fs/promises"; -import type { Server } from "node:http"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - saveMediaSource: vi.fn(), - getTailnetHostname: vi.fn(), - ensurePortAvailable: vi.fn(), - startMediaServer: vi.fn(), - logInfo: vi.fn(), -})); -const { saveMediaSource, getTailnetHostname, ensurePortAvailable, startMediaServer, logInfo } = - mocks; - -vi.mock("./store.js", () => ({ saveMediaSource })); -vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname })); -vi.mock("../infra/ports.js", async () => { - const actual = await vi.importActual("../infra/ports.js"); - return { ensurePortAvailable, PortInUseError: actual.PortInUseError }; -}); -vi.mock("./server.js", () => ({ startMediaServer })); -vi.mock("../logger.js", async () => { - const actual = await vi.importActual("../logger.js"); - return { ...actual, logInfo }; -}); - -const { ensureMediaHosted } = await import("./host.js"); -const { PortInUseError } = await import("../infra/ports.js"); - -describe("ensureMediaHosted", () => { - function mockSavedMedia(id: string, size: number) { - saveMediaSource.mockResolvedValue({ - id, - path: `/tmp/${id}`, - size, - }); - } - - async function expectHostedMediaCase( - params: - | { - filePath: string; - savedMedia: { id: string; size: number }; - tailnetHostname: string; - startServer: boolean; - expectedError: RegExp; - expectedCleanupPath: string; - } - | { - filePath: string; - savedMedia: { id: string; size: number }; - tailnetHostname: string; - port: number; - startServer: boolean; - ensurePortError?: Error; - expectedUrl: string; - expectServerStart: boolean; - }, - ) { - getTailnetHostname.mockResolvedValue(params.tailnetHostname); - if ("expectedError" in params) { - saveMediaSource.mockResolvedValue({ - id: params.savedMedia.id, - path: params.filePath, - size: params.savedMedia.size, - }); - ensurePortAvailable.mockResolvedValue(undefined); - const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); - - await expect( - ensureMediaHosted(params.filePath, { startServer: params.startServer }), - ).rejects.toThrow(params.expectedError); - expect(rmSpy).toHaveBeenCalledWith(params.expectedCleanupPath); - rmSpy.mockRestore(); - return; - } - - mockSavedMedia(params.savedMedia.id, params.savedMedia.size); - if (params.ensurePortError) { - ensurePortAvailable.mockRejectedValue(params.ensurePortError); - } else { - ensurePortAvailable.mockResolvedValue(undefined); - startMediaServer.mockResolvedValue({ unref: vi.fn() } as unknown as Server); - } - - const result = await ensureMediaHosted(params.filePath, { - startServer: params.startServer, - port: params.port, - }); - - if (params.expectServerStart) { - expect(startMediaServer).toHaveBeenCalledWith( - params.port, - expect.any(Number), - expect.anything(), - ); - expect(logInfo).toHaveBeenCalled(); - } else { - expect(startMediaServer).not.toHaveBeenCalled(); - } - expect(result).toEqual({ - url: params.expectedUrl, - id: params.savedMedia.id, - size: params.savedMedia.size, - }); - } - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each([ - { - name: "throws and cleans up when server not allowed to start", - filePath: "/tmp/file1", - savedMedia: { id: "id1", size: 5 }, - tailnetHostname: "tailnet-host", - startServer: false, - expectedError: /requires the webhook\/Funnel server/i, - expectedCleanupPath: "/tmp/file1", - }, - { - name: "starts media server when allowed", - filePath: "/tmp/id2", - savedMedia: { id: "id2", size: 9 }, - tailnetHostname: "tail.net", - port: 1234, - startServer: true, - expectedUrl: "https://tail.net/media/id2", - expectServerStart: true, - }, - { - name: "skips server start when port already in use", - filePath: "/tmp/id3", - savedMedia: { id: "id3", size: 7 }, - tailnetHostname: "tail.net", - port: 3000, - startServer: false, - ensurePortError: new PortInUseError(3000, "proc"), - expectedUrl: "https://tail.net/media/id3", - expectServerStart: false, - }, - ] as const)("$name", async (testCase) => { - await expectHostedMediaCase(testCase); - }); -}); diff --git a/src/media/host.ts b/src/media/host.ts deleted file mode 100644 index d2032192c3e..00000000000 --- a/src/media/host.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from "node:fs/promises"; -import { formatCliCommand } from "../cli/command-format.js"; -import { ensurePortAvailable, PortInUseError } from "../infra/ports.js"; -import { getTailnetHostname } from "../infra/tailscale.js"; -import { logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { startMediaServer } from "./server.js"; -import { saveMediaSource } from "./store.js"; - -const DEFAULT_PORT = 42873; -const TTL_MS = 2 * 60 * 1000; - -let mediaServer: import("http").Server | null = null; - -export type HostedMedia = { - url: string; - id: string; - size: number; -}; - -export async function ensureMediaHosted( - source: string, - opts: { - port?: number; - startServer?: boolean; - runtime?: RuntimeEnv; - } = {}, -): Promise { - const port = opts.port ?? DEFAULT_PORT; - const runtime = opts.runtime ?? defaultRuntime; - - const saved = await saveMediaSource(source); - const hostname = await getTailnetHostname(); - - // Decide whether we must start a media server. - const needsServerStart = await isPortFree(port); - if (needsServerStart && !opts.startServer) { - await fs.rm(saved.path).catch(() => {}); - throw new Error( - `Media hosting requires the webhook/Funnel server. Start \`${formatCliCommand("openclaw webhook")}\`/\`${formatCliCommand("openclaw up")}\` or re-run with --serve-media.`, - ); - } - if (needsServerStart && opts.startServer) { - if (!mediaServer) { - mediaServer = await startMediaServer(port, TTL_MS, runtime); - logInfo( - `🦞 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`, - runtime, - ); - mediaServer.unref?.(); - } - } - - const url = `https://${hostname}/media/${saved.id}`; - return { url, id: saved.id, size: saved.size }; -} - -async function isPortFree(port: number) { - try { - await ensurePortAvailable(port); - return true; - } catch (err) { - if (err instanceof PortInUseError) { - return false; - } - throw err; - } -} diff --git a/src/media/server.outside-workspace.test.ts b/src/media/server.outside-workspace.test.ts deleted file mode 100644 index 2a1a9c1df80..00000000000 --- a/src/media/server.outside-workspace.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { - LOOPBACK_FETCH_ENV, - startMediaServerTestHarness, - type MediaServerTestHarness, -} from "./server.test-support.js"; - -const mocks = vi.hoisted(() => ({ - readFileWithinRoot: vi.fn(), - cleanOldMedia: vi.fn().mockResolvedValue(undefined), - isSafeOpenError: vi.fn( - (error: unknown) => typeof error === "object" && error !== null && "code" in error, - ), -})); - -let mediaDir = ""; - -vi.mock("./server.runtime.js", () => { - return { - MEDIA_MAX_BYTES: 5 * 1024 * 1024, - readFileWithinRoot: mocks.readFileWithinRoot, - isSafeOpenError: mocks.isSafeOpenError, - getMediaDir: () => mediaDir, - cleanOldMedia: mocks.cleanOldMedia, - }; -}); - -let mediaHarness: MediaServerTestHarness | undefined; -const mediaRootTracker = createSuiteTempRootTracker({ - prefix: "openclaw-media-outside-workspace-", -}); - -async function expectOutsideWorkspaceServerResponse(url: string) { - const response = await withEnvAsync(LOOPBACK_FETCH_ENV, () => mediaHarness!.fetch(url)); - expect(response.status).toBe(400); - expect(await response.text()).toBe("file is outside workspace root"); -} - -describe("media server outside-workspace mapping", () => { - beforeAll(async () => { - mediaHarness = await startMediaServerTestHarness({ - setupMediaRoot: async () => { - await mediaRootTracker.setup(); - mediaDir = await mediaRootTracker.make("case"); - }, - cleanupMediaRoot: async () => { - await mediaRootTracker.cleanup(); - mediaDir = ""; - }, - }); - }); - - beforeEach(() => { - mocks.readFileWithinRoot.mockReset(); - mocks.cleanOldMedia.mockClear(); - }); - - afterAll(async () => { - await mediaHarness?.cleanup(); - mediaHarness = undefined; - }); - - it("returns 400 with a specific outside-workspace message", async () => { - if (mediaHarness?.listenBlocked) { - return; - } - mocks.readFileWithinRoot.mockRejectedValueOnce({ - code: "outside-workspace", - message: "file is outside workspace root", - }); - - await expectOutsideWorkspaceServerResponse(mediaHarness!.url("ok-id")); - }); -}); diff --git a/src/media/server.runtime.ts b/src/media/server.runtime.ts deleted file mode 100644 index b277fd6afaa..00000000000 --- a/src/media/server.runtime.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileWithinRoot as readFileWithinRootImpl, SafeOpenError } from "../infra/fs-safe.js"; -import { - cleanOldMedia as cleanOldMediaImpl, - getMediaDir as getMediaDirImpl, - MEDIA_MAX_BYTES, -} from "./store.js"; - -export type SafeOpenLikeError = { - code: - | "invalid-path" - | "not-found" - | "outside-workspace" - | "symlink" - | "not-file" - | "path-mismatch" - | "too-large"; - message: string; -}; - -export const readFileWithinRoot = readFileWithinRootImpl; -export const cleanOldMedia = cleanOldMediaImpl; -export const getMediaDir = getMediaDirImpl; -export { MEDIA_MAX_BYTES }; - -export function isSafeOpenError(error: unknown): error is SafeOpenLikeError { - return error instanceof SafeOpenError; -} diff --git a/src/media/server.test-support.ts b/src/media/server.test-support.ts deleted file mode 100644 index 7b2ff450dc5..00000000000 --- a/src/media/server.test-support.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createRequire } from "node:module"; -import type { AddressInfo } from "node:net"; -import { vi } from "vitest"; - -type MediaTestServer = Awaited>; -type UndiciFetch = typeof import("undici").fetch; - -export const LOOPBACK_FETCH_ENV = { - HTTP_PROXY: undefined, - HTTPS_PROXY: undefined, - ALL_PROXY: undefined, - http_proxy: undefined, - https_proxy: undefined, - all_proxy: undefined, - NO_PROXY: "127.0.0.1,localhost", - no_proxy: "127.0.0.1,localhost", -} as const; - -export interface MediaServerTestHarness { - fetch: UndiciFetch; - listenBlocked: boolean; - port: number; - url: (mediaPath: string) => string; - cleanup: () => Promise; -} - -function isListenPermissionError(error: unknown): boolean { - return ( - error instanceof Error && "code" in error && (error.code === "EPERM" || error.code === "EACCES") - ); -} - -export async function startMediaServerTestHarness(params: { - setupMediaRoot: () => Promise; - cleanupMediaRoot: () => Promise; - cleanupTtlMs?: number; -}): Promise { - vi.useRealTimers(); - vi.doUnmock("undici"); - - const require = createRequire(import.meta.url); - const { startMediaServer } = await import("./server.js"); - const { fetch } = require("undici") as typeof import("undici"); - - let server: MediaTestServer | undefined; - let listenBlocked = false; - let port = 0; - - await params.setupMediaRoot(); - - try { - server = await startMediaServer(0, params.cleanupTtlMs ?? 1_000); - } catch (error) { - if (!isListenPermissionError(error)) { - throw error; - } - listenBlocked = true; - } - - if (server) { - port = (server.address() as AddressInfo).port; - } - - return { - fetch, - listenBlocked, - port, - url: (mediaPath: string) => `http://127.0.0.1:${port}/media/${mediaPath}`, - cleanup: async () => { - if (server) { - await new Promise((resolve) => server?.close(resolve)); - } - await params.cleanupMediaRoot(); - }, - }; -} diff --git a/src/media/server.test.ts b/src/media/server.test.ts deleted file mode 100644 index f817e0c5fd4..00000000000 --- a/src/media/server.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import fs from "node:fs/promises"; -import { request } from "node:http"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { - LOOPBACK_FETCH_ENV, - startMediaServerTestHarness, - type MediaServerTestHarness, -} from "./server.test-support.js"; - -let MEDIA_DIR = ""; -const cleanOldMedia = vi.fn().mockResolvedValue(undefined); - -vi.mock("./store.js", async () => { - const actual = await vi.importActual("./store.js"); - return { - ...actual, - getMediaDir: () => MEDIA_DIR, - cleanOldMedia, - }; -}); - -let MEDIA_MAX_BYTES: typeof import("./store.js").MEDIA_MAX_BYTES; -let mediaHarness: MediaServerTestHarness | undefined; -const mediaRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-media-test-" }); - -async function waitForFileRemoval(filePath: string, maxTicks = 1000) { - for (let tick = 0; tick < maxTicks; tick += 1) { - try { - await fs.stat(filePath); - } catch { - return; - } - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error(`timed out waiting for ${filePath} removal`); -} - -describe("media server", () => { - function mediaUrl(id: string) { - return mediaHarness?.url(id) ?? ""; - } - - async function writeMediaFile(id: string, contents: string) { - const filePath = path.join(MEDIA_DIR, id); - await fs.writeFile(filePath, contents); - return filePath; - } - - async function ageMediaFile(filePath: string) { - const past = Date.now() - 10_000; - await fs.utimes(filePath, past / 1000, past / 1000); - } - - async function expectMissingMediaFile(filePath: string) { - await expect(fs.stat(filePath)).rejects.toThrow(); - } - - async function expectExistingMediaFile(filePath: string) { - await expect(fs.stat(filePath)).resolves.toEqual(expect.anything()); - } - - function expectFetchedResponse( - response: Awaited>, - expected: { status: number; noSniff?: boolean }, - ) { - expect(response.status).toBe(expected.status); - if (expected.noSniff) { - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - } - } - - async function expectMediaFileLifecycleCase(params: { - id: string; - contents: string; - expectedStatus: number; - expectedBody?: string; - mutateFile?: (filePath: string) => Promise; - assertAfterFetch?: (filePath: string) => Promise; - }) { - const file = await writeMediaFile(params.id, params.contents); - await params.mutateFile?.(file); - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => - mediaHarness!.fetch(mediaUrl(params.id)), - ); - expectFetchedResponse(res, { status: params.expectedStatus }); - if (params.expectedBody !== undefined) { - expect(await res.text()).toBe(params.expectedBody); - } - await params.assertAfterFetch?.(file); - } - - async function expectFetchedMediaCase(params: { - mediaPath: string; - expectedStatus: number; - expectedBody?: string; - expectedNoSniff?: boolean; - setup?: () => Promise; - }) { - await params.setup?.(); - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => - mediaHarness!.fetch(mediaUrl(params.mediaPath)), - ); - expectFetchedResponse(res, { - status: params.expectedStatus, - ...(params.expectedNoSniff ? { noSniff: true } : {}), - }); - if (params.expectedBody !== undefined) { - expect(await res.text()).toBe(params.expectedBody); - } - } - - async function requestAndAbort(url: string) { - await new Promise((resolve, reject) => { - const req = request(url, (res) => { - res.destroy(); - resolve(); - }); - req.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "ECONNRESET") { - resolve(); - return; - } - reject(error); - }); - req.end(); - }); - } - - beforeAll(async () => { - ({ MEDIA_MAX_BYTES } = await import("./store.js")); - mediaHarness = await startMediaServerTestHarness({ - setupMediaRoot: async () => { - await mediaRootTracker.setup(); - MEDIA_DIR = await mediaRootTracker.make("case"); - }, - cleanupMediaRoot: async () => { - await mediaRootTracker.cleanup(); - MEDIA_DIR = ""; - }, - }); - }); - - afterAll(async () => { - await mediaHarness?.cleanup(); - mediaHarness = undefined; - }); - - it.each([ - { - name: "serves media and cleans up after send", - id: "file1", - contents: "hello", - expectedStatus: 200, - expectedBody: "hello", - assertAfterFetch: async (filePath: string) => { - await waitForFileRemoval(filePath); - }, - }, - { - name: "expires old media", - id: "old", - contents: "stale", - expectedStatus: 410, - mutateFile: ageMediaFile, - assertAfterFetch: expectMissingMediaFile, - }, - ] as const)("$name", async (testCase) => { - if (mediaHarness?.listenBlocked) { - return; - } - await expectMediaFileLifecycleCase(testCase); - }); - - it("sets safe fallback headers for untyped media bytes", async () => { - if (mediaHarness?.listenBlocked) { - return; - } - await writeMediaFile("raw", "hello"); - - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => mediaHarness!.fetch(mediaUrl("raw"))); - - expectFetchedResponse(res, { status: 200, noSniff: true }); - expect(res.headers.get("content-type")).toBe("application/octet-stream"); - expect(res.headers.get("content-length")).toBe("5"); - expect(await res.text()).toBe("hello"); - }); - - it("answers HEAD media probes without consuming the media file", async () => { - if (mediaHarness?.listenBlocked) { - return; - } - const file = await writeMediaFile("head-probe", "hello"); - - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => - mediaHarness!.fetch(mediaUrl("head-probe"), { method: "HEAD" }), - ); - - expectFetchedResponse(res, { status: 200, noSniff: true }); - expect(res.headers.get("content-type")).toBe("application/octet-stream"); - expect(res.headers.get("content-length")).toBe("5"); - expect(await res.text()).toBe(""); - await expectExistingMediaFile(file); - }); - - it("forces active text media to download as opaque bytes", async () => { - if (mediaHarness?.listenBlocked) { - return; - } - await writeMediaFile("page.html", ""); - - const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => - mediaHarness!.fetch(mediaUrl("page.html")), - ); - - expectFetchedResponse(res, { status: 200, noSniff: true }); - expect(res.headers.get("content-type")).toBe("application/octet-stream"); - expect(res.headers.get("content-disposition")).toBe('attachment; filename="page.html"'); - expect(await res.text()).toBe(""); - }); - - it("cleans up served media when the client aborts the response", async () => { - if (mediaHarness?.listenBlocked) { - return; - } - const file = await writeMediaFile("abort", "hello"); - - await withEnvAsync(LOOPBACK_FETCH_ENV, () => requestAndAbort(mediaUrl("abort"))); - - await waitForFileRemoval(file); - }); - - it.each([ - { - testName: "blocks path traversal attempts", - mediaPath: "%2e%2e%2fpackage.json", - expectedStatus: 400, - expectedBody: "invalid path", - }, - { - testName: "rejects invalid media ids", - mediaPath: "invalid%20id", - expectedStatus: 400, - expectedBody: "invalid path", - setup: async () => { - await writeMediaFile("file2", "hello"); - }, - }, - { - testName: "blocks symlink escaping outside media dir", - mediaPath: "link-out", - setup: async () => { - const target = path.join(process.cwd(), "package.json"); // outside MEDIA_DIR - const link = path.join(MEDIA_DIR, "link-out"); - await fs.symlink(target, link); - }, - expectedStatus: 400, - expectedBody: "invalid path", - }, - { - name: "rejects oversized media files", - mediaPath: "big", - expectedStatus: 413, - expectedBody: "too large", - setup: async () => { - const file = await writeMediaFile("big", ""); - await fs.truncate(file, MEDIA_MAX_BYTES + 1); - }, - }, - { - name: "returns not found for missing media IDs", - mediaPath: "missing-file", - expectedStatus: 404, - expectedBody: "not found", - expectedNoSniff: true, - }, - { - name: "returns 404 when route param is missing (dot path)", - mediaPath: ".", - expectedStatus: 404, - }, - { - name: "rejects overlong media id", - mediaPath: `${"a".repeat(201)}.txt`, - expectedStatus: 400, - expectedBody: "invalid path", - }, - ] as const)("%#", async (testCase) => { - if (mediaHarness?.listenBlocked) { - return; - } - await expectFetchedMediaCase(testCase); - }); -}); diff --git a/src/media/server.ts b/src/media/server.ts deleted file mode 100644 index 5d0b5a0ad2d..00000000000 --- a/src/media/server.ts +++ /dev/null @@ -1,224 +0,0 @@ -import fs from "node:fs/promises"; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { danger } from "../globals.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { detectMime } from "./mime.js"; -import { - cleanOldMedia, - getMediaDir, - isSafeOpenError, - MEDIA_MAX_BYTES, - readFileWithinRoot, -} from "./server.runtime.js"; - -const DEFAULT_TTL_MS = 2 * 60 * 1000; -const MAX_MEDIA_ID_CHARS = 200; -const MEDIA_ID_PATTERN = /^[\p{L}\p{N}._-]+$/u; -const MAX_MEDIA_BYTES = MEDIA_MAX_BYTES; -const DEFAULT_MEDIA_CONTENT_TYPE = "application/octet-stream"; -const ACTIVE_CONTENT_MIME_TYPES = new Set([ - "application/xhtml+xml", - "application/xml", - "image/svg+xml", - "text/html", - "text/javascript", - "text/xml", -]); - -const isValidMediaId = (id: string) => { - if (!id) { - return false; - } - if (id.length > MAX_MEDIA_ID_CHARS) { - return false; - } - if (id === "." || id === "..") { - return false; - } - return MEDIA_ID_PATTERN.test(id); -}; - -function sendText(res: ServerResponse, statusCode: number, body: string): void { - const data = Buffer.from(body); - res.statusCode = statusCode; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.setHeader("Content-Length", String(data.byteLength)); - res.end(data); -} - -function resolveMediaId(req: IncomingMessage): { - routeMatched: boolean; - id?: string; - method?: string; -} { - if (req.method !== "GET" && req.method !== "HEAD") { - return { routeMatched: false }; - } - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - const prefix = "/media/"; - if (!url.pathname.startsWith(prefix)) { - return { routeMatched: false }; - } - const encodedId = url.pathname.slice(prefix.length); - if (!encodedId || encodedId.includes("/")) { - return { routeMatched: false }; - } - try { - return { routeMatched: true, id: decodeURIComponent(encodedId), method: req.method }; - } catch { - return { routeMatched: true, id: "", method: req.method }; - } -} - -function isActiveContentMime(mime?: string): boolean { - const normalized = mime?.split(";")[0]?.trim().toLowerCase(); - return normalized ? ACTIVE_CONTENT_MIME_TYPES.has(normalized) : false; -} - -function sanitizeAttachmentFilename(id: string): string { - const name = id.replace(/["\\\r\n]/g, "_").trim(); - return name || "media"; -} - -function setMediaHeaders( - res: ServerResponse, - params: { id: string; mime?: string; bytes: number }, -): void { - const activeContent = isActiveContentMime(params.mime); - res.setHeader( - "Content-Type", - activeContent ? DEFAULT_MEDIA_CONTENT_TYPE : (params.mime ?? DEFAULT_MEDIA_CONTENT_TYPE), - ); - res.setHeader("Content-Length", String(params.bytes)); - if (activeContent) { - res.setHeader( - "Content-Disposition", - `attachment; filename="${sanitizeAttachmentFilename(params.id)}"`, - ); - } -} - -function scheduleMediaCleanup(realPath: string): void { - const cleanup = () => { - void fs.rm(realPath).catch(() => {}); - }; - if (process.env.VITEST || process.env.NODE_ENV === "test") { - queueMicrotask(cleanup); - return; - } - setTimeout(cleanup, 50); -} - -function cleanupAfterGetResponse(res: ServerResponse, realPath: string): void { - let scheduled = false; - const scheduleOnce = () => { - if (scheduled) { - return; - } - scheduled = true; - scheduleMediaCleanup(realPath); - }; - res.once("finish", scheduleOnce); - res.once("close", scheduleOnce); - res.once("error", scheduleOnce); -} - -export function createMediaRequestHandler(ttlMs = DEFAULT_TTL_MS) { - const mediaDir = getMediaDir(); - - return (req: IncomingMessage, res: ServerResponse) => { - const route = resolveMediaId(req); - if (!route.routeMatched) { - sendText(res, 404, "not found"); - return; - } - - void (async () => { - res.setHeader("X-Content-Type-Options", "nosniff"); - const id = route.id ?? ""; - if (!isValidMediaId(id)) { - sendText(res, 400, "invalid path"); - return; - } - try { - const { - buffer: data, - realPath, - stat, - } = await readFileWithinRoot({ - rootDir: mediaDir, - relativePath: id, - maxBytes: MAX_MEDIA_BYTES, - }); - if (Date.now() - stat.mtimeMs > ttlMs) { - await fs.rm(realPath).catch(() => {}); - sendText(res, 410, "expired"); - return; - } - const mime = await detectMime({ buffer: data, filePath: realPath }); - setMediaHeaders(res, { id, mime, bytes: data.byteLength }); - res.statusCode = 200; - if (route.method === "HEAD") { - res.end(); - return; - } - cleanupAfterGetResponse(res, realPath); - if (req.aborted || res.destroyed || res.writableEnded) { - scheduleMediaCleanup(realPath); - return; - } - res.end(data); - } catch (err) { - if (isSafeOpenError(err)) { - if (err.code === "outside-workspace") { - sendText(res, 400, "file is outside workspace root"); - return; - } - if (err.code === "invalid-path") { - sendText(res, 400, "invalid path"); - return; - } - if (err.code === "not-found") { - sendText(res, 404, "not found"); - return; - } - if (err.code === "too-large") { - sendText(res, 413, "too large"); - return; - } - } - sendText(res, 404, "not found"); - } - })().catch(() => { - if (!res.headersSent) { - sendText(res, 404, "not found"); - } else { - res.destroy(); - } - }); - }; -} - -function startMediaCleanupInterval(ttlMs: number): void { - // periodic cleanup - setInterval(() => { - void cleanOldMedia(ttlMs, { recursive: false }); - }, ttlMs).unref(); -} - -export async function startMediaServer( - port: number, - ttlMs = DEFAULT_TTL_MS, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - const server = createServer(createMediaRequestHandler(ttlMs)); - startMediaCleanupInterval(ttlMs); - return await new Promise((resolve, reject) => { - server.listen(port, "127.0.0.1"); - server.once("listening", () => resolve(server)); - server.once("error", (err) => { - runtime.error(danger(`Media server failed: ${String(err)}`)); - reject(err); - }); - }); -}