Accounts (flo, andi, lolo), presence, activity log, spending split

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:53:56 +02:00
parent 9b9516026b
commit 6866316925
3 changed files with 255 additions and 59 deletions

228
server.js
View File

@@ -10,7 +10,6 @@ const { DatabaseSync } = require("node:sqlite");
const PORT = +(process.env.PORT || 8080);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
const PUBLIC_DIR = path.join(__dirname, "public");
const USER = process.env.BOARD_USER || "";
const PASS = process.env.BOARD_PASSWORD || "";
const MAX_UPLOAD = 20 * 1024 * 1024;
const MAX_PHOTO_REQ = 30 * 1024 * 1024;
@@ -35,6 +34,53 @@ db.exec(`
CREATE INDEX IF NOT EXISTS comments_part ON comments(part_id);
`);
for (const sql of [
"ALTER TABLE parts ADD COLUMN updated_by TEXT NOT NULL DEFAULT ''",
"ALTER TABLE links ADD COLUMN author TEXT NOT NULL DEFAULT ''",
"ALTER TABLE comments ADD COLUMN author TEXT NOT NULL DEFAULT ''",
"ALTER TABLE photos ADD COLUMN author TEXT NOT NULL DEFAULT ''",
]) { try { db.exec(sql); } catch {} }
db.exec(`
CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, must_change INTEGER NOT NULL DEFAULT 0, ver INTEGER NOT NULL DEFAULT 1, last_seen INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, part_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS activity_part ON activity(part_id);
CREATE TABLE IF NOT EXISTS spending (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, cents INTEGER NOT NULL, what TEXT NOT NULL, created INTEGER NOT NULL);
`);
// ---------- users ----------
const hashPw = (pw, salt) => crypto.scryptSync(String(pw), salt, 32).toString("hex");
function setPassword(name, pw, mustChange) {
const salt = crypto.randomBytes(16).toString("hex");
db.prepare("UPDATE users SET salt = ?, hash = ?, must_change = ?, ver = ver + 1 WHERE name = ?").run(salt, hashPw(pw, salt), mustChange ? 1 : 0, name);
}
function addUser(name, pw, mustChange) {
db.prepare("INSERT OR IGNORE INTO users (name, salt, hash, must_change) VALUES (?, '', '', 0)").run(name);
setPassword(name, pw, mustChange);
}
if (!db.prepare("SELECT 1 FROM users LIMIT 1").get()) {
addUser("flo", PASS || "lolo", false); // keeps the current password
addUser("andi", "lolo", true); // must choose a new one after the first login
addUser("lolo", "lolo", true);
}
const NAME_RE = /^[a-z0-9_]{2,20}$/;
function checkLogin(name, pw) {
const u = db.prepare("SELECT * FROM users WHERE name = ?").get(String(name || "").trim().toLowerCase());
// always hash, so unknown names cost the same time as wrong passwords
const h = hashPw(pw, u ? u.salt : "x");
return u && safeEq(h, u.hash) ? u : null;
}
const seen = new Map();
function touch(name) {
const now = Date.now();
if (now - (seen.get(name) || 0) < 20000) return;
seen.set(name, now); db.prepare("UPDATE users SET last_seen = ? WHERE name = ?").run(now, name);
}
function logActivity(author, part, action, detail = "") {
const last = db.prepare("SELECT id, author, part_id, action, detail, created FROM activity ORDER BY id DESC LIMIT 1").get();
if (action === "markers" && last && last.author === author && last.action === action && last.detail === detail && Date.now() - last.created < 600000) return;
db.prepare("INSERT INTO activity (author, part_id, action, detail, created) VALUES (?, ?, ?, ?, ?)").run(author, part, action, cleanText(detail, 300), Date.now());
}
const STATUSES = new Set(["none", "have", "miss", "ord", "skip"]);
const ID_RE = /^[a-z0-9-]{1,60}__[0-9]{1,6}$/;
const KEY_RE = /^[a-z0-9-]{1,60}$/;
@@ -61,18 +107,32 @@ async function readJson(req) {
const buf = await readBody(req, 1024 * 1024);
try { return JSON.parse(buf.toString("utf8") || "{}"); } catch { throw Object.assign(new Error("bad json"), { code: 400 }); }
}
const SECRET = crypto.createHash("sha256").update(`spike-session:${USER}:${PASS}`).digest();
const SECRET_FILE = path.join(DATA_DIR, "session-secret");
let SECRET;
try { SECRET = fs.readFileSync(SECRET_FILE); } catch { SECRET = crypto.randomBytes(32); fs.writeFileSync(SECRET_FILE, SECRET, { mode: 0o600 }); }
const SESSION_MS = 30 * 24 * 3600 * 1000;
const sign = (v) => crypto.createHmac("sha256", SECRET).update(v).digest("base64url");
const safeEq = (x, y) => { const a = Buffer.from(x), b = Buffer.from(y); return a.length === b.length && crypto.timingSafeEqual(a, b); };
function makeSession() { const exp = String(Date.now() + SESSION_MS); return `${exp}.${sign(exp)}`; }
function validSession(req) {
function safeEq(x, y) { const a = Buffer.from(String(x)), b = Buffer.from(String(y)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
const makeSession = (u) => { const body = `${u.name}.${u.ver}.${Date.now() + SESSION_MS}`; return `${body}.${sign(body)}`; };
function sessionUser(req) {
const m = /(?:^|;\s*)spike_session=([^;]+)/.exec(req.headers.cookie || "");
if (!m) return false;
const [exp, sig] = m[1].split(".");
return !!sig && +exp > Date.now() && safeEq(sig, sign(exp));
if (!m) return null;
const parts = m[1].split(".");
if (parts.length !== 4) return null;
const [name, ver, exp, sig] = parts;
if (!safeEq(sig, sign(`${name}.${ver}.${exp}`)) || +exp < Date.now()) return null;
const u = db.prepare("SELECT * FROM users WHERE name = ?").get(name);
return u && u.ver === +ver ? u : null;
}
const LOGIN_HTML = (err) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Spike Parts Board - Login</title><style>
function authUser(req) {
const u = sessionUser(req); if (u) return u;
const h = req.headers.authorization || "";
if (h.startsWith("Basic ")) { const [n, p] = Buffer.from(h.slice(6), "base64").toString().split(/:(.*)/s); return checkLogin(n, p); }
return null;
}
const COOKIE = "Path=/; HttpOnly; Secure; SameSite=Lax";
const setCookie = (u) => `spike_session=${makeSession(u)}; ${COOKIE}; Max-Age=${SESSION_MS / 1000}`;
const PAGE = (title, inner) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>
:root{--bg:#eef1f4;--panel:#fff;--ink:#18212c;--muted:#5d6977;--line:#d5dbe2;--accent:#1d5fc4;--accent-ink:#fff;--err:#b3261e}
@media (prefers-color-scheme:dark){:root{--bg:#12171d;--panel:#1a212a;--ink:#e6ebf0;--muted:#98a4b2;--line:#2c3642;--accent:#5b97ef;--accent-ink:#0d1520;--err:#f2958e}}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:16px;background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,sans-serif}
@@ -82,58 +142,67 @@ label{display:block;font-size:13px;color:var(--muted);margin:14px 0 6px}
input{width:100%;padding:11px 12px;font:inherit;color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:8px}
input:focus{outline:2px solid var(--accent);outline-offset:1px}
button{width:100%;margin-top:22px;padding:12px;font:inherit;font-weight:600;color:var(--accent-ink);background:var(--accent);border:0;border-radius:8px;cursor:pointer}
.err{margin-top:14px;color:var(--err);font-size:14px}</style></head><body>
<form method="post" action="/login"><h1>Spike 50 Parts Board</h1><p>Sign in to continue</p>
<label for="u">Username</label><input id="u" name="user" autocomplete="username" autofocus required>
a{color:var(--accent);font-size:14px}.err{margin-top:14px;color:var(--err);font-size:14px}</style></head><body>${inner}</body></html>`;
const LOGIN_HTML = (err) => PAGE("Spike Parts Board - Login", `<form method="post" action="/login"><h1>Spike 50 Parts Board</h1><p>Sign in to continue</p>
<label for="u">Username</label><input id="u" name="user" autocomplete="username" autocapitalize="none" autofocus required>
<label for="p">Password</label><input id="p" name="pass" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>${err ? '<div class="err">Wrong username or password.</div>' : ""}</form></body></html>`;
function authorized(req) {
if (!PASS) return true;
if (validSession(req)) return true;
const h = req.headers.authorization || "";
if (!h.startsWith("Basic ")) return false;
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(/:(.*)/s);
const a = Buffer.from(`${u}:${p}`), b = Buffer.from(`${USER}:${PASS}`);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<button type="submit">Sign in</button>${err ? `<div class="err">${err}</div>` : ""}</form>`);
const PASSWORD_HTML = (u, err) => PAGE("Spike Parts Board - Password", `<form method="post" action="/password"><h1>${u.must_change ? "Choose a new password" : "Change password"}</h1>
<p>${u.must_change ? `Welcome ${u.name}! Before you continue, set your own password.` : `Signed in as ${u.name}.`}</p>
<label for="c">Current password</label><input id="c" name="current" type="password" autocomplete="current-password" required>
<label for="n">New password (at least 6 characters)</label><input id="n" name="next" type="password" autocomplete="new-password" minlength="6" required>
<label for="r">New password again</label><input id="r" name="again" type="password" autocomplete="new-password" minlength="6" required>
<button type="submit">Save password</button>${err ? `<div class="err">${err}</div>` : ""}${u.must_change ? "" : '<p style="margin:16px 0 0"><a href="/">Back to the board</a></p>'}</form>`);
const fails = new Map();
const clientIp = (req) => String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
const cleanText = (s, max) => String(s ?? "").slice(0, max);
function cleanUrl(s) {
try { const u = new URL(String(s).trim()); return ["http:", "https:"].includes(u.protocol) ? u.href : null; } catch { return null; }
}
// ---------- state ----------
function fullState() {
function fullState(me) {
const parts = {}, links = {}, comments = {}, drawings = {}, photos = {};
for (const r of db.prepare("SELECT id, status, note FROM parts").all()) parts[r.id] = { s: r.status, note: r.note };
for (const r of db.prepare("SELECT id, part_id, url, label, created FROM links ORDER BY created").all())
(links[r.part_id] ||= []).push({ id: r.id, url: r.url, label: r.label, at: r.created });
for (const r of db.prepare("SELECT id, part_id, text, created FROM comments ORDER BY created").all())
(comments[r.part_id] ||= []).push({ id: r.id, text: r.text, at: r.created });
for (const r of db.prepare("SELECT id, status, note, updated, updated_by FROM parts").all()) parts[r.id] = { s: r.status, note: r.note, by: r.updated_by, at: r.updated };
for (const r of db.prepare("SELECT id, part_id, url, label, created, author FROM links ORDER BY created").all())
(links[r.part_id] ||= []).push({ id: r.id, url: r.url, label: r.label, at: r.created, by: r.author });
for (const r of db.prepare("SELECT id, part_id, text, created, author FROM comments ORDER BY created").all())
(comments[r.part_id] ||= []).push({ id: r.id, text: r.text, at: r.created, by: r.author });
for (const r of db.prepare("SELECT key, pins, image, updated FROM drawings").all())
drawings[r.key] = { pins: r.pins ? JSON.parse(r.pins) : null, image: r.image ? `uploads/${r.image}?v=${r.updated}` : null };
for (const r of db.prepare("SELECT id, scope, caption, created FROM photos ORDER BY created DESC, id DESC").all())
(photos[r.scope] ||= []).push({ id: r.id, caption: r.caption, at: r.created, src: `uploads/photo-${r.id}.jpg`, thumb: `uploads/photo-${r.id}-t.jpg` });
return { parts, links, comments, drawings, photos };
for (const r of db.prepare("SELECT id, scope, caption, created, author FROM photos ORDER BY created DESC, id DESC").all())
(photos[r.scope] ||= []).push({ id: r.id, caption: r.caption, at: r.created, by: r.author, src: `uploads/photo-${r.id}.jpg`, thumb: `uploads/photo-${r.id}-t.jpg` });
const now = Date.now();
const users = db.prepare("SELECT name, last_seen FROM users ORDER BY name").all().map((u) => ({ name: u.name, online: u.name === me || now - u.last_seen < 90000, last: u.last_seen }));
const spending = db.prepare("SELECT id, author, cents, what, created FROM spending ORDER BY created DESC, id DESC").all().map((r) => ({ id: r.id, by: r.author, cents: r.cents, what: r.what, at: r.created }));
const activity = db.prepare("SELECT id, author, part_id, action, detail, created FROM activity ORDER BY id DESC LIMIT 300").all().map((r) => ({ id: r.id, by: r.author, part: r.part_id, action: r.action, detail: r.detail, at: r.created }));
return { parts, links, comments, drawings, photos, users, me, spending, activity };
}
// ---------- routes ----------
async function api(req, res, url) {
const m = (re) => url.pathname.match(re);
let p;
if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, fullState());
if (req.method === "GET" && url.pathname === "/api/ping") return send(res, 200, { ok: true });
if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, fullState(req.user.name));
if (req.method === "GET" && url.pathname === "/api/export") {
res.setHeader("Content-Disposition", `attachment; filename="spike-board-${new Date().toISOString().slice(0, 10)}.json"`);
return send(res, 200, fullState());
return send(res, 200, fullState(req.user.name));
}
if (req.method === "PUT" && (p = m(/^\/api\/parts\/([^/]+)$/))) {
const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id");
const b = await readJson(req);
const status = STATUSES.has(b.s) ? b.s : "none";
db.prepare(`INSERT INTO parts (id, status, note, updated) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET status = excluded.status, note = excluded.note, updated = excluded.updated`)
.run(id, status, cleanText(b.note, 2000), Date.now());
const note = cleanText(b.note, 2000);
const old = db.prepare("SELECT status, note FROM parts WHERE id = ?").get(id) || { status: "none", note: "" };
if (old.status === status && old.note === note) return send(res, 200, { ok: true });
db.prepare(`INSERT INTO parts (id, status, note, updated, updated_by) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET status = excluded.status, note = excluded.note, updated = excluded.updated, updated_by = excluded.updated_by`)
.run(id, status, note, Date.now(), req.user.name);
if (old.status !== status) logActivity(req.user.name, id, "status", status);
if (old.note !== note) logActivity(req.user.name, id, "note", "");
return send(res, 200, { ok: true });
}
@@ -141,22 +210,30 @@ async function api(req, res, url) {
const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id");
const b = await readJson(req); const u = cleanUrl(b.url);
if (!u) return fail(res, 400, "Enter a full web address starting with http:// or https://");
const r = db.prepare("INSERT INTO links (part_id, url, label, created) VALUES (?, ?, ?, ?)").run(id, u, cleanText(b.label, 200), Date.now());
const r = db.prepare("INSERT INTO links (part_id, url, label, created, author) VALUES (?, ?, ?, ?, ?)").run(id, u, cleanText(b.label, 200), Date.now(), req.user.name);
logActivity(req.user.name, id, "link_add", cleanText(b.label, 200) || u);
return send(res, 201, { id: Number(r.lastInsertRowid) });
}
if (req.method === "DELETE" && (p = m(/^\/api\/links\/(\d+)$/))) {
db.prepare("DELETE FROM links WHERE id = ?").run(+p[1]); return send(res, 200, { ok: true });
const old = db.prepare("SELECT part_id, label, url FROM links WHERE id = ?").get(+p[1]);
db.prepare("DELETE FROM links WHERE id = ?").run(+p[1]);
if (old) logActivity(req.user.name, old.part_id, "link_del", old.label || old.url);
return send(res, 200, { ok: true });
}
if (req.method === "POST" && (p = m(/^\/api\/parts\/([^/]+)\/comments$/))) {
const id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id");
const b = await readJson(req); const text = cleanText(b.text, 4000).trim();
if (!text) return fail(res, 400, "The comment is empty");
const r = db.prepare("INSERT INTO comments (part_id, text, created) VALUES (?, ?, ?)").run(id, text, Date.now());
const r = db.prepare("INSERT INTO comments (part_id, text, created, author) VALUES (?, ?, ?, ?)").run(id, text, Date.now(), req.user.name);
logActivity(req.user.name, id, "comment_add", text.slice(0, 120));
return send(res, 201, { id: Number(r.lastInsertRowid) });
}
if (req.method === "DELETE" && (p = m(/^\/api\/comments\/(\d+)$/))) {
db.prepare("DELETE FROM comments WHERE id = ?").run(+p[1]); return send(res, 200, { ok: true });
const old = db.prepare("SELECT part_id, text FROM comments WHERE id = ?").get(+p[1]);
db.prepare("DELETE FROM comments WHERE id = ?").run(+p[1]);
if (old) logActivity(req.user.name, old.part_id, "comment_del", old.text.slice(0, 120));
return send(res, 200, { ok: true });
}
if (req.method === "PUT" && (p = m(/^\/api\/drawings\/([^/]+)\/pins$/))) {
@@ -166,6 +243,7 @@ async function api(req, res, url) {
db.prepare(`INSERT INTO drawings (key, pins, updated) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET pins = excluded.pins, updated = excluded.updated`)
.run(key, pins ? JSON.stringify(pins) : null, Date.now());
logActivity(req.user.name, "", "markers", key);
return send(res, 200, { ok: true });
}
@@ -183,16 +261,36 @@ async function api(req, res, url) {
return send(res, 200, { ok: true });
}
if (req.method === "POST" && url.pathname === "/api/spending") {
const b = await readJson(req);
const cents = Math.round(Number(String(b.amount ?? "").replace(",", ".")) * 100);
const what = cleanText(b.what, 200).trim();
if (!Number.isFinite(cents) || cents <= 0 || cents > 10000000) return fail(res, 400, "Enter an amount greater than 0");
if (!what) return fail(res, 400, "Say what it was spent on");
const r = db.prepare("INSERT INTO spending (author, cents, what, created) VALUES (?, ?, ?, ?)").run(req.user.name, cents, what, Date.now());
logActivity(req.user.name, "", "spend_add", `${(cents / 100).toFixed(2)} · ${what}`);
return send(res, 201, { id: Number(r.lastInsertRowid) });
}
if (req.method === "DELETE" && (p = m(/^\/api\/spending\/(\d+)$/))) {
const old = db.prepare("SELECT author, cents, what FROM spending WHERE id = ?").get(+p[1]);
if (!old) return send(res, 200, { ok: true });
if (old.author !== req.user.name) return fail(res, 403, "You can only remove your own entries");
db.prepare("DELETE FROM spending WHERE id = ?").run(+p[1]);
logActivity(req.user.name, "", "spend_del", `${(old.cents / 100).toFixed(2)} · ${old.what}`);
return send(res, 200, { ok: true });
}
if (req.method === "POST" && url.pathname === "/api/photos") {
const b = JSON.parse((await readBody(req, MAX_PHOTO_REQ)).toString("utf8") || "{}");
const scope = String(b.scope || "");
if (scope !== "gallery" && !ID_RE.test(scope)) return fail(res, 400, "Unknown photo target");
const img = dataUrlJpeg(b.image), thumb = dataUrlJpeg(b.thumb);
if (!img || !thumb) return fail(res, 415, "Upload a JPEG image");
const r = db.prepare("INSERT INTO photos (scope, caption, created) VALUES (?, ?, ?)").run(scope, cleanText(b.caption, 300), Date.now());
const r = db.prepare("INSERT INTO photos (scope, caption, created, author) VALUES (?, ?, ?, ?)").run(scope, cleanText(b.caption, 300), Date.now(), req.user.name);
const id = Number(r.lastInsertRowid);
fs.writeFileSync(path.join(DATA_DIR, "images", `photo-${id}.jpg`), img);
fs.writeFileSync(path.join(DATA_DIR, "images", `photo-${id}-t.jpg`), thumb);
logActivity(req.user.name, scope === "gallery" ? "" : scope, "photo_add", cleanText(b.caption, 300));
return send(res, 201, { id });
}
if (req.method === "PUT" && (p = m(/^\/api\/photos\/(\d+)$/))) {
@@ -201,7 +299,9 @@ async function api(req, res, url) {
return send(res, 200, { ok: true });
}
if (req.method === "DELETE" && (p = m(/^\/api\/photos\/(\d+)$/))) {
const old = db.prepare("SELECT scope, caption FROM photos WHERE id = ?").get(+p[1]);
db.prepare("DELETE FROM photos WHERE id = ?").run(+p[1]);
if (old) logActivity(req.user.name, old.scope === "gallery" ? "" : old.scope, "photo_del", old.caption);
for (const f of [`photo-${+p[1]}.jpg`, `photo-${+p[1]}-t.jpg`]) { try { fs.unlinkSync(path.join(DATA_DIR, "images", f)); } catch {} }
return send(res, 200, { ok: true });
}
@@ -222,25 +322,51 @@ function serveFile(res, file, cache) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
if (PASS && url.pathname === "/login") {
if (url.pathname === "/login") {
if (req.method === "POST") {
const f = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8"));
if (safeEq(`${f.get("user") || ""}:${f.get("pass") || ""}`, `${USER}:${PASS}`)) {
res.writeHead(303, { Location: "/", "Set-Cookie": `spike_session=${makeSession()}; Path=/; Max-Age=${SESSION_MS / 1000}; HttpOnly; Secure; SameSite=Lax` });
const ip = clientIp(req), f = fails.get(ip) || { n: 0, until: 0 };
if (f.until > Date.now()) return send(res, 429, LOGIN_HTML("Too many attempts. Try again in a few minutes."), "text/html; charset=utf-8");
const q = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8"));
const u = checkLogin(q.get("user"), q.get("pass") || "");
if (u) {
fails.delete(ip);
res.writeHead(303, { Location: u.must_change ? "/password" : "/", "Set-Cookie": setCookie(u) });
return res.end();
}
return send(res, 401, LOGIN_HTML(true), "text/html; charset=utf-8");
f.n++; if (f.n >= 8) { f.until = Date.now() + 600000; f.n = 0; } fails.set(ip, f);
return send(res, 401, LOGIN_HTML("Wrong username or password."), "text/html; charset=utf-8");
}
return send(res, 200, LOGIN_HTML(false), "text/html; charset=utf-8");
return send(res, 200, LOGIN_HTML(""), "text/html; charset=utf-8");
}
if (PASS && url.pathname === "/logout") {
res.writeHead(303, { Location: "/login", "Set-Cookie": "spike_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" });
if (url.pathname === "/logout") {
res.writeHead(303, { Location: "/login", "Set-Cookie": `spike_session=; ${COOKIE}; Max-Age=0` });
return res.end();
}
if (!authorized(req)) {
const user = authUser(req);
if (!user) {
if (url.pathname.startsWith("/api/")) return fail(res, 401, "Login required");
res.writeHead(303, { Location: "/login" }); return res.end();
}
req.user = user; touch(user.name);
if (url.pathname === "/password") {
if (req.method === "POST") {
const q = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8"));
const next = q.get("next") || "";
if (!checkLogin(user.name, q.get("current") || "")) return send(res, 401, PASSWORD_HTML(user, "Current password is wrong."), "text/html; charset=utf-8");
if (next.length < 6) return send(res, 400, PASSWORD_HTML(user, "The new password needs at least 6 characters."), "text/html; charset=utf-8");
if (next !== q.get("again")) return send(res, 400, PASSWORD_HTML(user, "The two new passwords don't match."), "text/html; charset=utf-8");
if (next === "lolo") return send(res, 400, PASSWORD_HTML(user, "Pick something other than the starting password."), "text/html; charset=utf-8");
setPassword(user.name, next, false);
const fresh = db.prepare("SELECT * FROM users WHERE name = ?").get(user.name);
res.writeHead(303, { Location: "/", "Set-Cookie": setCookie(fresh) });
return res.end();
}
return send(res, 200, PASSWORD_HTML(user, ""), "text/html; charset=utf-8");
}
if (user.must_change) {
if (url.pathname.startsWith("/api/")) return fail(res, 403, "Set a new password first");
res.writeHead(303, { Location: "/password" }); return res.end();
}
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
if (url.pathname.startsWith("/uploads/")) {
const name = path.basename(decodeURIComponent(url.pathname.slice(9)));
@@ -255,5 +381,5 @@ const server = http.createServer(async (req, res) => {
if (!e.code) console.error(e);
}
});
server.listen(PORT, () => console.log(`Spike Parts Board on http://0.0.0.0:${PORT} (data in ${DATA_DIR}${PASS ? ", password on" : ""})`));
server.listen(PORT, () => console.log(`Spike Parts Board on http://0.0.0.0:${PORT} (data in ${DATA_DIR})`));
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { server.close(); db.close(); process.exit(0); });