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", "scqny.jiansun.vip", "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) }; 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; 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"); }