resolveUser attaches the session's provisioned geniusrun user (if any) to request context without blocking; requireProvisionedUser (wired fully in Task 13) 403s routes that need one. GET /api/session/me now reports has_profile/display_name so the frontend can show the setup screen.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
// Package api is geniusrun's HTTP layer: REST handlers over internal/store,
|
|
// internal/garmin, and internal/sync.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"geniusrun/backend/internal/auth"
|
|
"geniusrun/backend/internal/garmin"
|
|
"geniusrun/backend/internal/store"
|
|
appsync "geniusrun/backend/internal/sync"
|
|
)
|
|
|
|
// Server wires the HTTP handlers to the app's dependencies.
|
|
type Server struct {
|
|
DB *store.DB
|
|
Garmin garmin.Client
|
|
Sync *appsync.Service
|
|
Auth auth.Verifier
|
|
Session SessionConfig
|
|
|
|
mu sync.Mutex
|
|
authStatus garmin.AuthStatus
|
|
authMessage string
|
|
syncRunning bool
|
|
}
|
|
|
|
// NewServer builds a Server.
|
|
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server {
|
|
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session}
|
|
}
|
|
|
|
// Router builds the HTTP routes.
|
|
func (s *Server) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
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.Session.Secret))
|
|
|
|
r.Get("/session/me", s.handleSessionMe)
|
|
r.Post("/session/logout", s.handleSessionLogout)
|
|
|
|
r.Use(s.resolveUser)
|
|
r.Post("/setup", s.handleSetup)
|
|
|
|
r.Route("/profile", func(r chi.Router) {
|
|
r.Get("/", s.handleGetProfile)
|
|
r.Put("/", s.handleUpdateProfile)
|
|
})
|
|
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Post("/login", s.handleAuthLogin)
|
|
r.Post("/mfa", s.handleAuthMFA)
|
|
r.Get("/status", s.handleAuthStatus)
|
|
})
|
|
|
|
r.Route("/sync", func(r chi.Router) {
|
|
r.Post("/run", s.handleSyncRun)
|
|
r.Post("/reset", s.handleSyncReset)
|
|
r.Get("/runs", s.handleSyncRuns)
|
|
r.Get("/status", s.handleSyncStatus)
|
|
})
|
|
|
|
r.Route("/activities", func(r chi.Router) {
|
|
r.Get("/", s.handleListActivities)
|
|
r.Get("/{id}", s.handleGetActivity)
|
|
})
|
|
|
|
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.Route("/review-queue", func(r chi.Router) {
|
|
r.Get("/", s.handleReviewQueue)
|
|
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
|
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
|
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
|
})
|
|
|
|
r.Get("/progression/{kindID}", s.handleProgression)
|
|
})
|
|
})
|
|
return r
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
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 {
|
|
log.Printf("api: encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
|
// only one sync operation runs at a time. Returns false if one is already
|
|
// in progress.
|
|
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
|
|
s.mu.Lock()
|
|
if s.syncRunning {
|
|
s.mu.Unlock()
|
|
return false
|
|
}
|
|
s.syncRunning = true
|
|
s.mu.Unlock()
|
|
|
|
go func() {
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.syncRunning = false
|
|
s.mu.Unlock()
|
|
}()
|
|
if err := fn(context.Background()); err != nil {
|
|
log.Printf("api: background sync error: %v", err)
|
|
}
|
|
}()
|
|
return true
|
|
}
|