From 0bf00e3f00e71c8212e09be31d5271a5fc5b08a7 Mon Sep 17 00:00:00 2001 From: Dani Date: Fri, 22 Aug 2025 19:35:00 -0400 Subject: [PATCH] Added avatars to make it a bit more friendly --- client/app.js | 627 +++++++++++++++++++------------------- client/avatar.js | 44 +++ client/index.html | 66 ++-- client/styles.css | 34 ++- internal/api/http.go | 66 ++-- internal/api/ratelimit.go | 88 +++--- internal/index/index.go | 42 +-- 7 files changed, 521 insertions(+), 446 deletions(-) create mode 100644 client/avatar.js diff --git a/client/app.js b/client/app.js index ce79a91..b562ea4 100644 --- a/client/app.js +++ b/client/app.js @@ -1,339 +1,352 @@ import { encryptString, decryptToString, toBlob } from "./crypto.js"; -// ---------- DOM ---------- -const els = { - shardUrl: document.getElementById("shardUrl"), - bearer: document.getElementById("bearer"), - passphrase: document.getElementById("passphrase"), - saveConn: document.getElementById("saveConn"), - keySignIn: document.getElementById("keySignIn"), - panicWipe: document.getElementById("panicWipe"), - health: document.getElementById("health"), - visibility: document.getElementById("visibility"), - title: document.getElementById("title"), - body: document.getElementById("body"), - publish: document.getElementById("publish"), - publishStatus: document.getElementById("publishStatus"), - posts: document.getElementById("posts"), - discordStart: document.getElementById("discordStart"), -}; +const els = {}; +function $(id){ return document.getElementById(id); } -// ---------- Config (no bearer in localStorage) ---------- -const LS_KEY = "gc_client_config_v1"; -const POSTS_KEY = "gc_posts_index_v1"; -function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } -function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify({ url: c.url, passphrase: c.passphrase })); Object.assign(cfg, c); } -function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } -function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } -function norm(u){ return (u||"").replace(/\/+$/,""); } -function getBearer(){ return sessionStorage.getItem("gc_bearer") || ""; } -function setBearer(tok){ if (!tok) sessionStorage.removeItem("gc_bearer"); else sessionStorage.setItem("gc_bearer", tok); els.bearer.value = tok ? "••• (session)" : ""; } -const cfg = loadConfig(); +// Bind after DOM is ready to avoid nulls +window.addEventListener("DOMContentLoaded", () => { + Object.assign(els, { + shardUrl: $("shardUrl"), + bearer: $("bearer"), + passphrase: $("passphrase"), + saveConn: $("saveConn"), + health: $("health"), + visibility: $("visibility"), + title: $("title"), + body: $("body"), + publish: $("publish"), + publishStatus: $("publishStatus"), + posts: $("posts"), + discordStart: $("discordStart"), + signIn: $("signIn"), + panic: $("panic"), + avatar: $("avatar"), + fp: $("fp"), + flash: $("flash"), + }); -// ---------- Security helpers ---------- -const enc = new TextEncoder(); -const dec = new TextDecoder(); -const b64 = (u) => { let s=""; u=new Uint8Array(u); for (let i=0;i { s=s.replace(/-/g,"+").replace(/_/g,"/"); while(s.length%4) s+="="; const bin=atob(s); const b=new Uint8Array(bin.length); for(let i=0;ix.toString(16).padStart(2,"0")).join(""); } + // Wire handlers (with logging so you can see it fires) + on(els.saveConn, "click", onSaveConn); + on(els.publish, "click", publish); + on(els.discordStart, "click", discordStart); + on(els.signIn, "click", deviceKeySignIn); + on(els.panic, "click", panicWipe); -// Device key (P-256), stored locally (not a bearer) -async function getDevice() { - let dev = JSON.parse(localStorage.getItem('gc_device_key_v1')||'null'); - if (!dev) { - const kp = await crypto.subtle.generateKey({name:"ECDSA", namedCurve:"P-256"}, true, ["sign","verify"]); - const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey); - const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey); // 65B 0x04||X||Y - dev = { alg:"p256", priv: b64(pkcs8), pub: b64(rawPub) }; - localStorage.setItem('gc_device_key_v1', JSON.stringify(dev)); - } - return dev; -} - -// Proof-of-Possession headers for this request -async function popHeaders(method, pathOnly, bodyBuf){ - const dev = await getDevice(); - const ts = Math.floor(Date.now()/1000).toString(); - const hashHex = await sha256Hex(bodyBuf || new Uint8Array()); - const msg = enc.encode(method.toUpperCase()+"\n"+pathOnly+"\n"+ts+"\n"+hashHex); - const priv = await crypto.subtle.importKey("pkcs8", ub64(dev.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); - const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, msg); - return { - "X-GC-Key": "p256:"+dev.pub, - "X-GC-TS": ts, - "X-GC-Proof": b64(sig), - }; -} - -// Idle timeout → clear bearer -(function idleGuard(){ - let idle; - const bump=()=>{ clearTimeout(idle); idle=setTimeout(()=>setBearer(""), 30*60*1000); }; // 30 min - ["click","keydown","mousemove","touchstart","focus","visibilitychange"].forEach(ev=>addEventListener(ev,bump,{passive:true})); - bump(); -})(); - -// ---------- API base detection ---------- -function defaultApiBase() { - try { - const qs = new URLSearchParams(window.location.search); - const qApi = qs.get("api"); if (qApi) return qApi.replace(/\/+$/, ""); - } catch {} - const m = document.querySelector('meta[name="gc-api-base"]'); - if (m && m.content) return m.content.replace(/\/+$/, ""); - try { - const u = new URL(window.location.href); - const proto = u.protocol, host = u.hostname, portStr = u.port; - const bracketHost = host.includes(":") ? `[${host}]` : host; - const port = portStr ? parseInt(portStr, 10) : null; - let apiPort = port; - if (port === 8082) apiPort = 8080; - else if (port === 9082) apiPort = 9080; - else if (port) apiPort = Math.max(1, port - 2); - return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`; - } catch { - return window.location.origin.replace(/\/+$/, ""); - } -} - -// ---------- App init ---------- -function applyConfig(){ - els.shardUrl.value = cfg.url ?? defaultApiBase(); - els.passphrase.value = cfg.passphrase ?? ""; - els.bearer.value = getBearer() ? "••• (session)" : ""; -} -applyConfig(); checkHealth(); syncIndex(); sse(); - -// ---------- UI wiring ---------- -els.saveConn.onclick = async () => { - const c = { url: norm(els.shardUrl.value), passphrase: els.passphrase.value }; - saveConfig(c); await checkHealth(); await syncIndex(); sse(true); -}; -els.publish.onclick = publish; -els.discordStart.onclick = discordStart; -els.keySignIn.onclick = keySignIn; -els.panicWipe.onclick = panicWipe; - -// Panic wipe hotkey (double-tap ESC) -let escT=0; -addEventListener("keydown", (e) => { - if (e.key === "Escape") { - const now = Date.now(); - if (now - escT < 600) panicWipe(); - escT = now; - } + // Boot + applyConfig(); + checkHealth(); + syncIndex(); + sse(); + renderAvatar(); + flash("GC client loaded"); }); -// ---------- Health / Index / SSE ---------- -async function checkHealth() { - if (!cfg.url) return; els.health.textContent = "Checking…"; +// ---------- small helpers ---------- + +function on(el, ev, fn){ if (el) el.addEventListener(ev, (e)=>{ console.log(`[ui] ${ev}#${el.id}`); fn(e); }, false); } +function flash(msg, ms=1800){ if(!els.flash) return; els.flash.textContent=msg; els.flash.style.display="block"; setTimeout(()=>els.flash.style.display="none", ms); } +function norm(u){ return (u||"").replace(/\/+$/,""); } + +function defaultApiBase() { + try { const qs = new URLSearchParams(window.location.search); const qApi = qs.get("api"); if (qApi) return qApi.replace(/\/+$/,""); } catch {} + const m = document.querySelector('meta[name="gc-api-base"]'); if (m && m.content) return m.content.replace(/\/+$/,""); + try { + const u = new URL(window.location.href); const proto = u.protocol; const host = u.hostname; const portStr = u.port; const bracketHost = host.includes(":") ? `[${host}]` : host; + const port = portStr ? parseInt(portStr,10) : null; let apiPort = port; + if (port === 8082) apiPort = 8080; else if (port === 9082) apiPort = 9080; else if (port) apiPort = Math.max(1, port-2); + return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`; + } catch { return window.location.origin.replace(/\/+$/,""); } +} + +const LS_KEY = "gc_client_config_v4"; +const POSTS_KEY = "gc_posts_index_v4"; +const KEY_PKCS8 = "gc_key_pkcs8"; +const KEY_PUB_RAW = "gc_key_pub_raw"; + +function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } +const cfg = loadConfig(); + +function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(Object.assign(cfg,c))); } +function applyConfig(){ + if (!els.shardUrl) return; + els.shardUrl.value = cfg.url ?? defaultApiBase(); + els.bearer.value = cfg.bearer ?? ""; + els.passphrase.value = cfg.passphrase ?? ""; +} + +async function checkHealth(){ + if (!cfg.url) { els.health.textContent="Set URL"; return; } + els.health.textContent="Checking…"; try { const r = await fetch(cfg.url + "/healthz"); els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; } catch { els.health.textContent = "Not reachable"; } } -async function syncIndex() { - if (!cfg.url) return; - try { - const hdrs = {}; - const b = getBearer(); - if (b) Object.assign(hdrs, await popHeaders("GET", "/v1/index", new Uint8Array())); - const r = await fetch(cfg.url + "/v1/index", { headers: Object.assign(hdrs, b?{Authorization:"Bearer "+b}:{}) }); - if (!r.ok) throw new Error("index fetch failed"); - const entries = await r.json(); - setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz||"" }))); - } catch(e){ console.warn("index sync failed", e); } -} +function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } +function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } -let sseCtrl; -function sse(reset){ - if (!cfg.url) return; - if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; } - sseCtrl = new AbortController(); - const url = cfg.url + "/v1/index/stream"; - const b = getBearer(); - const start = async () => { - const hdrs = {}; - if (b) Object.assign(hdrs, await popHeaders("GET", "/v1/index/stream", new Uint8Array()), { Authorization: "Bearer "+b }); - fetch(url, { headers: hdrs, signal: sseCtrl.signal }).then(async resp => { - if (!resp.ok) return; - const reader = resp.body.getReader(); const decoder = new TextDecoder(); - let buf = ""; - while (true) { - const { value, done } = await reader.read(); if (done) break; - buf += decoder.decode(value, { stream:true }); - let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { - const chunk = buf.slice(0, idx); buf = buf.slice(idx+2); - if (chunk.startsWith("data: ")) { - try { - const ev = JSON.parse(chunk.slice(6)); - if (ev.event === "put") { - const e = ev.data; - const posts = getPosts(); - if (!posts.find(p => p.hash === e.hash)) { - posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, tz:e.creator_tz||"" }); - setPosts(posts); - } - } else if (ev.event === "delete") { - const h = ev.data.hash; setPosts(getPosts().filter(p => p.hash !== h)); - } - } catch {} - } - } +// ---------- avatar (canvas PNG, onerror-safe) ---------- + +function b64uEncode(buf){ const bin = Array.from(new Uint8Array(buf)).map(b=>String.fromCharCode(b)).join(""); return btoa(bin).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""); } +function b64uDecodeToBytes(s){ s=s.replace(/-/g,"+").replace(/_/g,"/"); while(s.length%4) s+="="; const bin=atob(s); const out=new Uint8Array(bin.length); for(let i=0;ib.toString(16).padStart(2,"0")).join(""); } +function hexBytes(hex){ const u=new Uint8Array(hex.length/2); for(let i=0;i>i)&1); + const c=document.createElement("canvas"); c.width=c.height=size; const g=c.getContext("2d"); + g.fillStyle=bg; g.fillRect(0,0,size,size); + let k=0; + for(let y=0;y{}); - }; - start(); + } + } + return c.toDataURL("image/png"); } -// ---------- Auth ---------- -async function keySignIn(){ - try { - if (!cfg.url) { alert("Set shard URL first."); return; } - // 1) challenge - const cResp = await fetch(cfg.url + "/v1/auth/key/challenge", { method:"POST" }); - const cTxt = await cResp.text(); - if (!cResp.ok) { alert("Challenge failed: " + cTxt); return; } - const c = JSON.parse(cTxt); - // 2) sign and verify - const dev = await getDevice(); - const priv = await crypto.subtle.importKey("pkcs8", ub64(dev.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); - const msg = enc.encode("key-verify\n" + c.nonce); - const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, msg); - const vResp = await fetch(cfg.url + "/v1/auth/key/verify", { - method:"POST", - headers: { "Content-Type":"application/json" }, - body: JSON.stringify({ nonce:c.nonce, alg:"p256", pub: dev.pub, sig: b64(sig) }) - }); - const vTxt = await vResp.text(); - if (!vResp.ok) { alert("Verify failed: " + vTxt); return; } - const j = JSON.parse(vTxt); - setBearer(j.bearer); - alert("Signed in ✔ (session)"); - await syncIndex(); - } catch (e) { - alert("Key sign-in exception: " + (e?.message || e)); - } +async function renderAvatar(){ + if (!els.avatar) return; + let seed=null, label="(pseudonymous)"; + if (cfg.bearer){ const p=parseGC2(cfg.bearer); seed=p.cnf||p.sub||null; if(p.sub) label=p.sub; } + if (!seed){ els.avatar.removeAttribute("src"); if (els.fp) els.fp.textContent="(pseudonymous)"; return; } + const hex=await sha256Hex(seed); + const url=identiconPNG(hex, 64); + els.avatar.onerror = ()=>{ els.avatar.removeAttribute("src"); if (els.fp) els.fp.textContent="(pseudonymous)"; }; + els.avatar.src=url; + if (els.fp) els.fp.textContent=label+" (pseudonymous)"; +} + +// ---------- UI handlers ---------- + +async function onSaveConn(){ + const c = { + url: norm(els.shardUrl.value || defaultApiBase()), + bearer: els.bearer.value.trim(), + passphrase: els.passphrase.value, + }; + saveConfig(c); + flash("Saved"); + await checkHealth(); + await syncIndex(); + sse(true); + await renderAvatar(); } async function panicWipe(){ - try { - if (cfg.url) await fetch(cfg.url + "/v1/session/clear", { method:"POST" }); - } catch {} - sessionStorage.clear(); - localStorage.clear(); - caches && caches.keys().then(keys => keys.forEach(k => caches.delete(k))); - location.replace("about:blank"); + flash("Wiping local state…"); + try { if (cfg.url) await fetch(cfg.url + "/v1/session/clear", { method:"POST" }); } catch {} + localStorage.clear(); sessionStorage.clear(); caches?.keys?.().then(keys => keys.forEach(k => caches.delete(k))); + flash("Cleared — reloading"); setTimeout(()=>location.reload(), 300); } -// ---------- Publishing / Viewing ---------- -function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "inherit"; } - -async function publish() { - if (!cfg.url) return msg("Set shard URL first.", true); - const b = getBearer(); if (!b) return msg("Sign in first (device key).", true); - - const title = els.title.value.trim(); - const body = els.body.value; - const vis = els.visibility.value; - try { - let blob, encp=false; - if (vis === "private") { - if (!cfg.passphrase) return msg("Set a passphrase for private posts.", true); - const payload = await encryptString(JSON.stringify({ title, body }), cfg.passphrase); - blob = toBlob(payload); encp=true; - } else { - blob = toBlob(JSON.stringify({ title, body })); - } - const buf = new Uint8Array(await blob.arrayBuffer()); - const path = "/v1/object"; - const headers = { "Content-Type":"application/octet-stream", Authorization: "Bearer "+b }; - if (encp) headers["X-GC-Private"] = "1"; - const pop = await popHeaders("PUT", path, buf); - Object.assign(headers, pop); - const r = await fetch(cfg.url + path, { method:"PUT", headers, body: buf }); - if (!r.ok) throw new Error(await r.text()); - const j = await r.json(); - const posts = getPosts(); - posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc:j.private, tz:j.creator_tz||"" }); - setPosts(posts); - els.body.value = ""; msg(`Published ${encp?"private":"public"} post. Hash: ${j.hash}`); - } catch(e){ msg("Publish failed: " + (e?.message||e), true); } -} - -async function viewPost(p, pre) { - pre.textContent = "Loading…"; - try { - const path = "/v1/object/" + p.hash; - const headers = {}; - const b = getBearer(); - if (b) Object.assign(headers, await popHeaders("GET", path, new Uint8Array()), { Authorization: "Bearer "+b }); - const r = await fetch(cfg.url + path, { headers }); - if (!r.ok) throw new Error("fetch failed " + r.status); - const buf = new Uint8Array(await r.arrayBuffer()); - let text; - if (p.enc) { - if (!cfg.passphrase) throw new Error("passphrase required"); - text = await decryptToString(buf, cfg.passphrase); - } else { text = new TextDecoder().decode(buf); } - try { - const j = JSON.parse(text); - pre.textContent = (j.title ? `# ${j.title}\n\n` : "") + (j.body ?? text); - } catch { pre.textContent = text; } - } catch (e) { pre.textContent = "Error: " + (e?.message || e); } -} - -async function saveBlob(p) { - const path = "/v1/object/" + p.hash; - const headers = {}; - const b = getBearer(); - if (b) Object.assign(headers, await popHeaders("GET", path, new Uint8Array()), { Authorization: "Bearer "+b }); - const r = await fetch(cfg.url + path, { headers }); - if (!r.ok) return alert("download failed " + r.status); - const bl = await r.blob(); - const a = document.createElement("a"); a.href = URL.createObjectURL(bl); - a.download = p.hash + (p.enc ? ".gcenc" : ".json"); a.click(); URL.revokeObjectURL(a.href); -} - -async function delServer(p) { - const path = "/v1/object/" + p.hash; - const b = getBearer(); if (!b) return alert("Sign in first."); - const headers = { Authorization: "Bearer "+b }; - Object.assign(headers, await popHeaders("DELETE", path, new Uint8Array())); - if (!confirm("Delete blob from server by hash?")) return; - const r = await fetch(cfg.url + path, { method:"DELETE", headers }); - if (!r.ok) return alert("delete failed " + r.status); - setPosts(getPosts().filter(x=>x.hash!==p.hash)); -} - -// ---------- Discord SSO ---------- -async function discordStart() { - if (!cfg.url) { alert("Set shard URL first."); return; } +async function discordStart(){ + if (!cfg.url){ alert("Set shard URL first."); return; } + flash("Starting Discord…"); const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent":"1" }}); - if (!r.ok) { alert("Discord SSO not available"); return; } + if (!r.ok){ alert("Discord SSO not available"); return; } const j = await r.json(); location.href = j.url; } -// ---------- Render ---------- -function renderPosts() { - const posts = getPosts(); els.posts.innerHTML = ""; - for (const p of posts) { - const div = document.createElement("div"); div.className = "post"; - const badge = p.enc ? `private` : `public`; +// ---------- device-key sign-in & PoP ---------- + +async function getOrCreateKeyPair(){ + const pkcs8 = sessionStorage.getItem(KEY_PKCS8); + const pubRaw = sessionStorage.getItem(KEY_PUB_RAW); + if (pkcs8 && pubRaw){ + try{ + const priv = await crypto.subtle.importKey("pkcs8", b64uDecodeToBytes(pkcs8), {name:"ECDSA", namedCurve:"P-256"}, true, ["sign"]); + const pub = await crypto.subtle.importKey("raw", b64uDecodeToBytes(pubRaw), {name:"ECDSA", namedCurve:"P-256"}, true, ["verify"]); + return { priv, pub, pubRawB64u: pubRaw }; + }catch{} + } + const kp = await crypto.subtle.generateKey({name:"ECDSA", namedCurve:"P-256"}, true, ["sign","verify"]); + const pkcs8New = await crypto.subtle.exportKey("pkcs8", kp.privateKey); + const pubRawBytes = await crypto.subtle.exportKey("raw", kp.publicKey); + const pkcs8B64 = b64uEncode(pkcs8New); + const pubRawB64 = b64uEncode(pubRawBytes); + sessionStorage.setItem(KEY_PKCS8, pkcs8B64); + sessionStorage.setItem(KEY_PUB_RAW, pubRawB64); + return { priv: kp.privateKey, pub: kp.publicKey, pubRawB64u: pubRawB64 }; +} + +async function deviceKeySignIn(){ + if (!cfg.url){ alert("Set shard URL first."); return; } + flash("Signing in…"); + try{ + const { priv, pubRawB64u } = await getOrCreateKeyPair(); + const rc = await fetch(cfg.url + "/v1/auth/key/challenge", { method:"POST" }); + if (!rc.ok) throw new Error("challenge "+rc.status); + const cj = await rc.json(); + const msg = new TextEncoder().encode("key-verify\n"+cj.nonce); + const sig = await crypto.subtle.sign({name:"ECDSA", hash:"SHA-256"}, priv, msg); // DER + const body = JSON.stringify({ nonce:cj.nonce, alg:"p256", pub:pubRawB64u, sig:b64uEncode(sig) }); + const rv = await fetch(cfg.url + "/v1/auth/key/verify", { method:"POST", headers:{"Content-Type":"application/json"}, body }); + if (!rv.ok) throw new Error("verify "+rv.status); + const vj = await rv.json(); + saveConfig({ bearer: vj.bearer, url: cfg.url, passphrase: cfg.passphrase }); + applyConfig(); await renderAvatar(); await checkHealth(); await syncIndex(); sse(true); + flash("Signed in"); + }catch(e){ + console.error(e); + alert("Sign-in error: "+(e?.message||e)); + } +} + +async function signPoPHeaders(method, pathOnly, bodyBytes){ + const pubRaw = sessionStorage.getItem(KEY_PUB_RAW); + const pkcs8 = sessionStorage.getItem(KEY_PKCS8); + if (!pubRaw || !pkcs8) return {}; + const priv = await crypto.subtle.importKey("pkcs8", b64uDecodeToBytes(pkcs8), {name:"ECDSA", namedCurve:"P-256"}, false, ["sign"]); + const bodyHash = await crypto.subtle.digest("SHA-256", bodyBytes || new Uint8Array()); + const hex = Array.from(new Uint8Array(bodyHash)).map(b=>b.toString(16).padStart(2,"0")).join(""); + const ts = Math.floor(Date.now()/1000).toString(); + const msg = new TextEncoder().encode(method.toUpperCase()+"\n"+pathOnly+"\n"+ts+"\n"+hex); + const sig = await crypto.subtle.sign({name:"ECDSA", hash:"SHA-256"}, priv, msg); + return { "X-GC-Key":"p256:"+pubRaw, "X-GC-TS":ts, "X-GC-Proof":b64uEncode(sig) }; +} + +async function fetchWithPoP(url, opts){ + const u = new URL(url); const path = u.pathname; const method = (opts?.method||"GET").toUpperCase(); + const bodyBuf = opts?.body instanceof Blob ? new Uint8Array(await opts.body.arrayBuffer()) + : (opts?.body instanceof ArrayBuffer ? new Uint8Array(opts.body) : new Uint8Array()); + const pop = await signPoPHeaders(method, path, bodyBuf); + const headers = new Headers(opts?.headers||{}); + if (cfg.bearer) headers.set("Authorization", "Bearer "+cfg.bearer); + for (const [k,v] of Object.entries(pop)) headers.set(k,v); + return fetch(url, { ...(opts||{}), headers }); +} + +// ---------- posts ---------- + +function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "#8b949e"; } + +async function publish(){ + if (!cfg.url) return msg("Set shard URL first.", true); + const title = els.title.value.trim(); const body = els.body.value; const vis = els.visibility.value; + try{ + let blob, enc=false; + if (vis==="private"){ + if (!els.passphrase.value) return msg("Set a passphrase for private posts.", true); + const payload = await encryptString(JSON.stringify({title,body}), els.passphrase.value); + blob = toBlob(payload); enc=true; + } else { + blob = toBlob(JSON.stringify({title,body})); + } + const headers = {"Content-Type":"application/octet-stream"}; + if (enc) headers["X-GC-Private"]="1"; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (tz) headers["X-GC-TZ"]=tz; + + const r = await fetchWithPoP((cfg.url||defaultApiBase()) + "/v1/object", { method:"PUT", headers, body: blob }); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + const posts = getPosts(); + posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc, author:j.author||null, tz:j.creator_tz||null }); + setPosts(posts); + els.body.value=""; msg(`Published ${enc?"private":"public"} post. Hash: ${j.hash}`); + }catch(e){ msg("Publish failed: "+(e?.message||e), true); } +} + +async function syncIndex(){ + if (!cfg.url) return; + try{ + const r = await fetch(cfg.url + "/v1/index"); + if (!r.ok) throw new Error("index fetch failed"); + const entries = await r.json(); + setPosts(entries.map(e=>({hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, author:e.author||null, tz:e.creator_tz||null}))); + }catch(e){ console.warn("index sync failed", e); } +} + +let sseCtrl; +function sse(reset=false){ + if (!cfg.url) return; + if (sseCtrl){ sseCtrl.abort(); sseCtrl=undefined; if(!reset) return; } + sseCtrl = new AbortController(); + fetch(cfg.url + "/v1/index/stream", { signal:sseCtrl.signal }).then(async resp=>{ + if (!resp.ok) return; + const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf=""; + while(true){ const {value,done}=await reader.read(); if(done) break; + buf += dec.decode(value,{stream:true}); + let i; while((i=buf.indexOf("\n\n"))>=0){ + const chunk=buf.slice(0,i); buf=buf.slice(i+2); + if (chunk.startsWith("data: ")){ + try{ + const ev = JSON.parse(chunk.slice(6)); + if (ev.event==="put"){ + const e=ev.data; const posts=getPosts(); + if (!posts.find(p=>p.hash===e.hash)){ + posts.unshift({hash:e.hash,title:"(title unknown — fetch)",bytes:e.bytes,ts:e.stored_at,enc:e.private,author:e.author||null,tz:e.creator_tz||null}); + setPosts(posts); + } + } else if (ev.event==="delete"){ + const h=ev.data.hash; setPosts(getPosts().filter(x=>x.hash!==h)); + } + }catch{} + } + } + } + }).catch(()=>{}); +} + +async function viewPost(p, pre){ + pre.textContent="Loading…"; + try{ + const r = await fetch((cfg.url||defaultApiBase()) + "/v1/object/"+p.hash); + if (!r.ok) throw new Error("fetch failed "+r.status); + const buf = new Uint8Array(await r.arrayBuffer()); + let text; + if (p.enc){ + if (!els.passphrase.value) throw new Error("passphrase required"); + text = await decryptToString(buf, els.passphrase.value); + } else { text = new TextDecoder().decode(buf); } + try{ const j=JSON.parse(text); pre.textContent=(j.title?`# ${j.title}\n\n`:"")+(j.body??text); } + catch{ pre.textContent=text; } + }catch(e){ pre.textContent="Error: "+(e?.message||e); } +} + +async function saveBlob(p){ + const r = await fetch((cfg.url||defaultApiBase()) + "/v1/object/"+p.hash); + if (!r.ok) return alert("download failed "+r.status); + const b = await r.blob(); const a=document.createElement("a"); a.href=URL.createObjectURL(b); + a.download=p.hash+(p.enc?".gcenc":".json"); a.click(); URL.revokeObjectURL(a.href); +} + +async function delServer(p){ + if (!confirm("Delete blob from server by hash?")) return; + const r = await fetchWithPoP((cfg.url||defaultApiBase()) + "/v1/object/"+p.hash, { method:"DELETE" }); + if (!r.ok) return alert("delete failed "+r.status); + setPosts(getPosts().filter(x=>x.hash!==p.hash)); +} + +function renderPosts(){ + const posts = getPosts(); if (!els.posts) return; els.posts.innerHTML=""; + for (const p of posts){ + const div = document.createElement("div"); div.className="post"; + const badge = p.enc?`private`:`public`; + const tz = p.tz?` · tz:${p.tz}`:""; const who = p.author?` · by ${p.author.slice(0,8)}…`:""; div.innerHTML = ` -
${p.hash.slice(0,10)}… · ${p.bytes} bytes · ${p.ts} ${badge}
+
${p.hash.slice(0,10)}… · ${p.bytes} bytes · ${p.ts}${tz}${who} ${badge}
- - - - + + + +
-
`;
+      
`;
     const pre = div.querySelector(".content");
