Your Name 5864e9f4cf
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: proxy tripo texture assets in 3d viewer
2026-05-14 14:34:29 +08:00

186 lines
5.9 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";
function isAllowedModelHost(hostname: string) {
const normalized = hostname.toLowerCase();
return (
normalized === "bbyy.ai" ||
normalized.endsWith(".bbyy.ai") ||
normalized === "tripo3d.ai" ||
normalized.endsWith(".tripo3d.ai") ||
normalized === "tripo3d.com" ||
normalized.endsWith(".tripo3d.com") ||
normalized.includes("worldlabs") ||
normalized.includes("marble") ||
normalized.includes("s3.1080p.hk") ||
normalized.includes("img.yydd.ai") ||
normalized.includes("blob.vercel-storage.com") ||
normalized.includes("aliyuncs.com") ||
normalized.includes("amazonaws.com") ||
normalized.includes("googleapis.com") ||
normalized.includes("googleusercontent.com") ||
normalized.includes("cloudfront.net")
);
}
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;
}
}
export async function GET(request: Request) {
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:") {
return NextResponse.json({ success: false, error: "仅支持 https 模型地址" }, { status: 400 });
}
if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
}
const forwardedHeaders = new Headers();
const range = request.headers.get("range");
if (range) {
forwardedHeaders.set("range", range);
}
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers: forwardedHeaders,
cache: "no-store",
});
if (!upstream.ok && upstream.status !== 206) {
const upstreamText = await upstream.text().catch(() => "");
return NextResponse.json(
{
success: false,
error: upstreamText || `模型拉取失败(HTTP ${upstream.status})`,
},
{ status: upstream.status || 502 }
);
}
const responseHeaders = new Headers();
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
const shouldRewriteGltf = !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);
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 }
);
}
}