240 lines
9.0 KiB
JavaScript
240 lines
9.0 KiB
JavaScript
import { Router } from 'express';
|
|
import multer from 'multer';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
import { dirname, extname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import db from '../db/index.js';
|
|
import { requireAuth } from './auth-middleware.js';
|
|
import { convertGhostMp4 } from '../lib/ghost-media.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
|
|
mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
|
|
const router = Router();
|
|
router.use(requireAuth); // everything here requires a valid JWT
|
|
|
|
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp', '.webm', '.mp4']);
|
|
const VIDEO_EXTS = new Set(['.mp4', '.webm']);
|
|
const storage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
|
filename: (_req, file, cb) => {
|
|
const ext = extname(file.originalname).toLowerCase();
|
|
cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`);
|
|
},
|
|
});
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 64 * 1024 * 1024 }, // 64MB — source MP4s are larger than GIFs
|
|
fileFilter: (_req, file, cb) => {
|
|
const ext = extname(file.originalname).toLowerCase();
|
|
cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext));
|
|
},
|
|
});
|
|
|
|
// Remove an uploaded file by bare filename, ignoring errors.
|
|
function removeUpload(filename) {
|
|
if (!filename) return;
|
|
const p = join(UPLOAD_DIR, filename);
|
|
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
|
|
}
|
|
|
|
const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d);
|
|
|
|
/* ---------------- Ghosts ---------------- */
|
|
|
|
router.get('/ghosts', (req, res) => {
|
|
res.json({ ghosts: db.prepare('SELECT * FROM ghosts ORDER BY type, rarity, name').all() });
|
|
});
|
|
|
|
router.post('/ghosts', (req, res) => {
|
|
const b = req.body || {};
|
|
if (!b.name || !b.type || !b.rarity) {
|
|
return res.status(400).json({ error: 'name, type, rarity required' });
|
|
}
|
|
const info = db
|
|
.prepare(
|
|
`INSERT INTO ghosts
|
|
(name, display_name, type, rarity, speed, range, charge_shot,
|
|
health, damage, ability, is_boss, set_number, set_name, enabled)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
)
|
|
.run(
|
|
b.name, b.displayName || b.name, b.type, toInt(b.rarity, 1),
|
|
toInt(b.speed), toInt(b.range), toInt(b.chargeShot),
|
|
toInt(b.health, 300), toInt(b.damage, 150), b.ability || null,
|
|
b.isBoss ? 1 : 0, b.setNumber || null, b.setName || null,
|
|
b.enabled === false ? 0 : 1
|
|
);
|
|
res.status(201).json({ id: info.lastInsertRowid });
|
|
});
|
|
|
|
router.patch('/ghosts/:id', (req, res) => {
|
|
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
|
if (!ghost) return res.status(404).json({ error: 'not found' });
|
|
const b = req.body || {};
|
|
const map = {
|
|
name: 'name', displayName: 'display_name', type: 'type', rarity: 'rarity',
|
|
speed: 'speed', range: 'range', chargeShot: 'charge_shot',
|
|
health: 'health', damage: 'damage', ability: 'ability',
|
|
isBoss: 'is_boss', setNumber: 'set_number', setName: 'set_name', enabled: 'enabled',
|
|
};
|
|
const sets = [];
|
|
const vals = [];
|
|
for (const [k, col] of Object.entries(map)) {
|
|
if (k in b) {
|
|
sets.push(`${col} = ?`);
|
|
let v = b[k];
|
|
if (k === 'isBoss' || k === 'enabled') v = v ? 1 : 0;
|
|
vals.push(v);
|
|
}
|
|
}
|
|
if (!sets.length) return res.json({ ok: true, unchanged: true });
|
|
vals.push(req.params.id);
|
|
db.prepare(`UPDATE ghosts SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/ghosts/:id/image', upload.single('image'), async (req, res) => {
|
|
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
|
if (!ghost) {
|
|
if (req.file) removeUpload(req.file.filename);
|
|
return res.status(404).json({ error: 'not found' });
|
|
}
|
|
if (!req.file) return res.status(400).json({ error: 'no file' });
|
|
|
|
const ext = extname(req.file.filename).toLowerCase();
|
|
|
|
// Clear any previous media (image + video sprites) before recording the new set.
|
|
const cleanupOld = () => {
|
|
removeUpload(ghost.image_path);
|
|
removeUpload(ghost.webm_path);
|
|
removeUpload(ghost.webp_path);
|
|
};
|
|
|
|
if (ext === '.mp4') {
|
|
// Convert the source MP4 to a transparent WebM (VP9+alpha) plus a WebP
|
|
// fallback via luma keying. The original MP4 is removed afterwards.
|
|
let out;
|
|
try {
|
|
out = await convertGhostMp4(UPLOAD_DIR, req.file.filename);
|
|
} catch (e) {
|
|
removeUpload(req.file.filename);
|
|
return res.status(500).json({ error: 'conversion failed', detail: e.message });
|
|
}
|
|
removeUpload(req.file.filename); // discard the raw mp4
|
|
if (!out.webm && !out.webp) {
|
|
return res.status(500).json({ error: 'conversion produced no output (is ffmpeg installed?)' });
|
|
}
|
|
cleanupOld();
|
|
// webp doubles as the still/thumbnail image where present.
|
|
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = ?, image_path = ? WHERE id = ?')
|
|
.run(out.webm, out.webp, out.webp, ghost.id);
|
|
return res.json({
|
|
ok: true,
|
|
webm: out.webm ? `/uploads/${out.webm}` : null,
|
|
webp: out.webp ? `/uploads/${out.webp}` : null,
|
|
image: out.webp ? `/uploads/${out.webp}` : null,
|
|
});
|
|
}
|
|
|
|
if (ext === '.webm') {
|
|
// Pre-made transparent WebM uploaded directly — store as-is.
|
|
cleanupOld();
|
|
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = NULL, image_path = NULL WHERE id = ?')
|
|
.run(req.file.filename, ghost.id);
|
|
return res.json({ ok: true, webm: `/uploads/${req.file.filename}` });
|
|
}
|
|
|
|
// Plain image (gif/png/jpg/webp) — the original billboard path. Clear any
|
|
// previous video sprites so the ghost falls back cleanly to the image.
|
|
cleanupOld();
|
|
db.prepare('UPDATE ghosts SET image_path = ?, webm_path = NULL, webp_path = NULL WHERE id = ?')
|
|
.run(req.file.filename, ghost.id);
|
|
res.json({ ok: true, image: `/uploads/${req.file.filename}` });
|
|
});
|
|
|
|
router.delete('/ghosts/:id', (req, res) => {
|
|
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
|
|
if (!ghost) return res.status(404).json({ error: 'not found' });
|
|
removeUpload(ghost.image_path);
|
|
removeUpload(ghost.webm_path);
|
|
removeUpload(ghost.webp_path);
|
|
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/* ---------------- Sets ---------------- */
|
|
|
|
router.get('/sets', (req, res) => {
|
|
const sets = db.prepare('SELECT * FROM sets ORDER BY set_number, set_name').all();
|
|
const getRoster = db.prepare(
|
|
`SELECT g.id, g.name, g.type, g.rarity, g.is_boss
|
|
FROM ghosts g JOIN set_ghosts sg ON sg.ghost_id = g.id
|
|
WHERE sg.set_id = ? ORDER BY g.is_boss DESC, g.rarity DESC, g.name`
|
|
);
|
|
res.json({
|
|
sets: sets.map((s) => ({ ...s, roster: getRoster.all(s.id) })),
|
|
});
|
|
});
|
|
|
|
router.post('/sets', (req, res) => {
|
|
const b = req.body || {};
|
|
if (!b.code || !b.setName) return res.status(400).json({ error: 'code and setName required' });
|
|
try {
|
|
const info = db
|
|
.prepare(
|
|
'INSERT INTO sets (code, set_number, set_name, boss_ghost_id, enabled) VALUES (?,?,?,?,?)'
|
|
)
|
|
.run(b.code, b.setNumber || null, b.setName, b.bossGhostId || null, b.enabled === false ? 0 : 1);
|
|
res.status(201).json({ id: info.lastInsertRowid });
|
|
} catch (e) {
|
|
if (String(e).includes('UNIQUE')) return res.status(409).json({ error: 'code already exists' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
router.patch('/sets/:id', (req, res) => {
|
|
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
|
|
if (!set) return res.status(404).json({ error: 'not found' });
|
|
const b = req.body || {};
|
|
const map = {
|
|
code: 'code', setNumber: 'set_number', setName: 'set_name',
|
|
bossGhostId: 'boss_ghost_id', enabled: 'enabled',
|
|
};
|
|
const sets = []; const vals = [];
|
|
for (const [k, col] of Object.entries(map)) {
|
|
if (k in b) {
|
|
sets.push(`${col} = ?`);
|
|
vals.push(k === 'enabled' ? (b[k] ? 1 : 0) : b[k]);
|
|
}
|
|
}
|
|
if (!sets.length) return res.json({ ok: true, unchanged: true });
|
|
vals.push(req.params.id);
|
|
db.prepare(`UPDATE sets SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.put('/sets/:id/roster', (req, res) => {
|
|
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
|
|
if (!set) return res.status(404).json({ error: 'not found' });
|
|
const ids = Array.isArray(req.body?.ghostIds) ? req.body.ghostIds : [];
|
|
const tx = db.transaction(() => {
|
|
db.prepare('DELETE FROM set_ghosts WHERE set_id = ?').run(set.id);
|
|
const link = db.prepare('INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)');
|
|
for (const gid of ids) link.run(set.id, gid);
|
|
});
|
|
tx();
|
|
res.json({ ok: true, count: ids.length });
|
|
});
|
|
|
|
router.delete('/sets/:id', (req, res) => {
|
|
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
|
|
if (!set) return res.status(404).json({ error: 'not found' });
|
|
db.prepare('DELETE FROM sets WHERE id = ?').run(set.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
export default router;
|