267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
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");
|
||
}
|