session.idle_timeout (minutes, default 15) joins the app-config registry and drives the onboarding Garmin session eviction, distinct from session.duration (the login cookie lifetime in hours). The raw Keycloak ID token no longer rides in auth.Claims through every request context: it's minted into the session cookie separately and read back only by the logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent namespace alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Package api is geniusrun's HTTP layer: REST handlers over internal/store,
|
|
// internal/garmin, and internal/sync.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
applog "geniusrun/backend/internal/log"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
"geniusrun/backend/internal/garmin"
|
|
"geniusrun/backend/internal/store"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
|
// and sync.Service are per-user (each user might have their own Garmin
|
|
// account), built lazily on first use via GarminFactory and cached.
|
|
type Server struct {
|
|
DB *store.DB
|
|
Auth auth.Verifier
|
|
SessionConfig SessionConfig
|
|
|
|
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
|
// fully-resolved per-user Config. Production wiring passes
|
|
// garmin.NewClient; tests inject a factory returning a shared
|
|
// *mock.Client (see newTestServer in api_test.go).
|
|
GarminFactory func(garmin.ClientConfig) garmin.Client
|
|
|
|
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
|
// (the python interpreter path + the token-store root directory); only
|
|
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
|
// garminFor.
|
|
ClientConfig garmin.ClientConfig
|
|
SyncConfig garmin.SyncConfig
|
|
|
|
// EnvVars is the read-only, display-safe environment-configuration
|
|
// snapshot served by GET /api/config -- built once in main.go from
|
|
// config.Config.DisplayEnv() (secrets already masked there); handlers
|
|
// never call os.Getenv.
|
|
EnvVars []EnvVar
|
|
|
|
// SetupSessionIdleTimeout is the app-config session.idle_timeout value
|
|
// (see internal/config); zero falls back to
|
|
// defaultSetupSessionIdleTimeout.
|
|
SetupSessionIdleTimeout time.Duration
|
|
|
|
mu sync.Mutex
|
|
userClient map[int64]garmin.Client
|
|
userSync map[int64]*garmin.Sync
|
|
userAuthStatus map[int64]garmin.AuthStatus
|
|
userAuthMessage map[int64]string
|
|
userSyncRunning map[int64]bool
|
|
setupSession map[string]*setupSession
|
|
}
|
|
|
|
// NewServer builds a Server.
|
|
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
|
return &Server{
|
|
DB: db,
|
|
GarminFactory: garminFactory,
|
|
ClientConfig: garminBase,
|
|
SyncConfig: syncConfig,
|
|
Auth: authVerifier,
|
|
SessionConfig: sessionConfig,
|
|
userClient: map[int64]garmin.Client{},
|
|
userSync: map[int64]*garmin.Sync{},
|
|
userAuthStatus: map[int64]garmin.AuthStatus{},
|
|
userAuthMessage: map[int64]string{},
|
|
userSyncRunning: map[int64]bool{},
|
|
setupSession: map[string]*setupSession{},
|
|
}
|
|
}
|
|
|
|
// Router builds the HTTP routes.
|
|
func (s *Server) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(loggingMiddleware)
|
|
r.Use(corsMiddleware)
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.Get("/health", s.handleHealth)
|
|
|
|
// Unprotected: these two ARE the login flow, so they can't require
|
|
// a session yet.
|
|
r.Get("/session/login", s.handleSessionLogin)
|
|
r.Get("/session/callback", s.handleSessionCallback)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
|
r.Use(s.resolveUser)
|
|
|
|
r.Get("/session/me", s.handleSessionMe)
|
|
r.Post("/session/logout", s.handleSessionLogout)
|
|
|
|
r.Route("/setup", func(r chi.Router) {
|
|
r.Post("/complete", s.handleSetupComplete)
|
|
r.Route("/garmin", func(r chi.Router) {
|
|
r.Post("/login", s.handleSetupGarminLogin)
|
|
r.Post("/mfa", s.handleSetupGarminMFA)
|
|
})
|
|
})
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireProvisionedUser)
|
|
|
|
r.Route("/profile", func(r chi.Router) {
|
|
r.Get("/", s.handleGetProfile)
|
|
r.Put("/", s.handleUpdateProfile)
|
|
r.Delete("/", s.handleDeleteProfile)
|
|
})
|
|
|
|
r.Route("/garmin", func(r chi.Router) {
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Post("/login", s.handleGarminAuthLogin)
|
|
r.Post("/mfa", s.handleGarminAuthMFA)
|
|
r.Get("/status", s.handleGarminAuthStatus)
|
|
})
|
|
|
|
r.Route("/sync", func(r chi.Router) {
|
|
r.Post("/run", s.handleGarminSyncRun)
|
|
r.Post("/reset", s.handleGarminSyncReset)
|
|
r.Get("/runs", s.handleGarminSyncRuns)
|
|
r.Get("/status", s.handleGarminSyncStatus)
|
|
})
|
|
|
|
})
|
|
|
|
r.Route("/activities", func(r chi.Router) {
|
|
r.Get("/", s.handleListActivities)
|
|
r.Post("/{activityID}/assign", s.handleAssignActivity)
|
|
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
|
|
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
|
|
})
|
|
|
|
r.Route("/workout-kinds", func(r chi.Router) {
|
|
r.Get("/", s.handleListWorkoutKinds)
|
|
r.Get("/{id}", s.handleGetWorkoutKind)
|
|
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
|
})
|
|
|
|
r.Post("/reclassify", s.handleReclassifyAll)
|
|
|
|
r.Get("/config", s.handleGetConfig)
|
|
r.Put("/config", s.handlePutConfig)
|
|
|
|
r.Get("/progression/{kindID}", s.handleProgression)
|
|
})
|
|
})
|
|
})
|
|
return r
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
var requestIDCounter atomic.Int64
|
|
|
|
// loggingMiddleware logs one JSON line per HTTP request (method,
|
|
// path, status, duration) and attaches a per-request logger (tagged with a
|
|
// request_id) to the request context, so any downstream call this request
|
|
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
|
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
|
logger := applog.FromContext(r.Context()).With("request_id", id)
|
|
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
|
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
start := time.Now()
|
|
next.ServeHTTP(ww, r)
|
|
|
|
level := slog.LevelInfo
|
|
if ww.Status() >= 500 {
|
|
level = slog.LevelWarn
|
|
}
|
|
logger.LogAttrs(r.Context(), level, "HTTP request",
|
|
slog.String("method", r.Method),
|
|
slog.String("path", r.URL.Path),
|
|
slog.Int("status", ww.Status()),
|
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
|
)
|
|
})
|
|
}
|
|
|
|
// corsMiddleware allows the frontend dev server (a different port) to call
|
|
// this API. Reflecting any origin back is safe even with credentials
|
|
// enabled: this remains a single-operator app whose real access control is
|
|
// the OIDC login gate (internal/auth), not origin-based CSRF defense.
|
|
func corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// clientFor returns userID's garmin.Client, building and caching it (from
|
|
// userID's own profile row) on first use.
|
|
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
|
s.mu.Lock()
|
|
if c, ok := s.userClient[userID]; ok {
|
|
s.mu.Unlock()
|
|
return c, nil
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
profile, err := s.DB.GetProfile(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
|
}
|
|
cfg := s.ClientConfig
|
|
cfg.GarminEmail = profile.GarminEmail
|
|
cfg.GarminPassword = profile.GarminPassword
|
|
if cfg.TokenStorePath != "" {
|
|
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if c, ok := s.userClient[userID]; ok {
|
|
return c, nil // built concurrently by another request between our unlock and re-lock
|
|
}
|
|
client := s.GarminFactory(cfg)
|
|
s.userClient[userID] = client
|
|
return client, nil
|
|
}
|
|
|
|
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
|
// any) and every other per-user in-memory entry for userID, terminating the
|
|
// client's subprocess and best-effort removing its on-disk token-store
|
|
// directory. Called when a user's account has just been deleted from the
|
|
// DB, so nothing in memory keeps referencing a userID that no longer
|
|
// exists.
|
|
func (s *Server) removeUserClient(userID int64) {
|
|
s.mu.Lock()
|
|
client, ok := s.userClient[userID]
|
|
delete(s.userClient, userID)
|
|
delete(s.userSync, userID)
|
|
delete(s.userAuthStatus, userID)
|
|
delete(s.userAuthMessage, userID)
|
|
delete(s.userSyncRunning, userID)
|
|
s.mu.Unlock()
|
|
|
|
if ok {
|
|
if err := client.Close(); err != nil {
|
|
slog.Error("close garmin client for deleted user", "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
if s.ClientConfig.TokenStorePath == "" {
|
|
return
|
|
}
|
|
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
|
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
|
slog.Error("remove token store dir for deleted user", "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
// syncFor returns userID's sync.Service, building and caching it on first use.
|
|
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
|
s.mu.Lock()
|
|
if svc, ok := s.userSync[userID]; ok {
|
|
s.mu.Unlock()
|
|
return svc, nil
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
client, err := s.clientFor(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if svc, ok := s.userSync[userID]; ok {
|
|
return svc, nil
|
|
}
|
|
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
|
s.userSync[userID] = svc
|
|
return svc, nil
|
|
}
|
|
|
|
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
|
// only one sync operation per userID runs at a time. Returns false if one
|
|
// is already in progress for that user.
|
|
func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool {
|
|
s.mu.Lock()
|
|
if s.userSyncRunning[userID] {
|
|
s.mu.Unlock()
|
|
return false
|
|
}
|
|
s.userSyncRunning[userID] = true
|
|
s.mu.Unlock()
|
|
|
|
go func() {
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.userSyncRunning[userID] = false
|
|
s.mu.Unlock()
|
|
}()
|
|
if err := fn(context.Background()); err != nil {
|
|
slog.Error("background sync failed", "user_id", userID, "error", err)
|
|
}
|
|
}()
|
|
return true
|
|
}
|
|
|
|
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
|
// session for sub should use -- hashed rather than sub itself, since sub is
|
|
// an opaque string from the identity provider and using it verbatim in a
|
|
// filesystem path would be a directory-traversal risk if it ever contained
|
|
// path separators.
|
|
func setupTokenStoreDir(root, sub string) string {
|
|
h := sha256.Sum256([]byte(sub))
|
|
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
slog.Error("encode response", "error", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|