import crypto from "node:crypto"; import path from "node:path"; import cookieSession from "cookie-session"; import express, { type NextFunction, type Request, type Response } from "express"; import multer from "multer"; import { init, q, tx, type Q } from "./db.ts"; import { computeStandings } from "./standings.ts"; import { csvRows, extract, parseTable } from "./tables.ts"; const { SECRET_KEY, ADMIN_PASSWORD } = process.env; if (!SECRET_KEY || !ADMIN_PASSWORD) throw new Error("Set SECRET_KEY and ADMIN_PASSWORD"); if (SECRET_KEY.length < 32 || ADMIN_PASSWORD.length < 10 || [SECRET_KEY, ADMIN_PASSWORD].some((v) => v.startsWith("change-me"))) throw new Error("Weak secrets: SECRET_KEY needs 32+ chars (openssl rand -hex 32), ADMIN_PASSWORD 10+ chars, and neither may be the example value"); class HttpError extends Error { status: number; constructor(status: number, msg: string) { super(msg); this.status = status; } } const app = express(); app.set("trust proxy", 1); // behind your reverse proxy app.disable("x-powered-by"); app.use((_req, res, next) => { const nonce = crypto.randomBytes(16).toString("base64"); res.locals.nonce = nonce; res.set({ "Content-Security-Policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "Cache-Control": "no-store", // pages differ for admins, so never let a shared cache keep them }); next(); }); // CSRF defence in depth (the cookie is also SameSite=Lax): refuse cross-site state-changing requests function crossSite(req: Request) { const site = req.get("sec-fetch-site"); if (site) return site !== "same-origin" && site !== "none"; const origin = req.get("origin"); if (!origin) return false; try { return new URL(origin).host !== req.host; } catch { return true; } } app.use((req, _res, next) => next(!["GET", "HEAD", "OPTIONS"].includes(req.method) && crossSite(req) ? new HttpError(403, "Cross-site request blocked") : undefined)); app.set("view engine", "ejs"); app.set("views", path.resolve("views")); app.use(express.urlencoded({ extended: false, limit: "2mb" })); app.use((req, _res, next) => { req.body ??= {}; next(); }); app.use(cookieSession({ name: "league", keys: [SECRET_KEY], maxAge: 7 * 864e5, httpOnly: true, sameSite: "lax", secure: process.env.COOKIE_SECURE === "true", })); const isAdmin = (req: Request) => !!(req.session as any)?.admin; app.use((req, res, next) => { res.locals.admin = isAdmin(req); next(); }); const admin = (req: Request, _res: Response, next: NextFunction) => next(isAdmin(req) ? undefined : new HttpError(403, "Admin only")); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5e6 } }); const idOf = (req: Request) => { const n = Number(req.params.id); if (!Number.isInteger(n) || n < 1 || n > 2 ** 31 - 1) throw new HttpError(404, "Not found"); return n; }; const find = async (table: "leagues" | "sessions", id: number) => { const row = (await q(`SELECT * FROM ${table} WHERE id=$1`, [id]))[0]; if (!row) throw new HttpError(404, "Not found"); return row; }; const sha = (s: string) => crypto.createHash("sha256").update(s).digest(); const cell = (v: unknown) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v)); async function standingsFor(l: { id: number; best_n: number }) { const [ms, rs, ps] = await Promise.all([ q("SELECT m.* FROM matches m JOIN sessions s ON s.id=m.session_id WHERE s.league_id=$1", [l.id]), q("SELECT r.* FROM session_results r JOIN sessions s ON s.id=r.session_id WHERE s.league_id=$1", [l.id]), q("SELECT id, name FROM players"), ]); return computeStandings(ms, rs, new Map(ps.map((p) => [p.id, p.name])), l.best_n); } /** name -> player id; players are created on first sight, names match case-insensitively. */ async function resolver(c: Q) { const cache = new Map((await c.query("SELECT id, name FROM players")).rows.map((p) => [p.name.toLowerCase(), p.id])); return async (name: string): Promise => { if (!name) return null; let id = cache.get(name.toLowerCase()); if (id == null) { id = (await c.query("INSERT INTO players(name) VALUES($1) RETURNING id", [name])).rows[0].id as number; cache.set(name.toLowerCase(), id); } return id; }; } // ---------- public pages ---------- app.get("/", async (_req, res) => res.render("home", { leagues: await q("SELECT * FROM leagues ORDER BY id DESC") })); app.get("/league/:id", async (req, res) => { const lg = await find("leagues", idOf(req)); const { rows, decks } = await standingsFor(lg); const sessions = await q("SELECT * FROM sessions WHERE league_id=$1 ORDER BY number", [lg.id]); res.render("league", { lg, rows, decks, sessions }); }); app.get("/api/league/:id/leaderboard", async (req, res) => { res.json((await standingsFor(await find("leagues", idOf(req)))).rows); }); app.get("/session/:id", async (req, res) => { const sid = idOf(req); const gs = await find("sessions", sid); const lg = await find("leagues", gs.league_id); const ms = await q( `SELECT m.*, a.name AS p1, b.name AS p2 FROM matches m JOIN players a ON a.id=m.p1_id LEFT JOIN players b ON b.id=m.p2_id WHERE m.session_id=$1 ORDER BY m.round, m.id`, [sid]); const rounds: { round: number; matches: any[] }[] = []; for (const m of ms) { let r = rounds.find((x) => x.round === m.round); if (!r) rounds.push((r = { round: m.round, matches: [] })); r.matches.push(m); } // existing results as CSV so the admin can edit and resubmit const csvtext = ms.map((m) => [m.round, m.p1, m.p1_deck ?? "", m.p2 ?? "", m.p2_deck ?? "", m.p1_wins, m.p2_wins, m.draws].map(cell).join(",")).join("\n"); const results = (await q( `SELECT r.*, p.name FROM session_results r JOIN players p ON p.id=r.player_id WHERE r.session_id=$1 ORDER BY r.points DESC, p.name`, [sid])) .map((r) => ({ ...r, rec: r.wins == null ? "" : [r.wins, r.draws, r.losses].join("-") })); const known = (await q( `SELECT deck FROM (SELECT deck FROM session_results UNION SELECT p1_deck FROM matches UNION SELECT p2_deck FROM matches) d WHERE deck IS NOT NULL ORDER BY 1`)).map((r) => r.deck); res.render("session", { gs, lg, rounds, csvtext, results, known }); }); // ---------- auth ---------- app.get("/login", (_req, res) => res.render("login", { error: null })); const attempts = new Map(); // failed logins per IP, 15 minute window app.post("/login", (req, res) => { const ip = req.ip ?? "?", now = Date.now(); for (const [k, v] of attempts) if (now - v.t > 15 * 60e3) attempts.delete(k); const a = attempts.get(ip); if (a && a.n >= 10) throw new HttpError(429, "Too many failed logins, try again in 15 minutes"); if (crypto.timingSafeEqual(sha(String(req.body.password ?? "")), sha(ADMIN_PASSWORD))) { attempts.delete(ip); (req.session as any).admin = true; return res.redirect(303, "/"); } attempts.set(ip, { n: (a?.n ?? 0) + 1, t: a?.t ?? now }); res.render("login", { error: "Wrong password" }); }); app.post("/logout", (req, res) => { req.session = null; res.redirect(303, "/"); }); // ---------- admin ---------- app.post("/admin/league", admin, async (req, res) => { const name = String(req.body.name ?? "").trim(); if (!name) throw new HttpError(400, "Name required"); const [l] = await q("INSERT INTO leagues(name, best_n) VALUES($1,$2) RETURNING id", [name, Math.min(100, Math.max(1, parseInt(req.body.best_n) || 6))]); res.redirect(303, `/league/${l.id}`); }); app.post("/admin/league/:id/session", admin, async (req, res) => { const lg = await find("leagues", idOf(req)); const date = String(req.body.date ?? ""); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new HttpError(400, "Bad date"); const [s] = await q( `INSERT INTO sessions(league_id, number, date) VALUES($1, (SELECT COALESCE(MAX(number),0)+1 FROM sessions WHERE league_id=$1), $2) RETURNING id`, [lg.id, date]); res.redirect(303, `/session/${s.id}`); }); app.post("/admin/session/:id/delete", admin, async (req, res) => { const [s] = await q("DELETE FROM sessions WHERE id=$1 RETURNING league_id", [idOf(req)]); // matches/results cascade if (!s) throw new HttpError(404, "Not found"); res.redirect(303, `/league/${s.league_id}`); }); // Replace a session's matches: round, player1, deck1, player2, deck2, p1_wins, p2_wins, draws (blank player2 = bye) app.post("/admin/session/:id/import", admin, async (req, res) => { const sid = idOf(req); await find("sessions", sid); const data = String(req.body.data ?? ""); const rows = csvRows(data, data.includes("\t") ? "\t" : ",").flatMap((c, i) => { if (c[0].startsWith("#") || c[0].toLowerCase() === "round") return []; c = [...c, ...Array(8).fill("")].slice(0, 8); const [rnd, w1, w2, dr] = [c[0], c[5] || "0", c[6] || "0", c[7] || "0"].map(Number); if (![rnd, w1, w2, dr].every((n) => Number.isInteger(n) && n >= 0 && n <= 99)) throw new HttpError(400, `Row ${i + 1}: round and scores must be whole numbers from 0 to 99`); if (!c[1]) throw new HttpError(400, `Row ${i + 1}: player 1 is missing`); return [{ rnd, a: c[1], da: c[2] || null, b: c[3], db: c[4] || null, w1, w2, dr }]; }); await tx(async (c) => { const pid = await resolver(c); await c.query("DELETE FROM matches WHERE session_id=$1", [sid]); for (const r of rows) { const bye = !r.b; await c.query( `INSERT INTO matches(session_id, round, p1_id, p2_id, p1_deck, p2_deck, p1_wins, p2_wins, draws) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [sid, r.rnd, await pid(r.a), await pid(r.b), r.da, r.db, bye ? 2 : r.w1, bye ? 0 : r.w2, bye ? 0 : r.dr]); } }); res.redirect(303, `/session/${sid}`); }); // Replace a session's imported standings from a Melee export (CSV/JSON, uploaded or pasted); keeps decks already set app.post("/admin/session/:id/import-standings", admin, upload.single("file"), async (req, res) => { const sid = idOf(req); await find("sessions", sid); const text = req.file ? req.file.buffer.toString("utf8") : String(req.body.data ?? ""); let rows; try { rows = extract(parseTable(text)); } catch (e) { throw new HttpError(400, (e as Error).message); } await tx(async (c) => { const old = new Map((await c.query("SELECT player_id, deck FROM session_results WHERE session_id=$1", [sid])).rows.map((r) => [r.player_id, r.deck])); const pid = await resolver(c); await c.query("DELETE FROM session_results WHERE session_id=$1", [sid]); for (const r of rows) { const p = (await pid(r.name))!; await c.query( `INSERT INTO session_results(session_id, player_id, points, deck, wins, draws, losses) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (session_id, player_id) DO NOTHING`, [sid, p, r.points, r.deck || old.get(p) || null, ...(r.wdl ?? [null, null, null])]); } }); res.redirect(303, `/session/${sid}`); }); app.post("/admin/session/:id/decks", admin, async (req, res) => { const sid = idOf(req); for (const [k, v] of Object.entries(req.body)) { const m = /^deck_(\d+)$/.exec(k); if (m) await q("UPDATE session_results SET deck=$1 WHERE id=$2 AND session_id=$3", [String(v).trim() || null, Number(m[1]), sid]); } res.redirect(303, `/session/${sid}`); }); app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { if (!err.status) console.error(err); res.status(err.status ?? 500).type("text/plain").send(err.status ? err.message : "Server error"); }); await init(); app.listen(8000, () => console.log("MTG league listening on :8000"));