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
+
@@ -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=>`
`).join("")}
+ ${linksOf(G.key,r.r).map(l=>`
`).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
-