785 lines
20 KiB
Go
785 lines
20 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"
|
|
"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
|
|
|
|
// 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))
|
|
return &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{}),
|
|
}
|
|
}
|
|
|
|
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
|
|
mux.HandleFunc("/v1/admin/reindex", s.handleAdminReindex)
|
|
|
|
// GDPR
|
|
mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy)
|
|
|
|
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")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
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() }
|
|
|
|
// ---------- 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
sigDER, 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, 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 {
|
|
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 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:<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()
|
|
// 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
|
|
}
|
|
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:
|
|
// drop if slow
|
|
}
|
|
}
|
|
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.requirePOP && !s.verifyPoP(w, r, ac, nil) {
|
|
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)
|
|
}
|