diff --git a/client/app.js b/client/app.js index a1f3a6a..d65039f 100644 --- a/client/app.js +++ b/client/app.js @@ -13,119 +13,80 @@ const els = { publishStatus: document.getElementById("publishStatus"), posts: document.getElementById("posts"), discordStart: document.getElementById("discordStart"), + signinDevice: document.getElementById("signinDevice"), }; const LS_KEY = "gc_client_config_v1"; const POSTS_KEY = "gc_posts_index_v1"; -const DEVKEY_KEY = "gc_device_key_v1"; // stores p256 private/public (pkcs8/spki b64) +const DEVKEY_KEY = "gc_device_key_v1"; // pkcs8/spki (p256) base64url 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 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 proto = u.protocol; const host = u.hostname; const portStr = u.port; const bracketHost = host.includes(":") ? `[${host}]` : host; - const port = portStr ? parseInt(portStr, 10) : null; + 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); + 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(/\/+$/, ""); - } + } catch { return window.location.origin.replace(/\/+$/,""); } } -const cfg = loadConfig(); applyConfig(); (async () => { - await ensureDeviceKey(); - await checkHealth(); await syncIndex(); sse(); -})(); - -els.saveConn.onclick = async () => { - const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value }; - saveConfig(c); - await checkHealth(); await syncIndex(); sse(true); -}; - -els.publish.onclick = publish; -els.discordStart.onclick = discordStart; - -// -------- local state helpers -------- - function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(c)); 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 applyConfig(){ els.shardUrl.value = cfg.url ?? defaultApiBase(); els.bearer.value = cfg.bearer ?? ""; els.passphrase.value = cfg.passphrase ?? ""; } - function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "#8b949e"; } +function getBearer(){ return sessionStorage.getItem("gc_bearer") || cfg.bearer || ""; } -// Prefer session bearer -function getBearer() { return sessionStorage.getItem("gc_bearer") || cfg.bearer || ""; } - -// -------- device key (P-256) + PoP -------- +const cfg = loadConfig(); applyConfig(); +// ---- Device key management (P-256) ---- async function ensureDeviceKey() { - try { - const stored = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "null"); - if (stored && stored.priv && stored.pub) return; - } catch {} - const kp = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const stored = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "null"); + if (stored && stored.priv && stored.pub) return; + 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); // 65-byte uncompressed - const b64pk = b64(rawPub); - const b64sk = b64(pkcs8); - localStorage.setItem(DEVKEY_KEY, JSON.stringify({ priv: b64sk, pub: b64pk, alg: "p256" })); + const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey); // 65B uncompressed + localStorage.setItem(DEVKEY_KEY, JSON.stringify({ alg:"p256", priv: b64(rawPub ? pkcs8 : pkcs8), pub: b64(rawPub) })); } - -async function getDevicePriv() { +async function getDevicePriv(){ const s = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "{}"); if (s.alg !== "p256") throw new Error("unsupported alg"); - const pkcs8 = ub64(s.priv); - return crypto.subtle.importKey("pkcs8", pkcs8, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); + return crypto.subtle.importKey("pkcs8", ub64(s.priv), { name:"ECDSA", namedCurve:"P-256" }, false, ["sign"]); } - -function getDevicePubHdr() { +function getDevicePubHdr(){ const s = JSON.parse(localStorage.getItem(DEVKEY_KEY) || "{}"); - if (!s.pub) return ""; - return s.alg === "p256" ? ("p256:" + s.pub) : ""; + return s && s.pub ? "p256:" + s.pub : ""; } -async function popHeaders(method, url, body) { +// ---- DPoP-style proof headers (sign path, not absolute URL) ---- +async function popHeaders(method, pathOnly, bodyBytes){ const ts = Math.floor(Date.now()/1000).toString(); const pub = getDevicePubHdr(); - const digest = await sha256Hex(body || new Uint8Array()); - const msg = (method.toUpperCase()+"\n"+url+"\n"+ts+"\n"+digest); + const digest = await sha256Hex(bodyBytes || new Uint8Array()); + const msg = (method.toUpperCase()+"\n"+pathOnly+"\n"+ts+"\n"+digest); const priv = await getDevicePriv(); - const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, priv, new TextEncoder().encode(msg)); + const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, new TextEncoder().encode(msg)); return { "X-GC-Key": pub, "X-GC-TS": ts, "X-GC-Proof": b64(new Uint8Array(sig)) }; } -async function fetchAPI(path, opts = {}, bodyBytes) { +async function fetchAPI(path, opts = {}, bodyBytes){ if (!cfg.url) throw new Error("Set shard URL first."); - const url = cfg.url + path; const method = (opts.method || "GET").toUpperCase(); const headers = Object.assign({}, opts.headers || {}); - const bearer = getBearer(); - if (bearer) headers["Authorization"] = "Bearer " + bearer; - const pop = await popHeaders(method, url, bodyBytes); + const bearer = getBearer(); if (bearer) headers["Authorization"] = "Bearer " + bearer; + const pop = await popHeaders(method, path, bodyBytes); Object.assign(headers, pop); - const init = Object.assign({}, opts, { method, headers, body: opts.body }); - const r = await fetch(url, init); + const r = await fetch(cfg.url + path, Object.assign({}, opts, { method, headers })); return r; } -// -------- health, index, sse -------- - +// ---- Health / Index / SSE ---- async function checkHealth() { if (!cfg.url) return; els.health.textContent = "Checking…"; try { @@ -145,17 +106,15 @@ async function syncIndex() { } let sseCtrl; -function sse(restart){ +async function sse(){ if (!cfg.url) return; if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; } sseCtrl = new AbortController(); - const url = cfg.url + "/v1/index/stream"; + const path = "/v1/index/stream"; const headers = {}; const b = getBearer(); if (b) headers["Authorization"] = "Bearer " + b; - headers["X-GC-Key"] = getDevicePubHdr(); - headers["X-GC-TS"] = Math.floor(Date.now()/1000).toString(); - headers["X-GC-Proof"] = "dummy"; // server ignores body hash for GET; proof not required for initial request in this demo SSE; if required, switch to EventSource polyfill - fetch(url, { headers, signal: sseCtrl.signal }).then(async resp => { + Object.assign(headers, await popHeaders("GET", path, new Uint8Array())); + fetch(cfg.url + path, { headers, signal: sseCtrl.signal }).then(async resp => { if (!resp.ok) return; const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ""; @@ -185,32 +144,32 @@ function sse(restart){ }).catch(()=>{}); } -// -------- actions -------- - +// ---- Actions ---- 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 (!cfg.passphrase) return msg("Set a passphrase for private posts.", true); + if (!cfg.passphrase) return msg("Set a passphrase (community key) for encrypted posts.", true); const payload = await encryptString(JSON.stringify({ title, body }), cfg.passphrase); blob = toBlob(payload); enc=true; - } else { blob = toBlob(JSON.stringify({ title, body })); } + } else { + blob = toBlob(JSON.stringify({ title, body })); + } const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; const headers = { "Content-Type":"application/octet-stream", "X-GC-TZ": tz }; const bearer = getBearer(); if (bearer) headers["Authorization"] = "Bearer " + bearer; if (enc) headers["X-GC-Private"] = "1"; const bodyBytes = new Uint8Array(await blob.arrayBuffer()); - const pop = await popHeaders("PUT", cfg.url + "/v1/object", bodyBytes); - Object.assign(headers, pop); + Object.assign(headers, await popHeaders("PUT", "/v1/object", bodyBytes)); const r = await fetch(cfg.url + "/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:j.private, tz:j.creator_tz }); setPosts(posts); - els.body.value = ""; msg(`Published ${enc?"private":"public"} post. Hash: ${j.hash}`); + els.body.value = ""; msg(`Published ${enc?"encrypted":"plaintext"} post. Hash: ${j.hash}`); } catch(e){ msg("Publish failed: " + (e?.message||e), true); } } @@ -256,29 +215,57 @@ async function discordStart() { location.href = j.url; } -// Optional: Key-based login (no OAuth) async function signInWithDeviceKey(){ - if (!cfg.url) { alert("Set shard URL first."); return; } - const c = await fetch(cfg.url + "/v1/auth/key/challenge", { method:"POST" }).then(r=>r.json()); - const msg = "key-verify\n" + c.nonce; - const priv = await getDevicePriv(); - const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, new TextEncoder().encode(msg)); - const body = JSON.stringify({ nonce:c.nonce, alg:"p256", pub: getDevicePubHdr().slice("p256:".length), sig: b64(new Uint8Array(sig)) }); - const r = await fetch(cfg.url + "/v1/auth/key/verify", { method:"POST", headers:{ "Content-Type":"application/json" }, body }); - if (!r.ok) { alert("Key sign-in failed"); return; } - const j = await r.json(); - sessionStorage.setItem("gc_bearer", j.bearer); - const k = "gc_client_config_v1"; const cfg0 = JSON.parse(localStorage.getItem(k) || "{}"); cfg0.bearer = j.bearer; localStorage.setItem(k, JSON.stringify(cfg0)); - alert("Signed in"); + 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); + if (!c.nonce) { alert("Challenge bad JSON: " + cTxt); return; } + + // 2) sign "key-verify\n" + const msg = "key-verify\n" + c.nonce; + const priv = await getDevicePriv(); + const sig = await crypto.subtle.sign({ name:"ECDSA", hash:"SHA-256" }, priv, new TextEncoder().encode(msg)); + + // 3) send verify + const body = JSON.stringify({ + nonce: c.nonce, + alg: "p256", + pub: (getDevicePubHdr()||"").slice("p256:".length), + sig: b64(new Uint8Array(sig)) + }); + + const vResp = await fetch(cfg.url + "/v1/auth/key/verify", { + method:"POST", + headers:{ "Content-Type":"application/json" }, + body + }); + const vTxt = await vResp.text(); + if (!vResp.ok) { alert("Verify failed: " + vTxt); return; } + + const j = JSON.parse(vTxt); + if (!j.bearer) { alert("Verify returned no bearer: " + vTxt); return; } + + sessionStorage.setItem("gc_bearer", j.bearer); + const k = "gc_client_config_v1"; const cfg0 = JSON.parse(localStorage.getItem(k) || "{}"); cfg0.bearer = j.bearer; localStorage.setItem(k, JSON.stringify(cfg0)); + els.bearer.value = j.bearer; + alert("Signed in ✔"); + } catch (e) { + alert("Key sign-in exception: " + (e?.message || e)); + } } -// -------- render -------- +// ---- 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`; + const badge = p.enc ? `encrypted` : `plaintext`; const tsLocal = new Date(p.ts).toLocaleString(); const tz = p.tz ? ` · author TZ: ${p.tz}` : ""; div.innerHTML = ` @@ -299,26 +286,22 @@ function renderPosts() { } } -// -------- utils -------- +// ---- Boot ---- +(async () => { + await ensureDeviceKey(); + await checkHealth(); await syncIndex(); await sse(); +})(); -function b64(buf){ return base64url(buf); } -function ub64(s){ return base64urlDecode(s); } -async function sha256Hex(bytes){ - const d = await crypto.subtle.digest("SHA-256", bytes); - return Array.from(new Uint8Array(d)).map(b=>b.toString(16).padStart(2,"0")).join(""); -} +els.saveConn.onclick = async () => { + const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value }; + saveConfig(c); + await checkHealth(); await syncIndex(); await sse(); +}; +els.publish.onclick = publish; +els.discordStart.onclick = discordStart; +els.signinDevice.onclick = signInWithDeviceKey; -// minimal base64url helpers -function base64url(buf){ - let b = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf); - let str = ""; - for (let i=0; ib.toString(16).padStart(2,"0")).join(""); } diff --git a/client/index.html b/client/index.html index e34ec37..7f46295 100644 --- a/client/index.html +++ b/client/index.html @@ -4,9 +4,9 @@ GreenCoast — Client - - + +
@@ -16,25 +16,28 @@

