3af7f5e804
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.
114 lines
3.8 KiB
TypeScript
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) })
|
|
}
|