import type { NextRequest } from 'next/server' import { promises as fs } from 'fs' import path from 'path' /** * Proxy con CACHÉ EN DISCO del contenido del visor de modelos de wowhead. * * Sustituye al antiguo rewrite (que solo hacía de proxy transparente). La primera * vez que el visor pide un fichero (/modelviewer//...), se descarga de * wow.zamimg.com y se guarda en disco; a partir de ahí se sirve desde nuestro * servidor. Así la armería NO depende de que wowhead siga disponible: lo ya visto * queda cacheado localmente (mismo enfoque que el /wmmv-cache/ de otras armerías). * * Same-origin, por lo que además evita el bloqueo CORS del visor. */ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const UPSTREAM = 'https://wow.zamimg.com/modelviewer/' const CACHE_DIR = process.env.MODELVIEWER_CACHE_DIR || '/root/NightSpire/wmmv-cache' const TYPES: Record = { '.json': 'application/json; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.mo3': 'application/octet-stream', '.m2': 'application/octet-stream', '.blp': 'application/octet-stream', '.ktx': 'application/octet-stream', '.bone': 'application/octet-stream', '.wasm': 'application/wasm', } function contentType(rel: string): string { return TYPES[path.extname(rel).toLowerCase()] || 'application/octet-stream' } function safeRel(parts: string[]): string | null { const rel = (parts || []).join('/') if (!rel || rel.includes('..') || rel.includes('\0') || rel.startsWith('/')) return null return rel } const cacheHeaders = (ct: string, hit: boolean): HeadersInit => ({ 'Content-Type': ct, 'Cache-Control': 'public, max-age=31536000, immutable', 'X-Cache': hit ? 'HIT' : 'MISS', }) /** * Descarga de wowhead probando ramas alternativas. El visor pide todo bajo `live/` * (que tiene geometría), pero muchos metadatos/texturas de ítems de WotLK solo * existen en `wrath`/`cata`. Si `live` da 404, se reintenta con esas ramas y se * cachea el resultado bajo la ruta `live/` original que pidió el visor. */ async function fetchWithFallback(rel: string): Promise { const candidates = [rel] if (rel.startsWith('live/')) { const tail = rel.slice('live/'.length) candidates.push('wrath/' + tail, 'cata/' + tail) } for (const c of candidates) { try { const r = await fetch(UPSTREAM + c, { headers: { 'User-Agent': 'NightSpire-Armory/1.0' } }) if (r.ok) return r } catch { /* probar siguiente rama */ } } return null } export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { const { path: parts } = await params const rel = safeRel(parts) if (!rel) return new Response('Bad path', { status: 400 }) const filePath = path.join(CACHE_DIR, rel) const ct = contentType(rel) // 1) Servir desde la caché local si existe. try { const buf = await fs.readFile(filePath) return new Response(buf, { headers: cacheHeaders(ct, true) }) } catch { /* no cacheado todavía */ } // 2) Descargar de wowhead (con reintento en ramas alternativas). const upstream = await fetchWithFallback(rel) if (!upstream) return new Response(null, { status: 404 }) const buf = Buffer.from(await upstream.arrayBuffer()) // 3) Guardar en disco de forma atómica (temp + rename) y servir. try { await fs.mkdir(path.dirname(filePath), { recursive: true }) const tmp = `${filePath}.${process.pid}.${buf.length}.tmp` await fs.writeFile(tmp, buf) await fs.rename(tmp, filePath) } catch { /* si no se pudo guardar, servimos igualmente desde memoria */ } return new Response(buf, { headers: cacheHeaders(ct, false) }) }