refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,38 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
)
|
||||
|
||||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||||
// survives without being touched (login, MFA, or complete) before it's
|
||||
// evicted -- long enough to check email for an MFA code, short enough that
|
||||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||||
const setupSessionIdleTimeout = 15 * time.Minute
|
||||
|
||||
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||
// attempt made during onboarding, before any users/profile row exists --
|
||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
||||
// rather than a user id. Closed (never promoted into Server.userClient) once
|
||||
// /api/setup/complete actually creates the account -- its Client's
|
||||
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||
// setup/{hash} directory, so reusing the object after that directory is
|
||||
// renamed to the permanent {userID} one would respawn against a stale path
|
||||
// the next time anything closes and restarts its subprocess; a later
|
||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
||||
// subject checks staleness first) once idle past setupSessionIdleTimeout.
|
||||
type setupSession struct {
|
||||
Client garmin.Client
|
||||
Email, Password string
|
||||
Status garmin.AuthStatus
|
||||
Message string
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||||
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||||
// row exists yet at this point (see setupSession in server.go).
|
||||
@@ -90,7 +117,7 @@ func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||
// credentials, marks it connected, closes the ephemeral client, and
|
||||
// renames its token-store directory into the permanent per-user path --
|
||||
// the next real garminFor(ctx, userID) builds a fresh client from scratch
|
||||
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||
// against that now-permanent directory, whose subprocess's lazy
|
||||
// startup-login resumes the just-renamed, still-valid session without
|
||||
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||
@@ -160,14 +187,14 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
sess.Client.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.setupGarmin, claims.Sub)
|
||||
delete(s.setupSession, claims.Sub)
|
||||
s.userAuthStatus[userID] = sess.Status
|
||||
s.userAuthMessage[userID] = sess.Message
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.GarminBase.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
|
||||
}
|
||||
@@ -175,3 +202,80 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||
}
|
||||
|
||||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||
// any and not stale. A stale session is closed and evicted first, so the
|
||||
// caller always either gets a fresh, live session or none.
|
||||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||||
sess.Client.Close()
|
||||
delete(s.setupSession, sub)
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||
// (if any) with a freshly built one for the given credentials -- same
|
||||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||
s.mu.Lock()
|
||||
old, ok := s.setupSession[sub]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
old.Client.Close()
|
||||
}
|
||||
|
||||
cfg := s.ClientConfig
|
||||
cfg.GarminEmail = email
|
||||
cfg.GarminPassword = password
|
||||
if cfg.TokenStorePath != "" {
|
||||
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||
}
|
||||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||
|
||||
s.mu.Lock()
|
||||
s.setupSession[sub] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.setupSession[sub]; ok {
|
||||
sess.Status = res.Status
|
||||
sess.Message = res.Message
|
||||
sess.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||
// any, and best-effort removes its token-store directory. Used when
|
||||
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||
// same way but keeps (renames) the directory instead of removing it, since
|
||||
// that's the real, now-permanent session.
|
||||
func (s *Server) removeSetupSession(sub string) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.setupSession[sub]
|
||||
delete(s.setupSession, sub)
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess.Client.Close()
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user