import "dotenv/config"; import express from "express"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; import bcrypt from "bcryptjs"; import db from "./db.js"; import { issueToken, requireAuth } from "./auth.js"; import { upload, UPLOAD_DIR } from "./upload.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const PORT = process.env.PORT || 3000; const app = express(); app.use(express.json()); // Static: the game + admin UI app.use(express.static(path.join(ROOT, "public"))); // Uploaded GIFs (public read — they're game assets) app.use("/uploads", express.static(UPLOAD_DIR, { maxAge: "1h" })); /* ============================================================ AUTH ============================================================ */ app.post("/api/login", (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: "username and password required" }); const admin = db.prepare("SELECT * FROM admins WHERE username = ?").get(username); // constant-ish time: always run a compare const hash = admin?.pw_hash || "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const ok = bcrypt.compareSync(password, hash); if (!admin || !ok) return res.status(401).json({ error: "Invalid credentials" }); res.json({ token: issueToken(admin), username: admin.username }); }); /* ============================================================ GHOSTS (admin-only writes) ============================================================ */ app.get("/api/ghosts", requireAuth, (_req, res) => { res.json(db.prepare("SELECT * FROM ghosts ORDER BY created DESC").all()); }); app.post("/api/ghosts", requireAuth, upload.single("gif"), (req, res) => { if (!req.file) return res.status(400).json({ error: "A GIF/PNG/WebP file is required" }); const { name, rarity = "common", scale = 1.0 } = req.body; if (!name) { fs.unlink(path.join(UPLOAD_DIR, req.file.filename), () => {}); return res.status(400).json({ error: "name is required" }); } const info = db .prepare("INSERT INTO ghosts (name, gif_file, rarity, scale) VALUES (?, ?, ?, ?)") .run(name, req.file.filename, rarity, Number(scale) || 1.0); res.status(201).json(db.prepare("SELECT * FROM ghosts WHERE id = ?").get(info.lastInsertRowid)); }); app.patch("/api/ghosts/:id", requireAuth, (req, res) => { const g = db.prepare("SELECT * FROM ghosts WHERE id = ?").get(req.params.id); if (!g) return res.status(404).json({ error: "Not found" }); const name = req.body.name ?? g.name; const rarity = req.body.rarity ?? g.rarity; const scale = req.body.scale ?? g.scale; const enabled = req.body.enabled != null ? (req.body.enabled ? 1 : 0) : g.enabled; db.prepare("UPDATE ghosts SET name=?, rarity=?, scale=?, enabled=? WHERE id=?") .run(name, rarity, Number(scale) || 1.0, enabled, g.id); res.json(db.prepare("SELECT * FROM ghosts WHERE id = ?").get(g.id)); }); app.delete("/api/ghosts/:id", requireAuth, (req, res) => { const g = db.prepare("SELECT * FROM ghosts WHERE id = ?").get(req.params.id); if (!g) return res.status(404).json({ error: "Not found" }); db.prepare("DELETE FROM ghosts WHERE id = ?").run(g.id); fs.unlink(path.join(UPLOAD_DIR, g.gif_file), () => {}); res.json({ deleted: true }); }); /* ============================================================ SETS (admin-only writes) ============================================================ */ app.get("/api/sets", requireAuth, (_req, res) => { const sets = db.prepare("SELECT * FROM sets ORDER BY created DESC").all(); const linkStmt = db.prepare("SELECT ghost_id FROM set_ghosts WHERE set_id = ?"); for (const s of sets) s.ghost_ids = linkStmt.all(s.id).map((r) => r.ghost_id); res.json(sets); }); app.post("/api/sets", requireAuth, (req, res) => { const { code, title, ghost_ids = [] } = req.body || {}; if (!code || !title) return res.status(400).json({ error: "code and title required" }); if (db.prepare("SELECT 1 FROM sets WHERE code = ?").get(code)) return res.status(409).json({ error: "A set with that code already exists" }); const info = db.prepare("INSERT INTO sets (code, title) VALUES (?, ?)").run(code, title); const setId = info.lastInsertRowid; const link = db.prepare("INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)"); for (const gid of ghost_ids) link.run(setId, gid); res.status(201).json({ id: setId, code, title, ghost_ids }); }); app.patch("/api/sets/:id", requireAuth, (req, res) => { const s = db.prepare("SELECT * FROM sets WHERE id = ?").get(req.params.id); if (!s) return res.status(404).json({ error: "Not found" }); const title = req.body.title ?? s.title; const enabled = req.body.enabled != null ? (req.body.enabled ? 1 : 0) : s.enabled; db.prepare("UPDATE sets SET title=?, enabled=? WHERE id=?").run(title, enabled, s.id); if (Array.isArray(req.body.ghost_ids)) { db.prepare("DELETE FROM set_ghosts WHERE set_id = ?").run(s.id); const link = db.prepare("INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)"); for (const gid of req.body.ghost_ids) link.run(s.id, gid); } res.json({ ok: true }); }); app.delete("/api/sets/:id", requireAuth, (req, res) => { const info = db.prepare("DELETE FROM sets WHERE id = ?").run(req.params.id); if (info.changes === 0) return res.status(404).json({ error: "Not found" }); res.json({ deleted: true }); }); /* ============================================================ PUBLIC SCAN — the game calls this with a scanned QR code Returns the enabled ghosts that set should spawn (no auth). ============================================================ */ app.get("/api/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 ghosts = db .prepare( `SELECT g.id, g.name, g.gif_file, g.rarity, g.scale FROM ghosts g JOIN set_ghosts sg ON sg.ghost_id = g.id WHERE sg.set_id = ? AND g.enabled = 1` ) .all(set.id) .map((g) => ({ ...g, gif_url: `/uploads/${g.gif_file}` })); res.json({ set: { code: set.code, title: set.title }, ghosts }); }); /* ============================================================ Error handler (multer + misc) ============================================================ */ app.use((err, _req, res, _next) => { console.error(err.message); res.status(400).json({ error: err.message || "Request failed" }); }); app.listen(PORT, () => console.log(`Hidden Spectre server on http://localhost:${PORT}`));