Files
NightSpire/web-next/app/modelviewer/[...path]/route.ts
T
Inna 3af7f5e804 Armería: muñeco de papel, talentos, actividad y caché del visor 3D
La ficha de personaje pasa a mostrar el equipo sobre el paperdoll (con
encantamientos y gemas en los tooltips de wowhead), el árbol de talentos y
la actividad reciente del personaje.

El proxy de /modelviewer/* deja de ser un rewrite de Next y pasa a un route
handler que cachea en disco los assets de zamimg, para no repetir la descarga
en cada visita.
2026-07-16 21:07:39 +00:00

114 lines
3.8 KiB
TypeScript

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/<rama>/...), 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<string, string> = {
'.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<Response | null> {
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) })
}