This repository has been archived on 2025-08-23. You can view files and clone it, but cannot push or open issues or pull requests.
Files
GreenCoast/internal/api/http.go

837 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"greencoast/internal/index"
)
// ---------- Storage & server types ----------
type BlobStore interface {
Put(hash string, r io.Reader) error
Get(hash string) (io.ReadCloser, int64, error)
Delete(hash string) error
Walk(func(hash string, size int64, mod time.Time) error) error
}
type DiscordProvider struct {
Enabled bool
ClientID string
ClientSecret string
RedirectURI string
}
type AuthProviders struct {
SigningSecretHex string
Discord DiscordProvider
}
type Server struct {
store BlobStore
idx *index.Index
zeroTrust bool
coarseTS bool
encRequired bool
requirePOP bool
signingKey []byte
discord DiscordProvider
// rate limiter
rl *rateLimiter
// device-key challenge cache
chMu sync.Mutex
chal map[string]time.Time // nonce -> expires
// replay cache for PoP proofs
rpMu sync.Mutex
replay map[string]time.Time // proof b64 -> expires
// SSE subscribers
sseMu sync.Mutex
sseSub map[chan string]struct{}
}
func New(store BlobStore, idx *index.Index, coarseTS, zeroTrust bool, providers AuthProviders, encRequired bool) *Server {
key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex))
s := &Server{
store: store,
idx: idx,
zeroTrust: zeroTrust,
coarseTS: coarseTS,
encRequired: encRequired,
requirePOP: true,
signingKey: key,
discord: providers.Discord,
chal: make(map[string]time.Time),
replay: make(map[string]time.Time),
sseSub: make(map[chan string]struct{}),
}
// Default limiter: ~2 req/sec, burst 20, 10 min idle eviction
s.rl = newRateLimiter(2.0, 20, 10*time.Minute)
return s
}
func (s *Server) ListenHTTP(addr string) error {
mux := s.routes()
log.Printf("http listening on %s", addr)
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)
return http.ListenAndServeTLS(addr, certFile, keyFile, s.cors(mux))
}
// ---------- 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 (requires bearer+PoP)
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP)
// Index
mux.HandleFunc("/v1/index", s.handleIndexList)
mux.HandleFunc("/v1/index/stream", s.handleIndexStream)
// Admin (requires admin device sub)
mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex)
// GDPR
mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy)
// Session panic wipe (no auth; clears browser caches)
mux.HandleFunc("/v1/session/clear", s.handleSessionClear)
return mux
}
func (s *Server) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
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")
// Preflight is free (dont rate-limit OPTIONS)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Rate limit (by device key CNF when available; else IP)
key := ""
if b := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); b != "" {
if ac, err := s.parseBearer(b); err == nil && ac.CNF != "" {
key = ac.CNF
}
}
if key == "" {
key = "ip:" + clientIP(r)
}
if !s.rl.allow(key) {
w.Header().Set("Retry-After", "5")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
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 {
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() }
// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2"
func (s *Server) isAdmin(ac authContext) bool {
raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS"))
if raw == "" || ac.CNF == "" {
return false
}
sub := thumbprintFromCNF(ac.CNF)
for _, x := range strings.Split(raw, ",") {
if sub == strings.TrimSpace(x) {
return true
}
}
return false
}
// ---------- 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")
_, _ = w.Write([]byte("ok"))
}
// ---------- Device-key auth ----------
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 (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"
Pub string `json:"pub"` // base64url raw (uncompressed point, 65 bytes)
Sig string `json:"sig"` // base64url DER(ECDSA) or raw r||s (64B)
}
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 {
writeErr(http.StatusBadRequest, "bad json")
return
}
// nonce
s.chMu.Lock()
exp, ok := s.chal[req.Nonce]
if ok {
delete(s.chal, req.Nonce)
}
s.chMu.Unlock()
if !ok || nowUTC().After(exp) {
writeErr(http.StatusUnauthorized, "nonce expired")
return
}
// 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
}
sig, err := b64ud(req.Sig)
if err != nil {
writeErr(http.StatusBadRequest, "bad sig")
return
}
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, sig) {
writeErr(http.StatusUnauthorized, "verify failed")
return
}
// mint token
expTok := nowUTC().Add(8 * time.Hour)
token, err := s.gc2Mint("p256:"+req.Pub, expTok)
if err != nil {
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": token,
"sub": thumbprint(pubRaw),
"exp": expTok.Format(time.RFC3339Nano),
})
}
// 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 tolerant to long-form lengths and leading 0x00.
func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) {
if len(der) < 8 || der[0] != 0x30 {
return nil, nil, false
}
i := 1
// sequence len
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++
}
_ = seqLen // not strictly needed
// INTEGER R
if i >= len(der) || 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:<b64u-raw>"
}
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()
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()
// Tight window: ±120s
if ts < now-120 || ts > now+120 {
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(2 * 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
}
sig, 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, sig) {
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
}
const maxBlob = int64(10 << 20)
r.Body = http.MaxBytesReader(w, r.Body, maxBlob)
var buf bytes.Buffer
n64, err := io.Copy(&buf, r.Body)
if err != nil {
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 = ""
}
sum := sha256.Sum256(buf.Bytes())
hash := hex.EncodeToString(sum[:])
if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
when := time.Now().UTC()
if s.coarseTS {
when = when.Truncate(time.Minute)
}
ent := index.Entry{
Hash: hash,
Bytes: n64,
StoredAt: when.Format(time.RFC3339Nano),
Private: isPrivate,
CreatorTZ: creatorTZ,
}
if err := s.idx.Put(ent); err != nil {
http.Error(w, "index error", http.StatusInternalServerError)
return
}
s.sseBroadcastJSON(map[string]any{"event": "put", "data": ent})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(ent)
}
func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
hash := strings.TrimPrefix(r.URL.Path, "/v1/object/")
if hash == "" {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
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
}
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")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
_, _ = io.Copy(w, rc)
case http.MethodDelete:
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", http.StatusInternalServerError)
return
}
_ = s.idx.Delete(hash)
s.sseBroadcastJSON(map[string]any{"event": "delete", "data": map[string]string{"hash": hash}})
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ---------- 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, _ := 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(ents)
}
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 {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
ch := make(chan string, 8)
s.sseMu.Lock()
s.sseSub[ch] = struct{}{}
s.sseMu.Unlock()
defer func() {
s.sseMu.Lock()
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
}
}
}
func (s *Server) sseBroadcastJSON(v any) {
b, _ := json.Marshal(v)
s.sseMu.Lock()
for ch := range s.sseSub {
select {
case ch <- string(b):
default:
}
}
s.sseMu.Unlock()
}
// ---------- 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, _ := s.parseBearer(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if !s.verifyPoP(w, r, ac, nil) || !s.isAdmin(ac) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
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", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"walked": walked,
"indexed": indexed,
})
}
// ---------- GDPR ----------
func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
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"`
}
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)"
}(),
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(p)
}