-    div.querySelector('[data-act="view"]').onclick = () => viewPost(p, pre);
-    div.querySelector('[data-act="save"]').onclick = () => saveBlob(p);
-    div.querySelector('[data-act="delete"]').onclick = () => delServer(p);
-    div.querySelector('[data-act="remove"]').onclick = () => { setPosts(getPosts().filter(x=>x.hash!==p.hash)); };
+    div.querySelector('[data-act="view"]').onclick = ()=>viewPost(p, pre);
+    div.querySelector('[data-act="save"]').onclick = ()=>saveBlob(p);
+    div.querySelector('[data-act="delete"]').onclick = ()=>delServer(p);
+    div.querySelector('[data-act="remove"]').onclick = ()=>{ setPosts(getPosts().filter(x=>x.hash!==p.hash)); };
     els.posts.appendChild(div);
   }
 }
diff --git a/client/avatar.js b/client/avatar.js
new file mode 100644
index 0000000..e93cb9c
--- /dev/null
+++ b/client/avatar.js
@@ -0,0 +1,44 @@
+// Deterministic, local-only avatars. No network calls.
+export function avatarDataURL(seed, size = 40) {
+  // Hash seed → bytes
+  const h = sha256(seed);
+  // Colors from bytes
+  const hue = h[0] % 360;
+  const bg = `hsl(${(h[1]*3)%360} 25% 14%)`;
+  const fg = `hsl(${hue} 70% 60%)`;
+
+  // 5x5 grid mirrored; draw squares where bits set
+  const cells = 5, scale = Math.floor(size / cells);
+  let rects = "";
+  for (let y = 0; y < cells; y++) {
+    for (let x = 0; x < Math.ceil(cells/2); x++) {
+      const bit = (h[(y*3 + x) % h.length] >> (y % 5)) & 1;
+      if (bit) {
+        const xL = x*scale, xR = (cells-1-x)*scale, yP = y*scale;
+        rects += ``;
+        if (x !== cells-1-x) {
+          rects += ``;
+        }
+      }
+    }
+  }
+  const svg = `
+    ${rects}
+  `;
+  return "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
+}
+
+function sha256(s) {
+  // Simple synchronous hash-ish bytes from string (non-cryptographic; fine for visuals)
+  let h1 = 0x6a09e667, h2 = 0xbb67ae85;
+  for (let i=0;i>>25));
+    h2 = (h2 ^ (c<<1)) * 0x27d4eb2d + ((h2<<9) | (h2>>>23));
+  }
+  const out = new Uint8Array(32);
+  for (let i=0;i<32;i++){
+    out[i] = (h1 >> (i%24)) ^ (h2 >> ((i*3)%24)) ^ (i*31);
+  }
+  return out;
+}
diff --git a/client/index.html b/client/index.html
index 27ece13..1ac42a0 100644
--- a/client/index.html
+++ b/client/index.html
@@ -5,42 +5,54 @@
   GreenCoast — Client
   
   
