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

+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")))
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) => {