723 lines
18 KiB
Go
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
|
|
}
|