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

View File

@@ -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
<div class="tally" id="tally"></div>
<div class="bar" id="bar"></div>
<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center"><span class="savebadge" id="savebadge">Loading saved progress…</span><a class="savebadge" href="api/export">Download backup</a></div>
<div class="who" id="who"></div>
</div>
</header>
@@ -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=`<span>Online:</span>${(state.users||[]).map(u=>`<span class="chip" title="${u.online?"online now":(u.last?"last seen "+when(u.last):"never signed in")}"><span class="dot" style="background:${u.online?"var(--have)":"var(--none)"}"></span>${esc(u.name)}${u.name===state.me?" (you)":""}</span>`).join("")}<span>· <a href="/password">Password</a> · <a href="/logout">Log out</a></span>`;
$("#tally").innerHTML = ["have","miss","ord","skip","none"].map(s=>`<span class="chip s-${s}"><span class="dot"></span>${s==="none"?"Unchecked":STATUS[s]} <b class="mono">${c[s]}</b></span>`).join("");
$("#bar").innerHTML = ["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("");
}
@@ -355,6 +366,8 @@ function renderTabs(){
<span class="bar">${["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("")}</span>
<span class="m"><span>${done}/${c.total} done</span><span>${c.miss?`<span style="color:var(--miss)">${c.miss} missing</span>`:(imageFor(G.key)?"drawing":"no drawing yet")}</span></span></button>`;
}).join("") + `<button class="tab photos" role="tab" aria-selected="${current==="gallery"}" data-g="gallery"><span class="t">Photos</span><span class="m"><span>${Object.values(state.photos||{}).reduce((n,l)=>n+l.length,0)} photos</span><span></span></span></button>`
+ `<button class="tab" role="tab" aria-selected="${current==="spending"}" data-g="spending"><span class="t">Spending</span><span class="m"><span>${eur((state.spending||[]).reduce((n,x)=>n+x.cents,0))}</span><span></span></span></button>`
+ `<button class="tab" role="tab" aria-selected="${current==="activity"}" data-g="activity"><span class="t">Activity</span><span class="m"><span>who did what</span><span></span></span></button>`
+ `<button class="tab shop" role="tab" aria-selected="${current==="shop"}" data-g="shop"><span class="t">Shopping list</span><span class="m"><span>${shopN} parts to get</span><span></span></span></button>`;
}
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=`<button class="row s-${s}${selRef===r.r?" sel":""}" data-r="${r.r}"><span class="ref">${r.r}</span>
<span style="min-width:0"><span class="rname">${esc(bestName(r))}</span><br><span class="rsub mono">${r.v.length>1?r.v.length+" variants":esc(r.v[0].o)}${img?onDraw:""}${extra}${note?" · "+esc(note):""}</span></span>
<span style="min-width:0"><span class="rname">${esc(bestName(r))}</span><br><span class="rsub mono">${r.v.length>1?r.v.length+" variants":esc(r.v[0].o)}${img?onDraw:""}${extra}${pr.by&&s!=="none"?" · "+esc(pr.by):""}${note?" · "+esc(note):""}</span></span>
<span class="stat"><span class="dot s-${s}" style="display:inline-block;vertical-align:middle"></span> ${s==="none"?"":STATUS[s]}</span></button>`;
if(selRef===r.r) html+=detailHtml(G,r);
return html;
@@ -430,17 +445,18 @@ function detailHtml(G,r){
<label class="lbl" for="note">Note</label>
<textarea id="note" rows="2" placeholder="Where it is, condition, where you ordered it…">${esc(p.note||"")}</textarea>
<div class="sub"><span class="lbl">Links</span>
${linksOf(G.key,r.r).map(l=>`<div class="item"><span><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label||host(l.url))}</a><br><span class="when">${esc(host(l.url))} · added ${when(l.at)}</span></span>${l.id>0?`<button class="x" data-dellink="${l.id}" aria-label="Remove link">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
${linksOf(G.key,r.r).map(l=>`<div class="item"><span><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label||host(l.url))}</a><br><span class="when">${esc(host(l.url))} · added ${l.by?"by "+esc(l.by)+" ":""}${when(l.at)}</span></span>${l.id>0?`<button class="x" data-dellink="${l.id}" aria-label="Remove link">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
<form class="addrow" data-form="link"><input type="url" id="lurl" placeholder="https://… (eBay listing, shop, forum post)" required><input type="text" id="llabel" placeholder="Label (optional)"><button class="btn primary" type="submit">Add link</button></form>
</div>
<div class="sub"><span class="lbl">Comments</span>
${commentsOf(G.key,r.r).map(c=>`<div class="item"><span><p>${esc(c.text)}</p><span class="when">${when(c.at)}</span></span>${c.id>0?`<button class="x" data-delcomment="${c.id}" aria-label="Delete comment">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
${commentsOf(G.key,r.r).map(c=>`<div class="item"><span><p>${esc(c.text)}</p><span class="when">${c.by?esc(c.by)+" · ":""}${when(c.at)}</span></span>${c.id>0?`<button class="x" data-delcomment="${c.id}" aria-label="Delete comment">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
<form class="sub" data-form="comment"><textarea id="ctext" rows="2" placeholder="Write a comment, e.g. “Ordered from Motostion, 12 € + shipping”" required></textarea><div><button class="btn primary" type="submit">Add comment</button></div></form>
</div>
<div class="sub"><span class="lbl">Photos</span>
<div class="pgrid">${photoItems(pid(G.key,r.r)).map((p,i)=>`<button class="ph" data-ph="${pid(G.key,r.r)}|${i}" aria-label="Open photo"><img loading="lazy" src="${p.thumb}" alt="">${p.pending?`<i class="pend">waiting</i>`:""}</button>`).join("")}</div>
<div><label class="btn" for="pupl">+ Add photo</label><input type="file" id="pupl" accept="image/*" multiple hidden></div>
</div>
<div class="sub"><span class="lbl">History</span>${state.activity.filter(a=>a.part===pid(G.key,r.r)).slice(0,8).map(a=>`<div class="rsub"><b>${esc(a.by)}</b> ${esc(actText(a))} · ${when(a.at)}</div>`).join("")||`<div class="rsub">No changes yet.</div>`}</div>
${editing&&pinsFor(G.key)[r.r]?`<button class="btn" data-act="delpin">Remove this part’s markers</button>`:""}
</div>`;
}
@@ -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(i<give.length&&j<get.length){ const m=Math.min(give[i].v,get[j].v); out.push({from:give[i].n,to:get[j].n,c:Math.round(m)}); give[i].v-=m; get[j].v-=m; if(give[i].v<0.5) i++; if(get[j].v<0.5) j++; }
return out;
}
function renderSpending(){
const sp=state.spending||[]; const names=(state.users||[]).map(u=>u.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=`<section class="card shop">
<div><h2 style="font-size:20px">Spending</h2><p class="rsub" style="margin:4px 0 0">Everyone adds what they paid for the bike. The total is split equally between ${n} people (${names.map(esc).join(", ")}).</p></div>
<form class="addrow" data-form="spend"><input type="text" inputmode="decimal" id="samt" placeholder="Amount in €" required autocomplete="off"><input type="text" id="swhat" placeholder="What was it for?" required maxlength="200" autocomplete="off"><button class="btn primary" type="submit">Add</button></form>
<div class="sumgrid"><div class="sum"><span class="rsub">Total spent</span><b>${eur(total)}</b></div><div class="sum"><span class="rsub">Fair share each</span><b>${eur(Math.round(share))}</b></div>
${bal.map(b=>`<div class="sum"><span class="rsub">${esc(b.n)} paid ${eur(paid[b.n]||0)}</span><b class="${b.v>=0.5?"pos":b.v<=-0.5?"neg":""}">${b.v>=0.5?"+":""}${eur(Math.round(b.v))}</b></div>`).join("")}</div>
<div><span class="lbl">Settle up</span>${pay.length?pay.map(x=>`<div class="item"><span><b>${esc(x.from)}</b> pays <b>${esc(x.to)}</b></span><span class="amt">${eur(x.c)}</span></div>`).join(""):`<div class="rsub">${total?"Everyone is even.":"Nothing spent yet."}</div>`}</div>
<div class="sub"><span class="lbl">All entries</span>${sp.map(x=>`<div class="item"><span><b>${esc(x.what)}</b><br><span class="when">${esc(x.by)} · ${when(x.at)}${x.id<0?" · waiting to upload":""}</span></span><span style="display:flex;gap:6px;align-items:center"><span class="amt">${eur(x.cents)}</span>${x.by===state.me&&x.id>0?`<button class="x" data-delspend="${x.id}" aria-label="Remove entry">×</button>`:""}</span></div>`).join("")||`<div class="rsub">No entries yet.</div>`}</div>
</section>`;
}
function renderActivity(){
const list=state.activity||[];
$("#main").innerHTML=`<section class="card shop"><div><h2 style="font-size:20px">Activity</h2><p class="rsub" style="margin:4px 0 0">The latest changes by everyone. Tap a part to jump to it.</p></div>
<div class="sub">${list.map(a=>{ const [g,r]=(a.part||"").split("__"); const G=GROUPS.find(x=>x.key===g);
return `<div class="item"><span><b>${esc(a.by)}</b> ${esc(actText(a))}${G?` · <button class="btn" style="padding:1px 8px;font-size:12px" data-go="${a.part.replace("__","|")}">${esc(G.title)} #${esc(r)}</button>`:""}<br><span class="when">${when(a.at)}</span></span></div>`; }).join("")||`<div class="empty">Nothing yet.</div>`}</div></section>`;
}
// ---------- 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(); });
</script>

View File

@@ -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;

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); });