-  
-  
+  
+  
+  
 
 
-  
-

GreenCoast (Client)

+
+
GreenCoast
+
+ + + +
+
+
-

Connect

+

Connection

- +
- - + +
- - + +
-
- -
- -
- We use external providers only if you choose to. We cannot vouch for their security. -
+ +
+

+ We do not store PII or logs. Third-party SSO is optional and not endorsed for security. +

+
+ +
+

Your profile

+
+ avatar +
+
(pseudonymous)
+
Avatar is derived locally from your device key.
-
- - - -
-
@@ -48,8 +60,8 @@
@@ -60,9 +72,7 @@
-
- -
+
@@ -72,6 +82,8 @@
+
+ diff --git a/client/styles.css b/client/styles.css index c4a7bf9..fe5c2d6 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1,15 +1,19 @@ -:root { color-scheme: light dark; } -body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Noto Sans", sans-serif; margin: 0; padding: 2rem; } -.container { max-width: 860px; margin: 0 auto; } -h1 { margin: 0 0 1rem 0; } -.card { border: 1px solid #30363d; border-radius: 16px; padding: 1rem; margin: 1rem 0; box-shadow: 0 2px 6px rgba(0,0,0,.1); } -.row { display: grid; grid-template-columns: 160px 1fr; gap: .8rem; align-items: center; margin: .6rem 0; } -label { opacity: .8; } -input, textarea, select, button { font: inherit; padding: .6rem .7rem; border-radius: 10px; border: 1px solid #30363d; background: transparent; color: inherit; } -button { cursor: pointer; } -button.danger { border-color: #a4002a; color: #a4002a; } -.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: .4rem; } -.muted { opacity: .7; font-size: .9rem; } -.badge { display: inline-block; padding: .1rem .4rem; border-radius: 8px; border: 1px solid #30363d; font-size: .75rem; margin-left: .4rem; } -.post { border-top: 1px dashed #30363d; padding: .6rem 0; } -pre.content { white-space: pre-wrap; margin-top: .5rem; } +:root{--bg:#0f172a;--surface:#111827;--muted:#8b949e;--text:#e5e7eb;--accent:#22c55e;--card:#0b1222;--border:#1f2937} +*{box-sizing:border-box} +html,body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Ubuntu,"Helvetica Neue","Noto Sans",Arial,"Apple Color Emoji","Segoe UI Emoji"} +.topbar{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--border);background:#0b1222;position:sticky;top:0;z-index:10} +.topbar .brand{font-weight:700} +.topbar .actions button{margin-left:.5rem} +.container{max-width:980px;margin:1rem auto;padding:0 1rem} +.card{background:var(--card);border:1px solid var(--border);border-radius:.75rem;padding:1rem;margin-bottom:1rem} +.row{display:flex;gap:.75rem;align-items:center;margin:.5rem 0} +.row label{min-width:140px;color:#cbd5e1} +.row input, .row select, textarea{flex:1;background:#0f172a;border:1px solid var(--border);border-radius:.5rem;padding:.6rem .7rem;color:var(--text)} +button{background:#134e4a;border:1px solid #0f766e;color:white;border-radius:.6rem;padding:.5rem .75rem;cursor:pointer} +button:hover{filter:brightness(1.05)} +.muted{color:var(--muted)} +.profile{display:flex;align-items:center;gap:1rem} +#avatar{border-radius:50%;border:1px solid var(--border);background:#0f172a;image-rendering:pixelated} +.post{border:1px dashed var(--border);border-radius:.5rem;padding:.6rem .7rem;margin-bottom:.6rem} +.post .meta{color:var(--muted);font-size:.9rem;margin-bottom:.25rem} +.badge{background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:.05rem .5rem;font-size:.75rem;margin-left:.5rem} diff --git a/internal/api/http.go b/internal/api/http.go index d1fc87c..38ede31 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -14,6 +14,7 @@ import ( "io" "log" "math/big" + "net" "net/http" "os" "sort" @@ -115,8 +116,8 @@ func (s *Server) routes() *http.ServeMux { mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify) // Objects - mux.HandleFunc("/v1/object", s.handlePutObject) // PUT (requires bearer+PoP) - mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP) + mux.HandleFunc("/v1/object", s.handlePutObject) // PUT + mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE // Index mux.HandleFunc("/v1/index", s.handleIndexList) @@ -128,7 +129,7 @@ func (s *Server) routes() *http.ServeMux { // GDPR mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy) - // Session panic wipe (no auth; clears browser caches) + // Panic wipe signal (client clears itself) mux.HandleFunc("/v1/session/clear", s.handleSessionClear) return mux @@ -149,7 +150,7 @@ func (s *Server) cors(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") - // Preflight is free (don’t rate-limit OPTIONS) + // Preflight is free if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -175,15 +176,6 @@ func (s *Server) cors(next http.Handler) http.Handler { }) } -func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Clear-Site-Data", `"cache","storage"`) - w.WriteHeader(http.StatusNoContent) -} - // ---------- Helpers ---------- func b64u(b []byte) string { @@ -197,7 +189,7 @@ func b64ud(s string) ([]byte, error) { } func nowUTC() time.Time { return time.Now().UTC() } -// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2" +// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2" (subs are thumbprints) func (s *Server) isAdmin(ac authContext) bool { raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS")) if raw == "" || ac.CNF == "" { @@ -212,6 +204,19 @@ func (s *Server) isAdmin(ac authContext) bool { return false } +func clientIP(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + // ---------- Health ---------- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -230,7 +235,7 @@ func (s *Server) handleAuthKeyChallenge(w http.ResponseWriter, r *http.Request) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - // Nonce (32B hex) valid 5 minutes + // Nonce valid 5 minutes sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d", time.Now().UnixNano(), time.Now().Unix()))) nonce := hex.EncodeToString(sum[:]) exp := nowUTC().Add(5 * time.Minute) @@ -371,7 +376,7 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { seqLen = int(der[i]) i++ } - _ = seqLen // not strictly needed + _ = seqLen // INTEGER R if i >= len(der) || der[i] != 0x02 { @@ -536,7 +541,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex http.Error(w, "ts window", http.StatusUnauthorized) return false } - // Replay cache + // Replay cache cleanup + check s.rpMu.Lock() for k, v := range s.replay { if nowUTC().After(v) { @@ -635,6 +640,12 @@ func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { Private: isPrivate, CreatorTZ: creatorTZ, } + // Optional author fingerprint for avatars (pseudonymous). + // Disable by setting GC_SUPPRESS_AUTHOR=1 + if os.Getenv("GC_SUPPRESS_AUTHOR") != "1" && ac.CNF != "" { + ent.Author = thumbprintFromCNF(ac.CNF) + } + if err := s.idx.Put(ent); err != nil { http.Error(w, "index error", http.StatusInternalServerError) return @@ -667,6 +678,7 @@ func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { defer rc.Close() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Content-Disposition", "attachment; filename="+hash) _, _ = io.Copy(w, rc) case http.MethodDelete: @@ -777,22 +789,21 @@ func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) { } var walked, indexed int64 err := s.store.Walk(func(hash string, size int64, mod time.Time) error { + walked++ ent := index.Entry{ Hash: hash, Bytes: size, StoredAt: mod.UTC().Format(time.RFC3339Nano), Private: false, } - if err := s.idx.Put(ent); err == nil { - indexed++ - } - walked++ - return nil + return s.idx.Put(ent) }) if err != nil { http.Error(w, "walk error", http.StatusInternalServerError) return } + ents := s.idx.All() + indexed = int64(len(ents)) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "walked": walked, @@ -834,3 +845,14 @@ func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(p) } + +// ---------- Session clear ---------- + +func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Clear-Site-Data", `"cache","storage"`) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 3aec4ba..04d101a 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -1,31 +1,33 @@ package api import ( - "net" - "net/http" "sync" "time" ) +// Simple token-bucket rate limiter used by Server.cors middleware. + +type tokenBucket struct { + tokens float64 + lastFill time.Time +} + type rateLimiter struct { - mu sync.Mutex - bk map[string]*bucket - rate float64 // tokens per second - burst float64 - window time.Duration + rate float64 // tokens per second + burst float64 + mu sync.Mutex + bk map[string]*tokenBucket + evictDur time.Duration + lastGC time.Time } -type bucket struct { - tokens float64 - last time.Time -} - -func newRateLimiter(rps float64, burst int, window time.Duration) *rateLimiter { +func newRateLimiter(rate float64, burst int, evict time.Duration) *rateLimiter { return &rateLimiter{ - bk: make(map[string]*bucket), - rate: rps, - burst: float64(burst), - window: window, + rate: rate, + burst: float64(burst), + bk: make(map[string]*tokenBucket), + evictDur: evict, + lastGC: time.Now(), } } @@ -34,45 +36,37 @@ func (rl *rateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() - b := rl.bk[key] - if b == nil { - b = &bucket{tokens: rl.burst, last: now} + // GC old buckets occasionally + if now.Sub(rl.lastGC) > rl.evictDur { + for k, b := range rl.bk { + if now.Sub(b.lastFill) > rl.evictDur { + delete(rl.bk, k) + } + } + rl.lastGC = now + } + + b, ok := rl.bk[key] + if !ok { + b = &tokenBucket{tokens: rl.burst, lastFill: now} rl.bk[key] = b } - // refill - elapsed := now.Sub(b.last).Seconds() - b.tokens = min(rl.burst, b.tokens+elapsed*rl.rate) - b.last = now - if b.tokens < 1.0 { - return false - } - b.tokens -= 1.0 + // Refill + elapsed := now.Sub(b.lastFill).Seconds() + b.tokens = minf(rl.burst, b.tokens+elapsed*rl.rate) + b.lastFill = now - // occasional cleanup - for k, v := range rl.bk { - if now.Sub(v.last) > rl.window { - delete(rl.bk, k) - } + if b.tokens >= 1.0 { + b.tokens -= 1.0 + return true } - return true + return false } -func min(a, b float64) float64 { +func minf(a, b float64) float64 { if a < b { return a } return b } - -func clientIP(r *http.Request) string { - // Prefer Cloudflare’s header if present; fall back to RemoteAddr. - if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { - return ip - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} diff --git a/internal/index/index.go b/internal/index/index.go index a01fbd1..6708747 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -1,62 +1,48 @@ package index import ( - "errors" "sync" ) -// Entry is the minimal metadata we expose to clients. +// Entry is the index record returned to clients. +// Keep metadata minimal to protect users. type Entry struct { Hash string `json:"hash"` Bytes int64 `json:"bytes"` - StoredAt string `json:"stored_at"` // RFC3339Nano - Private bool `json:"private"` // true if client marked encrypted - CreatorTZ string `json:"creator_tz,omitempty"` // optional IANA TZ from client + StoredAt string `json:"stored_at"` // RFC3339Nano string + Private bool `json:"private"` + CreatorTZ string `json:"creator_tz,omitempty"` + Author string `json:"author,omitempty"` // pseudonymous (thumbprint), optional } -// Index is an in-memory map from hash -> Entry, safe for concurrent use. type Index struct { - mu sync.RWMutex - m map[string]Entry + mu sync.RWMutex + data map[string]Entry } func New() *Index { - return &Index{m: make(map[string]Entry)} + return &Index{data: make(map[string]Entry)} } func (ix *Index) Put(e Entry) error { - if e.Hash == "" { - return errors.New("empty hash") - } ix.mu.Lock() - ix.m[e.Hash] = e + ix.data[e.Hash] = e ix.mu.Unlock() return nil } func (ix *Index) Delete(hash string) error { - if hash == "" { - return errors.New("empty hash") - } ix.mu.Lock() - delete(ix.m, hash) + delete(ix.data, hash) ix.mu.Unlock() return nil } -func (ix *Index) Get(hash string) (Entry, bool) { - ix.mu.RLock() - e, ok := ix.m[hash] - ix.mu.RUnlock() - return e, ok -} - -// All returns an unsorted copy of all entries. func (ix *Index) All() []Entry { ix.mu.RLock() - out := make([]Entry, 0, len(ix.m)) - for _, v := range ix.m { - out = append(out, v) + out := make([]Entry, 0, len(ix.data)) + for _, e := range ix.data { + out = append(out, e) } ix.mu.RUnlock() return out