diff --git a/.env.example b/.env.example index eb11990..e1a3c58 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ COOKIE_SECURE=false BIND_ADDR=127.0.0.1 # port exposed on the host (the container itself always listens on 8000) HOST_PORT=8888 +# serving under a sub path? e.g. /mtg (no trailing slash). Leave empty for a domain root. +BASE_PATH= diff --git a/docker-compose.yml b/docker-compose.yml index a45a56c..bb20944 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} SECRET_KEY: ${SECRET_KEY} COOKIE_SECURE: ${COOKIE_SECURE:-false} + BASE_PATH: ${BASE_PATH:-} ports: ["${BIND_ADDR:-127.0.0.1}:${HOST_PORT:-8888}:8000"] # BIND_ADDR: this host's Tailscale IP, never 0.0.0.0. HOST_PORT: port on the host volumes: pgdata: diff --git a/src/server.ts b/src/server.ts index d0a9427..84c5d0b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,10 @@ if (!SECRET_KEY || !ADMIN_PASSWORD) throw new Error("Set SECRET_KEY and ADMIN_PA 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"); +// Sub-path support: the proxy strips this prefix (e.g. Caddy handle_path); the app puts it back into every link +const BASE = (process.env.BASE_PATH ?? "").replace(/\/+$/, ""); +if (!/^(\/[A-Za-z0-9._~-]+)*$/.test(BASE)) throw new Error("BASE_PATH must look like /league (no trailing slash)"); + class HttpError extends Error { status: number; constructor(status: number, msg: string) { super(msg); this.status = status; } @@ -23,6 +27,7 @@ app.disable("x-powered-by"); app.use((_req, res, next) => { const nonce = crypto.randomBytes(16).toString("base64"); res.locals.nonce = nonce; + res.locals.base = BASE; 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", @@ -46,7 +51,7 @@ 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", + name: "league", keys: [SECRET_KEY], maxAge: 7 * 864e5, httpOnly: true, sameSite: "lax", path: BASE || "/", secure: process.env.COOKIE_SECURE === "true", })); const isAdmin = (req: Request) => !!(req.session as any)?.admin; @@ -141,19 +146,19 @@ app.post("/login", (req, res) => { if (crypto.timingSafeEqual(sha(String(req.body.password ?? "")), sha(ADMIN_PASSWORD))) { attempts.delete(ip); (req.session as any).admin = true; - return res.redirect(303, "/"); + return res.redirect(303, BASE + "/"); } 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, "/"); }); +app.post("/logout", (req, res) => { req.session = null; res.redirect(303, BASE + "/"); }); // ---------- 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}`); + res.redirect(303, BASE + `/league/${l.id}`); }); app.post("/admin/league/:id/session", admin, async (req, res) => { @@ -163,13 +168,13 @@ app.post("/admin/league/:id/session", admin, async (req, res) => { 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}`); + res.redirect(303, BASE + `/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}`); + res.redirect(303, BASE + `/league/${s.league_id}`); }); // Replace a session's matches: round, player1, deck1, player2, deck2, p1_wins, p2_wins, draws (blank player2 = bye) @@ -196,7 +201,7 @@ app.post("/admin/session/:id/import", admin, async (req, res) => { [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}`); + res.redirect(303, BASE + `/session/${sid}`); }); // Replace a session's imported standings from a Melee export (CSV/JSON, uploaded or pasted); keeps decks already set @@ -218,7 +223,7 @@ app.post("/admin/session/:id/import-standings", admin, upload.single("file"), as [sid, p, r.points, r.deck || old.get(p) || null, ...(r.wdl ?? [null, null, null])]); } }); - res.redirect(303, `/session/${sid}`); + res.redirect(303, BASE + `/session/${sid}`); }); app.post("/admin/session/:id/decks", admin, async (req, res) => { @@ -227,7 +232,7 @@ app.post("/admin/session/:id/decks", admin, async (req, res) => { 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}`); + res.redirect(303, BASE + `/session/${sid}`); }); app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { diff --git a/views/head.ejs b/views/head.ejs index bfd81df..4edcdd0 100644 --- a/views/head.ejs +++ b/views/head.ejs @@ -8,6 +8,6 @@ table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #8884;pa form.i{display:inline}input,select{max-width:100%} - + diff --git a/views/home.ejs b/views/home.ejs index 78e8851..ebdb53c 100644 --- a/views/home.ejs +++ b/views/home.ejs @@ -1,8 +1,8 @@ <%- include('head') %>
| Deck | W-D-L |
|---|---|
| <%= d %> | <%= r.join('-') %> |
| <%= m.p1 %> (<%= m.p1_deck || '?' %>) | @@ -9,7 +9,7 @@ <% }) %>
No results yet.
<% } %> <% if (results.length) { %>One match per line: round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws. Leave player 2 blank for a bye.
Comma- or tab-separated. Saving replaces this session's matches.