This commit is contained in:
2025-08-26 19:33:02 -07:00
commit 79b95a9f1f
53 changed files with 47463 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
package api
import (
"context"
"fmt"
"time"
)
// Activity represents a Garmin Connect activity
type Activity struct {
ActivityID int64 `json:"activityId"`
Name string `json:"activityName"`
Type string `json:"activityType"`
StartTime time.Time `json:"startTimeLocal"`
Duration float64 `json:"duration"`
Distance float64 `json:"distance"`
}
// ActivitiesResponse represents the response from the activities endpoint
type ActivitiesResponse struct {
Activities []Activity `json:"activities"`
Pagination Pagination `json:"pagination"`
}
// Pagination represents pagination information in API responses
type Pagination struct {
PageSize int `json:"pageSize"`
TotalCount int `json:"totalCount"`
Page int `json:"page"`
}
// GetActivities retrieves a list of activities with pagination
func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) {
path := "/activitylist-service/activities/search"
query := fmt.Sprintf("?page=%d&pageSize=%d", page, pageSize)
var response ActivitiesResponse
err := c.Get(ctx, path+query, &response)
if err != nil {
return nil, nil, fmt.Errorf("failed to get activities: %w", err)
}
// Validate we received some activities
if len(response.Activities) == 0 {
return nil, nil, fmt.Errorf("no activities found")
}
return response.Activities, &response.Pagination, nil
}

121
internal/api/client.go Normal file
View File

