296 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
export const runtime = "nodejs";
export const maxDuration = 60;
const THREE_ASSET_BROWSER_CACHE_CONTROL =
"private, max-age=86400, stale-while-revalidate=604800";
const ALLOWED_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"tripo3d.ai",
"tripo3d.com",
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"yybb.top",
"qiniucs.com",
"qiniudn.com",
"clouddn.com",
"qbox.me",
"blob.vercel-storage.com",
"aliyuncs.com",
"amazonaws.com",
"googleapis.com",
"googleusercontent.com",
"cloudfront.net",
];
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai",
];
function isHostnameOrSubdomain(hostname: string, domain: string) {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
function isPrivateNetworkHostname(hostname: string) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (normalized === "localhost") return true;
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map((value) => Number(value));
if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
return true;
}
const [a, b] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
if (!normalized.includes(":")) return false;
return (
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80:")
);
}
function isAllowedModelHost(hostname: string) {
const normalized = hostname.toLowerCase();
if (isPrivateNetworkHostname(normalized)) return false;
return ALLOWED_MODEL_HOST_DOMAINS.some((domain) =>
isHostnameOrSubdomain(normalized, domain)
);
}
function isAllowedHttpModelHost(hostname: string) {
const normalized = hostname.toLowerCase();
return ALLOWED_HTTP_MODEL_HOST_DOMAINS.some((domain) =>
isHostnameOrSubdomain(normalized, domain)
);
}
function isGltfAssetRequest(targetUrl: URL, contentType: string | null) {
const normalizedType = String(contentType || "")
.toLowerCase()
.split(";")[0]
?.trim();
if (normalizedType === "model/gltf+json") return true;
if (targetUrl.pathname.toLowerCase().endsWith(".gltf")) return true;
return false;
}
function rewriteGltfAssetUri(assetUri: string, sourceUrl: URL) {
const normalized = assetUri.trim();
if (!normalized) return normalized;
if (normalized.startsWith("data:") || normalized.startsWith("blob:")) {
return normalized;
}
try {
const resolved = new URL(normalized, sourceUrl).toString();
return buildThreeAssetUrl(resolved);
} catch {
return normalized;
}
}
function rewriteGltfAssetList(value: unknown, sourceUrl: URL) {
if (!Array.isArray(value)) return value;
return value.map((item) => {
if (!item || typeof item !== "object") return item;
const next = { ...(item as Record<string, unknown>) };
if (typeof next.uri === "string") {
next.uri = rewriteGltfAssetUri(next.uri, sourceUrl);
}
return next;
});
}
function rewriteGltfPayload(raw: string, sourceUrl: URL) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const next = { ...parsed };
if (Array.isArray(parsed.buffers)) {
next.buffers = rewriteGltfAssetList(parsed.buffers, sourceUrl);
}
if (Array.isArray(parsed.images)) {
next.images = rewriteGltfAssetList(parsed.images, sourceUrl);
}
return JSON.stringify(next);
} catch {
return raw;
}
}
function normalizeRangeHeader(range: string | null) {
const normalized = String(range || "").trim();
if (!normalized) return null;
const match = normalized.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return "";
const [, start, end] = match;
if (!start && !end) return "";
if (start && end && Number(start) > Number(end)) return "";
return normalized;
}
function inferProxyContentType(targetUrl: URL, upstreamContentType: string | null) {
const pathname = targetUrl.pathname.toLowerCase();
if (pathname.endsWith(".spz")) return "application/octet-stream";
if (pathname.endsWith(".glb")) return "model/gltf-binary";
if (pathname.endsWith(".gltf")) return "model/gltf+json";
return upstreamContentType || "application/octet-stream";
}
async function handleRequest(request: Request, method: "GET" | "HEAD") {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const rawUrl = String(searchParams.get("url") || "").trim();
if (!rawUrl) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
}
let targetUrl: URL;
try {
targetUrl = new URL(rawUrl);
} catch {
return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 });
}
if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
}
if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
}
if (targetUrl.protocol === "http:" && !isAllowedHttpModelHost(targetUrl.hostname)) {
return NextResponse.json(
{ success: false, error: "HTTP 模型地址仅允许已配置的公开存储域名" },
{ status: 400 }
);
}
const forwardedHeaders = new Headers();
const range = normalizeRangeHeader(request.headers.get("range"));
if (range === "") {
return NextResponse.json(
{ success: false, error: "Range 请求格式无效" },
{ status: 400 }
);
}
if (range) {
forwardedHeaders.set("range", range);
}
const upstream = await fetch(targetUrl.toString(), {
method,
headers: forwardedHeaders,
cache: "no-store",
signal: request.signal,
});
if (!upstream.ok && upstream.status !== 206) {
const upstreamText =
method === "HEAD" ? "" : await upstream.text().catch(() => "");
return NextResponse.json(
{
success: false,
error: upstreamText || `模型拉取失败(HTTP ${upstream.status})`,
},
{ status: upstream.status || 502 }
);
}
const responseHeaders = new Headers();
const contentType = inferProxyContentType(
targetUrl,
upstream.headers.get("content-type")
);
const shouldRewriteGltf =
method === "GET" && !range && isGltfAssetRequest(targetUrl, contentType);
responseHeaders.set("content-type", contentType);
const contentLength = upstream.headers.get("content-length");
if (contentLength) responseHeaders.set("content-length", contentLength);
const contentRange = upstream.headers.get("content-range");
if (contentRange) responseHeaders.set("content-range", contentRange);
const acceptRanges = upstream.headers.get("accept-ranges");
if (acceptRanges) responseHeaders.set("accept-ranges", acceptRanges);
const etag = upstream.headers.get("etag");
if (etag) responseHeaders.set("etag", etag);
const lastModified = upstream.headers.get("last-modified");
if (lastModified) responseHeaders.set("last-modified", lastModified);
responseHeaders.set("cache-control", THREE_ASSET_BROWSER_CACHE_CONTROL);
responseHeaders.set("x-content-type-options", "nosniff");
if (method === "HEAD") {
return new NextResponse(null, {
status: upstream.status,
headers: responseHeaders,
});
}
if (shouldRewriteGltf) {
const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl);
responseHeaders.set("content-type", "model/gltf+json; charset=utf-8");
responseHeaders.delete("content-length");
return new NextResponse(rewrittenPayload, {
status: upstream.status,
headers: responseHeaders,
});
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "代理拉取 3D 模型失败",
},
{ status: 500 }
);
}
}
export async function GET(request: Request) {
return handleRequest(request, "GET");
}
export async function HEAD(request: Request) {
return handleRequest(request, "HEAD");
}