diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..ab9ca5f --- /dev/null +++ b/server/index.js @@ -0,0 +1,153 @@ +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}`));