diff --git a/public/index.html b/public/index.html index 6ad686a..9db3e83 100644 --- a/public/index.html +++ b/public/index.html @@ -135,6 +135,14 @@ input[type=url],input[type=text]{width:100%;border:1px solid var(--line);backgro /* photos, viewer, mobile */ .pin::after{content:"";position:absolute;inset:-8px} +.who{display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:12.5px;color:var(--muted)} +.who a{color:var(--muted)} +.who .chip{padding:2px 9px} +.sumgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} +.sum{background:var(--soft);border-radius:8px;padding:8px 10px} +.sum b{display:block;font-size:18px;font-family:var(--f-mono)} +.pos{color:var(--have)}.neg{color:var(--miss)} +.item .amt{font-family:var(--f-mono);font-weight:600;white-space:nowrap} .pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:6px} .pgrid.big{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px} .ph{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;padding:0;background:var(--soft);aspect-ratio:1} @@ -199,6 +207,7 @@ input[type=url],input[type=text]{width:100%;border:1px solid var(--line);backgro
Loading saved progress…Download backup
+
@@ -251,7 +260,7 @@ function bestName(ref){ } // ---------- state ---------- -const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}}); +const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}, users:[], me:"", spending:[], activity:[]}); let state = EMPTY(); let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false; const pid=(g,r)=>g+"__"+r; @@ -264,6 +273,7 @@ async function api(method,url,body,isRaw){ let res; try{ res=await fetch(url,opt); }catch(_){ const er=new Error("You’re offline"); er.offline=true; throw er; } if(res.status===401){ location.href="/login"; throw new Error("Please sign in again"); } + if(res.status===403){ location.href="/password"; throw new Error("Set a new password first"); } const data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.error||("Server answered "+res.status)); return data; @@ -343,6 +353,7 @@ function counts(keys){ } function renderHead(){ const c=counts(); + $("#who").innerHTML=`Online:${(state.users||[]).map(u=>`${esc(u.name)}${u.name===state.me?" (you)":""}`).join("")}· Password · Log out`; $("#tally").innerHTML = ["have","miss","ord","skip","none"].map(s=>`${s==="none"?"Unchecked":STATUS[s]} ${c[s]}`).join(""); $("#bar").innerHTML = ["have","skip","ord","miss"].map(s=>``).join(""); } @@ -355,6 +366,8 @@ function renderTabs(){ ${["have","skip","ord","miss"].map(s=>``).join("")} ${done}/${c.total} done${c.miss?`${c.miss} missing`:(imageFor(G.key)?"drawing":"no drawing yet")}`; }).join("") + `` + + `` + + `` + ``; } function visibleRefs(G){ @@ -368,6 +381,8 @@ function visibleRefs(G){ function renderMain(){ if(current==="shop") return renderShop(); if(current==="gallery") return renderGallery(); + if(current==="spending") return renderSpending(); + if(current==="activity") return renderActivity(); const G=GROUPS.find(x=>x.key===current); const img=imageFor(G.key); const pins=pinsFor(G.key); const refset=new Set(G.refs.map(r=>r.r)); @@ -386,12 +401,12 @@ function renderMain(){ } const c=counts([G.key]); const rows=visibleRefs(G).map(r=>{ - const s=statusOf(G.key,r.r); const note=(state.parts[pid(G.key,r.r)]||{}).note; + const s=statusOf(G.key,r.r); const pr=state.parts[pid(G.key,r.r)]||{}; const note=pr.note; const onDraw = pins[r.r]?"":" · not on drawing"; const nl=linksOf(G.key,r.r).length, nc=commentsOf(G.key,r.r).length; const extra=(nl?` · ${nl} link${nl>1?"s":""}`:"")+(nc?` · ${nc} comment${nc>1?"s":""}`:""); let html=``; if(selRef===r.r) html+=detailHtml(G,r); return html; @@ -430,17 +445,18 @@ function detailHtml(G,r){
Links - ${linksOf(G.key,r.r).map(l=>`
${esc(l.label||host(l.url))}
${esc(host(l.url))} · added ${when(l.at)}
${l.id>0?``:`waiting`}
`).join("")} + ${linksOf(G.key,r.r).map(l=>`
${esc(l.label||host(l.url))}
${esc(host(l.url))} · added ${l.by?"by "+esc(l.by)+" ":""}${when(l.at)}
${l.id>0?``:`waiting`}
`).join("")}
Comments - ${commentsOf(G.key,r.r).map(c=>`

${esc(c.text)}

${when(c.at)}
${c.id>0?``:`waiting`}
`).join("")} + ${commentsOf(G.key,r.r).map(c=>`

${esc(c.text)}

${c.by?esc(c.by)+" · ":""}${when(c.at)}
${c.id>0?``:`waiting`}
`).join("")}
Photos
${photoItems(pid(G.key,r.r)).map((p,i)=>``).join("")}
+
History${state.activity.filter(a=>a.part===pid(G.key,r.r)).slice(0,8).map(a=>`
${esc(a.by)} ${esc(actText(a))} · ${when(a.at)}
`).join("")||`
No changes yet.
`}
${editing&&pinsFor(G.key)[r.r]?``:""} `; } @@ -465,6 +481,9 @@ function toast(t){const el=$("#toast"); el.textContent=t; el.hidden=false; clear document.addEventListener("click",async e=>{ if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; } const t=e.target.closest("button,[data-g]"); if(!t) return; + if(t.dataset.go){ const [g,r]=t.dataset.go.split("|"); current=g; selRef=r; filter="all"; render(); try{localStorage.setItem(LS+"-tab",current)}catch(_){} return; } + if(t.dataset.delspend){ const id=+t.dataset.delspend; if(!confirm("Remove this spending entry?")) return; + try{ const r=await send("DELETE","api/spending/"+id,undefined,()=>{ state.spending=state.spending.filter(x=>x.id!==id); }); if(!r.queued) await reload(); }catch(err){ toast("Couldn’t remove: "+err.message); } return; } if(t.dataset.ph){ const [sc,i]=t.dataset.ph.split("|"); openPhotos(photoItems(sc),+i); return; } if(t.dataset.g){ current=t.dataset.g; selRef=null; editing=false; confirmBulk=false; zoom=1; render(); try{localStorage.setItem(LS+"-tab",current)}catch(_){} return; } if(t.classList.contains("pin")){ if(dragMoved) return; selRef=t.dataset.r; filter="all"; render(); return; } @@ -500,7 +519,7 @@ document.addEventListener("change",async e=>{ } }); document.addEventListener("submit",async e=>{ - const f=e.target.closest("form[data-form]"); if(!f) return; e.preventDefault(); + const f=e.target.closest("form[data-form]"); if(!f) return; e.preventDefault(); if(f.dataset.form==="spend") return; const id=pid(current,selRef); try{ let r; @@ -531,6 +550,52 @@ document.addEventListener("pointerup",()=>{ if(!drag) return; else { selRef=drag.r; render(); } drag=null; setTimeout(()=>dragMoved=false,0); }); +// ---------- spending, activity ---------- +const eur=c=>(c/100).toLocaleString("de-DE",{style:"currency",currency:"EUR"}); +function groupTitle(k){ const G=GROUPS.find(x=>x.key===k); return G?G.title:k; } +function actText(a){ + const d=a.detail||""; + switch(a.action){ + case "status": return "set the status to "+(STATUS[d]||"unchecked"); + case "note": return "edited the note"; + case "link_add": return `added the link “${d}”`; + case "link_del": return `removed the link “${d}”`; + case "comment_add": return `commented “${d}”`; + case "comment_del": return "deleted a comment"; + case "photo_add": return "added a photo"+(d?` “${d}”`:""); + case "photo_del": return "deleted a photo"; + case "markers": return "moved markers on "+groupTitle(d); + case "spend_add": { const [m,...w]=d.split(" · "); return `spent €${m} on ${w.join(" · ")}`; } + case "spend_del": { const [m,...w]=d.split(" · "); return `removed the spending €${m} ${w.join(" · ")}`; } + default: return a.action; + } +} +function settlements(bal){ + const give=bal.filter(b=>b.v<-0.5).map(b=>({n:b.n,v:-b.v})).sort((a,b)=>b.v-a.v), get=bal.filter(b=>b.v>0.5).map(b=>({n:b.n,v:b.v})).sort((a,b)=>b.v-a.v), out=[]; + let i=0,j=0; while(iu.name); const n=Math.max(1,names.length); + const total=sp.reduce((a,x)=>a+x.cents,0), share=total/n; + const paid=Object.fromEntries(names.map(u=>[u,0])); for(const x of sp) paid[x.by]=(paid[x.by]||0)+x.cents; + const bal=names.map(u=>({n:u,v:(paid[u]||0)-share})); const pay=settlements(bal); + $("#main").innerHTML=`
+

Spending

Everyone adds what they paid for the bike. The total is split equally between ${n} people (${names.map(esc).join(", ")}).

+
+
Total spent${eur(total)}
Fair share each${eur(Math.round(share))}
+ ${bal.map(b=>`
${esc(b.n)} paid ${eur(paid[b.n]||0)}${b.v>=0.5?"+":""}${eur(Math.round(b.v))}
`).join("")}
+
Settle up${pay.length?pay.map(x=>`
${esc(x.from)} pays ${esc(x.to)}${eur(x.c)}
`).join(""):`
${total?"Everyone is even.":"Nothing spent yet."}
`}
+
All entries${sp.map(x=>`
${esc(x.what)}
${esc(x.by)} · ${when(x.at)}${x.id<0?" · waiting to upload":""}
${eur(x.cents)}${x.by===state.me&&x.id>0?``:""}
`).join("")||`
No entries yet.
`}
+
`; +} +function renderActivity(){ + const list=state.activity||[]; + $("#main").innerHTML=`

Activity

The latest changes by everyone. Tap a part to jump to it.

+
${list.map(a=>{ const [g,r]=(a.part||"").split("__"); const G=GROUPS.find(x=>x.key===g); + return `
${esc(a.by)} ${esc(actText(a))}${G?` · `:""}
${when(a.at)}
`; }).join("")||`
Nothing yet.
`}
`; +} + // ---------- photos ---------- function partLabel(k){ const [g,r]=k.split("__"); const G=GROUPS.find(x=>x.key===g); return G?`${G.title} #${r}`:k; } function photoItems(scope){ @@ -692,12 +757,17 @@ document.addEventListener("click",async e=>{ })(); // ---------- boot ---------- -try{ const t=localStorage.getItem(LS+"-tab"); if(t && (t==="shop"||t==="gallery"||GROUPS.some(g=>g.key===t))) current=t; }catch(_){} +try{ const t=localStorage.getItem(LS+"-tab"); if(t && (t==="shop"||t==="gallery"||t==="spending"||t==="activity"||GROUPS.some(g=>g.key===t))) current=t; }catch(_){} if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(()=>{}); window.addEventListener("online",()=>reload()); window.addEventListener("offline",()=>{ online=false; badgeNow(); }); render(); (async()=>{ try{ const c=await kvGet("state"); if(c&&!online) state=Object.assign(EMPTY(),c); pendingN=(await qAll()).length; }catch(_){} render(); reload(); })(); +setInterval(()=>{ + if(document.hidden||!navigator.onLine) return; + const typing=document.activeElement&&document.activeElement.matches("textarea,input"); + if(typing||vw.open||drag) api("GET","api/ping").catch(()=>{}); else reload(); +},30000); // pick up changes made on another device when you come back to the tab document.addEventListener("visibilitychange",()=>{ if(!document.hidden && !drag && !document.activeElement.matches("textarea,input")) reload(); }); diff --git a/public/sw.js b/public/sw.js index 6d340b9..6b37bff 100644 --- a/public/sw.js +++ b/public/sw.js @@ -14,7 +14,7 @@ self.addEventListener("activate", (e) => { self.addEventListener("fetch", (e) => { const req = e.request, url = new URL(req.url); if (req.method !== "GET" || url.origin !== location.origin) return; - if (url.pathname.startsWith("/api/") || url.pathname === "/login" || url.pathname === "/logout") return; + if (url.pathname.startsWith("/api/") || url.pathname === "/login" || url.pathname === "/logout" || url.pathname === "/password") return; if (url.pathname.startsWith("/uploads/photo-") || url.pathname.startsWith("/img/")) { e.respondWith(caches.match(req).then((hit) => hit || fetch(req).then((r) => { if (okToCache(r)) { const c = r.clone(); caches.open(V).then((ch) => ch.put(req, c)); } return r; }))); return; diff --git a/server.js b/server.js index a8eff57..bb0cb92 100644 --- a/server.js +++ b/server.js @@ -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) => `Spike Parts Board - Login -

Spike 50 Parts Board

Sign in to continue

- +a{color:var(--accent);font-size:14px}.err{margin-top:14px;color:var(--err);font-size:14px}${inner}`; +const LOGIN_HTML = (err) => PAGE("Spike Parts Board - Login", `

Spike 50 Parts Board

Sign in to continue

+ -${err ? '
Wrong username or password.
' : ""}
`; -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); -} +${err ? `
${err}
` : ""}`); +const PASSWORD_HTML = (u, err) => PAGE("Spike Parts Board - Password", `

${u.must_change ? "Choose a new password" : "Change password"}

+

${u.must_change ? `Welcome ${u.name}! Before you continue, set your own password.` : `Signed in as ${u.name}.`}

+ + + +${err ? `
${err}
` : ""}${u.must_change ? "" : '

Back to the board

'}
`); +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); });