From b6822af0470ae6d09bf0b1311504e236c25229be Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 24 Jul 2026 14:38:46 +1000 Subject: [PATCH] Add asset upload module for models and textures (validated, stored in live volume) --- server/uploads.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 server/uploads.js diff --git a/server/uploads.js b/server/uploads.js new file mode 100644 index 0000000..b6f2c8e --- /dev/null +++ b/server/uploads.js @@ -0,0 +1,64 @@ +/* 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 };