847 lines
23 KiB
Go
847 lines
23 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/ecdsa"
|
||
"crypto/ed25519"
|
||
"crypto/elliptic"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"math/big"
|
||
"mime"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"greencoast/internal/auth"
|
||
"greencoast/internal/index"
|
||
)
|
||
|
||
// BlobStore minimal interface for storage backends.
|
||
type BlobStore interface {
|
||
Put(hash string, r io.Reader) error
|
||
Get(hash string) (io.ReadCloser, int64, error)
|
||
Delete(hash string) error
|
||
}
|
||
type blobWalker interface {
|
||
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 {
|
||
mux *http.ServeMux
|
||
|
||
store BlobStore
|
||
idx *index.Index
|
||
|
||
coarseTS bool
|
||
zeroTrust bool
|
||
|
||
signingKey []byte
|
||
|
||
// dev/testing flags
|
||
allowUnauth bool
|
||
devBearer string
|
||
|
||
// require proof-of-possession on every auth’d call
|
||
requirePoP bool
|
||
|
||
// SSE in-process
|
||
sseMu sync.Mutex
|
||
sseSubs map[chan []byte]struct{}
|
||
sseClosed bool
|
||
|
||
// SSO state + PKCE verifier + device key binding
|
||
stateMu sync.Mutex
|
||
states map[string]stateItem
|
||
|
||
// Nonce challenges for key-based login
|
||
nonceMu sync.Mutex
|
||
nonceExpiry map[string]time.Time
|
||
|
||
// PoP replay cache
|
||
replayMu sync.Mutex
|
||
replays map[string]time.Time
|
||
}
|
||
|
||
type stateItem struct {
|
||
Exp time.Time
|
||
Verifier string // PKCE code_verifier
|
||
DeviceKey string // "p256:<b64raw>" or "ed25519:<b64raw>"
|
||
ReturnNext string // optional
|
||
}
|
||
|
||
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,
|
||
signingKey: key,
|
||
allowUnauth: os.Getenv("GC_DEV_ALLOW_UNAUTH") == "true",
|
||
devBearer: os.Getenv("GC_DEV_BEARER"),
|
||
requirePoP: strings.ToLower(os.Getenv("GC_REQUIRE_POP")) != "false", // default true
|
||
sseSubs: make(map[chan []byte]struct{}),
|
||
states: make(map[string]stateItem),
|
||
nonceExpiry: make(map[string]time.Time),
|
||
replays: make(map[string]time.Time),
|
||
}
|
||
|
||
_ = 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)
|
||
|
||
// Auth (public-key)
|
||
s.mux.Handle("/v1/auth/key/challenge", s.withCORS(http.HandlerFunc(s.handleKeyChallenge)))
|
||
s.mux.Handle("/v1/auth/key/verify", s.withCORS(http.HandlerFunc(s.handleKeyVerify)))
|
||
|
||
// 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)
|
||
}))
|
||
|
||
// 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
|
||
s.mux.Handle("/v1/gdpr/policy", s.withCORS(http.HandlerFunc(s.handleGDPRPolicy)))
|
||
// Admin: reindex
|
||
s.mux.Handle("/v1/admin/reindex", s.withCORS(http.HandlerFunc(s.handleAdminReindex)))
|
||
|
||
return s
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func (s *Server) secureHeaders(w http.ResponseWriter) {
|
||
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")
|
||
}
|
||
|
||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
s.secureHeaders(w)
|
||
// Strong CSP for static will be set in static server; API allows connect from client origin
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS, POST")
|
||
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")
|
||
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"`
|
||
Accounts string `json:"accounts"`
|
||
ProofOfPoss bool `json:"proof_of_possession"`
|
||
}
|
||
resp := policy{
|
||
StoresPII: false,
|
||
CollectIP: false,
|
||
CollectUA: false,
|
||
Timestamps: map[bool]string{true: "coarse_utc", false: "utc"}[s.coarseTS],
|
||
ZeroTrust: s.zeroTrust,
|
||
Accounts: "public-key only",
|
||
ProofOfPoss: s.requirePoP,
|
||
}
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// ---------- Auth helpers ----------
|
||
|
||
type authCtx struct {
|
||
sub string
|
||
cnf string // "p256:<b64raw>" or "ed25519:<b64raw>"
|
||
}
|
||
|
||
func (s *Server) parseAuth(w http.ResponseWriter, r *http.Request) (*authCtx, bool) {
|
||
// Dev bypass
|
||
if s.allowUnauth {
|
||
return &authCtx{sub: "dev"}, true
|
||
}
|
||
// Dev bearer
|
||
if s.devBearer != "" && r.Header.Get("Authorization") == "Bearer "+s.devBearer {
|
||
return &authCtx{sub: "dev"}, true
|
||
}
|
||
h := r.Header.Get("Authorization")
|
||
if h == "" {
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
// gc2 HMAC token
|
||
if strings.HasPrefix(h, "Bearer gc2.") && len(s.signingKey) != 0 {
|
||
claims, err := auth.VerifyGC2(s.signingKey, strings.TrimPrefix(h, "Bearer "), time.Now())
|
||
if err != nil {
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
return &authCtx{sub: claims.Sub, cnf: claims.CNF}, true
|
||
}
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return nil, false
|
||
}
|
||
|
||
func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac *authCtx, body []byte) bool {
|
||
if !s.requirePoP {
|
||
return true
|
||
}
|
||
pubHdr := r.Header.Get("X-GC-Key")
|
||
ts := r.Header.Get("X-GC-TS")
|
||
proof := r.Header.Get("X-GC-Proof")
|
||
if pubHdr == "" || ts == "" || proof == "" {
|
||
http.Error(w, "missing proof", http.StatusUnauthorized)
|
||
return false
|
||
}
|
||
// timestamp window
|
||
sec, _ := strconv.ParseInt(ts, 10, 64)
|
||
d := time.Since(time.Unix(sec, 0))
|
||
if d < -5*time.Minute || d > 5*time.Minute {
|
||
http.Error(w, "stale proof", http.StatusUnauthorized)
|
||
return false
|
||
}
|
||
// cnf must match
|
||
if ac.cnf == "" || ac.cnf != pubHdr {
|
||
http.Error(w, "key mismatch", http.StatusUnauthorized)
|
||
return false
|
||
}
|
||
// build message
|
||
sum := sha256.Sum256(body)
|
||
msg := strings.ToUpper(r.Method) + "\n" + r.URL.String() + "\n" + ts + "\n" + hex.EncodeToString(sum[:])
|
||
|
||
// verify signature
|
||
ok := false
|
||
switch {
|
||
case strings.HasPrefix(pubHdr, "ed25519:"):
|
||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "ed25519:"))
|
||
if err == nil {
|
||
sig, err := base64.RawURLEncoding.DecodeString(proof)
|
||
if err == nil && len(raw) == ed25519.PublicKeySize {
|
||
ok = ed25519.Verify(ed25519.PublicKey(raw), []byte(msg), sig)
|
||
}
|
||
}
|
||
case strings.HasPrefix(pubHdr, "p256:"):
|
||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(pubHdr, "p256:"))
|
||
if err == nil && len(raw) == 65 && raw[0] == 0x04 {
|
||
x := new(big.Int).SetBytes(raw[1:33])
|
||
y := new(big.Int).SetBytes(raw[33:65])
|
||
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||
der, err := base64.RawURLEncoding.DecodeString(proof)
|
||
if err == nil {
|
||
ok = ecdsa.VerifyASN1(&pk, []byte(msg), der)
|
||
}
|
||
}
|
||
}
|
||
if !ok {
|
||
http.Error(w, "bad proof", http.StatusUnauthorized)
|
||
return false
|
||
}
|
||
// replay cache
|
||
h := sha256.Sum256([]byte(proof + "|" + ts))
|
||
key := base64.RawURLEncoding.EncodeToString(h[:])
|
||
s.replayMu.Lock()
|
||
defer s.replayMu.Unlock()
|
||
if exp, exists := s.replays[key]; exists && time.Now().Before(exp) {
|
||
http.Error(w, "replay", http.StatusUnauthorized)
|
||
return false
|
||
}
|
||
s.replays[key] = time.Now().Add(10 * time.Minute)
|
||
return true
|
||
}
|
||
|
||
// ---------- Public-key auth: challenge/verify ----------
|
||
|
||
func (s *Server) handleKeyChallenge(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
nonce := s.randToken(16)
|
||
exp := time.Now().Add(10 * time.Minute)
|
||
s.nonceMu.Lock()
|
||
s.nonceExpiry[nonce] = exp
|
||
s.nonceMu.Unlock()
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"nonce": nonce, "exp": exp.Unix()})
|
||
}
|
||
|
||
type keyVerifyReq struct {
|
||
Nonce string `json:"nonce"`
|
||
Alg string `json:"alg"` // "p256" or "ed25519"
|
||
Pub string `json:"pub"` // base64(raw) for that alg (p256 uncompressed point 65B; ed25519 32B)
|
||
Sig string `json:"sig"` // base64(signature over "key-verify\n"+nonce)
|
||
}
|
||
|
||
func (s *Server) handleKeyVerify(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
var req keyVerifyReq
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Nonce == "" || req.Alg == "" || req.Pub == "" || req.Sig == "" {
|
||
http.Error(w, "bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// check nonce
|
||
s.nonceMu.Lock()
|
||
exp, ok := s.nonceExpiry[req.Nonce]
|
||
if ok {
|
||
delete(s.nonceExpiry, req.Nonce)
|
||
}
|
||
s.nonceMu.Unlock()
|
||
if !ok || time.Now().After(exp) {
|
||
http.Error(w, "nonce invalid", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
msg := "key-verify\n" + req.Nonce
|
||
pubRaw, err := base64.RawURLEncoding.DecodeString(req.Pub)
|
||
if err != nil {
|
||
http.Error(w, "bad pub", http.StatusBadRequest)
|
||
return
|
||
}
|
||
sigRaw, err := base64.RawURLEncoding.DecodeString(req.Sig)
|
||
if err != nil {
|
||
http.Error(w, "bad sig", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var cnf string
|
||
switch strings.ToLower(req.Alg) {
|
||
case "ed25519":
|
||
if len(pubRaw) != ed25519.PublicKeySize || len(sigRaw) != ed25519.SignatureSize {
|
||
http.Error(w, "bad key", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !ed25519.Verify(ed25519.PublicKey(pubRaw), []byte(msg), sigRaw) {
|
||
http.Error(w, "verify failed", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
cnf = "ed25519:" + req.Pub
|
||
case "p256":
|
||
if len(pubRaw) != 65 || pubRaw[0] != 0x04 {
|
||
http.Error(w, "bad key", http.StatusBadRequest)
|
||
return
|
||
}
|
||
x := new(big.Int).SetBytes(pubRaw[1:33])
|
||
y := new(big.Int).SetBytes(pubRaw[33:65])
|
||
pk := ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||
// sigRaw assumed DER (WebCrypto)
|
||
if !ecdsa.VerifyASN1(&pk, []byte(msg), sigRaw) {
|
||
http.Error(w, "verify failed", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
cnf = "p256:" + req.Pub
|
||
default:
|
||
http.Error(w, "unsupported alg", http.StatusBadRequest)
|
||
return
|
||
}
|
||
sub := auth.AccountIDFromPub(pubRaw)
|
||
ttl := 8 * time.Hour
|
||
now := time.Now()
|
||
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
|
||
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
|
||
Iss: "greencoast", Aud: "api", CNF: cnf,
|
||
})
|
||
if err != nil {
|
||
http.Error(w, "sign error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"bearer": bearer,
|
||
"sub": sub,
|
||
"exp": now.Add(ttl).Unix(),
|
||
})
|
||
}
|
||
|
||
// ---------- 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
|
||
}
|
||
// Limit body to 10 MiB by default
|
||
const maxBlob = int64(10 << 20)
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxBlob)
|
||
|
||
// Read body first to support PoP over body hash
|
||
var buf bytes.Buffer
|
||
n, err := io.Copy(&buf, r.Body)
|
||
if err != nil {
|
||
http.Error(w, "read error", 500)
|
||
return
|
||
}
|
||
ac, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, buf.Bytes()) {
|
||
return
|
||
}
|
||
|
||
isPrivate := r.Header.Get("X-GC-Private") == "1"
|
||
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", 500)
|
||
return
|
||
}
|
||
|
||
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]any{"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) {
|
||
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:
|
||
ac, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, nil) {
|
||
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:
|
||
ac, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, nil) {
|
||
return
|
||
}
|
||
if err := s.store.Delete(hash); err != nil {
|
||
http.Error(w, "delete error", 500)
|
||
return
|
||
}
|
||
_ = s.idx.Delete(hash)
|
||
s.sseBroadcast(map[string]any{"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
|
||
}
|
||
ac, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, nil) {
|
||
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)
|
||
}
|
||
|
||
func (s *Server) handleIndexSSE(w http.ResponseWriter, r *http.Request) {
|
||
ac, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, nil) {
|
||
return
|
||
}
|
||
flusher, ok2 := w.(http.Flusher)
|
||
if !ok2 {
|
||
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)
|
||
s.sseMu.Lock()
|
||
if s.sseClosed {
|
||
s.sseMu.Unlock()
|
||
http.Error(w, "closed", http.StatusGone)
|
||
return
|
||
}
|
||
s.sseSubs[ch] = struct{}{}
|
||
s.sseMu.Unlock()
|
||
|
||
fmt.Fprintf(w, "data: %s\n\n", `{"event":"hello","data":"ok"}`)
|
||
flusher.Flush()
|
||
|
||
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 ----------
|
||
|
||
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, ok := s.parseAuth(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if !s.verifyPoP(w, r, ac, nil) {
|
||
return
|
||
}
|
||
walker, ok2 := s.store.(blobWalker)
|
||
if !ok2 {
|
||
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 with PKCE + device key binding ----------
|
||
|
||
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
|
||
}
|
||
if r.Header.Get("X-GC-3P-Assent") != "1" {
|
||
http.Error(w, "third-party provider not assented", http.StatusForbidden)
|
||
return
|
||
}
|
||
deviceKey := strings.TrimSpace(r.Header.Get("X-GC-Key"))
|
||
if deviceKey == "" {
|
||
http.Error(w, "device key required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// PKCE
|
||
verifier := s.randToken(32)
|
||
chalSum := sha256.Sum256([]byte(verifier))
|
||
challenge := base64.RawURLEncoding.EncodeToString(chalSum[:])
|
||
|
||
state := s.randToken(16)
|
||
s.stateMu.Lock()
|
||
s.states[state] = stateItem{
|
||
Exp: time.Now().Add(10 * time.Minute),
|
||
Verifier: verifier,
|
||
DeviceKey: deviceKey,
|
||
}
|
||
s.stateMu.Unlock()
|
||
|
||
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("state", state)
|
||
v.Set("code_challenge", challenge)
|
||
v.Set("code_challenge_method", "S256")
|
||
|
||
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 == "" {
|
||
http.Error(w, "invalid state/code", http.StatusBadRequest)
|
||
return
|
||
}
|
||
s.stateMu.Lock()
|
||
item, ok := s.states[state]
|
||
if ok && time.Now().Before(item.Exp) {
|
||
delete(s.states, state)
|
||
}
|
||
s.stateMu.Unlock()
|
||
if !ok {
|
||
http.Error(w, "state expired", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Exchange code for token (with verifier)
|
||
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)
|
||
form.Set("code_verifier", item.Verifier)
|
||
|
||
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"`
|
||
}
|
||
if err := json.NewDecoder(res.Body).Decode(&tok); err != nil {
|
||
http.Error(w, "token decode failed", 502)
|
||
return
|
||
}
|
||
|
||
// Fetch user id
|
||
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"`
|
||
}
|
||
if err := json.NewDecoder(ures.Body).Decode(&user); err != nil {
|
||
http.Error(w, "user decode failed", 502)
|
||
return
|
||
}
|
||
|
||
// Bind token to device key from /start
|
||
ttl := 8 * time.Hour
|
||
now := time.Now()
|
||
sub := "discord:" + user.ID
|
||
bearer, err := auth.MintGC2(s.signingKey, auth.Claims{
|
||
Sub: sub, Exp: now.Add(ttl).Unix(), Nbf: now.Add(-60 * time.Second).Unix(),
|
||
Iss: "greencoast", Aud: "api", CNF: item.DeviceKey,
|
||
})
|
||
if err != nil {
|
||
http.Error(w, "sign error", 500)
|
||
return
|
||
}
|
||
u, _ := url.Parse(cfg.RedirectURI)
|
||
u.Fragment = "bearer=" + url.QueryEscape(bearer) + "&next=/"
|
||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||
}
|
||
|
||
// ---------- Utilities, shutdown ----------
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (s *Server) randToken(n int) string {
|
||
// HMAC over time + counter to avoid importing crypto/rand; good enough for state/nonce
|
||
// (If you prefer, switch to crypto/rand.)
|
||
b := []byte(fmt.Sprintf("%d|%d", time.Now().UnixNano(), len(s.states)+len(s.nonceExpiry)))
|
||
m := hmac.New(sha256.New, []byte(fmt.Sprintf("%p", s)))
|
||
m.Write(b)
|
||
sum := m.Sum(nil)
|
||
return base64.RawURLEncoding.EncodeToString(sum[:n])
|
||
}
|