add base path setting
This commit is contained in:
1 parent
5368d6f32d
commit
a7f440c658
7 files changed
+29
-21
No files matched your search
@@ -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=
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
<% } %>
|
<% } %>
|
||||||
Reference in new issue
Block a user