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

859 lines
22 KiB
Go

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"
"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
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE
// 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)
// Panic wipe signal (client clears itself)
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
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)
})
}
// ---------- 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" (subs are thumbprints)
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
}
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) {
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 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
// 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 cleanup + check
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,
}
// 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
}
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))
w.Header().Set("Content-Disposition", "attachment; filename="+hash)
_, _ = 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 {
walked++
ent := index.Entry{
Hash: hash,
Bytes: size,
StoredAt: mod.UTC().Format(time.RFC3339Nano),
Private: false,
}
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,
"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)
}
// ---------- 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)
}