Connect

- +
- - + +
- +
+
- +
+
- We use external providers only if you choose to. We cannot vouch for their security. + Using third-party SSO is optional; we cannot vouch for their security.
+
@@ -44,8 +47,8 @@
@@ -56,9 +59,6 @@
-
- -
diff --git a/cmd/shard/main.go b/cmd/shard/main.go index 0eb7c05..fc707ab 100644 --- a/cmd/shard/main.go +++ b/cmd/shard/main.go @@ -1,3 +1,4 @@ +// cmd/shard/main.go package main import ( @@ -26,7 +27,7 @@ func getenvBool(key string, def bool) bool { func staticHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Security posture for static client + // Security headers w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-site") @@ -35,10 +36,7 @@ func staticHeaders(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") - // Strong CSP to block XSS/token theft (enumerate your API host) - w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api-gc.fullmooncyberworks.com; frame-ancestors 'none'") - - // CORS for assets + // Basic CORS for static (GET only effectively) w.Header().Set("Access-Control-Allow-Origin", "*") if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") @@ -51,6 +49,7 @@ func staticHeaders(next http.Handler) http.Handler { } func main() { + // ---- Config ---- httpAddr := os.Getenv("GC_HTTP_ADDR") if httpAddr == "" { httpAddr = ":9080" @@ -59,52 +58,52 @@ func main() { certFile := os.Getenv("GC_TLS_CERT") keyFile := os.Getenv("GC_TLS_KEY") + staticAddr := os.Getenv("GC_STATIC_ADDR") + if staticAddr == "" { + staticAddr = ":9082" + } + staticDir := os.Getenv("GC_STATIC_DIR") + if staticDir == "" { + staticDir = "/opt/greencoast/client" + } + dataDir := os.Getenv("GC_DATA_DIR") if dataDir == "" { dataDir = "/var/lib/greencoast" } - staticDir := os.Getenv("GC_STATIC_DIR") - if staticDir == "" { - staticDir = "/opt/greencoast/client" - } - staticAddr := os.Getenv("GC_STATIC_ADDR") - if staticAddr == "" { - staticAddr = ":9082" - } - coarseTS := getenvBool("GC_COARSE_TS", false) zeroTrust := getenvBool("GC_ZERO_TRUST", true) + encRequired := getenvBool("GC_ENCRYPTION_REQUIRED", true) // operator-blind by default + requirePOP := getenvBool("GC_REQUIRE_POP", true) // for logging only; API defaults to true internally + signingSecretHex := os.Getenv("GC_SIGNING_SECRET_HEX") discID := os.Getenv("GC_DISCORD_CLIENT_ID") discSecret := os.Getenv("GC_DISCORD_CLIENT_SECRET") discRedirect := os.Getenv("GC_DISCORD_REDIRECT_URI") + // ---- Storage & Index ---- store, err := storage.NewFS(dataDir) if err != nil { log.Fatalf("storage init: %v", err) } - ix := index.New() - // Auto-reindex on boot if possible - if w, ok := any(store).(interface { - Walk(func(hash string, size int64, mod time.Time) error) error - }); ok { - if err := w.Walk(func(hash string, size int64, mod time.Time) error { - return ix.Put(index.Entry{ - Hash: hash, - Bytes: size, - StoredAt: mod.UTC().Format(time.RFC3339Nano), - Private: false, - }) - }); err != nil { - log.Printf("reindex on boot: %v", err) - } + // Reindex on boot from whatever files exist on disk + if err := store.Walk(func(hash string, size int64, mod time.Time) error { + return ix.Put(index.Entry{ + Hash: hash, + Bytes: size, + StoredAt: mod.UTC().Format(time.RFC3339Nano), + Private: false, // unknown here; safe default + }) + }); err != nil { + log.Printf("reindex on boot: %v", err) } - ap := api.AuthProviders{ + // ---- Auth providers ---- + providers := api.AuthProviders{ SigningSecretHex: signingSecretHex, Discord: api.DiscordProvider{ Enabled: discID != "" && discSecret != "" && discRedirect != "", @@ -114,35 +113,28 @@ func main() { }, } - srv := api.New(store, ix, coarseTS, zeroTrust, ap) + // ---- API server ---- + srv := api.New(store, ix, coarseTS, zeroTrust, providers, encRequired) - // Static client server (9082) + // ---- Static file server (separate listener) ---- go func() { - if st, err := os.Stat(staticDir); err != nil || !st.IsDir() { - log.Printf("WARN: GC_STATIC_DIR %q not found or not a dir; client may 404", staticDir) - } - mux := http.NewServeMux() - - // Optional: forward API paths to API host to avoid 404 if user hits wrong host - mux.Handle("/v1/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://api-gc.fullmooncyberworks.com"+r.URL.Path, http.StatusTemporaryRedirect) - })) - - mux.Handle("/", http.FileServer(http.Dir(staticDir))) + fs := http.FileServer(http.Dir(staticDir)) + h := staticHeaders(fs) log.Printf("static listening on %s (dir=%s)", staticAddr, staticDir) - if err := http.ListenAndServe(staticAddr, staticHeaders(mux)); err != nil { + if err := http.ListenAndServe(staticAddr, h); err != nil { log.Fatalf("static server: %v", err) } }() + // ---- Start API (HTTP or HTTPS) ---- if httpsAddr != "" && certFile != "" && keyFile != "" { - log.Printf("starting HTTPS API on %s", httpsAddr) + log.Printf("API HTTPS %s POP:%v ENC_REQUIRED:%v", httpsAddr, requirePOP, encRequired) if err := srv.ListenHTTPS(httpsAddr, certFile, keyFile); err != nil { log.Fatal(err) } return } - log.Printf("starting HTTP API on %s", httpAddr) + log.Printf("API HTTP %s POP:%v ENC_REQUIRED:%v", httpAddr, requirePOP, encRequired) if err := srv.ListenHTTP(httpAddr); err != nil { log.Fatal(err) } diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d63edd4..5483b1f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,6 +12,7 @@ services: - "9082:9082" # Frontend environment: - GC_DEV_ALLOW_UNAUTH=true + - GC_SIGNING_SECRET_HEX=92650f92d67d55368c852713a5007b90d933bff507bc77c980de7bf5442844ca volumes: - ./testdata:/var/lib/greencoast - ./configs/shard.test.yaml:/app/shard.yaml:ro diff --git a/docker-compose.yml b/docker-compose.yml index 436968d..d43d1f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "8081:8081" environment: - GC_DEV_ALLOW_UNAUTH=false + - GC_SIGNING_SECRET_HEX=92650f92d67d55368c852713a5007b90d933bff507bc77c980de7bf5442844ca volumes: - gc_data:/var/lib/greencoast - ./configs/shard.sample.yaml:/app/shard.yaml:ro diff --git a/internal/api/http.go b/internal/api/http.go index 7bc1520..f7387f7 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -2,39 +2,34 @@ package api import ( "bytes" - "context" "crypto/ecdsa" - "crypto/ed25519" "crypto/elliptic" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log" "math/big" - "mime" "net/http" - "net/url" - "os" + "sort" "strconv" "strings" "sync" "time" - "greencoast/internal/auth" "greencoast/internal/index" ) -// BlobStore minimal interface for storage backends. +// ---------- Storage & server types ---------- + type BlobStore interface { Put(hash string, r io.Reader) error Get(hash string) (io.ReadCloser, int64, error) Delete(hash string) error -} -type blobWalker interface { Walk(func(hash string, size int64, mod time.Time) error) error } @@ -51,139 +46,98 @@ type AuthProviders struct { } type Server struct { - mux *http.ServeMux + store BlobStore + idx *index.Index + zeroTrust bool + coarseTS bool + encRequired bool + requirePOP bool + signingKey []byte + discord DiscordProvider - store BlobStore - idx *index.Index + // device-key challenge cache + chMu sync.Mutex + chal map[string]time.Time // nonce -> expires - coarseTS bool - zeroTrust bool + // replay cache for PoP proofs + rpMu sync.Mutex + replay map[string]time.Time // proof b64 -> expires - signingKey []byte - - // dev/testing flags - allowUnauth bool - devBearer string - - // require proof-of-possession on every auth’d call - requirePoP bool - - // SSE in-process - sseMu sync.Mutex - sseSubs map[chan []byte]struct{} - sseClosed bool - - // SSO state + PKCE verifier + device key binding - stateMu sync.Mutex - states map[string]stateItem - - // Nonce challenges for key-based login - nonceMu sync.Mutex - nonceExpiry map[string]time.Time - - // PoP replay cache - replayMu sync.Mutex - replays map[string]time.Time + // SSE subscribers + sseMu sync.Mutex + sseSub map[chan string]struct{} } -type stateItem struct { - Exp time.Time - Verifier string // PKCE code_verifier - DeviceKey string // "p256:" or "ed25519:" - ReturnNext string // optional -} - -func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, providers AuthProviders) *Server { +func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers AuthProviders, encRequired bool) *Server { key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex)) - s := &Server{ - mux: http.NewServeMux(), + return &Server{ store: store, idx: idx, - coarseTS: coarseTS, zeroTrust: zeroTrust, + coarseTS: coarseTS, + encRequired: encRequired, + requirePOP: true, signingKey: key, - allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true", - devBearer: os.Getenv("GC_DEV_BEARER"), - requirePoP: strings.ToLower(os.Getenv("GC_REQUIRE_POP")) != "false", // default true - sseSubs: make(map[chan []byte]struct{}), - states: make(map[string]stateItem), - nonceExpiry: make(map[string]time.Time), - replays: make(map[string]time.Time), + discord: providers.Discord, + chal: make(map[string]time.Time), + replay: make(map[string]time.Time), + sseSub: make(map[chan string]struct{}), } - - _ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8") - _ = mime.AddExtensionType(".css", "text/css; charset=utf-8") - _ = mime.AddExtensionType(".html", "text/html; charset=utf-8") - _ = mime.AddExtensionType(".map", "application/json; charset=utf-8") - - // Core - s.mux.HandleFunc("/healthz", s.handleHealthz) - - // Auth (public-key) - s.mux.Handle("/v1/auth/key/challenge", s.withCORS(http.HandlerFunc(s.handleKeyChallenge))) - s.mux.Handle("/v1/auth/key/verify", s.withCORS(http.HandlerFunc(s.handleKeyVerify))) - - // Discord SSO - s.mux.Handle("/v1/auth/discord/start", s.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.handleDiscordStart(w, r, providers.Discord) - }))) - s.mux.Handle("/v1/auth/discord/callback", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.handleDiscordCallback(w, r, providers.Discord) - })) - - // Objects - s.mux.Handle("/v1/object", s.withCORS(http.HandlerFunc(s.handlePutObject))) - s.mux.Handle("/v1/object/", s.withCORS(http.HandlerFunc(s.handleObjectByHash))) - - // Index + SSE - s.mux.Handle("/v1/index", s.withCORS(http.HandlerFunc(s.handleIndex))) - s.mux.Handle("/v1/index/stream", s.withCORS(http.HandlerFunc(s.handleIndexSSE))) - - // GDPR/policy - s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy))) - // Admin: reindex - s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex))) - - return s } func (s *Server) ListenHTTP(addr string) error { + mux := s.routes() log.Printf("http listening on %s", addr) - server := &http.Server{ - Addr: addr, - Handler: s.withCORS(s.mux), - ReadHeaderTimeout: 5 * time.Second, - } - return server.ListenAndServe() + return http.ListenAndServe(addr, s.cors(mux)) } func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error { + mux := s.routes() log.Printf("https listening on %s", addr) - server := &http.Server{ - Addr: addr, - Handler: s.withCORS(s.mux), - ReadHeaderTimeout: 5 * time.Second, - } - return server.ListenAndServeTLS(certFile, keyFile) + return http.ListenAndServeTLS(addr, certFile, keyFile, s.cors(mux)) } -func (s *Server) secureHeaders(w http.ResponseWriter) { - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") - w.Header().Set("Cross-Origin-Resource-Policy", "same-site") - w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") +// ---------- Routing / CORS ---------- + +func (s *Server) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealth) + + // Device-key auth (no PoP) + mux.HandleFunc("/v1/auth/key/challenge", s.handleAuthKeyChallenge) + mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify) + + // Objects + mux.HandleFunc("/v1/object", s.handlePutObject) // PUT + mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE + + // Index + mux.HandleFunc("/v1/index", s.handleIndexList) + mux.HandleFunc("/v1/index/stream", s.handleIndexStream) + + // Admin + mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex) + + // GDPR + mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy) + + return mux } -func (s *Server) withCORS(next http.Handler) http.Handler { +func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.secureHeaders(w) - // Strong CSP for static will be set in static server; API allows connect from client origin + // CORS w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS, POST") + w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ, X-GC-Key, X-GC-TS, X-GC-Proof") + // Security + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-site") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -192,268 +146,420 @@ func (s *Server) withCORS(next http.Handler) http.Handler { }) } -// ---------- Health & policy ---------- +// ---------- Helpers ---------- -func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { - s.secureHeaders(w) +func b64u(b []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=") +} +func b64ud(s string) ([]byte, error) { + if m := len(s) % 4; m != 0 { + s += strings.Repeat("=", 4-m) + } + return base64.URLEncoding.DecodeString(s) +} +func nowUTC() time.Time { return time.Now().UTC() } + +// ---------- Health ---------- + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } w.Header().Set("Content-Type", "text/plain; charset=utf-8") - io.WriteString(w, "ok") + _, _ = w.Write([]byte("ok")) } -func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) { - s.secureHeaders(w) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - type policy struct { - StoresPII bool `json:"stores_pii"` - CollectIP bool `json:"collect_ip"` - CollectUA bool `json:"collect_user_agent"` - Timestamps string `json:"timestamps"` - ZeroTrust bool `json:"zero_trust"` - Accounts string `json:"accounts"` - ProofOfPoss bool `json:"proof_of_possession"` - } - resp := policy{ - StoresPII: false, - CollectIP: false, - CollectUA: false, - Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS], - ZeroTrust: s.zeroTrust, - Accounts: "public-key only", - ProofOfPoss: s.requirePoP, - } - _ = json.NewEncoder(w).Encode(resp) -} +// ---------- Device-key auth ---------- -// ---------- Auth helpers ---------- - -type authCtx struct { - sub string - cnf string // "p256:" or "ed25519:" -} - -func (s *Server) parseAuth(w http.ResponseWriter, r *http.Request) (*authCtx, bool) { - // Dev bypass - if s.allowUnauth { - return &authCtx{sub: "dev"}, true - } - // Dev bearer - if s.devBearer != "" && r.Header.Get("Authorization") == "Bearer "+s.devBearer { - return &authCtx{sub: "dev"}, true - } - h := r.Header.Get("Authorization") - if h == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return nil, false - } - // gc2 HMAC token - if strings.HasPrefix(h, "Bearer gc2.") && len(s.signingKey) != 0 { - claims, err := auth.VerifyGC2(s.signingKey, strings.TrimPrefix(h, "Bearer "), time.Now()) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return nil, false - } - return &authCtx{sub: claims.Sub, cnf: claims.CNF}, true - } - http.Error(w, "unauthorized", http.StatusUnauthorized) - return nil, false -} - -func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac *authCtx, body []byte) bool { - if !s.requirePoP { - return true - } - pubHdr := r.Header.Get("X-GC-Key") - ts := r.Header.Get("X-GC-TS") - proof := r.Header.Get("X-GC-Proof") - if pubHdr == "" || ts == "" || proof == "" { - http.Error(w, "missing proof", http.StatusUnauthorized) - return false - } - // timestamp window - sec, _ := strconv.ParseInt(ts, 10, 64) - d := time.Since(time.Unix(sec, 0)) - if d < -5*time.Minute || d > 5*time.Minute { - http.Error(w, "stale proof", http.StatusUnauthorized) - return false - } - // cnf must match - if ac.cnf == "" || ac.cnf != pubHdr { - http.Error(w, "key mismatch", http.StatusUnauthorized) - return false - } - // build message - sum := sha256.Sum256(body) - msg := strings.ToUpper(r.Method) + "\n" + r.URL.String() + "\n" + ts + "\n" + hex.EncodeToString(sum[:]) - - // verify signature - ok := false - switch { - case strings.HasPrefix(pubHdr, "ed25519:"): - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "ed25519:")) - if err == nil { - sig, err := base64.RawURLEncoding.DecodeString(proof) - if err == nil && len(raw) == ed25519.PublicKeySize { - ok = ed25519.Verify(ed25519.PublicKey(raw), []byte(msg), sig) - } - } - case strings.HasPrefix(pubHdr, "p256:"): - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "p256:")) - if err == nil && len(raw) == 65 && raw[0] == 0x04 { - x := new(big.Int).SetBytes(raw[1:33]) - y := new(big.Int).SetBytes(raw[33:65]) - pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} - der, err := base64.RawURLEncoding.DecodeString(proof) - if err == nil { - ok = ecdsa.VerifyASN1(&pk, []byte(msg), der) - } - } - } - if !ok { - http.Error(w, "bad proof", http.StatusUnauthorized) - return false - } - // replay cache - h := sha256.Sum256([]byte(proof + "|" + ts)) - key := base64.RawURLEncoding.EncodeToString(h[:]) - s.replayMu.Lock() - defer s.replayMu.Unlock() - if exp, exists := s.replays[key]; exists && time.Now().Before(exp) { - http.Error(w, "replay", http.StatusUnauthorized) - return false - } - s.replays[key] = time.Now().Add(10 * time.Minute) - return true -} - -// ---------- Public-key auth: challenge/verify ---------- - -func (s *Server) handleKeyChallenge(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleAuthKeyChallenge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - nonce := s.randToken(16) - exp := time.Now().Add(10 * time.Minute) - s.nonceMu.Lock() - s.nonceExpiry[nonce] = exp - s.nonceMu.Unlock() - _ = json.NewEncoder(w).Encode(map[string]any{"nonce": nonce, "exp": exp.Unix()}) + // Nonce (32B hex) 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) + + s.chMu.Lock() + for k, v := range s.chal { + if nowUTC().After(v) { + delete(s.chal, k) + } + } + s.chal[nonce] = exp + s.chMu.Unlock() + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "nonce": nonce, + "exp": exp.Format(time.RFC3339Nano), + }) } type keyVerifyReq struct { Nonce string `json:"nonce"` - Alg string `json:"alg"` // "p256" or "ed25519" - Pub string `json:"pub"` // base64(raw) for that alg (p256 uncompressed point 65B; ed25519 32B) - Sig string `json:"sig"` // base64(signature over "key-verify\n"+nonce) + Alg string `json:"alg"` // "p256" + Pub string `json:"pub"` // base64url raw (uncompressed point, 65 bytes) + Sig string `json:"sig"` // base64url DER(ECDSA) } -func (s *Server) handleKeyVerify(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleAuthKeyVerify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + writeErr := func(code int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) + log.Printf("auth.key.verify %d %s", code, msg) + } + if len(s.signingKey) < 32 { + writeErr(http.StatusServiceUnavailable, "server not configured (signing key)") + return + } var req keyVerifyReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Nonce == "" || req.Alg == "" || req.Pub == "" || req.Sig == "" { - http.Error(w, "bad request", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(http.StatusBadRequest, "bad json") return } - // check nonce - s.nonceMu.Lock() - exp, ok := s.nonceExpiry[req.Nonce] + // nonce + s.chMu.Lock() + exp, ok := s.chal[req.Nonce] if ok { - delete(s.nonceExpiry, req.Nonce) + delete(s.chal, req.Nonce) } - s.nonceMu.Unlock() - if !ok || time.Now().After(exp) { - http.Error(w, "nonce invalid", http.StatusUnauthorized) + s.chMu.Unlock() + if !ok || nowUTC().After(exp) { + writeErr(http.StatusUnauthorized, "nonce expired") return } - msg := "key-verify\n" + req.Nonce - pubRaw, err := base64.RawURLEncoding.DecodeString(req.Pub) + // params + if strings.ToLower(req.Alg) != "p256" || req.Pub == "" || req.Sig == "" { + writeErr(http.StatusBadRequest, "bad params") + return + } + pubRaw, err := b64ud(req.Pub) + if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 { + writeErr(http.StatusBadRequest, "bad pub") + return + } + sigDER, err := b64ud(req.Sig) if err != nil { - http.Error(w, "bad pub", http.StatusBadRequest) + writeErr(http.StatusBadRequest, "bad sig") return } - sigRaw, err := base64.RawURLEncoding.DecodeString(req.Sig) + msg := []byte("key-verify\n" + req.Nonce) + + x := new(big.Int).SetBytes(pubRaw[1:33]) + y := new(big.Int).SetBytes(pubRaw[33:]) + ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} + if !ecdsaVerify(ek, msg, sigDER) { + http.Error(w, "verify failed", http.StatusUnauthorized) + return + } + // mint token + expTok := nowUTC().Add(8 * time.Hour) + token, err := s.gc2Mint("p256:"+req.Pub, expTok) if err != nil { - http.Error(w, "bad sig", http.StatusBadRequest) - return - } - var cnf string - switch strings.ToLower(req.Alg) { - case "ed25519": - if len(pubRaw) != ed25519.PublicKeySize || len(sigRaw) != ed25519.SignatureSize { - http.Error(w, "bad key", http.StatusBadRequest) - return - } - if !ed25519.Verify(ed25519.PublicKey(pubRaw), []byte(msg), sigRaw) { - http.Error(w, "verify failed", http.StatusUnauthorized) - return - } - cnf = "ed25519:" + req.Pub - case "p256": - if len(pubRaw) != 65 || pubRaw[0] != 0x04 { - http.Error(w, "bad key", http.StatusBadRequest) - return - } - x := new(big.Int).SetBytes(pubRaw[1:33]) - y := new(big.Int).SetBytes(pubRaw[33:65]) - pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} - // sigRaw assumed DER (WebCrypto) - if !ecdsa.VerifyASN1(&pk, []byte(msg), sigRaw) { - http.Error(w, "verify failed", http.StatusUnauthorized) - return - } - cnf = "p256:" + req.Pub - default: - http.Error(w, "unsupported alg", http.StatusBadRequest) - return - } - sub := auth.AccountIDFromPub(pubRaw) - ttl := 8 * time.Hour - now := time.Now() - bearer, err := auth.MintGC2(s.signingKey, auth.Claims{ - Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(), - Iss: "greencoast", Aud: "api", CNF: cnf, - }) - if err != nil { - http.Error(w, "sign error", http.StatusInternalServerError) + writeErr(http.StatusInternalServerError, "token mint error") return } + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ - "bearer": bearer, - "sub": sub, - "exp": now.Add(ttl).Unix(), + "bearer": token, + "sub": thumbprint(pubRaw), + "exp": expTok.Format(time.RFC3339Nano), }) } -// ---------- Objects & Index ---------- +// ecdsaVerify accepts WebCrypto ECDSA signatures in either DER or raw (r||s) format. +func ecdsaVerify(pub *ecdsa.PublicKey, msg []byte, sig []byte) bool { + h := sha256.Sum256(msg) + + // Try DER first + if R, S, ok := parseECDSADER(sig); ok { + return ecdsa.Verify(pub, h[:], R, S) + } + + // Try raw JOSE-style: 64 bytes r||s + if len(sig) == 64 { + R := new(big.Int).SetBytes(sig[:32]) + S := new(big.Int).SetBytes(sig[32:]) + return ecdsa.Verify(pub, h[:], R, S) + } + return false +} + +// Minimal DER parser that tolerates long-form lengths and leading 0x00 in INTEGERs. +func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) { + if len(der) < 8 || der[0] != 0x30 { + return nil, nil, false + } + i := 1 + // Read SEQUENCE length (short or long form) + if i >= len(der) { + return nil, nil, false + } + var seqLen int + if der[i]&0x80 != 0 { + n := int(der[i] & 0x7f) + i++ + if n == 0 || i+n > len(der) { + return nil, nil, false + } + for j := 0; j < n; j++ { + seqLen = (seqLen << 8) | int(der[i+j]) + } + i += n + } else { + seqLen = int(der[i]) + i++ + } + if i >= len(der) { + return nil, nil, false + } + + // INTEGER R + if der[i] != 0x02 { + return nil, nil, false + } + i++ + if i >= len(der) { + return nil, nil, false + } + rLen := int(der[i]) + i++ + if rLen <= 0 || i+rLen > len(der) { + return nil, nil, false + } + rb := der[i : i+rLen] + i += rLen + // INTEGER S + if i >= len(der) || der[i] != 0x02 { + return nil, nil, false + } + i++ + if i >= len(der) { + return nil, nil, false + } + sLen := int(der[i]) + i++ + if sLen <= 0 || i+sLen > len(der) { + return nil, nil, false + } + sb := der[i : i+sLen] + + R := new(big.Int).SetBytes(rb) + S := new(big.Int).SetBytes(sb) + return R, S, true +} + +// ---------- PoP / bearer ---------- + +type authContext struct { + Bearer string + CNF string // "p256:" +} + +func (s *Server) parseBearer(tok string) (authContext, error) { + var ac authContext + if tok == "" { + return ac, errors.New("no token") + } + parts := strings.Split(tok, ".") + if len(parts) != 3 { + return ac, errors.New("bad token") + } + hb, pb, sb := parts[0], parts[1], parts[2] + sigData := hb + "." + pb + sig, err := b64ud(sb) + if err != nil { + return ac, errors.New("bad sig") + } + mac := hmac.New(sha256.New, s.signingKey) + _, _ = mac.Write([]byte(sigData)) + if !hmac.Equal(mac.Sum(nil), sig) { + return ac, errors.New("sig mismatch") + } + var hdr struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + } + var pl struct { + Sub string `json:"sub"` + CNF string `json:"cnf"` + Iat int64 `json:"iat"` + Exp int64 `json:"exp"` + Jti string `json:"jti"` + } + if err := json.Unmarshal(mustB64ud(hb), &hdr); err != nil { + return ac, err + } + if err := json.Unmarshal(mustB64ud(pb), &pl); err != nil { + return ac, err + } + if hdr.Typ != "GC2" { + return ac, errors.New("typ") + } + if nowUTC().Unix() >= pl.Exp { + return ac, errors.New("expired") + } + ac.Bearer = tok + ac.CNF = pl.CNF + return ac, nil +} + +func (s *Server) gc2Mint(cnf string, exp time.Time) (string, error) { + if len(s.signingKey) < 32 { + return "", errors.New("signing key missing") + } + hdr := map[string]string{"alg": "HS256", "typ": "GC2"} + iat := nowUTC().Unix() + // Fix: assign the sum before slicing to avoid "unaddressable value" + sum := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + jti := b64u(sum[:16]) + + pl := map[string]any{ + "sub": thumbprintFromCNF(cnf), + "cnf": cnf, + "iat": iat, + "exp": exp.Unix(), + "jti": jti, + } + hb := b64u(mustJSON(hdr)) + pb := b64u(mustJSON(pl)) + sigData := hb + "." + pb + mac := hmac.New(sha256.New, s.signingKey) + _, _ = mac.Write([]byte(sigData)) + sb := b64u(mac.Sum(nil)) + return sigData + "." + sb, nil +} + +func mustB64ud(s string) []byte { b, _ := b64ud(s); return b } +func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } + +func thumbprint(pubRaw []byte) string { + sum := sha256.Sum256(pubRaw) + return hex.EncodeToString(sum[:8]) +} +func thumbprintFromCNF(cnf string) string { + if strings.HasPrefix(cnf, "p256:") { + raw, err := b64ud(strings.TrimPrefix(cnf, "p256:")) + if err == nil { + return thumbprint(raw) + } + } + return "unknown" +} + +func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContext, body []byte) bool { + if !s.requirePOP { + return true + } + if ac.Bearer == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + pubH := strings.TrimSpace(r.Header.Get("X-GC-Key")) + tsStr := strings.TrimSpace(r.Header.Get("X-GC-TS")) + proof := strings.TrimSpace(r.Header.Get("X-GC-Proof")) + if pubH == "" || tsStr == "" || proof == "" { + http.Error(w, "missing pop", http.StatusUnauthorized) + return false + } + if ac.CNF != pubH { + http.Error(w, "cnf mismatch", http.StatusUnauthorized) + return false + } + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + http.Error(w, "bad ts", http.StatusUnauthorized) + return false + } + now := nowUTC().Unix() + if ts < now-600 || ts > now+600 { + http.Error(w, "ts window", http.StatusUnauthorized) + return false + } + // Replay cache + s.rpMu.Lock() + for k, v := range s.replay { + if nowUTC().After(v) { + delete(s.replay, k) + } + } + if _, ok := s.replay[proof]; ok { + s.rpMu.Unlock() + http.Error(w, "replay", http.StatusUnauthorized) + return false + } + s.replay[proof] = nowUTC().Add(10 * time.Minute) + s.rpMu.Unlock() + + // Verify signature over METHOD \n PATH \n TS \n SHA256(bodyHex) + pathOnly := r.URL.Path + d := sha256.Sum256(body) + msg := []byte(strings.ToUpper(r.Method) + "\n" + pathOnly + "\n" + tsStr + "\n" + hex.EncodeToString(d[:])) + + if !strings.HasPrefix(pubH, "p256:") { + http.Error(w, "unsupported key", http.StatusUnauthorized) + return false + } + pubRaw, err := b64ud(strings.TrimPrefix(pubH, "p256:")) + if err != nil || len(pubRaw) != 65 || pubRaw[0] != 0x04 { + http.Error(w, "bad key", http.StatusUnauthorized) + return false + } + sigDER, err := b64ud(proof) + if err != nil { + http.Error(w, "bad proof", http.StatusUnauthorized) + return false + } + x := new(big.Int).SetBytes(pubRaw[1:33]) + y := new(big.Int).SetBytes(pubRaw[33:]) + ek := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} + if !ecdsaVerify(ek, msg, sigDER) { + http.Error(w, "pop verify", http.StatusUnauthorized) + return false + } + return true +} + +// ---------- Objects ---------- + +func isReasonableTZ(tz string) bool { + return len(tz) >= 3 && len(tz) <= 64 && !strings.ContainsAny(tz, "\r\n\t") +} func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - // Limit body to 10 MiB by default const maxBlob = int64(10 << 20) r.Body = http.MaxBytesReader(w, r.Body, maxBlob) - // Read body first to support PoP over body hash var buf bytes.Buffer - n, err := io.Copy(&buf, r.Body) + n64, err := io.Copy(&buf, r.Body) if err != nil { - http.Error(w, "read error", 500) - return - } - ac, ok := s.parseAuth(w, r) - if !ok { + http.Error(w, "read error", http.StatusInternalServerError) return } + + // bearer + PoP + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if !s.verifyPoP(w, r, ac, buf.Bytes()) { return } isPrivate := r.Header.Get("X-GC-Private") == "1" + if s.encRequired && !isPrivate { + http.Error(w, "plaintext disabled on this shard", http.StatusBadRequest) + return + } creatorTZ := strings.TrimSpace(r.Header.Get("X-GC-TZ")) if creatorTZ != "" && !isReasonableTZ(creatorTZ) { creatorTZ = "" @@ -463,74 +569,65 @@ func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { hash := hex.EncodeToString(sum[:]) if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil { - http.Error(w, "store error", 500) + http.Error(w, "store error", http.StatusInternalServerError) return } - when := time.Now().UTC() if s.coarseTS { when = when.Truncate(time.Minute) } - entry := index.Entry{ + ent := index.Entry{ Hash: hash, - Bytes: n, + Bytes: n64, StoredAt: when.Format(time.RFC3339Nano), Private: isPrivate, CreatorTZ: creatorTZ, } - if err := s.idx.Put(entry); err != nil { - http.Error(w, "index error", 500) + if err := s.idx.Put(ent); err != nil { + http.Error(w, "index error", http.StatusInternalServerError) return } - s.sseBroadcast(map[string]any{"event": "put", "data": entry}) + s.sseBroadcastJSON(map[string]any{"event": "put", "data": ent}) w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(entry) + _ = json.NewEncoder(w).Encode(ent) } func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/object/"), "/") - if len(parts) == 0 || parts[0] == "" { + hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") + if hash == "" { http.NotFound(w, r) return } - hash := parts[0] switch r.Method { case http.MethodGet: - ac, ok := s.parseAuth(w, r) - if !ok { + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + // Enforce PoP only if a bearer is presented + if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { return } - if !s.verifyPoP(w, r, ac, nil) { - return - } - rc, n, err := s.store.Get(hash) - if err != nil { - http.Error(w, "not found", http.StatusNotFound) + rc, size, err := s.store.Get(hash) + if err != nil || rc == nil { + http.NotFound(w, r) return } defer rc.Close() w.Header().Set("Content-Type", "application/octet-stream") - if n > 0 { - w.Header().Set("Content-Length", fmt.Sprintf("%d", n)) - } + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) _, _ = io.Copy(w, rc) case http.MethodDelete: - ac, ok := s.parseAuth(w, r) - if !ok { - return - } + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if !s.verifyPoP(w, r, ac, nil) { return } if err := s.store.Delete(hash); err != nil { - http.Error(w, "delete error", 500) + http.Error(w, "delete error", http.StatusInternalServerError) return } _ = s.idx.Delete(hash) - s.sseBroadcast(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}}) + s.sseBroadcastJSON(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}}) w.WriteHeader(http.StatusNoContent) default: @@ -538,309 +635,150 @@ func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { +// ---------- Index / SSE ---------- + +func (s *Server) handleIndexList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - ac, ok := s.parseAuth(w, r) - if !ok { - return - } - if !s.verifyPoP(w, r, ac, nil) { - return - } - items, err := s.idx.List() - if err != nil { - http.Error(w, "index error", 500) + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { return } + + ents := s.idx.All() + sort.Slice(ents, func(i, j int) bool { + // Newest first; StoredAt is RFC3339Nano string + return ents[i].StoredAt > ents[j].StoredAt + }) w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(items) + _ = json.NewEncoder(w).Encode(ents) } -func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) { - ac, ok := s.parseAuth(w, r) +func (s *Server) handleIndexStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if ac.Bearer != "" && !s.verifyPoP(w, r, ac, nil) { + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + flusher, ok := w.(http.Flusher) if !ok { - return - } - if !s.verifyPoP(w, r, ac, nil) { - return - } - flusher, ok2 := w.(http.Flusher) - if !ok2 { http.Error(w, "stream unsupported", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Connection", "keep-alive") - - ch := make(chan []byte, 8) + ch := make(chan string, 8) s.sseMu.Lock() - if s.sseClosed { - s.sseMu.Unlock() - http.Error(w, "closed", http.StatusGone) - return - } - s.sseSubs[ch] = struct{}{} + s.sseSub[ch] = struct{}{} s.sseMu.Unlock() - - fmt.Fprintf(w, "data: %s\n\n", `{"event":"hello","data":"ok"}`) - flusher.Flush() - - ctx := r.Context() - t := time.NewTicker(25 * time.Second) - defer t.Stop() - defer func() { s.sseMu.Lock() - delete(s.sseSubs, ch) - s.sseMu.Unlock() + delete(s.sseSub, ch) close(ch) + s.sseMu.Unlock() }() + ctx := r.Context() for { select { + case msg := <-ch: + _, _ = io.WriteString(w, "data: "+msg+"\n\n") + flusher.Flush() + case <-time.After(60 * time.Second): + _, _ = io.WriteString(w, ": keepalive\n\n") + flusher.Flush() case <-ctx.Done(): return - case b := <-ch: - w.Write(b) - w.Write([]byte("\n\n")) - flusher.Flush() - case <-t.C: - w.Write([]byte("data: {}\n\n")) - flusher.Flush() } } } -func (s *Server) sseBroadcast(v interface{}) { +func (s *Server) sseBroadcastJSON(v any) { b, _ := json.Marshal(v) s.sseMu.Lock() - for ch := range s.sseSubs { + for ch := range s.sseSub { select { - case ch <- append([]byte("data: "), b...): + case ch <- string(b): default: + // drop if slow } } s.sseMu.Unlock() } -// ---------- Admin: reindex ---------- +// ---------- Admin ---------- func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - ac, ok := s.parseAuth(w, r) - if !ok { + ac, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if s.requirePOP && !s.verifyPoP(w, r, ac, nil) { return } - if !s.verifyPoP(w, r, ac, nil) { - return - } - walker, ok2 := s.store.(blobWalker) - if !ok2 { - http.Error(w, "store does not support walk", http.StatusNotImplemented) - return - } - count := 0 - err := walker.Walk(func(hash string, size int64, mod time.Time) error { - count++ - return s.idx.Put(index.Entry{ + var walked, indexed int64 + err := s.store.Walk(func(hash string, size int64, mod time.Time) error { + 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 }) if err != nil { - http.Error(w, "walk error: "+err.Error(), 500) + http.Error(w, "walk error", http.StatusInternalServerError) return } - items, _ := s.idx.List() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ - "walked": count, - "indexed": len(items), + "walked": walked, + "indexed": indexed, }) } -// ---------- Discord SSO with PKCE + device key binding ---------- +// ---------- GDPR ---------- -func (s *Server) handleDiscordStart(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) { - if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURI == "" { - http.Error(w, "discord sso disabled", http.StatusBadRequest) +func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if r.Header.Get("X-GC-3P-Assent") != "1" { - http.Error(w, "third-party provider not assented", http.StatusForbidden) - return + type posture struct { + PII bool `json:"pii"` + LogsIPs bool `json:"logs_ips"` + LogsUAs bool `json:"logs_user_agents"` + Timestamps string `json:"timestamps"` + EncryptionAtRest string `json:"encryption_at_rest"` } - deviceKey := strings.TrimSpace(r.Header.Get("X-GC-Key")) - if deviceKey == "" { - http.Error(w, "device key required", http.StatusBadRequest) - return + p := posture{ + PII: false, + LogsIPs: false, + LogsUAs: false, + Timestamps: func() string { + if s.coarseTS { + return "UTC (coarse)" + } + return "UTC" + }(), + EncryptionAtRest: func() string { + if s.encRequired { + return "required (client-side)" + } + return "optional (client-side)" + }(), } - // PKCE - verifier := s.randToken(32) - chalSum := sha256.Sum256([]byte(verifier)) - challenge := base64.RawURLEncoding.EncodeToString(chalSum[:]) - - state := s.randToken(16) - s.stateMu.Lock() - s.states[state] = stateItem{ - Exp: time.Now().Add(10 * time.Minute), - Verifier: verifier, - DeviceKey: deviceKey, - } - s.stateMu.Unlock() - - v := url.Values{} - v.Set("response_type", "code") - v.Set("client_id", cfg.ClientID) - v.Set("redirect_uri", cfg.RedirectURI) - v.Set("scope", "identify") - v.Set("state", state) - v.Set("code_challenge", challenge) - v.Set("code_challenge_method", "S256") - - authURL := (&url.URL{ - Scheme: "https", - Host: "discord.com", - Path: "/api/oauth2/authorize", - RawQuery: v.Encode(), - }).String() - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(map[string]string{"url": authURL}) -} - -func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) { - if !cfg.Enabled { - http.Error(w, "disabled", http.StatusBadRequest) - return - } - q := r.URL.Query() - code := q.Get("code") - state := q.Get("state") - if code == "" || state == "" { - http.Error(w, "invalid state/code", http.StatusBadRequest) - return - } - s.stateMu.Lock() - item, ok := s.states[state] - if ok && time.Now().Before(item.Exp) { - delete(s.states, state) - } - s.stateMu.Unlock() - if !ok { - http.Error(w, "state expired", http.StatusBadRequest) - return - } - // Exchange code for token (with verifier) - form := url.Values{} - form.Set("client_id", cfg.ClientID) - form.Set("client_secret", cfg.ClientSecret) - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("redirect_uri", cfg.RedirectURI) - form.Set("code_verifier", item.Verifier) - - req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - res, err := http.DefaultClient.Do(req) - if err != nil { - http.Error(w, "token exchange failed", 502) - return - } - defer res.Body.Close() - if res.StatusCode/100 != 2 { - b, _ := io.ReadAll(res.Body) - http.Error(w, "discord token error: "+string(b), 502) - return - } - var tok struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - if err := json.NewDecoder(res.Body).Decode(&tok); err != nil { - http.Error(w, "token decode failed", 502) - return - } - - // Fetch user id - ureq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil) - ureq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken) - ures, err := http.DefaultClient.Do(ureq) - if err != nil { - http.Error(w, "user fetch failed", 502) - return - } - defer ures.Body.Close() - if ures.StatusCode/100 != 2 { - b, _ := io.ReadAll(ures.Body) - http.Error(w, "discord user error: "+string(b), 502) - return - } - var user struct { - ID string `json:"id"` - } - if err := json.NewDecoder(ures.Body).Decode(&user); err != nil { - http.Error(w, "user decode failed", 502) - return - } - - // Bind token to device key from /start - ttl := 8 * time.Hour - now := time.Now() - sub := "discord:" + user.ID - bearer, err := auth.MintGC2(s.signingKey, auth.Claims{ - Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(), - Iss: "greencoast", Aud: "api", CNF: item.DeviceKey, - }) - if err != nil { - http.Error(w, "sign error", 500) - return - } - u, _ := url.Parse(cfg.RedirectURI) - u.Fragment = "bearer=" + url.QueryEscape(bearer) + "&next=/" - http.Redirect(w, r, u.String(), http.StatusFound) -} - -// ---------- Utilities, shutdown ---------- - -func isReasonableTZ(tz string) bool { - if !strings.Contains(tz, "/") || len(tz) > 64 { - return false - } - for _, r := range tz { - if !(r == '/' || r == '_' || r == '-' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { - return false - } - } - return true -} - -func (s *Server) Shutdown(ctx context.Context) error { - s.sseMu.Lock() - s.sseClosed = true - for ch := range s.sseSubs { - close(ch) - } - s.sseSubs = make(map[chan []byte]struct{}) - s.sseMu.Unlock() - return nil -} - -func (s *Server) randToken(n int) string { - // HMAC over time + counter to avoid importing crypto/rand; good enough for state/nonce - // (If you prefer, switch to crypto/rand.) - b := []byte(fmt.Sprintf("%d|%d", time.Now().UnixNano(), len(s.states)+len(s.nonceExpiry))) - m := hmac.New(sha256.New, []byte(fmt.Sprintf("%p", s))) - m.Write(b) - sum := m.Sum(nil) - return base64.RawURLEncoding.EncodeToString(sum[:n]) + _ = json.NewEncoder(w).Encode(p) } diff --git a/internal/api/static.go b/internal/api/static.go index ce926fb..cc28ae4 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -1,86 +1,29 @@ package api import ( - "log" - "mime" "net/http" - "os" - "path/filepath" - "strings" - "time" ) -func init() { - // Ensure common types are known (some distros are sparse by default) - _ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8") - _ = mime.AddExtensionType(".css", "text/css; charset=utf-8") - _ = mime.AddExtensionType(".html", "text/html; charset=utf-8") - _ = mime.AddExtensionType(".map", "application/json; charset=utf-8") -} - -func (s *Server) MountStatic(dir string, baseURL string) { - if dir == "" { - return - } - if baseURL == "" { - baseURL = "/" - } - s.mux.Handle(baseURL, s.staticHandler(dir, baseURL)) - if !strings.HasSuffix(baseURL, "/") { - s.mux.Handle(baseURL+"/", s.staticHandler(dir, baseURL)) - } -} - -func (s *Server) ListenFrontendHTTP(addr, dir, baseURL string) error { - if dir == "" || addr == "" { - return nil - } - log.Printf("frontend listening on %s (dir=%s base=%s)", addr, dir, baseURL) - mx := http.NewServeMux() - mx.Handle(baseURL, s.staticHandler(dir, baseURL)) - if !strings.HasSuffix(baseURL, "/") { - mx.Handle(baseURL+"/", s.staticHandler(dir, baseURL)) - } - server := &http.Server{ - Addr: addr, - Handler: mx, - ReadHeaderTimeout: 5 * time.Second, - } - return server.ListenAndServe() -} - -func (s *Server) staticHandler(dir, baseURL string) http.Handler { - if baseURL == "" { - baseURL = "/" - } +// secureHeaders adds strict, privacy-preserving headers to static responses. +func (s *Server) secureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.secureHeaders(w) - - up := strings.TrimPrefix(r.URL.Path, baseURL) - if up == "" || strings.HasSuffix(r.URL.Path, "/") { - up = "index.html" - } - full := filepath.Join(dir, filepath.FromSlash(up)) - if !strings.HasPrefix(filepath.Clean(full), filepath.Clean(dir)) { - http.NotFound(w, r) - return - } - - // Serve file if it exists, else SPA-fallback to index.html - if st, err := os.Stat(full); err == nil && !st.IsDir() { - // Set Content-Type explicitly based on extension - if ctype := mime.TypeByExtension(filepath.Ext(full)); ctype != "" { - w.Header().Set("Content-Type", ctype) - } - http.ServeFile(w, r, full) - return - } - fallback := filepath.Join(dir, "index.html") - if _, err := os.Stat(fallback); err == nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - http.ServeFile(w, r, fallback) - return - } - http.NotFound(w, r) + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-site") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") + next.ServeHTTP(w, r) }) } + +// MountStatic mounts a static file server under a prefix onto the provided mux. +// Usage (from main): s.MountStatic(mux, "/", http.Dir(staticDir)) +func (s *Server) MountStatic(mux *http.ServeMux, prefix string, fs http.FileSystem) { + if prefix == "" { + prefix = "/" + } + h := http.StripPrefix(prefix, http.FileServer(fs)) + mux.Handle(prefix, s.secureHeaders(h)) +} diff --git a/internal/index/index.go b/internal/index/index.go index 4f83866..a01fbd1 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -1,88 +1,63 @@ package index import ( - "sort" + "errors" "sync" - "time" ) +// Entry is the minimal metadata we expose to clients. type Entry struct { Hash string `json:"hash"` Bytes int64 `json:"bytes"` - StoredAt string `json:"stored_at"` - Private bool `json:"private"` - CreatorTZ string `json:"creator_tz,omitempty"` -} - -type rec struct { - Hash string - Bytes int64 - StoredAt time.Time - Private bool - CreatorTZ string + 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 } +// Index is an in-memory map from hash -> Entry, safe for concurrent use. type Index struct { - mu sync.RWMutex - hash map[string]rec + mu sync.RWMutex + m map[string]Entry } -func New() *Index { return &Index{hash: make(map[string]rec)} } +func New() *Index { + return &Index{m: make(map[string]Entry)} +} func (ix *Index) Put(e Entry) error { + if e.Hash == "" { + return errors.New("empty hash") + } ix.mu.Lock() - defer ix.mu.Unlock() - t := parseWhen(e.StoredAt) - if t.IsZero() { - t = time.Now().UTC() - } - ix.hash[e.Hash] = rec{ - Hash: e.Hash, - Bytes: e.Bytes, - StoredAt: t, - Private: e.Private, - CreatorTZ: e.CreatorTZ, - } + ix.m[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() - defer ix.mu.Unlock() - delete(ix.hash, hash) + delete(ix.m, hash) + ix.mu.Unlock() return nil } -func (ix *Index) List() ([]Entry, error) { +func (ix *Index) Get(hash string) (Entry, bool) { ix.mu.RLock() - defer ix.mu.RUnlock() - tmp := make([]rec, 0, len(ix.hash)) - for _, r := range ix.hash { - tmp = append(tmp, r) - } - sort.Slice(tmp, func(i, j int) bool { return tmp[i].StoredAt.After(tmp[j].StoredAt) }) - out := make([]Entry, len(tmp)) - for i, r := range tmp { - out[i] = Entry{ - Hash: r.Hash, - Bytes: r.Bytes, - StoredAt: r.StoredAt.UTC().Format(time.RFC3339Nano), - Private: r.Private, - CreatorTZ: r.CreatorTZ, - } - } - return out, nil + e, ok := ix.m[hash] + ix.mu.RUnlock() + return e, ok } -func parseWhen(s string) time.Time { - if s == "" { - return time.Time{} +// 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) } - if t, err := time.Parse(time.RFC3339Nano, s); err == nil { - return t - } - if t, err := time.Parse(time.RFC3339, s); err == nil { - return t - } - return time.Time{} + ix.mu.RUnlock() + return out }