Fix: decode base64-corrupted source files (html/css/js + backend)

This commit is contained in:
jessikitty committed 2026-06-19 05:06:43 +00:00
1 parent 327b37babb
commit 458c66a2c0
6 files changed
+2699 -5

No files matched your search

+239 -1
View File
@@ -1 +1,239 @@
aW1wb3J0IHsgUm91dGVyIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBtdWx0ZXIgZnJvbSAnbXVsdGVyJzsKaW1wb3J0IHsgcmFuZG9tQnl0ZXMgfSBmcm9tICdub2RlOmNyeXB0byc7CmltcG9ydCB7IG1rZGlyU3luYywgZXhpc3RzU3luYywgdW5saW5rU3luYyB9IGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgeyBkaXJuYW1lLCBleHRuYW1lLCBqb2luIH0gZnJvbSAnbm9kZTpwYXRoJzsKaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gJ25vZGU6dXJsJzsKaW1wb3J0IGRiIGZyb20gJy4uL2RiL2luZGV4LmpzJzsKaW1wb3J0IHsgcmVxdWlyZUF1dGggfSBmcm9tICcuL2F1dGgtbWlkZGxld2FyZS5qcyc7CmltcG9ydCB7IGNvbnZlcnRHaG9zdE1wNCB9IGZyb20gJy4uL2xpYi9naG9zdC1tZWRpYS5qcyc7Cgpjb25zdCBfX2Rpcm5hbWUgPSBkaXJuYW1lKGZpbGVVUkxUb1BhdGgoaW1wb3J0Lm1ldGEudXJsKSk7CmNvbnN0IFVQTE9BRF9ESVIgPSBqb2luKF9fZGlybmFtZSwgJy4uJywgcHJvY2Vzcy5lbnYuVVBMT0FEX0RJUiB8fCAndXBsb2FkcycpOwpta2RpclN5bmMoVVBMT0FEX0RJUiwgeyByZWN1cnNpdmU6IHRydWUgfSk7Cgpjb25zdCByb3V0ZXIgPSBSb3V0ZXIoKTsKcm91dGVyLnVzZShyZXF1aXJlQXV0aCk7IC8vIGV2ZXJ5dGhpbmcgaGVyZSByZXF1aXJlcyBhIHZhbGlkIEpXVAoKY29uc3QgQUxMT1dFRCA9IG5ldyBTZXQoWycuZ2lmJywgJy5wbmcnLCAnLmpwZycsICcuanBlZycsICcud2VicCcsICcud2VibScsICcubXA0J10pOwpjb25zdCBWSURFT19FWFRTID0gbmV3IFNldChbJy5tcDQnLCAnLndlYm0nXSk7CmNvbnN0IHN0b3JhZ2UgPSBtdWx0ZXIuZGlza1N0b3JhZ2UoewogIGRlc3RpbmF0aW9uOiAoX3JlcSwgX2ZpbGUsIGNiKSA9PiBjYihudWxsLCBVUExPQURfRElSKSwKICBmaWxlbmFtZTogKF9yZXEsIGZpbGUsIGNiKSA9PiB7CiAgICBjb25zdCBleHQgPSBleHRuYW1lKGZpbGUub3JpZ2luYWxuYW1lKS50b0xvd2VyQ2FzZSgpOwogICAgY2IobnVsbCwgYCR7RGF0ZS5ub3coKX0tJHtyYW5kb21CeXRlcyg2KS50b1N0cmluZygnaGV4Jyl9JHtleHR9YCk7CiAgfSwKfSk7CmNvbnN0IHVwbG9hZCA9IG11bHRlcih7CiAgc3RvcmFnZSwKICBsaW1pdHM6IHsgZmlsZVNpemU6IDY0ICogMTAyNCAqIDEwMjQgfSwgLy8gNjRNQiDigJQgc291cmNlIE1QNHMgYXJlIGxhcmdlciB0aGFuIEdJRnMKICBmaWxlRmlsdGVyOiAoX3JlcSwgZmlsZSwgY2IpID0+IHsKICAgIGNvbnN0IGV4dCA9IGV4dG5hbWUoZmlsZS5vcmlnaW5hbG5hbWUpLnRvTG93ZXJDYXNlKCk7CiAgICBjYihBTExPV0VELmhhcyhleHQpID8gbnVsbCA6IG5ldyBFcnJvcigndW5zdXBwb3J0ZWQgZmlsZSB0eXBlJyksIEFMTE9XRUQuaGFzKGV4dCkpOwogIH0sCn0pOwoKLy8gUmVtb3ZlIGFuIHVwbG9hZGVkIGZpbGUgYnkgYmFyZSBmaWxlbmFtZSwgaWdub3JpbmcgZXJyb3JzLgpmdW5jdGlvbiByZW1vdmVVcGxvYWQoZmlsZW5hbWUpIHsKICBpZiAoIWZpbGVuYW1lKSByZXR1cm47CiAgY29uc3QgcCA9IGpvaW4oVVBMT0FEX0RJUiwgZmlsZW5hbWUpOwogIGlmIChleGlzdHNTeW5jKHApKSB0cnkgeyB1bmxpbmtTeW5jKHApOyB9IGNhdGNoIHsgLyogaWdub3JlICovIH0KfQoKY29uc3QgdG9JbnQgPSAodiwgZCA9IDApID0+IChOdW1iZXIuaXNGaW5pdGUoK3YpID8gcGFyc2VJbnQodiwgMTApIDogZCk7CgovKiAtLS0tLS0tLS0tLS0tLS0tIEdob3N0cyAtLS0tLS0tLS0tLS0tLS0tICovCgpyb3V0ZXIuZ2V0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBnaG9zdHM6IGRiLnByZXBhcmUoJ1NFTEVDVCAqIEZST00gZ2hvc3RzIE9SREVSIEJZIHR5cGUsIHJhcml0eSwgbmFtZScpLmFsbCgpIH0pOwp9KTsKCnJvdXRlci5wb3N0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgYiA9IHJlcS5ib2R5IHx8IHt9OwogIGlmICghYi5uYW1lIHx8ICFiLnR5cGUgfHwgIWIucmFyaXR5KSB7CiAgICByZXR1cm4gcmVzLnN0YXR1cyg0MDApLmpzb24oeyBlcnJvcjogJ25hbWUsIHR5cGUsIHJhcml0eSByZXF1aXJlZCcgfSk7CiAgfQogIGNvbnN0IGluZm8gPSBkYgogICAgLnByZXBhcmUoCiAgICAgIGBJTlNFUlQgSU5UTyBnaG9zdHMKICAgICAgICAobmFtZSwgZGlzcGxheV9uYW1lLCB0eXBlLCByYXJpdHksIHNwZWVkLCByYW5nZSwgY2hhcmdlX3Nob3QsCiAgICAgICAgIGhlYWx0aCwgZGFtYWdlLCBhYmlsaXR5LCBpc19ib3NzLCBzZXRfbnVtYmVyLCBzZXRfbmFtZSwgZW5hYmxlZCkKICAgICAgIFZBTFVFUyAoPyw/LD8sPyw/LD8sPyw/LD8sPyw/LD8sPyw/KWAKICAgICkKICAgIC5ydW4oCiAgICAgIGIubmFtZSwgYi5kaXNwbGF5TmFtZSB8fCBiLm5hbWUsIGIudHlwZSwgdG9JbnQoYi5yYXJpdHksIDEpLAogICAgICB0b0ludChiLnNwZWVkKSwgdG9JbnQoYi5yYW5nZSksIHRvSW50KGIuY2hhcmdlU2hvdCksCiAgICAgIHRvSW50KGIuaGVhbHRoLCAzMDApLCB0b0ludChiLmRhbWFnZSwgMTUwKSwgYi5hYmlsaXR5IHx8IG51bGwsCiAgICAgIGIuaXNCb3NzID8gMSA6IDAsIGIuc2V0TnVtYmVyIHx8IG51bGwsIGIuc2V0TmFtZSB8fCBudWxsLAogICAgICBiLmVuYWJsZWQgPT09IGZhbHNlID8gMCA6IDEKICAgICk7CiAgcmVzLnN0YXR1cygyMDEpLmpzb24oeyBpZDogaW5mby5sYXN0SW5zZXJ0Um93aWQgfSk7Cn0pOwoKcm91dGVyLnBhdGNoKCcvZ2hvc3RzLzppZCcsIChyZXEsIHJlcykgPT4gewogIGNvbnN0IGdob3N0ID0gZGIucHJlcGFyZSgnU0VMRUNUICogRlJPTSBnaG9zdHMgV0hFUkUgaWQgPSA/JykuZ2V0KHJlcS5wYXJhbXMuaWQpOwogIGlmICghZ2hvc3QpIHJldHVybiByZXMuc3RhdHVzKDQwNCkuanNvbih7IGVycm9yOiAnbm90IGZvdW5kJyB9KTsKICBjb25zdCBiID0gcmVxLmJvZHkgfHwge307CiAgY29uc3QgbWFwID0gewogICAgbmFtZTogJ25hbWUnLCBkaXNwbGF5TmFtZTogJ2Rpc3BsYXlfbmFtZScsIHR5cGU6ICd0eXBlJywgcmFyaXR5OiAncmFyaXR5JywKICAgIHNwZWVkOiAnc3BlZWQnLCByYW5nZTogJ3JhbmdlJywgY2hhcmdlU2hvdDogJ2NoYXJnZV9zaG90JywKICAgIGhlYWx0aDogJ2hlYWx0aCcsIGRhbWFnZTogJ2RhbWFnZScsIGFiaWxpdHk6ICdhYmlsaXR5JywKICAgIGlzQm9zczogJ2lzX2Jvc3MnLCBzZXROdW1iZXI6ICdzZXRfbnVtYmVyJywgc2V0TmFtZTogJ3NldF9uYW1lJywgZW5hYmxlZDogJ2VuYWJsZWQnLAogIH07CiAgY29uc3Qgc2V0cyA9IFtdOwogIGNvbnN0IHZhbHMgPSBbXTsKICBmb3IgKGNvbnN0IFtrLCBjb2xdIG9mIE9iamVjdC5lbnRyaWVzKG1hcCkpIHsKICAgIGlmIChrIGluIGIpIHsKICAgICAgc2V0cy5wdXNoKGAke2NvbH0gPSA/YCk7CiAgICAgIGxldCB2ID0gYltrXTsKICAgICAgaWYgKGsgPT09ICdpc0Jvc3MnIHx8IGsgPT09ICdlbmFibGVkJykgdiA9IHYgPyAxIDogMDsKICAgICAgdmFscy5wdXNoKHYpOwogICAgfQogIH0KICBpZiAoIXNldHMubGVuZ3RoKSByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSwgdW5jaGFuZ2VkOiB0cnVlIH0pOwogIHZhbHMucHVzaChyZXEucGFyYW1zLmlkKTsKICBkYi5wcmVwYXJlKGBVUERBVEUgZ2hvc3RzIFNFVCAke3NldHMuam9pbignLCAnKX0gV0hFUkUgaWQgPSA/YCkucnVuKC4uLnZhbHMpOwogIHJlcy5qc29uKHsgb2s6IHRydWUgfSk7Cn0pOwoKcm91dGVyLnBvc3QoJy9naG9zdHMvOmlkL2ltYWdlJywgdXBsb2FkLnNpbmdsZSgnaW1hZ2UnKSwgYXN5bmMgKHJlcSwgcmVLine truncated
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;
+111 -1
View File
@@ -1 +1,111 @@
aW1wb3J0IHsgUm91dGVyIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBkYiBmcm9tICcuLi9kYi9pbmRleC5qcyc7Cgpjb25zdCByb3V0ZXIgPSBSb3V0ZXIoKTsKCi8vIFJhcml0eSBzcGF3biB3ZWlnaHRzIChjb21tb24gLT4gbGVnZW5kYXJ5KS4gTWlycm9ycyB0aGUgcHJvamVjdCdzCi8vIGNvbW1vbig3KS9yYXJlKDMpL2xlZ2VuZGFyeSgxKSB3ZWlnaHRpbmcsIG1hcHBlZCBvbnRvIHRoZSA0IHN0YXIgdGllcnMuCmNvbnN0IFJBUklUWV9XRUlHSFRTID0geyAxOiA3LCAyOiA0LCAzOiAzLCA0OiAxIH07CgpmdW5jdGlvbiByb3dUb0dob3N0KGcpIHsKICByZXR1cm4gewogICAgaWQ6IGcuaWQsCiAgICBuYW1lOiBnLm5hbWUsCiAgICBkaXNwbGF5TmFtZTogZy5kaXNwbGF5X25hbWUgfHwgZy5uYW1lLAogICAgdHlwZTogZy50eXBlLAogICAgcmFyaXR5OiBnLnJhcml0eSwKICAgIHNwZWVkOiBnLnNwZWVkLAogICAgcmFuZ2U6IGcucmFuZ2UsCiAgICBjaGFyZ2VTaG90OiBnLmNoYXJnZV9zaG90LAogICAgaGVhbHRoOiBnLmhlYWx0aCwKICAgIGRhbWFnZTogZy5kYW1hZ2UsCiAgICBhYmlsaXR5OiBnLmFiaWxpdHksCiAgICBpc0Jvc3M6ICEhZy5pc19ib3NzLAogICAgc2V0TnVtYmVyOiBnLnNldF9udW1iZXIsCiAgICBzZXROYW1lOiBnLnNldF9uYW1lLAogICAgaW1hZ2U6IGcuaW1hZ2VfcGF0aCA/IGAvdXBsb2Fkcy8ke2cuaW1hZ2VfcGF0aH1gIDogbnVsbCwKICAgIHdlYm06IGcud2VibV9wYXRoID8gYC91cGxvYWRzLyR7Zy53ZWJtX3BhdGh9YCA6IG51bGwsCiAgICB3ZWJwOiBnLndlYnBfcGF0aCA/IGAvdXBsb2Fkcy8ke2cud2VicF9wYXRofWAgOiBudWxsLAogIH07Cn0KCmZ1bmN0aW9uIHdlaWdodGVkUGljayhnaG9zdHMpIHsKICBjb25zdCB3ZWlnaHRlZCA9IFtdOwogIGZvciAoY29uc3QgZyBvZiBnaG9zdHMpIHsKICAgIGNvbnN0IHcgPSBSQVJJVFlfV0VJR0hUU1tnLnJhcml0eV0gPz8gMTsKICAgIGZvciAobGV0IGkgPSAwOyBpIDwgdzsgaSsrKSB3ZWlnaHRlZC5wdXNoKGcpOwogIH0KICBpZiAoIXdlaWdodGVkLmxlbmd0aCkgcmV0dXJuIG51bGw7CiAgcmV0dXJuIHdlaWdodGVkW01hdGguZmxvb3IoTWF0aC5yYW5kb20oKSAqIHdlaWdodGVkLmxlbmd0aCldOwp9CgovLyBHRVQgL2FwaS9zY2FuLzpjb2RlICDigJQgbm8gYXV0aC4gUmV0dXJucyB0aGUgc2V0J3MgZ2hvc3Qgcm9zdGVyICsgYm9zcy4Kcm91dGVyLmdldCgnL3NjYW4vOmNvZGUnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBzZXQgPSBkYgogICAgLnByZXBhcmUoJ1NFTEVDVCAqIEZST00gc2V0cyBXSEVSRSBjb2RlID0gPyBBTkQgZW5hYmxlZCA9IDEnKQogICAgLmdldChyZXEucGFyYW1zLmNvZGUpOwogIGlmICghc2V0KSByZXR1cm4gcmVzLnN0YXR1cyg0MDQpLmpzb24oeyBlcnJvcjogJ3Vua25vd24gb3IgZGlzYWJsZWQgc2V0IGNvZGUnIH0pOwoKICBjb25zdCByb3N0ZXIgPSBkYgogICAgLnByZXBhcmUoCiAgICAgIGBTRUxFQ1QgZy4qIEZST00gZ2hvc3RzIGcKICAgICAgIEpPSU4gc2V0X2dob3N0cyBzZyBPTiBzZy5naG9zdF9pZCA9IGcuaWQKICAgICAgIFdIRVJFIHNnLnNldF9pZCA9ID8gQU5EIGcuZW5hYmxlZCA9IDEKICAgICAgIE9SREVSIEJZIGcuaXNfYm9zcyBERVNDLCBnLnJhcml0eSBERVNDLCBnLm5hbWVgCiAgICApCiAgICAuYWxsKHNldC5pZCk7CgogIGNvbnN0IGJvc3MgPSBzZXQuYm9zc19naG9zdF9pZAogICAgPyBkYi5wcmVwYXJlKCdTRUxFQ1QgKiBGUk9NIGdob3N0cyBXSEVSRSBpZCA9ID8nKS5nZXQoc2V0LmJvc3NfZ2hvc3RfaWQpCiAgICA6IG51bGw7CgogIHJlcy5qc29uKHsKICAgIHNldDogewogICAgICBjb2RlOiBzZXQuY29kZSwKICAgICAgc2V0TnVtYmVyOiBzZXQuc2V0X251bWJlciwKICAgICAgc2V0TmFtZTogc2V0LnNldF9uYW1lLAogICAgfSwKICAgIGJvc3M6IGJvc3MgPyByb3dUb0dob3N0KGJvc3MpIDogbnVsbCwKICAgIHJvc3Rlcjogcm9zdGVyLm1hcChyb3dUb0dob3N0KSwKICB9KTsKfSk7CgovLyBHRVQgL2FwaS9mcmVlaHVudCAg4oCUIG5vIGF1dGguIFNwYXducyBOIHdlaWdodGVkIHJhbmRvbSBlbmFibGVkIG5vbi1ib3NzIGdob3N0cwovLyBmb3IgZnJlZS1odW50IG1vZGUgKHByb2NlZHVyYWwgd2lzcHMgY2xpZW50LXNpZGUgaWYgbm8gaW1hZ2UpLgpyb3V0ZXIuZ2V0KCcvZnJlZWh1bnQnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBuID0gTWF0aC5taW4ocGFyc2VJbnQocmVxLnF1ZXJ5Lm4sIDEwKSB8fCAzLCAxMCk7CiAgY29uc3QgdHlwZSA9IHJlcS5xdWVyeS50eXBlOyAvLyBvcHRpb25hbCByZWR8eWVsbG93fGJsdWUgZmlsdGVyCiAgbGV0IHEgPSAnU0VMRUNUICogRlJPTSBnaG9zdHMgV0hFUkUgZW5hYmxlZCA9IDEgQU5EIGlzX2Jvc3MgPSAwJzsKICBjb25zdCBwYXJhbXMgPSBbXTsKICBpZiAodHlwZSAmJiBbJ3JlZCcsICd5ZWxsb3cnLCAnYmx1ZSddLmluY2x1ZGVzKHR5cGUpKSB7CiAgICBxICs9ICcgQU5EIHR5cGUgPSA/JzsKICAgIHBhcmFtcy5wdXNoKHR5cGUpOwogIH0KICBjb25zdCBwb29sID0gZGIucHJlcGFyZShxKS5hbGwoLi4ucGFyYW1zKTsKICBjb25zdCBzcGF3bnMgPSBbXTsKICBmb3IgKGxldCBpID0gMDsgaSA8IG4gJiYgcG9vbC5sZW5ndGg7IGkrKykgewogICAgY29uc3QgcGljayA9IHdlaWdodGVkUGljayhwb29sKTsKICAgIGlmIChwaWNrKSBzcGF3bnMucHVzaChyb3dUb0dob3N0KHBpY2spKTsKICB9CiAgcmVzLmpzb24oeyBzcGF3bnMgfSk7Cn0pOwoKLy8gR0VUIC9hcGkvZ2hvc3RzICDigJQgbm8gYXV0aC4gUHVibGljIHJvc3RlciBicm93c2VyIChlbmFibGVkIG9ubHkpLgpyb3V0ZXIuZ2V0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgeyB0eXBlLCByYXJpdHksIGJvc3MgfSA9IHJlcS5xdWVyeTsKICBsZXQgcSA9ICdTRUxFQ1QgKiBGUk9NIGdob3N0cyBXSEVSRSBlbmFibGVkID0gMSc7CiAgY29uc3QgcGFyYW1zID0gW107CiAgaWYgKHR5cGUpIHsgcSArPSAnIEFORCB0eXBlID0gPyc7IHBhcmFtcy5wdXNoKHR5cGUpOyB9CiAgaWYgKHJhcml0eSkgeyBxICs9ICcgQU5EIHJhcml0eSA9ID8nOyBwYXJhbXMucHVzaChwYXJzZUludChyYXJpdHksIDEwKSk7IH0KICBpZiAoYm9zcyA9PT0gJzEnKSBxICs9ICcgQU5EIGlzX2Jvc3MgPSAxJzsKICBxICs9ICcgT1JERVIgQlkgdHlwZSwgcmFyaXR5LCBuYW1lJzsKICByZXMuanNvbih7IGdob3N0czogZGIucHJlcGFyZShxKS5hbGwoLi4ucGFyYW1zKS5tYXAocm93VG9HaG9zdCkgfSk7Cn0pOwoKLy8gR0VUIC9hcGkvYWJpbGl0aWVzIOKAlCBubyBhdXRoLiBSZWZlcmVuY2UgZGF0YS4Kcm91dGVyLmdldCgnL2FiaWxpdGllcycsIChyZXEsIHJlcykgPT4gewogIGNvbnN0IHJvd3MgPSBkYi5wcmVwYXJlKCdTRUxFQ1QgKiBGUk9NIGFiaWxpdGllcyBPUkRFUiBCWSBraW5kLCBuYW1lJykuYWxsKCk7CiAgcmVzLmpzb24oeyBhYmlsaXRpZXM6IHJvd3MgfSk7Cn0pOwoKZXhwb3J0IGRlZmF1bHQgcm91dGVyOwo=
import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
// Rarity spawn weights (common -> legendary). Mirrors the project's
// common(7)/rare(3)/legendary(1) weighting, mapped onto the 4 star tiers.
const RARITY_WEIGHTS = { 1: 7, 2: 4, 3: 3, 4: 1 };
function rowToGhost(g) {
return {
id: g.id,
name: g.name,
displayName: g.display_name || g.name,
type: g.type,
rarity: g.rarity,
speed: g.speed,
range: g.range,
chargeShot: g.charge_shot,
health: g.health,
damage: g.damage,
ability: g.ability,
isBoss: !!g.is_boss,
setNumber: g.set_number,
setName: g.set_name,
image: g.image_path ? `/uploads/${g.image_path}` : null,
webm: g.webm_path ? `/uploads/${g.webm_path}` : null,
webp: g.webp_path ? `/uploads/${g.webp_path}` : null,
};
}
function weightedPick(ghosts) {
const weighted = [];
for (const g of ghosts) {
const w = RARITY_WEIGHTS[g.rarity] ?? 1;
for (let i = 0; i < w; i++) weighted.push(g);
}
if (!weighted.length) return null;
return weighted[Math.floor(Math.random() * weighted.length)];
}
// GET /api/scan/:code — no auth. Returns the set's ghost roster + boss.
router.get('/scan/:code', (req, res) => {
const set = db
.prepare('SELECT * FROM sets WHERE code = ? AND enabled = 1')
.get(req.params.code);
if (!set) return res.status(404).json({ error: 'unknown or disabled set code' });
const roster = db
.prepare(
`SELECT g.* FROM ghosts g
JOIN set_ghosts sg ON sg.ghost_id = g.id
WHERE sg.set_id = ? AND g.enabled = 1
ORDER BY g.is_boss DESC, g.rarity DESC, g.name`
)
.all(set.id);
const boss = set.boss_ghost_id
? db.prepare('SELECT * FROM ghosts WHERE id = ?').get(set.boss_ghost_id)
: null;
res.json({
set: {
code: set.code,
setNumber: set.set_number,
setName: set.set_name,
},
boss: boss ? rowToGhost(boss) : null,
roster: roster.map(rowToGhost),
});
});
// GET /api/freehunt — no auth. Spawns N weighted random enabled non-boss ghosts
// for free-hunt mode (procedural wisps client-side if no image).
router.get('/freehunt', (req, res) => {
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
const type = req.query.type; // optional red|yellow|blue filter
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0';
const params = [];
if (type && ['red', 'yellow', 'blue'].includes(type)) {
q += ' AND type = ?';
params.push(type);
}
const pool = db.prepare(q).all(...params);
const spawns = [];
for (let i = 0; i < n && pool.length; i++) {
const pick = weightedPick(pool);
if (pick) spawns.push(rowToGhost(pick));
}
res.json({ spawns });
});
// GET /api/ghosts — no auth. Public roster browser (enabled only).
router.get('/ghosts', (req, res) => {
const { type, rarity, boss } = req.query;
let q = 'SELECT * FROM ghosts WHERE enabled = 1';
const params = [];
if (type) { q += ' AND type = ?'; params.push(type); }
if (rarity) { q += ' AND rarity = ?'; params.push(parseInt(rarity, 10)); }
if (boss === '1') q += ' AND is_boss = 1';
q += ' ORDER BY type, rarity, name';
res.json({ ghosts: db.prepare(q).all(...params).map(rowToGhost) });
});
// GET /api/abilities — no auth. Reference data.
router.get('/abilities', (req, res) => {
const rows = db.prepare('SELECT * FROM abilities ORDER BY kind, name').all();
res.json({ abilities: rows });
});
export default router;