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.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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) })
|
||||
}
|
||||
Reference in New Issue
Block a user