import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries"; import { normalizeOpenClawChatConfig, OPENCLAW_CHAT_CONFIG_KEY, } from "@/lib/openclaw-chat/config"; import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 120; const querySchema = z.object({ chatId: z.string().uuid(), messageId: z.string().uuid(), index: z.coerce.number().int().min(0).max(127), download: z.enum(["0", "1"]).optional().default("0"), }); const MAX_REDIRECTS = 4; const RESPONSE_HEADERS = [ "accept-ranges", "content-length", "content-range", "etag", "last-modified", ] as const; function isPrivateIpv4(address: string) { const octets = address.split(".").map(Number); if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) { return true; } const [a, b] = octets; return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224 ); } function isPrivateIpAddress(address: string) { if (isIP(address) === 4) return isPrivateIpv4(address); if (isIP(address) !== 6) return true; const normalized = address.toLowerCase(); if (normalized.startsWith("::ffff:")) { return isPrivateIpv4(normalized.slice("::ffff:".length)); } return ( normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("ff") ); } async function validateTargetUrl(target: URL, trustedOrigin: string) { if (target.username || target.password) return false; if (target.origin === trustedOrigin) { return target.protocol === "http:" || target.protocol === "https:"; } if (target.protocol !== "https:") return false; const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ""); if ( hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local") ) { return false; } if (isIP(hostname)) return !isPrivateIpAddress(hostname); const addresses = await lookup(hostname, { all: true, verbatim: true }).catch( () => [] ); return ( addresses.length > 0 && addresses.every((entry) => !isPrivateIpAddress(entry.address)) ); } function normalizeRangeHeader(value: string | null) { const range = String(value || "").trim(); if (!range) return null; const match = range.match(/^bytes=(\d*)-(\d*)$/); if (!match || (!match[1] && !match[2])) return ""; if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return ""; return range; } function inferContentType(filename: string, declaredType: string) { if (declaredType) return declaredType; const lower = filename.toLowerCase(); if (lower.endsWith(".glb")) return "model/gltf-binary"; if (lower.endsWith(".gltf")) return "model/gltf+json"; if (lower.endsWith(".png")) return "image/png"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; return "application/octet-stream"; } function safeDownloadName(value: string) { const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact"; return normalized.slice(0, 180); } async function fetchArtifact(params: { request: Request; initialUrl: URL; method: "GET" | "HEAD"; range: string | null; token: string; trustedOrigin: string; }) { let target = params.initialUrl; for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { if (!(await validateTargetUrl(target, params.trustedOrigin))) { throw new Error("不允许访问该文件地址"); } const headers = new Headers({ Accept: "*/*" }); if (params.range) headers.set("Range", params.range); if (target.origin === params.trustedOrigin && params.token) { headers.set("Authorization", `Bearer ${params.token}`); } const response = await fetch(target, { method: params.method, headers, cache: "no-store", redirect: "manual", signal: params.request.signal, }); if (![301, 302, 303, 307, 308].includes(response.status)) { return { response, target }; } if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多"); const location = response.headers.get("location"); if (!location) throw new Error("文件重定向地址无效"); target = new URL(location, target); } throw new Error("文件地址无效"); } async function handleRequest(request: Request, method: "GET" | "HEAD") { const session = await auth().catch(() => null); if (!session?.user?.id) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } const url = new URL(request.url); const parsed = querySchema.safeParse({ chatId: url.searchParams.get("chatId"), messageId: url.searchParams.get("messageId"), index: url.searchParams.get("index"), download: url.searchParams.get("download") || undefined, }); if (!parsed.success) { return NextResponse.json({ error: "请求参数不正确" }, { status: 400 }); } const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null); if (!chat || chat.userId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const rows = await getMessageById({ id: parsed.data.messageId }).catch( () => [] ); const message = rows.find((row) => row.chatId === parsed.data.chatId); const metadata = normalizeClawdTaskMetadata(message?.videoMetadata); const artifact = metadata?.artifacts?.[parsed.data.index]; if (!message || !artifact) { return NextResponse.json({ error: "文件不存在" }, { status: 404 }); } const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch( () => null ); const config = normalizeOpenClawChatConfig(setting?.value); let target: URL; let trustedOrigin: string; try { target = new URL(artifact.downloadUrl); trustedOrigin = new URL(config.baseUrl).origin; } catch { return NextResponse.json({ error: "文件地址无效" }, { status: 400 }); } const range = normalizeRangeHeader(request.headers.get("range")); if (range === "") { return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 }); } try { const { response, target: finalTarget } = await fetchArtifact({ request, initialUrl: target, method, range, token: config.token, trustedOrigin, }); if (!response.ok && response.status !== 206) { return NextResponse.json( { error: `文件读取失败(HTTP ${response.status})` }, { status: response.status >= 400 && response.status < 600 ? response.status : 502 } ); } const headers = new Headers(); for (const name of RESPONSE_HEADERS) { const value = response.headers.get(name); if (value) headers.set(name, value); } headers.set( "content-type", response.headers.get("content-type") || inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType) ); headers.set("cache-control", "private, max-age=3600, must-revalidate"); headers.set("x-content-type-options", "nosniff"); if (parsed.data.download === "1") { const filename = safeDownloadName(artifact.filename || artifact.displayName); headers.set( "content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(filename)}` ); } return new Response(method === "HEAD" ? null : response.body, { status: response.status, headers, }); } catch (error) { console.warn("[Clawd Artifact] Proxy failed:", { chatId: parsed.data.chatId, messageId: parsed.data.messageId, artifactIndex: parsed.data.index, error: error instanceof Error ? error.message : String(error), }); return NextResponse.json( { error: error instanceof Error ? error.message : "文件读取失败" }, { status: 502 } ); } } export async function GET(request: Request) { return handleRequest(request, "GET"); } export async function HEAD(request: Request) { return handleRequest(request, "HEAD"); }