211 lines
6.9 KiB
JavaScript
211 lines
6.9 KiB
JavaScript
import { createWriteStream } from 'node:fs'
|
|
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { Readable } from 'node:stream'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import { fetch as undiciFetch } from 'undici'
|
|
import { LOCAL_MODEL_DIR, MODEL_UPLOAD_LIMIT, OUTBOUND_PROXY_AGENT, TRIPO_API_BASE, TRIPO_API_KEY } from './config.mjs'
|
|
import { readRawBody, sanitizeFileName } from './http-utils.mjs'
|
|
|
|
export async function saveLocalModel(taskId, modelData, ext = 'glb') {
|
|
const buffer = Buffer.isBuffer(modelData) ? modelData : parseModelBase64(modelData)
|
|
validateModelBuffer(buffer, ext)
|
|
|
|
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
|
|
await writeFile(localModelPath(taskId, ext), buffer)
|
|
}
|
|
|
|
export async function hasLocalModel(taskId, ext = 'glb') {
|
|
try {
|
|
await access(localModelPath(taskId, ext))
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function localModelPath(taskId, ext = 'glb') {
|
|
return path.join(LOCAL_MODEL_DIR, `${sanitizeModelId(taskId)}.${ext}`)
|
|
}
|
|
|
|
export function localModelUrl(taskId, ext = 'glb') {
|
|
return `/api/3d/local-model/${encodeURIComponent(sanitizeModelId(taskId))}.${ext}`
|
|
}
|
|
|
|
export async function serveLocalModel(url, response) {
|
|
const rawFileName = decodeURIComponent(url.pathname.replace('/api/3d/local-model/', ''))
|
|
const ext = getModelExtension(rawFileName)
|
|
const modelId = rawFileName.replace(/\.(?:glb|gltf)$/i, '')
|
|
const buffer = await readFile(localModelPath(modelId, ext))
|
|
response.writeHead(200, {
|
|
'Content-Type': ext === 'gltf' ? 'model/gltf+json' : 'model/gltf-binary',
|
|
'Cache-Control': 'private, max-age=3600',
|
|
})
|
|
response.end(buffer)
|
|
}
|
|
|
|
export async function importLocalModel(request, url) {
|
|
const fileName = sanitizeFileName(url.searchParams.get('fileName') || 'local-model.glb')
|
|
const ext = getModelExtension(fileName)
|
|
const buffer = await readRawBody(request, MODEL_UPLOAD_LIMIT)
|
|
validateModelBuffer(buffer, ext)
|
|
|
|
const baseName = fileName.replace(/\.(?:glb|gltf)$/i, '') || 'local-model'
|
|
const modelId = `local-${Date.now()}-${baseName}`
|
|
await saveLocalModel(modelId, buffer, ext)
|
|
|
|
return {
|
|
provider: 'local',
|
|
taskId: sanitizeModelId(modelId),
|
|
status: 'success',
|
|
progress: 100,
|
|
modelUrl: localModelUrl(modelId, ext),
|
|
rawModelUrl: '',
|
|
fileName,
|
|
}
|
|
}
|
|
|
|
export async function cacheRemoteModel(taskId, rawModelUrl) {
|
|
return cacheRemoteModelAs(taskId, rawModelUrl, getModelExtension(rawModelUrl))
|
|
}
|
|
|
|
export async function cacheRemoteModelAs(taskId, rawModelUrl, ext = 'glb') {
|
|
if (await hasLocalModel(taskId, ext)) return localModelUrl(taskId, ext)
|
|
|
|
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
|
|
const targetPath = localModelPath(taskId, ext)
|
|
const tempPath = `${targetPath}.${Date.now()}.tmp`
|
|
|
|
try {
|
|
const remote = await fetchRemoteModel(rawModelUrl)
|
|
await pipeline(Readable.fromWeb(remote.body), createWriteStream(tempPath))
|
|
const buffer = await readFile(tempPath)
|
|
validateModelBuffer(buffer, ext)
|
|
await rename(tempPath, targetPath)
|
|
} catch (error) {
|
|
await rm(tempPath, { force: true }).catch(() => {})
|
|
throw error
|
|
}
|
|
|
|
return localModelUrl(taskId, ext)
|
|
}
|
|
|
|
export async function proxyModel(url, response) {
|
|
const rawUrl = url.searchParams.get('url')
|
|
if (!rawUrl || !isAllowedProxyModelUrl(rawUrl)) {
|
|
throw Object.assign(new Error('A valid HTTPS or localhost model URL is required.'), { status: 400 })
|
|
}
|
|
|
|
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
|
|
const remote = await undiciFetch(rawUrl, fetchOptions)
|
|
if (!remote.ok || !remote.body) {
|
|
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
|
|
if (!retry.ok || !retry.body) {
|
|
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
|
|
}
|
|
await streamRemoteModel(retry, response)
|
|
return
|
|
}
|
|
|
|
await streamRemoteModel(remote, response)
|
|
}
|
|
|
|
export function getModelExtension(value) {
|
|
const pathname = /^https?:\/\//i.test(String(value)) ? new URL(value).pathname : String(value)
|
|
const ext = path.extname(pathname).replace('.', '').toLowerCase()
|
|
if (ext === 'gltf') return 'gltf'
|
|
if (ext === 'glb') return 'glb'
|
|
throw Object.assign(new Error('Only GLB or self-contained GLTF models are supported.'), { status: 400 })
|
|
}
|
|
|
|
export function validateModelBuffer(buffer, ext = 'glb') {
|
|
if (!Buffer.isBuffer(buffer) || buffer.length < 32) {
|
|
throw Object.assign(new Error('Model file is too small or invalid.'), { status: 400 })
|
|
}
|
|
|
|
if (ext === 'glb') {
|
|
if (buffer.subarray(0, 4).toString('ascii') !== 'glTF') {
|
|
throw Object.assign(new Error('GLB files must start with a glTF binary header.'), { status: 400 })
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
JSON.parse(buffer.toString('utf8'))
|
|
} catch {
|
|
throw Object.assign(new Error('GLTF files must be valid JSON.'), { status: 400 })
|
|
}
|
|
}
|
|
|
|
export function sanitizeModelId(value) {
|
|
return sanitizeFileName(String(value)).replace(/\.(?:glb|gltf)$/i, '').replace(/\s+/g, '-').slice(0, 96) || `model-${Date.now()}`
|
|
}
|
|
|
|
export function shouldUseProxy(rawUrl) {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
return !['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
export function shouldAttachTripoAuth(rawUrl) {
|
|
if (!TRIPO_API_KEY) return false
|
|
|
|
try {
|
|
const host = new URL(rawUrl).hostname
|
|
const tripoHost = new URL(TRIPO_API_BASE).hostname
|
|
return host === tripoHost || host.endsWith('.tripo3d.ai')
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function fetchRemoteModel(rawUrl) {
|
|
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
|
|
const remote = await undiciFetch(rawUrl, fetchOptions)
|
|
if (remote.ok && remote.body) return remote
|
|
|
|
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
|
|
if (retry.ok && retry.body) return retry
|
|
|
|
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
|
|
}
|
|
|
|
async function fetchWithTripoAuth(rawUrl, fetchOptions) {
|
|
if (!shouldAttachTripoAuth(rawUrl)) {
|
|
return { ok: false, status: 401, body: null }
|
|
}
|
|
|
|
return undiciFetch(rawUrl, {
|
|
headers: { Authorization: `Bearer ${TRIPO_API_KEY}` },
|
|
...fetchOptions,
|
|
})
|
|
}
|
|
|
|
function parseModelBase64(modelBase64) {
|
|
const raw = String(modelBase64 || '').replace(/^data:.*?;base64,/, '')
|
|
return Buffer.from(raw, 'base64')
|
|
}
|
|
|
|
function isAllowedProxyModelUrl(rawUrl) {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
if (parsed.protocol === 'https:') return true
|
|
if (parsed.protocol !== 'http:') return false
|
|
return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function streamRemoteModel(remote, response) {
|
|
response.writeHead(200, {
|
|
'Content-Type': remote.headers.get('content-type') || 'model/gltf-binary',
|
|
'Cache-Control': 'private, max-age=3600',
|
|
})
|
|
|
|
await pipeline(Readable.fromWeb(remote.body), response)
|
|
}
|