154 lines
6.6 KiB
JavaScript
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}`));
|