add base path setting

This commit is contained in:
derek committed 2026-10-28 14:41:43 +00:00
1 parent 5368d6f32d
commit a7f440c658
7 files changed
+29 -21

No files matched your search

+2
View File
@@ -10,3 +10,5 @@ COOKIE_SECURE=false
BIND_ADDR=127.0.0.1 BIND_ADDR=127.0.0.1
# port exposed on the host (the container itself always listens on 8000) # port exposed on the host (the container itself always listens on 8000)
HOST_PORT=8888 HOST_PORT=8888
# serving under a sub path? e.g. /mtg (no trailing slash). Leave empty for a domain root.
BASE_PATH=
+1
View File
@@ -21,6 +21,7 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
COOKIE_SECURE: ${COOKIE_SECURE:-false} 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 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: volumes:
pgdata: pgdata:
+14 -9
View File
@@ -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"))) 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"); 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 { class HttpError extends Error {
status: number; status: number;
constructor(status: number, msg: string) { super(msg); this.status = status; } constructor(status: number, msg: string) { super(msg); this.status = status; }
@@ -23,6 +27,7 @@ app.disable("x-powered-by");
app.use((_req, res, next) => { app.use((_req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64"); const nonce = crypto.randomBytes(16).toString("base64");
res.locals.nonce = nonce; res.locals.nonce = nonce;
res.locals.base = BASE;
res.set({ 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'`, "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", "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(express.urlencoded({ extended: false, limit: "2mb" }));
app.use((req, _res, next) => { req.body ??= {}; next(); }); app.use((req, _res, next) => { req.body ??= {}; next(); });
app.use(cookieSession({ 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", secure: process.env.COOKIE_SECURE === "true",
})); }));
const isAdmin = (req: Request) => !!(req.session as any)?.admin; 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))) { if (crypto.timingSafeEqual(sha(String(req.body.password ?? "")), sha(ADMIN_PASSWORD))) {
attempts.delete(ip); attempts.delete(ip);
(req.session as any).admin = true; (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 }); attempts.set(ip, { n: (a?.n ?? 0) + 1, t: a?.t ?? now });
res.render("login", { error: "Wrong password" }); 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 ---------- // ---------- admin ----------
app.post("/admin/league", admin, async (req, res) => { app.post("/admin/league", admin, async (req, res) => {
const name = String(req.body.name ?? "").trim(); const name = String(req.body.name ?? "").trim();
if (!name) throw new HttpError(400, "Name required"); 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))]); 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) => { 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( const [s] = await q(
`INSERT INTO sessions(league_id, number, date) `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]); 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) => { 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 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"); 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) // 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]); [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 // 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])]); [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) => { 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); 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]); 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) => { app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
+3 -3
View File
@@ -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%} form.i{display:inline}input,select{max-width:100%}
</style> </style>
<script nonce="<%= nonce %>">document.addEventListener("submit",e=>{const m=e.target.dataset.confirm;if(m&&!confirm(m))e.preventDefault()})</script> <script nonce="<%= nonce %>">document.addEventListener("submit",e=>{const m=e.target.dataset.confirm;if(m&&!confirm(m))e.preventDefault()})</script>
<nav><a href="/">Leagues</a> · <nav><a href="<%= base %>/">Leagues</a> ·
<% if (admin) { %><b>admin</b> <form class="i" method="post" action="/logout"><button>Logout</button></form> <% if (admin) { %><b>admin</b> <form class="i" method="post" action="<%= base %>/logout"><button>Logout</button></form>
<% } else { %><a href="/login">Admin login</a><% } %></nav> <% } else { %><a href="<%= base %>/login">Admin login</a><% } %></nav>
+2 -2
View File
@@ -1,8 +1,8 @@
<%- include('head') %> <%- include('head') %>
<h1>MTG Leagues</h1> <h1>MTG Leagues</h1>
<ul><% leagues.forEach(l => { %><li><a href="/league/<%= l.id %>"><%= l.name %></a> (best <%= l.best_n %> sessions count)</li><% }) %></ul> <ul><% leagues.forEach(l => { %><li><a href="<%= base %>/league/<%= l.id %>"><%= l.name %></a> (best <%= l.best_n %> sessions count)</li><% }) %></ul>
<% if (admin) { %> <% if (admin) { %>
<h2>New league</h2> <h2>New league</h2>
<form method="post" action="/admin/league"><input name="name" placeholder="Name" required> <form method="post" action="<%= base %>/admin/league"><input name="name" placeholder="Name" required>
<input name="best_n" type="number" min="1" value="6"> best sessions count <button>Create</button></form> <input name="best_n" type="number" min="1" value="6"> best sessions count <button>Create</button></form>
<% } %> <% } %>
+2 -2
View File
@@ -5,7 +5,7 @@
<% rows.forEach((r, i) => { %><tr><td><%= i + 1 %></td><td><%= r.name %></td><td><b><%= r.score %></b></td><td><%= r.total %></td> <% rows.forEach((r, i) => { %><tr><td><%= i + 1 %></td><td><%= r.name %></td><td><b><%= r.score %></b></td><td><%= r.total %></td>
<td><%= r.sessions %></td><td><%= r.rec ? r.rec.join('-') : '–' %></td></tr><% }) %></table> <td><%= r.sessions %></td><td><%= r.rec ? r.rec.join('-') : '–' %></td></tr><% }) %></table>
<h2>Sessions</h2> <h2>Sessions</h2>
<ul><% sessions.forEach(g => { %><li><a href="/session/<%= g.id %>">Session <%= g.number %> – <%= g.date %></a></li><% }) %></ul> <ul><% sessions.forEach(g => { %><li><a href="<%= base %>/session/<%= g.id %>">Session <%= g.number %> – <%= g.date %></a></li><% }) %></ul>
<% if (admin) { %><form method="post" action="/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %> <% if (admin) { %><form method="post" action="<%= base %>/admin/league/<%= lg.id %>/session"><input type="date" name="date" required> <button>New session</button></form><% } %>
<h2>Deck performance <small>(match W-D-L)</small></h2> <h2>Deck performance <small>(match W-D-L)</small></h2>
<table><tr><th>Deck</th><th>W-D-L</th></tr><% decks.forEach(([d, r]) => { %><tr><td><%= d %></td><td><%= r.join('-') %></td></tr><% }) %></table> <table><tr><th>Deck</th><th>W-D-L</th></tr><% decks.forEach(([d, r]) => { %><tr><td><%= d %></td><td><%= r.join('-') %></td></tr><% }) %></table>
+5 -5
View File
@@ -1,6 +1,6 @@
<%- include('head') %> <%- include('head') %>
<h1><%= lg.name %> – Session <%= gs.number %> (<%= gs.date %>)</h1> <h1><%= lg.name %> – Session <%= gs.number %> (<%= gs.date %>)</h1>
<p><a href="/league/<%= lg.id %>">← Standings</a></p> <p><a href="<%= base %>/league/<%= lg.id %>">← Standings</a></p>
<% rounds.forEach(r => { %><h2>Round <%= r.round %></h2><table> <% rounds.forEach(r => { %><h2>Round <%= r.round %></h2><table>
<% r.matches.forEach(m => { %><tr> <% r.matches.forEach(m => { %><tr>
<td><%= m.p1 %> <small>(<%= m.p1_deck || '?' %>)</small></td> <td><%= m.p1 %> <small>(<%= m.p1_deck || '?' %>)</small></td>
@@ -9,7 +9,7 @@
<% }) %></table><% }) %> <% }) %></table><% }) %>
<% if (!rounds.length && !results.length) { %><p>No results yet.</p><% } %> <% if (!rounds.length && !results.length) { %><p>No results yet.</p><% } %>
<% if (results.length) { %><h2>Standings <small>(imported)</small></h2> <% if (results.length) { %><h2>Standings <small>(imported)</small></h2>
<form method="post" action="/admin/session/<%= gs.id %>/decks"> <form method="post" action="<%= base %>/admin/session/<%= gs.id %>/decks">
<table><tr><th>Player</th><th>Points</th><th>W-D-L</th><th>Deck</th></tr> <table><tr><th>Player</th><th>Points</th><th>W-D-L</th><th>Deck</th></tr>
<% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td> <% results.forEach(r => { %><tr><td><%= r.name %></td><td><b><%= r.points %></b></td><td><%= r.rec %></td>
<td><% if (admin) { %><input name="deck_<%= r.id %>" value="<%= r.deck || '' %>" list="decks"><% } else { %><%= r.deck || '' %><% } %></td></tr><% }) %></table> <td><% if (admin) { %><input name="deck_<%= r.id %>" value="<%= r.deck || '' %>" list="decks"><% } else { %><%= r.deck || '' %><% } %></td></tr><% }) %></table>
@@ -19,13 +19,13 @@
<% if (admin) { %> <% if (admin) { %>
<h2>Import Melee standings export</h2> <h2>Import Melee standings export</h2>
<p>Upload the CSV/JSON from Melee's Standings tab (or paste it). Saving replaces this session's imported standings but keeps decks already entered.</p> <p>Upload the CSV/JSON from Melee's Standings tab (or paste it). Saving replaces this session's imported standings but keeps decks already entered.</p>
<form method="post" enctype="multipart/form-data" action="/admin/session/<%= gs.id %>/import-standings"> <form method="post" enctype="multipart/form-data" action="<%= base %>/admin/session/<%= gs.id %>/import-standings">
<input type="file" name="file" accept=".csv,.json,.txt"><br> <input type="file" name="file" accept=".csv,.json,.txt"><br>
<textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import standings</button></form> <textarea name="data" rows="5" style="width:100%;font-family:monospace" placeholder="...or paste here"></textarea><br><button>Import standings</button></form>
<h2>Import matches by hand</h2> <h2>Import matches by hand</h2>
<p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye. <p>One match per line: <code>round, player1, deck1, player2, deck2, p1 wins, p2 wins, draws</code>. Leave player 2 blank for a bye.
Comma- or tab-separated. Saving <b>replaces</b> this session's matches.</p> Comma- or tab-separated. Saving <b>replaces</b> this session's matches.</p>
<form method="post" action="/admin/session/<%= gs.id %>/import"> <form method="post" action="<%= base %>/admin/session/<%= gs.id %>/import">
<textarea name="data" rows="12" style="width:100%;font-family:monospace"><%= csvtext %></textarea><br><button>Save matches</button></form> <textarea name="data" rows="12" style="width:100%;font-family:monospace"><%= csvtext %></textarea><br><button>Save matches</button></form>
<form method="post" action="/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form> <form method="post" action="<%= base %>/admin/session/<%= gs.id %>/delete" data-confirm="Delete this session and its results?"><button>Delete session</button></form>
<% } %> <% } %>