296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
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");
|
||
}
|