Files
geniusrun/backend/internal/api/server.go
Christophe Vila 6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00

341 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"
"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
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"})
}
// loggingMiddleware logs one type=http JSON line per HTTP request. The
// record's meaning is fully carried by its fields (http_method, path,
// status, duration_ms), so it has no msg; "method" stays reserved for the
// emitting function per the log schema, hence http_method for the verb.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
slog.Default().LogAttrs(r.Context(), level, "",
slog.String("type", "http"),
slog.String("class", "api"),
slog.String("method", "loggingMiddleware"),
slog.String("http_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 {
applog.App("api.Server", "removeUserClient").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 {
applog.App("api.Server", "removeUserClient").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 {
applog.App("api.Server", "backgroundSync").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 {
applog.App("api", "writeJSON").Error("encode response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}