@@ -0,0 +1,121 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"golang.org/x/time/rate"
)
// Client handles communication with the Garmin Connect API
type Client struct {
baseURL *url.URL
httpClient *http.Client
limiter *rate.Limiter
logger Logger
}
// NewClient creates a new API client
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{
baseURL: u,
httpClient: httpClient,
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
logger: &stdLogger{},
}, nil
}
// SetLogger sets the client's logger
func (c *Client) SetLogger(logger Logger) {
c.logger = logger
}
// SetRateLimit configures the rate limiter
func (c *Client) SetRateLimit(interval time.Duration, burst int) {
c.limiter = rate.NewLimiter(rate.Every(interval), burst)
}
// Get performs a GET request
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
return c.doRequest(ctx, http.MethodGet, path, nil, v)
}
// Post performs a POST request
func (c *Client) Post(ctx context.Context, path string, body io.Reader, v interface{}) error {
return c.doRequest(ctx, http.MethodPost, path, body, v)
}
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, v interface{}) error {
// Wait for rate limiter
if err := c.limiter.Wait(ctx); err != nil {
return fmt.Errorf("rate limit wait failed: %w", err)
}
// Create request
u := c.baseURL.ResolveReference(&url.URL{Path: path})
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
// Set headers
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.logger.Debugf("Request: %s %s", method, u.String())
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
c.logger.Debugf("Response status: %s", resp.Status)
// Handle non-200 responses
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Parse response
if v == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("decode response failed: %w", err)
}
return nil
}
// Logger defines the logging interface for the client
type Logger interface {
Debugf(format string, args ...interface{})
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// stdLogger is the default logger that uses the standard log package
type stdLogger struct{}
func (l *stdLogger) Debugf(format string, args ...interface{}) {}
func (l *stdLogger) Infof(format string, args ...interface{}) {}
func (l *stdLogger) Errorf(format string, args ...interface{}) {}

38
internal/api/user.go Normal file
View File

@@ -0,0 +1,38 @@
package api
import (
"context"
"fmt"
)
// UserProfile represents a Garmin Connect user profile
type UserProfile struct {
DisplayName string `json:"displayName"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
Username string `json:"username"`
ProfileID string `json:"profileId"`
ProfileImage string `json:"profileImageUrlLarge"`
Location string `json:"location"`
FitnessLevel string `json:"fitnessLevel"`
Height float64 `json:"height"`
Weight float64 `json:"weight"`
Birthdate string `json:"birthDate"`
}
// GetUserProfile retrieves the user's profile information
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
var profile UserProfile
path := "/userprofile-service/socialProfile"
if err := c.Get(ctx, path, &profile); err != nil {
return nil, fmt.Errorf("failed to get user profile: %w", err)
}
// Handle empty profile response
if profile.ProfileID == "" {
return nil, fmt.Errorf("user profile not found")
}
return &profile, nil
}

92
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,92 @@
package auth
import (
"net/http"
"github.com/dghubble/oauth1"
)
// OAuthConfig holds OAuth1 configuration for Garmin Connect
type OAuthConfig struct {
ConsumerKey string
ConsumerSecret string
}
// TokenStorage defines the interface for storing and retrieving OAuth tokens
type TokenStorage interface {
GetToken() (*oauth1.Token, error)
SaveToken(*oauth1.Token) error
}
// Authenticate initiates the OAuth1 authentication flow
func Authenticate(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage) {
// Create OAuth1 config
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
CallbackURL: "http://localhost:8080/callback",
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AuthorizeURL: "https://connect.garmin.com/oauth-service/oauth/authorize",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get request token
requestToken, _, err := oauthConfig.RequestToken()
if err != nil {
http.Error(w, "Failed to get request token", http.StatusInternalServerError)
return
}
// Save request token secret temporarily (for callback)
// In a real application, you'd store this in a session
// Redirect to authorization URL
authURL, err := oauthConfig.AuthorizationURL(requestToken)
if err != nil {
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError)
return
}
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect)
}
// Callback handles OAuth1 callback
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) {
// Get request token and verifier from query params
requestToken := r.URL.Query().Get("oauth_token")
verifier := r.URL.Query().Get("oauth_verifier")
// Create OAuth1 config
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get access token
accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, verifier)
if err != nil {
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
return
}
// Create token and save
token := &oauth1.Token{
Token: accessToken,
TokenSecret: accessSecret,
}
err = storage.SaveToken(token)
if err != nil {
http.Error(w, "Failed to save token", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Authentication successful!"))
}

View File

@@ -0,0 +1,59 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"github.com/dghubble/oauth1"
)
// FileStorage implements TokenStorage using a JSON file
type FileStorage struct {
Path string
}
// NewFileStorage creates a new FileStorage instance
func NewFileStorage() *FileStorage {
// Default to storing token in user's home directory
home, _ := os.UserHomeDir()
return &FileStorage{
Path: filepath.Join(home, ".garminconnect", "token.json"),
}
}
// GetToken retrieves token from file
func (s *FileStorage) GetToken() (*oauth1.Token, error) {
data, err := os.ReadFile(s.Path)
if err != nil {
return nil, err
}
var token oauth1.Token
err = json.Unmarshal(data, &token)
if err != nil {
return nil, err
}
// Check if token is expired
if token.Token == "" || token.TokenSecret == "" {
return nil, os.ErrNotExist
}
return &token, nil
}
// SaveToken saves token to file
func (s *FileStorage) SaveToken(token *oauth1.Token) error {
// Create directory if it doesn't exist
dir := filepath.Dir(s.Path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.Path, data, 0600)
}

View File

@@ -0,0 +1,85 @@
package auth
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/dghubble/oauth1"
"github.com/stretchr/testify/assert"
)
func TestFileStorage(t *testing.T) {
// Create temp directory for tests
tempDir, err := os.MkdirTemp("", "garmin-test")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
storage := &FileStorage{
Path: filepath.Join(tempDir, "token.json"),
}
// Test saving and loading token
t.Run("SaveAndLoadToken", func(t *testing.T) {
testToken := &oauth1.Token{
Token: "access-token",
TokenSecret: "access-secret",
}
// Save token
err := storage.SaveToken(testToken)
assert.NoError(t, err)
// Load token
loadedToken, err := storage.GetToken()
assert.NoError(t, err)
assert.Equal(t, testToken.Token, loadedToken.Token)
assert.Equal(t, testToken.TokenSecret, loadedToken.TokenSecret)
})
// Test missing token file
t.Run("TokenMissing", func(t *testing.T) {
_, err := storage.GetToken()
assert.ErrorIs(t, err, os.ErrNotExist)
})
// Test token expiration
t.Run("TokenExpiration", func(t *testing.T) {
testCases := []struct {
name string
token *oauth1.Token
expected bool
}{
{
name: "EmptyToken",
token: &oauth1.Token{},
expected: true,
},
{
name: "ValidToken",
token: &oauth1.Token{
Token: "valid",
TokenSecret: "valid",
},
expected: false,
},
{
name: "ExpiredToken",
token: &oauth1.Token{
Token: "expired",
TokenSecret: "expired",
CreatedAt: time.Now().Add(-200 * 24 * time.Hour), // 200 days ago
},
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
expired := storage.TokenExpired(tc.token)
assert.Equal(t, tc.expected, expired)
})
}
})
}

42
internal/auth/mfa.go Normal file
View File

@@ -0,0 +1,42 @@
package auth
import (
"fmt"
"net/http"
)
// MFAHandler handles multi-factor authentication
func MFAHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
// Show MFA form
fmt.Fprintf(w, `<html>
<body>
<form method="POST">
<label>MFA Code: <input type="text" name="mfa_code"></label>
<button type="submit">Verify</button>
</form>
</body>
</html>`)
case "POST":
// Process MFA code
code := r.FormValue("mfa_code")
// Validate MFA code - in a real app, this would be sent to Garmin
if len(code) != 6 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid MFA code format. Please enter a 6-digit code."))
return
}
// Store MFA verification status in session
// In a real app, we'd store this in a session store
w.Write([]byte("MFA verification successful! Please return to your application."))
}
}
// RequiresMFA checks if MFA is required based on Garmin response
func RequiresMFA(err error) bool {
// In a real implementation, we'd check the error type
// or response from Garmin to determine if MFA is needed
return err != nil && err.Error() == "mfa_required"
}

111
internal/fit/decoder.go Normal file
View File

@@ -0,0 +1,111 @@
package fit
import (
"encoding/binary"
"errors"
"io"
"os"
)
const (
headerSize = 12
protocolMajor = 2
)
// FileHeader represents the header of a FIT file
type FileHeader struct {
Size uint8
Protocol uint8
Profile [4]byte
DataSize uint32
Signature [4]byte
}
// Activity represents activity data from a FIT file
type Activity struct {
Type string
StartTime int64
TotalDistance float64
Duration float64
}
// Decoder parses FIT files
type Decoder struct {
r io.Reader
}
// NewDecoder creates a new FIT decoder
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{r: r}
}
// Parse decodes the FIT file and returns the activity data
func (d *Decoder) Parse() (*Activity, error) {
var header FileHeader
if err := binary.Read(d.r, binary.LittleEndian, &header); err != nil {
return nil, err
}
// Validate header
if header.Protocol != protocolMajor {
return nil, errors.New("unsupported FIT protocol version")
}
// For simplicity, we'll just extract basic activity data
activity := &Activity{}
// Skip to activity record (simplified for example)
// In a real implementation, we would parse the file structure properly
for {
var recordHeader uint8
if err := binary.Read(d.r, binary.LittleEndian, &recordHeader); err == io.EOF {
break
} else if err != nil {
return nil, err
}
if recordHeader == 0x21 { // Activity record header (example value)
var record struct {
Type uint8
StartTime int64
TotalDistance float32
Duration uint32
}
if err := binary.Read(d.r, binary.LittleEndian, &record); err != nil {
return nil, err
}
activity.Type = activityType(record.Type)
activity.StartTime = record.StartTime
activity.TotalDistance = float64(record.TotalDistance)
activity.Duration = float64(record.Duration)
break
}
}
return activity, nil
}
func activityType(t uint8) string {
switch t {
case 1:
return "Running"
case 2:
return "Cycling"
case 3:
return "Swimming"
default:
return "Unknown"
}
}
// ReadFile reads and parses a FIT file
func ReadFile(path string) (*Activity, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return NewDecoder(file).Parse()
}