Add Express app: auth, ghost/set CRUD, public scan endpoint
This commit is contained in:
+153
@@ -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}`));
|
||||
Reference in New Issue
Block a user