Files
newbury-nights/routes/admin.js
T

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;