Files

154 lines
6.6 KiB
JavaScript

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}`));