This repository has been archived on 2025-08-23. You can view files and clone it, but cannot push or open issues or pull requests.
Files
GreenCoast/internal/api/http.go
Dani fb7428064f Fixed the Discord SSO somewhat
Fixed FS system
Added TZ options
2025-08-22 12:00:58 -04:00

723 lines
18 KiB
Go

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
}