/* uploads.js — model/texture asset handling for the character manager. * Files land in LIVE_DIR/assets (Docker volume) and are served at /assets/... * so uploads survive image rebuilds without being baked into the repo. */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const ALLOWED = { '.obj': 'model/obj', '.mtl': 'model/mtl', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', }; const MAX_BYTES = 12 * 1024 * 1024; function safeName(name) { const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_'); return base.slice(-80) || 'file'; } class Assets { constructor(liveDir) { this.dir = path.join(liveDir, 'assets'); this.indexPath = path.join(liveDir, 'assets.json'); fs.mkdirSync(this.dir, { recursive: true }); this.index = this.read(); } read() { try { return JSON.parse(fs.readFileSync(this.indexPath, 'utf8')); } catch { return { assets: [] }; } } write() { fs.writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2)); } list() { return this.index; } /* body: { name, kind: 'model'|'texture', dataBase64 } */ save({ name, kind, dataBase64 }) { const clean = safeName(name); const ext = path.extname(clean).toLowerCase(); if (!ALLOWED[ext]) throw new Error(`unsupported file type ${ext || '(none)'} — allowed: ${Object.keys(ALLOWED).join(', ')}`); const buf = Buffer.from(String(dataBase64 || ''), 'base64'); if (!buf.length) throw new Error('empty file'); if (buf.length > MAX_BYTES) throw new Error(`file too large (${(buf.length / 1048576).toFixed(1)} MB, max 12 MB)`); const id = crypto.randomBytes(6).toString('hex'); const stored = `${id}${ext}`; fs.writeFileSync(path.join(this.dir, stored), buf); const rec = { id, name: clean, kind: kind === 'texture' ? 'texture' : 'model', url: `/assets/${stored}`, bytes: buf.length, uploadedAt: Date.now(), }; this.index.assets.push(rec); this.write(); return rec; } remove(id) { const i = this.index.assets.findIndex(a => a.id === id); if (i < 0) throw new Error('asset not found'); const [rec] = this.index.assets.splice(i, 1); try { fs.unlinkSync(path.join(this.dir, path.basename(rec.url))); } catch {} this.write(); return rec; } } module.exports = { Assets, ALLOWED, MAX_BYTES };