837 lines
21 KiB
Go
837 lines
21 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/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 (don’t 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)
|
||
}
|