package api import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "os" "sort" "strings" "sync" "time" "greencoast/internal/index" "greencoast/internal/storage" ) // ----------- Auth Providers & config (SSO / 2FA stubs) ------------ type DiscordProvider struct { Enabled bool ClientID string ClientSecret string RedirectURI string } type AuthProviders struct { SigningSecretHex string Discord DiscordProvider GoogleEnabled bool // placeholder FacebookEnabled bool // placeholder WebAuthnEnabled bool // placeholder TOTPEnabled bool // placeholder } // ----------- SSE hub (live index) ------------ type sseEvent struct { Event string `json:"event"` // "put" | "delete" Data interface{} `json:"data"` } type hub struct { mu sync.Mutex subs map[chan []byte]struct{} } func newHub() *hub { return &hub{subs: make(map[chan []byte]struct{})} } func (h *hub) subscribe() (ch chan []byte, cancel func()) { ch = make(chan []byte, 16) h.mu.Lock(); h.subs[ch] = struct{}{}; h.mu.Unlock() cancel = func() { h.mu.Lock(); if _, ok := h.subs[ch]; ok { delete(h.subs, ch); close(ch) }; h.mu.Unlock() } return ch, cancel } func (h *hub) broadcast(ev sseEvent) { b, _ := json.Marshal(ev) line := append([]byte("data: "), b...) line = append(line, '\n', '\n') h.mu.Lock() for ch := range h.subs { select { case ch <- line: default: } } h.mu.Unlock() } // ----------- Server ------------ type Server struct { mux *http.ServeMux store *storage.FSStore idx *index.Index coarseTS bool zeroTrust bool signingSecret []byte discord DiscordProvider devAllow bool devToken string live *hub } func New(store *storage.FSStore, idx *index.Index, coarseTimestamps bool, zeroTrust bool, auth AuthProviders) *Server { devAllow := strings.ToLower(os.Getenv("GC_DEV_ALLOW_UNAUTH")) == "true" devToken := os.Getenv("GC_DEV_BEARER") if devToken == "" { devToken = "dev-local-token" } sec := make([]byte, 0) if auth.SigningSecretHex != "" { if b, err := hex.DecodeString(auth.SigningSecretHex); err == nil { sec = b } } s := &Server{ mux: http.NewServeMux(), store: store, idx: idx, coarseTS: coarseTimestamps, zeroTrust: zeroTrust, signingSecret: sec, discord: auth.Discord, devAllow: devAllow, devToken: devToken, live: newHub(), } s.routes() return s } // ---------- middleware helpers (privacy, CORS, auth) ---------- func (s *Server) secureHeaders(w http.ResponseWriter) { // Anti-fingerprinting posture: do not echo request details; set strict policies. w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-site") // CORS: allow simple cross-origin usage without credentials (we do not use cookies). w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS") // No-store by default (content blobs may be large but not user-identifying) w.Header().Set("Cache-Control", "no-store") // HSTS is meaningful over HTTPS; harmless otherwise. w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") } func (s *Server) with(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { s.secureHeaders(w) // Handle CORS preflight if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } handler(w, r) } func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) if !s.zeroTrust { next.ServeHTTP(w, r); return } authz := r.Header.Get("Authorization") // Dev bypass if explicitly enabled if s.devAllow { if authz == "" || authz == "Bearer "+s.devToken { next.ServeHTTP(w, r); return } } if !strings.HasPrefix(authz, "Bearer ") { http.Error(w, "unauthorized", http.StatusUnauthorized); return } if len(s.signingSecret) == 0 { // If no signing secret configured, accept presence only (dev posture). next.ServeHTTP(w, r); return } token := strings.TrimPrefix(authz, "Bearer ") if ok := s.verifyShardToken(token); !ok { http.Error(w, "unauthorized", http.StatusUnauthorized); return } next.ServeHTTP(w, r) } } // ---------- shard token (HMAC, short-lived) ---------- // Format: gc|prov|sub|expEpoch|hex(hmacSHA256(secret, prov+'|'+sub+'|'+exp)) func (s *Server) signShardToken(provider, subject string, exp time.Time) (string, error) { if len(s.signingSecret) == 0 { return "", errors.New("signing disabled (missing auth.signing_secret)") } msg := provider + "|" + subject + "|" + fmt.Sprint(exp.Unix()) mac := hmac.New(sha256.New, s.signingSecret) _, _ = mac.Write([]byte(msg)) sig := hex.EncodeToString(mac.Sum(nil)) return "gc|" + msg + "|" + sig, nil } func (s *Server) verifyShardToken(tok string) bool { parts := strings.Split(tok, "|") if len(parts) != 5 || parts[0] != "gc" { return false } prov, sub, expStr, sig := parts[1], parts[2], parts[3], parts[4] msg := prov + "|" + sub + "|" + expStr mac := hmac.New(sha256.New, s.signingSecret) _, _ = mac.Write([]byte(msg)) want := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(sig)) { return false } // expiry secs, err := time.ParseDuration(expStr + "s") if err != nil { // expStr is epoch; parse manually epoch, e2 := time.ParseDuration("0s"); _ = epoch; _ = e2 } // treat expStr as epoch seconds var expUnix int64 fmt.Sscan(expStr, &expUnix) return time.Now().UTC().Unix() < expUnix } // ---------- routes ---------- func (s *Server) routes() { s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) }) // PUT object (opaque). Client may flag privacy in index via X-GC-Private: 1. s.mux.HandleFunc("/v1/object", s.auth(func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return } isPrivate := strings.TrimSpace(r.Header.Get("X-GC-Private")) == "1" hash, n, err := s.store.Put(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return } ts := s.nowCoarse() _ = s.idx.AppendPut(index.Entry{ Hash: hash, Bytes: n, StoredAt: s.parseRFC3339(ts), Private: isPrivate, }) s.live.broadcast(sseEvent{Event: "put", Data: map[string]any{ "hash": hash, "bytes": n, "stored_at": ts, "private": isPrivate, }}) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"ok":true,"hash":"%s","bytes":%d,"stored_at":"%s"}`, hash, n, ts) }) })) // GET/DELETE object by hash s.mux.HandleFunc("/v1/object/", s.auth(func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") p, err := s.store.Get(hash) if err != nil { http.NotFound(w, r); return } f, err := os.Open(p) if err != nil { http.Error(w, "open error", http.StatusInternalServerError); return } defer f.Close() w.Header().Set("Content-Type", "application/octet-stream") _, _ = io.Copy(w, f) case http.MethodDelete: hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") if err := s.store.Delete(hash); err != nil { http.NotFound(w, r); return } _ = s.idx.AppendDelete(hash) s.live.broadcast(sseEvent{Event: "delete", Data: map[string]any{"hash": hash}}) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true,"deleted":true}`)) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }) })) // Index snapshot s.mux.HandleFunc("/v1/index", s.auth(func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return } entries, err := s.idx.Snapshot() if err != nil { http.Error(w, err.Error(), 500); return } sort.Slice(entries, func(i, j int) bool { return entries[i].StoredAt.After(entries[j].StoredAt) }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(entries) }) })) // Index live (SSE) s.mux.HandleFunc("/v1/index/stream", s.auth(func(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "stream unsupported", http.StatusInternalServerError); return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Connection", "keep-alive") ch, cancel := s.live.subscribe() defer cancel() _, _ = w.Write([]byte(": ok\n\n")); flusher.Flush() ticker := time.NewTicker(25 * time.Second); defer ticker.Stop() notify := r.Context().Done() for { select { case <-notify: return case <-ticker.C: _, _ = w.Write([]byte(": ping\n\n")); flusher.Flush() case msg, ok := <-ch: if !ok { return } _, _ = w.Write(msg); flusher.Flush() } } })) // GDPR policy + Third-party disclaimer s.mux.HandleFunc("/v1/gdpr/policy", func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "collect_ip": false, "collect_useragent": false, "timestamp_policy": s.ternary(s.coarseTS, "coarse-hour", "exact"), "stores_pii": false, "erasure": "DELETE /v1/object/{hash}", "portability": "GET /v1/object/{hash}", "third_party_auth": "Using external SSO providers is optional. We cannot vouch for their security; proceed only if you trust the provider.", }) }) }) // ---------- Discord SSO (first provider) ---------- // Start: returns authorization URL. Requires explicit assent. s.mux.HandleFunc("/v1/auth/discord/start", func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if !s.discord.Enabled { http.Error(w, "discord SSO disabled", http.StatusNotImplemented); return } if !assented(r) { http.Error(w, "third-party assent required (set header X-GC-3P-Assent: 1)", http.StatusPreconditionFailed); return } state := randHex(24) url := "https://discord.com/api/oauth2/authorize" + "?response_type=code" + "&client_id=" + urlq(s.discord.ClientID) + "&scope=" + urlq("identify") + "&redirect_uri=" + urlq(s.discord.RedirectURI) + "&prompt=consent" + "&state=" + urlq(state) _ = state // stateless; client returns same state to callback; you can verify in client _ = json.NewEncoder(w).Encode(map[string]any{"url": url, "note": "We cannot vouch for external IdP security."}) }) }) // Callback: exchanges code for Discord access_token, fetches @me to get subject id // then issues a short-lived shard token (HMAC). No data persisted. s.mux.HandleFunc("/v1/auth/discord/callback", func(w http.ResponseWriter, r *http.Request) { s.with(w, r, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if !s.discord.Enabled { http.Error(w, "discord SSO disabled", http.StatusNotImplemented); return } if !assented(r) { http.Error(w, "third-party assent required (set header X-GC-3P-Assent: 1)", http.StatusPreconditionFailed); return } code := r.URL.Query().Get("code") if code == "" { http.Error(w, "missing code", 400); return } // Exchange code -> access_token form := "client_id=" + urlq(s.discord.ClientID) + "&client_secret=" + urlq(s.discord.ClientSecret) + "&grant_type=authorization_code" + "&code=" + urlq(code) + "&redirect_uri=" + urlq(s.discord.RedirectURI) req, _ := http.NewRequest(http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, "token exchange failed", 502); return } defer resp.Body.Close() var tok struct{ AccessToken, TokenType string `json:"access_token","token_type"` } if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil || tok.AccessToken == "" { http.Error(w, "invalid token response", 502); return } // Fetch user id (PII seen in transit only; not stored) uReq, _ := http.NewRequest(http.MethodGet, "https://discord.com/api/users/@me", nil) uReq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken) uResp, err := http.DefaultClient.Do(uReq) if err != nil { http.Error(w, "userinfo failed", 502); return } defer uResp.Body.Close() var me struct{ ID string `json:"id"` } if err := json.NewDecoder(uResp.Body).Decode(&me); err != nil || me.ID == "" { http.Error(w, "userinfo parse failed", 502); return } // Issue shard token exp := time.Now().UTC().Add(30 * time.Minute) gcTok, err := s.signShardToken("discord", me.ID, exp) if err != nil { http.Error(w, err.Error(), 500); return } _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "token": gcTok, "expires_at": exp.Format(time.RFC3339), "disclaimer": "This token is issued after authenticating with a third-party provider (Discord). We cannot vouch for third-party security.", }) }) }) } // ---------- helpers ---------- func (s *Server) nowCoarse() string { ts := time.Now().UTC() if s.coarseTS { ts = ts.Truncate(time.Hour) } return ts.Format(time.RFC3339) } func (s *Server) parseRFC3339(v string) time.Time { t, _ := time.Parse(time.RFC3339, v); return t } func (s *Server) ternary[T any](cond bool, a, b T) T { if cond { return a }; return b } func assented(r *http.Request) bool { if r.Header.Get("X-GC-3P-Assent") == "1" { return true } if r.URL.Query().Get("assent") == "1" { return true } return false } func randHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { // very unlikely; fall back to timestamp bytes ts := time.Now().UnixNano() for i := 0; i < n; i++ { b[i] = byte(ts >> (8 * (i % 8))) } } return hex.EncodeToString(b) } // randReader uses crypto/rand without importing directly to keep imports tidy here. type randReader struct{} func (randReader) Read(p []byte) (int, error) { return io.ReadFull(os.OpenFile("/dev/urandom", os.O_RDONLY, 0), p) } // fallback if needed (linux-only) // ----- listeners ----- func (s *Server) ListenHTTP(addr string) error { log.Printf("http listening on %s", addr) server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second } ln, err := net.Listen("tcp", addr) if err != nil { return err } return server.Serve(ln) } func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error { log.Printf("https listening on %s", addr) server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second } return server.ListenAndServeTLS(certFile, keyFile) } func (s *Server) ListenMTLS(addr string, tlsCfg *tls.Config) error { log.Printf("federation mTLS listening on %s", addr) server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second, TLSConfig: tlsCfg } ln, err := tls.Listen("tcp", addr, tlsCfg) if err != nil { return err } return server.Serve(ln) }