package api import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "mime" "net/http" "net/url" "os" "path" "strings" "sync" "time" "greencoast/internal/index" ) // BlobStore is the minimal storage interface the API needs. type BlobStore interface { Put(hash string, r io.Reader) error Get(hash string) (io.ReadCloser, int64, error) Delete(hash string) error } // optional capability for stores that can enumerate blobs type blobWalker interface { Walk(func(hash string, size int64, mod time.Time) error) error } // ----------------------------- // Public wiring // ----------------------------- type DiscordProvider struct { Enabled bool ClientID string ClientSecret string RedirectURI string } type AuthProviders struct { SigningSecretHex string // HMAC secret in hex Discord DiscordProvider GoogleEnabled bool FacebookEnabled bool WebAuthnEnabled bool TOTPEnabled bool } type Server struct { mux *http.ServeMux store BlobStore idx *index.Index coarseTS bool zeroTrust bool allowClientSignedTokens bool // accept self-signed tokens (no DB) signingKey []byte // dev flags (from env) allowUnauth bool devBearer string // SSE fanout (in-process) sseMu sync.Mutex sseSubs map[chan []byte]struct{} sseClosed bool // SSO ephemeral state stateMu sync.Mutex states map[string]time.Time } // New constructs the API server and registers routes. func New(store BlobStore, idx *index.Index, coarseTS bool, zeroTrust bool, providers AuthProviders) *Server { key, _ := hex.DecodeString(strings.TrimSpace(providers.SigningSecretHex)) s := &Server{ mux: http.NewServeMux(), store: store, idx: idx, coarseTS: coarseTS, zeroTrust: zeroTrust, allowClientSignedTokens: true, signingKey: key, allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true", devBearer: os.Getenv("GC_DEV_BEARER"), sseSubs: make(map[chan []byte]struct{}), states: make(map[string]time.Time), } // MIME safety (minimal base images can be sparse) _ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8") _ = mime.AddExtensionType(".css", "text/css; charset=utf-8") _ = mime.AddExtensionType(".html", "text/html; charset=utf-8") _ = mime.AddExtensionType(".map", "application/json; charset=utf-8") // Core s.mux.HandleFunc("/healthz", s.handleHealthz) // Objects s.mux.Handle("/v1/object", s.withCORS(http.HandlerFunc(s.handlePutObject))) s.mux.Handle("/v1/object/", s.withCORS(http.HandlerFunc(s.handleObjectByHash))) // Index + SSE s.mux.Handle("/v1/index", s.withCORS(http.HandlerFunc(s.handleIndex))) s.mux.Handle("/v1/index/stream", s.withCORS(http.HandlerFunc(s.handleIndexSSE))) // GDPR+policy endpoint (minimal; no PII) s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy))) // Admin: reindex from disk if store supports Walk s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex))) // Discord SSO s.mux.Handle("/v1/auth/discord/start", s.withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.handleDiscordStart(w, r, providers.Discord) }))) s.mux.Handle("/v1/auth/discord/callback", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.handleDiscordCallback(w, r, providers.Discord) })) return s } // ListenHTTP serves the API on addr. func (s *Server) ListenHTTP(addr string) error { log.Printf("http listening on %s", addr) server := &http.Server{ Addr: addr, Handler: s.withCORS(s.mux), ReadHeaderTimeout: 5 * time.Second, } return server.ListenAndServe() } // ListenHTTPS serves TLS directly. func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error { log.Printf("https listening on %s", addr) server := &http.Server{ Addr: addr, Handler: s.withCORS(s.mux), ReadHeaderTimeout: 5 * time.Second, } return server.ListenAndServeTLS(certFile, keyFile) } // ----------------------------- // Middleware / headers // ----------------------------- func (s *Server) secureHeaders(w http.ResponseWriter) { // Privacy / security posture 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") // HSTS (harmless over HTTP; browsers only enforce under HTTPS) w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") } func (s *Server) withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent, X-GC-TZ") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } // ----------------------------- // Health & policy // ----------------------------- func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) w.Header().Set("Content-Type", "text/plain; charset=utf-8") io.WriteString(w, "ok") } func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) w.Header().Set("Content-Type", "application/json; charset=utf-8") type policy struct { StoresPII bool `json:"stores_pii"` CollectIP bool `json:"collect_ip"` CollectUA bool `json:"collect_user_agent"` Timestamps string `json:"timestamps"` ZeroTrust bool `json:"zero_trust"` } resp := policy{ StoresPII: false, CollectIP: false, CollectUA: false, Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS], ZeroTrust: s.zeroTrust, } _ = json.NewEncoder(w).Encode(resp) } // ----------------------------- // Auth helpers // ----------------------------- func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool { // Developer bypass if s.allowUnauth { return true } // Optional dev bearer if s.devBearer != "" { h := r.Header.Get("Authorization") if h == "Bearer "+s.devBearer { return true } } // Accept self-signed HMAC tokens if configured if s.allowClientSignedTokens && len(s.signingKey) > 0 { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) if s.verifyToken(tok) == nil { return true } } } http.Error(w, "unauthorized", http.StatusUnauthorized) return false } func (s *Server) makeToken(subject string, ttl time.Duration) (string, error) { if len(s.signingKey) == 0 { return "", errors.New("signing key not set") } type claims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` Iss string `json:"iss"` } c := claims{ Sub: subject, Exp: time.Now().Add(ttl).Unix(), Iss: "greencoast", } body, _ := json.Marshal(c) mac := hmac.New(sha256.New, s.signingKey) mac.Write(body) sig := mac.Sum(nil) return "gc1." + base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(sig), nil } func (s *Server) verifyToken(tok string) error { if !strings.HasPrefix(tok, "gc1.") { return errors.New("bad prefix") } parts := strings.Split(tok, ".") if len(parts) != 3 { return errors.New("bad parts") } body, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } want, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return err } mac := hmac.New(sha256.New, s.signingKey) mac.Write(body) if !hmac.Equal(want, mac.Sum(nil)) { return errors.New("bad sig") } var c struct { Sub string `json:"sub"` Exp int64 `json:"exp"` } if err := json.Unmarshal(body, &c); err != nil { return err } if time.Now().Unix() > c.Exp { return errors.New("expired") } return nil } // ----------------------------- // Objects & Index // ----------------------------- func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.requireAuth(w, r) { return } isPrivate := r.Header.Get("X-GC-Private") == "1" creatorTZ := strings.TrimSpace(r.Header.Get("X-GC-TZ")) if creatorTZ != "" && !isReasonableTZ(creatorTZ) { creatorTZ = "" } // Write to store; compute hash while streaming var buf bytes.Buffer n, err := io.Copy(&buf, r.Body) if err != nil { http.Error(w, "read error", 500) return } sum := sha256.Sum256(buf.Bytes()) hash := hex.EncodeToString(sum[:]) // Persist if err := s.store.Put(hash, bytes.NewReader(buf.Bytes())); err != nil { http.Error(w, "store error", 500) return } // Index when := time.Now().UTC() if s.coarseTS { when = when.Truncate(time.Minute) } entry := index.Entry{ Hash: hash, Bytes: n, StoredAt: when.Format(time.RFC3339Nano), Private: isPrivate, CreatorTZ: creatorTZ, } if err := s.idx.Put(entry); err != nil { http.Error(w, "index error", 500) return } s.sseBroadcast(map[string]interface{}{"event": "put", "data": entry}) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(entry) } func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) { // path: /v1/object/{hash} parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/object/"), "/") if len(parts) == 0 || parts[0] == "" { http.NotFound(w, r) return } hash := parts[0] switch r.Method { case http.MethodGet: if !s.requireAuth(w, r) { return } 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") if n > 0 { w.Header().Set("Content-Length", fmt.Sprintf("%d", n)) } _, _ = io.Copy(w, rc) case http.MethodDelete: if !s.requireAuth(w, r) { return } if err := s.store.Delete(hash); err != nil { http.Error(w, "delete error", 500) return } // prune index if present _ = s.idx.Delete(hash) s.sseBroadcast(map[string]interface{}{"event": "delete", "data": map[string]string{"hash": hash}}) w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.requireAuth(w, r) { return } items, err := s.idx.List() if err != nil { http.Error(w, "index error", 500) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(items) } // Simple in-process SSE fanout. func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) { if !s.requireAuth(w, r) { return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "stream unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Connection", "keep-alive") ch := make(chan []byte, 8) // subscribe s.sseMu.Lock() if s.sseClosed { s.sseMu.Unlock() http.Error(w, "closed", http.StatusGone) return } s.sseSubs[ch] = struct{}{} s.sseMu.Unlock() // Send a hello/heartbeat fmt.Fprintf(w, "data: %s\n\n", `{"event":"hello","data":"ok"}`) flusher.Flush() // pump ctx := r.Context() t := time.NewTicker(25 * time.Second) defer t.Stop() defer func() { s.sseMu.Lock() delete(s.sseSubs, ch) s.sseMu.Unlock() close(ch) }() for { select { case <-ctx.Done(): return case b := <-ch: w.Write(b) w.Write([]byte("\n\n")) flusher.Flush() case <-t.C: w.Write([]byte("data: {}\n\n")) flusher.Flush() } } } func (s *Server) sseBroadcast(v interface{}) { b, _ := json.Marshal(v) s.sseMu.Lock() for ch := range s.sseSubs { select { case ch <- append([]byte("data: "), b...): default: } } s.sseMu.Unlock() } // ----------------------------- // Admin: reindex from disk // ----------------------------- func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.requireAuth(w, r) { return } walker, ok := s.store.(blobWalker) if !ok { http.Error(w, "store does not support walk", http.StatusNotImplemented) return } count := 0 err := walker.Walk(func(hash string, size int64, mod time.Time) error { count++ return s.idx.Put(index.Entry{ Hash: hash, Bytes: size, StoredAt: mod.UTC().Format(time.RFC3339Nano), Private: false, }) }) if err != nil { http.Error(w, "walk error: "+err.Error(), 500) return } items, _ := s.idx.List() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "walked": count, "indexed": len(items), }) } // ----------------------------- // Discord SSO (server-side code flow) // ----------------------------- func (s *Server) handleDiscordStart(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) { if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURI == "" { http.Error(w, "discord sso disabled", http.StatusBadRequest) return } // Require explicit 3P assent (UI shows disclaimer) if r.Header.Get("X-GC-3P-Assent") != "1" { http.Error(w, "third-party provider not assented", http.StatusForbidden) return } state := s.newState(5 * time.Minute) v := url.Values{} v.Set("response_type", "code") v.Set("client_id", cfg.ClientID) v.Set("redirect_uri", cfg.RedirectURI) v.Set("scope", "identify") v.Set("prompt", "consent") v.Set("state", state) authURL := (&url.URL{ Scheme: "https", Host: "discord.com", Path: "/api/oauth2/authorize", RawQuery: v.Encode(), }).String() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]string{"url": authURL}) } func (s *Server) handleDiscordCallback(w http.ResponseWriter, r *http.Request, cfg DiscordProvider) { if !cfg.Enabled { http.Error(w, "disabled", http.StatusBadRequest) return } q := r.URL.Query() code := q.Get("code") state := q.Get("state") if code == "" || state == "" || !s.consumeState(state) { http.Error(w, "invalid state/code", http.StatusBadRequest) return } // Exchange code for token form := url.Values{} form.Set("client_id", cfg.ClientID) form.Set("client_secret", cfg.ClientSecret) form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("redirect_uri", cfg.RedirectURI) req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, "token exchange failed", 502) return } defer res.Body.Close() if res.StatusCode/100 != 2 { b, _ := io.ReadAll(res.Body) http.Error(w, "discord token error: "+string(b), 502) return } var tok struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` ExpiresIn int64 `json:"expires_in"` } if err := json.NewDecoder(res.Body).Decode(&tok); err != nil { http.Error(w, "token decode failed", 502) return } // Fetch user id (identify scope) ureq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil) ureq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken) ures, err := http.DefaultClient.Do(ureq) if err != nil { http.Error(w, "user fetch failed", 502) return } defer ures.Body.Close() if ures.StatusCode/100 != 2 { b, _ := io.ReadAll(ures.Body) http.Error(w, "discord user error: "+string(b), 502) return } var user struct { ID string `json:"id"` Username string `json:"username"` } if err := json.NewDecoder(ures.Body).Decode(&user); err != nil { http.Error(w, "user decode failed", 502) return } // Mint self-signed bearer with Discord snowflake as subject bearer, err := s.makeToken("discord:"+user.ID, time.Hour*8) if err != nil { http.Error(w, "signing error", 500) return } // Redirect to frontend callback with bearer in fragment (not query) target := cfg.RedirectURI u, _ := url.Parse(target) u.Fragment = "bearer=" + url.QueryEscape(bearer) + "&next=/" http.Redirect(w, r, u.String(), http.StatusFound) } // simple in-memory state store func (s *Server) newState(ttl time.Duration) string { s.stateMu.Lock() defer s.stateMu.Unlock() b := make([]byte, 12) now := time.Now().UnixNano() copy(b, []byte(fmt.Sprintf("%x", now))) val := base64.RawURLEncoding.EncodeToString(b) s.states[val] = time.Now().Add(ttl) return val } func (s *Server) consumeState(v string) bool { s.stateMu.Lock() defer s.stateMu.Unlock() exp, ok := s.states[v] if !ok { return false } delete(s.states, v) return time.Now().Before(exp) } // ----------------------------- // Utilities // ----------------------------- func isReasonableTZ(tz string) bool { if !strings.Contains(tz, "/") || len(tz) > 64 { return false } for _, r := range tz { if !(r == '/' || r == '_' || r == '-' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { return false } } return true } // ----------------------------- // Optional: graceful shutdown // ----------------------------- func (s *Server) Shutdown(ctx context.Context) error { s.sseMu.Lock() s.sseClosed = true for ch := range s.sseSubs { close(ch) } s.sseSubs = make(map[chan []byte]struct{}) s.sseMu.Unlock() return nil } // ----------------------------- // Helpers for static serving (optional use) // ----------------------------- func fileExists(p string) bool { st, err := os.Stat(p) return err == nil && !st.IsDir() } func joinClean(dir, p string) (string, bool) { fp := path.Clean("/" + p) full := path.Clean(dir + fp) if !strings.HasPrefix(full, path.Clean(dir)) { return "", false } return full, true }