Accounts (flo, andi, lolo), presence, activity log, spending split
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b9516026b
commit
6866316925
3 files changed
+255
-59
No files matched your search
+77
-7
@@ -135,6 +135,14 @@ input[type=url],input[type=text]{width:100%;border:1px solid var(--line);backgro
|
|||||||
|
|
||||||
/* photos, viewer, mobile */
|
/* photos, viewer, mobile */
|
||||||
.pin::after{content:"";position:absolute;inset:-8px}
|
.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{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}
|
.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}
|
.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="tally" id="tally"></div>
|
||||||
<div class="bar" id="bar"></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 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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -251,7 +260,7 @@ function bestName(ref){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- state ----------
|
// ---------- state ----------
|
||||||
const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}});
|
const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}, users:[], me:"", spending:[], activity:[]});
|
||||||
let state = EMPTY();
|
let state = EMPTY();
|
||||||
let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false;
|
let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false;
|
||||||
const pid=(g,r)=>g+"__"+r;
|
const pid=(g,r)=>g+"__"+r;
|
||||||
@@ -264,6 +273,7 @@ async function api(method,url,body,isRaw){
|
|||||||
let res;
|
let res;
|
||||||
try{ res=await fetch(url,opt); }catch(_){ const er=new Error("You’re offline"); er.offline=true; throw er; }
|
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===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(()=>({}));
|
const data=await res.json().catch(()=>({}));
|
||||||
if(!res.ok) throw new Error(data.error||("Server answered "+res.status));
|
if(!res.ok) throw new Error(data.error||("Server answered "+res.status));
|
||||||
return data;
|
return data;
|
||||||
@@ -343,6 +353,7 @@ function counts(keys){
|
|||||||
}
|
}
|
||||||
function renderHead(){
|
function renderHead(){
|
||||||
const c=counts();
|
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("");
|
$("#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("");
|
$("#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="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>`;
|
<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>`
|
}).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>`;
|
+ `<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){
|
function visibleRefs(G){
|
||||||
@@ -368,6 +381,8 @@ function visibleRefs(G){
|
|||||||
function renderMain(){
|
function renderMain(){
|
||||||
if(current==="shop") return renderShop();
|
if(current==="shop") return renderShop();
|
||||||
if(current==="gallery") return renderGallery();
|
if(current==="gallery") return renderGallery();
|
||||||
|
if(current==="spending") return renderSpending();
|
||||||
|
if(current==="activity") return renderActivity();
|
||||||
const G=GROUPS.find(x=>x.key===current);
|
const G=GROUPS.find(x=>x.key===current);
|
||||||
const img=imageFor(G.key); const pins=pinsFor(G.key);
|
const img=imageFor(G.key); const pins=pinsFor(G.key);
|
||||||
const refset=new Set(G.refs.map(r=>r.r));
|
const refset=new Set(G.refs.map(r=>r.r));
|
||||||
@@ -386,12 +401,12 @@ function renderMain(){
|
|||||||
}
|
}
|
||||||
const c=counts([G.key]);
|
const c=counts([G.key]);
|
||||||
const rows=visibleRefs(G).map(r=>{
|
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 onDraw = pins[r.r]?"":" · not on drawing";
|
||||||
const nl=linksOf(G.key,r.r).length, nc=commentsOf(G.key,r.r).length;
|
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":""}`:"");
|
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>
|
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>`;
|
<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);
|
if(selRef===r.r) html+=detailHtml(G,r);
|
||||||
return html;
|
return html;
|
||||||
@@ -430,17 +445,18 @@ function detailHtml(G,r){
|
|||||||
<label class="lbl" for="note">Note</label>
|
<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>
|
<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>
|
<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>
|
<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>
|
||||||
<div class="sub"><span class="lbl">Comments</span>
|
<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>
|
<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>
|
||||||
<div class="sub"><span class="lbl">Photos</span>
|
<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 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><label class="btn" for="pupl">+ Add photo</label><input type="file" id="pupl" accept="image/*" multiple hidden></div>
|
||||||
</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>`:""}
|
${editing&&pinsFor(G.key)[r.r]?`<button class="btn" data-act="delpin">Remove this part’s markers</button>`:""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -465,6 +481,9 @@ function toast(t){const el=$("#toast"); el.textContent=t; el.hidden=false; clear
|
|||||||
document.addEventListener("click",async e=>{
|
document.addEventListener("click",async e=>{
|
||||||
if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; }
|
if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; }
|
||||||
const t=e.target.closest("button,[data-g]"); if(!t) 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.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.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; }
|
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=>{
|
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);
|
const id=pid(current,selRef);
|
||||||
try{
|
try{
|
||||||
let r;
|
let r;
|
||||||
@@ -531,6 +550,52 @@ document.addEventListener("pointerup",()=>{ if(!drag) return;
|
|||||||
else { selRef=drag.r; render(); }
|
else { selRef=drag.r; render(); }
|
||||||
drag=null; setTimeout(()=>dragMoved=false,0); });
|
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 ----------
|
// ---------- photos ----------
|
||||||
function partLabel(k){ const [g,r]=k.split("__"); const G=GROUPS.find(x=>x.key===g); return G?`${G.title} #${r}`:k; }
|
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){
|
function photoItems(scope){
|
||||||
@@ -692,12 +757,17 @@ document.addEventListener("click",async e=>{
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// ---------- boot ----------
|
// ---------- 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(()=>{});
|
if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(()=>{});
|
||||||
window.addEventListener("online",()=>reload());
|
window.addEventListener("online",()=>reload());
|
||||||
window.addEventListener("offline",()=>{ online=false; badgeNow(); });
|
window.addEventListener("offline",()=>{ online=false; badgeNow(); });
|
||||||
render();
|
render();
|
||||||
(async()=>{ try{ const c=await kvGet("state"); if(c&&!online) state=Object.assign(EMPTY(),c); pendingN=(await qAll()).length; }catch(_){} render(); reload(); })();
|
(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
|
// 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(); });
|
document.addEventListener("visibilitychange",()=>{ if(!document.hidden && !drag && !document.activeElement.matches("textarea,input")) reload(); });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ self.addEventListener("activate", (e) => {
|
|||||||
self.addEventListener("fetch", (e) => {
|
self.addEventListener("fetch", (e) => {
|
||||||
const req = e.request, url = new URL(req.url);
|
const req = e.request, url = new URL(req.url);
|
||||||
if (req.method !== "GET" || url.origin !== location.origin) return;
|
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/")) {
|
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; })));
|
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;
|
return;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const { DatabaseSync } = require("node:sqlite");
|
|||||||
const PORT = +(process.env.PORT || 8080);
|
const PORT = +(process.env.PORT || 8080);
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
|
||||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||||
const USER = process.env.BOARD_USER || "";
|
|
||||||
const PASS = process.env.BOARD_PASSWORD || "";
|
const PASS = process.env.BOARD_PASSWORD || "";
|
||||||
const MAX_UPLOAD = 20 * 1024 * 1024;
|
const MAX_UPLOAD = 20 * 1024 * 1024;
|
||||||
const MAX_PHOTO_REQ = 30 * 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);
|
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 STATUSES = new Set(["none", "have", "miss", "ord", "skip"]);
|
||||||
const ID_RE = /^[a-z0-9-]{1,60}__[0-9]{1,6}$/;
|
const ID_RE = /^[a-z0-9-]{1,60}__[0-9]{1,6}$/;
|
||||||
const KEY_RE = /^[a-z0-9-]{1,60}$/;
|
const KEY_RE = /^[a-z0-9-]{1,60}$/;
|
||||||
@@ -61,18 +107,32 @@ async function readJson(req) {
|
|||||||
const buf = await readBody(req, 1024 * 1024);
|
const buf = await readBody(req, 1024 * 1024);
|
||||||
try { return JSON.parse(buf.toString("utf8") || "{}"); } catch { throw Object.assign(new Error("bad json"), { code: 400 }); }
|
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 SESSION_MS = 30 * 24 * 3600 * 1000;
|
||||||
const sign = (v) => crypto.createHmac("sha256", SECRET).update(v).digest("base64url");
|
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 safeEq(x, y) { const a = Buffer.from(String(x)), b = Buffer.from(String(y)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||||
function makeSession() { const exp = String(Date.now() + SESSION_MS); return `${exp}.${sign(exp)}`; }
|
const makeSession = (u) => { const body = `${u.name}.${u.ver}.${Date.now() + SESSION_MS}`; return `${body}.${sign(body)}`; };
|
||||||
function validSession(req) {
|
function sessionUser(req) {
|
||||||
const m = /(?:^|;\s*)spike_session=([^;]+)/.exec(req.headers.cookie || "");
|
const m = /(?:^|;\s*)spike_session=([^;]+)/.exec(req.headers.cookie || "");
|
||||||
if (!m) return false;
|
if (!m) return null;
|
||||||
const [exp, sig] = m[1].split(".");
|
const parts = m[1].split(".");
|
||||||
return !!sig && +exp > Date.now() && safeEq(sig, sign(exp));
|
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}
|
: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}}
|
@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}
|
*{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{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}
|
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}
|
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>
|
a{color:var(--accent);font-size:14px}.err{margin-top:14px;color:var(--err);font-size:14px}</style></head><body>${inner}</body></html>`;
|
||||||
<form method="post" action="/login"><h1>Spike 50 Parts Board</h1><p>Sign in to continue</p>
|
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" autofocus required>
|
<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>
|
<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>`;
|
<button type="submit">Sign in</button>${err ? `<div class="err">${err}</div>` : ""}</form>`);
|
||||||
function authorized(req) {
|
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>
|
||||||
if (!PASS) return true;
|
<p>${u.must_change ? `Welcome ${u.name}! Before you continue, set your own password.` : `Signed in as ${u.name}.`}</p>
|
||||||
if (validSession(req)) return true;
|
<label for="c">Current password</label><input id="c" name="current" type="password" autocomplete="current-password" required>
|
||||||
const h = req.headers.authorization || "";
|
<label for="n">New password (at least 6 characters)</label><input id="n" name="next" type="password" autocomplete="new-password" minlength="6" required>
|
||||||
if (!h.startsWith("Basic ")) return false;
|
<label for="r">New password again</label><input id="r" name="again" type="password" autocomplete="new-password" minlength="6" required>
|
||||||
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(/:(.*)/s);
|
<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 a = Buffer.from(`${u}:${p}`), b = Buffer.from(`${USER}:${PASS}`);
|
const fails = new Map();
|
||||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
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);
|
const cleanText = (s, max) => String(s ?? "").slice(0, max);
|
||||||
function cleanUrl(s) {
|
function cleanUrl(s) {
|
||||||
try { const u = new URL(String(s).trim()); return ["http:", "https:"].includes(u.protocol) ? u.href : null; } catch { return null; }
|
try { const u = new URL(String(s).trim()); return ["http:", "https:"].includes(u.protocol) ? u.href : null; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- state ----------
|
// ---------- state ----------
|
||||||
function fullState() {
|
function fullState(me) {
|
||||||
const parts = {}, links = {}, comments = {}, drawings = {}, photos = {};
|
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, 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 FROM links ORDER BY created").all())
|
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 });
|
(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 FROM comments ORDER BY created").all())
|
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 });
|
(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())
|
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 };
|
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())
|
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, src: `uploads/photo-${r.id}.jpg`, thumb: `uploads/photo-${r.id}-t.jpg` });
|
(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` });
|
||||||
return { parts, links, comments, drawings, photos };
|
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 ----------
|
// ---------- routes ----------
|
||||||
async function api(req, res, url) {
|
async function api(req, res, url) {
|
||||||
const m = (re) => url.pathname.match(re);
|
const m = (re) => url.pathname.match(re);
|
||||||
let p;
|
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") {
|
if (req.method === "GET" && url.pathname === "/api/export") {
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="spike-board-${new Date().toISOString().slice(0, 10)}.json"`);
|
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\/([^/]+)$/))) {
|
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 id = p[1]; if (!ID_RE.test(id)) return fail(res, 400, "Unknown part id");
|
||||||
const b = await readJson(req);
|
const b = await readJson(req);
|
||||||
const status = STATUSES.has(b.s) ? b.s : "none";
|
const status = STATUSES.has(b.s) ? b.s : "none";
|
||||||
db.prepare(`INSERT INTO parts (id, status, note, updated) VALUES (?, ?, ?, ?)
|
const note = cleanText(b.note, 2000);
|
||||||
ON CONFLICT(id) DO UPDATE SET status = excluded.status, note = excluded.note, updated = excluded.updated`)
|
const old = db.prepare("SELECT status, note FROM parts WHERE id = ?").get(id) || { status: "none", note: "" };
|
||||||
.run(id, status, cleanText(b.note, 2000), Date.now());
|
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 });
|
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 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);
|
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://");
|
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) });
|
return send(res, 201, { id: Number(r.lastInsertRowid) });
|
||||||
}
|
}
|
||||||
if (req.method === "DELETE" && (p = m(/^\/api\/links\/(\d+)$/))) {
|
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$/))) {
|
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 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();
|
const b = await readJson(req); const text = cleanText(b.text, 4000).trim();
|
||||||
if (!text) return fail(res, 400, "The comment is empty");
|
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) });
|
return send(res, 201, { id: Number(r.lastInsertRowid) });
|
||||||
}
|
}
|
||||||
if (req.method === "DELETE" && (p = m(/^\/api\/comments\/(\d+)$/))) {
|
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$/))) {
|
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 (?, ?, ?)
|
db.prepare(`INSERT INTO drawings (key, pins, updated) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(key) DO UPDATE SET pins = excluded.pins, updated = excluded.updated`)
|
ON CONFLICT(key) DO UPDATE SET pins = excluded.pins, updated = excluded.updated`)
|
||||||
.run(key, pins ? JSON.stringify(pins) : null, Date.now());
|
.run(key, pins ? JSON.stringify(pins) : null, Date.now());
|
||||||
|
logActivity(req.user.name, "", "markers", key);
|
||||||
return send(res, 200, { ok: true });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +261,36 @@ async function api(req, res, url) {
|
|||||||
return send(res, 200, { ok: true });
|
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") {
|
if (req.method === "POST" && url.pathname === "/api/photos") {
|
||||||
const b = JSON.parse((await readBody(req, MAX_PHOTO_REQ)).toString("utf8") || "{}");
|
const b = JSON.parse((await readBody(req, MAX_PHOTO_REQ)).toString("utf8") || "{}");
|
||||||
const scope = String(b.scope || "");
|
const scope = String(b.scope || "");
|
||||||
if (scope !== "gallery" && !ID_RE.test(scope)) return fail(res, 400, "Unknown photo target");
|
if (scope !== "gallery" && !ID_RE.test(scope)) return fail(res, 400, "Unknown photo target");
|
||||||
const img = dataUrlJpeg(b.image), thumb = dataUrlJpeg(b.thumb);
|
const img = dataUrlJpeg(b.image), thumb = dataUrlJpeg(b.thumb);
|
||||||
if (!img || !thumb) return fail(res, 415, "Upload a JPEG image");
|
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);
|
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}.jpg`), img);
|
||||||
fs.writeFileSync(path.join(DATA_DIR, "images", `photo-${id}-t.jpg`), thumb);
|
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 });
|
return send(res, 201, { id });
|
||||||
}
|
}
|
||||||
if (req.method === "PUT" && (p = m(/^\/api\/photos\/(\d+)$/))) {
|
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 });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
if (req.method === "DELETE" && (p = m(/^\/api\/photos\/(\d+)$/))) {
|
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]);
|
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 {} }
|
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 });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
@@ -222,25 +322,51 @@ function serveFile(res, file, cache) {
|
|||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url, "http://localhost");
|
const url = new URL(req.url, "http://localhost");
|
||||||
if (PASS && url.pathname === "/login") {
|
if (url.pathname === "/login") {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const f = new URLSearchParams((await readBody(req, 10 * 1024)).toString("utf8"));
|
const ip = clientIp(req), f = fails.get(ip) || { n: 0, until: 0 };
|
||||||
if (safeEq(`${f.get("user") || ""}:${f.get("pass") || ""}`, `${USER}:${PASS}`)) {
|
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");
|
||||||
res.writeHead(303, { Location: "/", "Set-Cookie": `spike_session=${makeSession()}; Path=/; Max-Age=${SESSION_MS / 1000}; HttpOnly; Secure; SameSite=Lax` });
|
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 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") {
|
if (url.pathname === "/logout") {
|
||||||
res.writeHead(303, { Location: "/login", "Set-Cookie": "spike_session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" });
|
res.writeHead(303, { Location: "/login", "Set-Cookie": `spike_session=; ${COOKIE}; Max-Age=0` });
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
if (!authorized(req)) {
|
const user = authUser(req);
|
||||||
|
if (!user) {
|
||||||
if (url.pathname.startsWith("/api/")) return fail(res, 401, "Login required");
|
if (url.pathname.startsWith("/api/")) return fail(res, 401, "Login required");
|
||||||
res.writeHead(303, { Location: "/login" }); return res.end();
|
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("/api/")) return await api(req, res, url);
|
||||||
if (url.pathname.startsWith("/uploads/")) {
|
if (url.pathname.startsWith("/uploads/")) {
|
||||||
const name = path.basename(decodeURIComponent(url.pathname.slice(9)));
|
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);
|
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); });
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => { server.close(); db.close(); process.exit(0); });
|
||||||
Reference in new issue
Block a user