// internal/api/http.go package api import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "path" "strconv" "strings" "sync" "time" "greencoast/internal/index" ) // ---- Contracts ---- type BlobStore interface { Get(hash string) (io.ReadCloser, int64, error) Put(r io.Reader, private bool) (hash string, n int64, storedAt time.Time, err error) Delete(hash string) error Walk(fn func(hash string, bytes int64, private bool, storedAt time.Time) error) (int, error) } type AuthProviders struct{} // ---- Server ---- type Server struct { Mux *http.ServeMux // exported for other files mux *http.ServeMux // alias store BlobStore idx *index.Index uiOn bool devAllowUnauth bool allowAnonPlaintext bool StaticDir string sseMu sync.Mutex sseSubs map[chan []byte]struct{} } // New(store, idx, enableUI, devMode, providers, allowAnonPlaintext) func New(store BlobStore, idx *index.Index, enableUI bool, devMode bool, _ AuthProviders, allowAnonPlaintext bool) *Server { m := http.NewServeMux() s := &Server{ Mux: m, mux: m, store: store, idx: idx, uiOn: enableUI, devAllowUnauth: devMode, allowAnonPlaintext: allowAnonPlaintext, StaticDir: "./client", sseSubs: make(map[chan []byte]struct{}), } // Health + caps s.Mux.HandleFunc("/healthz", s.healthz) s.Mux.HandleFunc("/v1/caps", s.handleCaps) // Object I/O s.Mux.Handle("/v1/object", s.requireAuth(http.HandlerFunc(s.handlePutObject))) // PUT s.Mux.Handle("/v1/object/", s.requireAuth(http.HandlerFunc(s.handleObjectByHash))) // GET/DELETE // Index (public read) s.Mux.HandleFunc("/v1/index", s.handleIndex) s.Mux.HandleFunc("/v1/index/stream", s.handleIndexStream) return s } func (s *Server) ListenHTTP(addr string) error { handler := corsSecurity(s.Mux) server := &http.Server{Addr: addr, Handler: handler} return server.ListenAndServe() } // ---- Global CORS/security ---- func corsSecurity(next http.Handler) http.Handler { allowedHeaders := "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ, X-GC-Key, X-GC-TS, X-GC-Proof" allowedMethods := "GET, PUT, POST, DELETE, OPTIONS" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", allowedMethods) w.Header().Set("Access-Control-Allow-Headers", allowedHeaders) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") 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=()") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } // ---- Auth (with anon-plaintext bypass) ---- func (s *Server) isPlaintextPut(r *http.Request) bool { if !s.allowAnonPlaintext { return false } if r.Method != http.MethodPut { return false } if !strings.HasPrefix(r.URL.Path, "/v1/object") { return false } if r.Header.Get("X-GC-Private") == "1" { return false } return true } func (s *Server) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.isPlaintextPut(r) || s.devAllowUnauth { next.ServeHTTP(w, r) return } bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) hasPoP := r.Header.Get("X-GC-Key") != "" && r.Header.Get("X-GC-TS") != "" && r.Header.Get("X-GC-Proof") != "" if bearer != "" || hasPoP { next.ServeHTTP(w, r) return } http.Error(w, "unauthorized", http.StatusUnauthorized) }) } // ---- Small utils ---- func ReadAllStrict(r io.Reader, max int64) ([]byte, error) { if max <= 0 { return io.ReadAll(r) } lr := io.LimitedReader{R: r, N: max + 1} b, err := io.ReadAll(&lr) if err != nil { return nil, err } if int64(len(b)) > max { return nil, errors.New("payload too large") } return b, nil } func maxObjectBytes() int64 { v := strings.TrimSpace(os.Getenv("GC_MAX_OBJECT_KB")) if v == "" { return 256 * 1024 // default 256 KiB } n, err := strconv.Atoi(v) if err != nil || n <= 0 { return 256 * 1024 } return int64(n) * 1024 } // ---- Basic endpoints ---- func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok")) } type caps struct { AllowAnonPlaintext bool `json:"allow_anon_plaintext"` ZeroTrust bool `json:"zero_trust"` } func (s *Server) handleCaps(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(caps{ AllowAnonPlaintext: s.allowAnonPlaintext, ZeroTrust: true, }) } // ---- Object handlers ---- type putResp struct { Hash string `json:"hash"` Bytes int64 `json:"bytes"` StoredAt time.Time `json:"stored_at"` Private bool `json:"private"` } func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.NotFound(w, r) return } private := r.Header.Get("X-GC-Private") == "1" // Strict read (prevents runaway memory and surfaces clear error) data, err := ReadAllStrict(r.Body, maxObjectBytes()) if err != nil { log.Printf("PUT /v1/object read error: %v", err) http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } // Store hash, n, storedAt, err := s.store.Put(bytes.NewReader(data), private) if err != nil { log.Printf("PUT /v1/object store error: %v", err) http.Error(w, "store failed: "+err.Error(), http.StatusInternalServerError) return } // Broadcast SSE "put" s.broadcastEvent("put", map[string]any{ "hash": hash, "bytes": n, "stored_at": storedAt.UTC(), "private": private, }) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(putResp{ Hash: hash, Bytes: n, StoredAt: storedAt.UTC(), Private: private, }) } func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { seg := strings.TrimPrefix(r.URL.Path, "/v1/object") seg = strings.TrimPrefix(seg, "/") if seg == "" { http.NotFound(w, r) return } hash := path.Clean(seg) switch r.Method { case http.MethodGet: rc, n, err := s.store.Get(hash) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } defer rc.Close() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", fmt.Sprintf("%d", n)) if _, err := io.Copy(w, rc); err != nil { return } case http.MethodDelete: if err := s.store.Delete(hash); err != nil { http.Error(w, "not found", http.StatusNotFound) return } s.broadcastEvent("delete", map[string]any{"hash": hash}) w.WriteHeader(http.StatusNoContent) default: http.NotFound(w, r) } } // ---- Index handlers ---- type indexEntry struct { Hash string `json:"hash"` Bytes int64 `json:"bytes"` Private bool `json:"private"` StoredAt time.Time `json:"stored_at"` } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } out := make([]indexEntry, 0, 256) _, err := s.store.Walk(func(hash string, bytes int64, private bool, storedAt time.Time) error { out = append(out, indexEntry{ Hash: hash, Bytes: bytes, Private: private, StoredAt: storedAt.UTC(), }) return nil }) if err != nil { http.Error(w, "index walk failed: "+err.Error(), http.StatusInternalServerError) return } sortByStoredAtDesc(out) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) } func sortByStoredAtDesc(a []indexEntry) { for i := 1; i < len(a); i++ { j := i for j > 0 && a[j].StoredAt.After(a[j-1].StoredAt) { a[j], a[j-1] = a[j-1], a[j] j-- } } } // ---- SSE ---- func (s *Server) handleIndexStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "stream unsupported", http.StatusInternalServerError) return } ch := make(chan []byte, 32) s.addSub(ch) defer s.removeSub(ch) _, _ = io.WriteString(w, ": ok\n\n") flusher.Flush() notify := r.Context().Done() hb := time.NewTicker(20 * time.Second) defer hb.Stop() for { select { case <-notify: return case <-hb.C: _, _ = io.WriteString(w, ": ping\n\n") flusher.Flush() case msg := <-ch: _, _ = io.WriteString(w, "data: ") _, _ = w.Write(msg) _, _ = io.WriteString(w, "\n\n") flusher.Flush() } } } func (s *Server) addSub(ch chan []byte) { s.sseMu.Lock() s.sseSubs[ch] = struct{}{} s.sseMu.Unlock() } func (s *Server) removeSub(ch chan []byte) { s.sseMu.Lock() delete(s.sseSubs, ch) close(ch) s.sseMu.Unlock() } func (s *Server) broadcastEvent(ev string, payload any) { body, _ := json.Marshal(map[string]any{"event": ev, "data": payload}) s.sseMu.Lock() for ch := range s.sseSubs { select { case ch <- body: default: } } s.sseMu.Unlock() } // ---- Helpers ---- func bufioReader(r io.Reader) *bufio.Reader { if br, ok := r.(*bufio.Reader); ok { return br } return bufio.NewReader(r) }