Fix: decode base64-corrupted source files (html/css/js + backend)
This commit is contained in:
1 parent
327b37babb
commit
458c66a2c0
6 files changed
+2699
-5
No files matched your search
+239
-1
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user