This commit is contained in:
296
server/fitbit.go
Normal file
296
server/fitbit.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config structure to hold credentials
|
||||
type FitbitConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURI string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var fitbitConfig FitbitConfig
|
||||
|
||||
const (
|
||||
scopes = "activity profile"
|
||||
)
|
||||
|
||||
func InitFitbit() {
|
||||
fitbitConfig.ClientID = os.Getenv("FITBIT_CLIENT_ID")
|
||||
fitbitConfig.ClientSecret = os.Getenv("FITBIT_CLIENT_SECRET")
|
||||
fitbitConfig.RedirectURI = "http://localhost:8080/auth/callback"
|
||||
|
||||
if envRedirect := os.Getenv("FITBIT_AUTH_REDIRECT_URI"); envRedirect != "" {
|
||||
fitbitConfig.RedirectURI = envRedirect
|
||||
}
|
||||
}
|
||||
|
||||
func loadTokens(userID string) (*FitbitConfig, error) {
|
||||
configPath := fmt.Sprintf("data/users/%s/fitbit_tokens.json", userID)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config FitbitConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func saveTokens(userID string, config *FitbitConfig) error {
|
||||
userDir := fmt.Sprintf("data/users/%s", userID)
|
||||
if err := os.MkdirAll(userDir, 0755); err != nil {
|
||||
return fmt.Errorf("error creating user directory: %w", err)
|
||||
}
|
||||
|
||||
configPath := fmt.Sprintf("%s/fitbit_tokens.json", userDir)
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0600)
|
||||
}
|
||||
|
||||
// GetDailySteps fetches step count for a specific date (YYYY-MM-DD) for a specific user
|
||||
func GetDailySteps(userID, date string) (int, error) {
|
||||
config, err := loadTokens(userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to load tokens: %w", err)
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if time.Now().After(config.ExpiresAt) {
|
||||
err := refreshTokens(userID, config)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", date)
|
||||
|
||||
req, _ := http.NewRequest("GET", apiURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+config.AccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("fitbit api error: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Summary struct {
|
||||
Steps int `json:"steps"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.Summary.Steps, nil
|
||||
}
|
||||
|
||||
func refreshTokens(userID string, config *FitbitConfig) error {
|
||||
// POST https://api.fitbit.com/oauth2/token
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", config.RefreshToken)
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
req.SetBasicAuth(fitbitConfig.ClientID, fitbitConfig.ClientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("token refresh failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.AccessToken = result.AccessToken
|
||||
config.RefreshToken = result.RefreshToken
|
||||
config.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||
|
||||
return saveTokens(userID, config)
|
||||
}
|
||||
|
||||
// HandleFitbitAuth redirects user to Fitbit authorization page
|
||||
func HandleFitbitAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if fitbitConfig.ClientID == "" {
|
||||
http.Error(w, "FITBIT_CLIENT_ID not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf(
|
||||
"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
|
||||
url.QueryEscape(fitbitConfig.ClientID),
|
||||
url.QueryEscape(fitbitConfig.RedirectURI),
|
||||
url.QueryEscape(scopes),
|
||||
)
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// HandleFitbitCallback receives the authorization code and exchanges it for tokens
|
||||
func HandleFitbitCallback(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println("[OAuth Callback] Started")
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
fmt.Println("[OAuth Callback] ERROR: No authorization code")
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Println("[OAuth Callback] Received authorization code")
|
||||
|
||||
// Exchange code for tokens
|
||||
fmt.Println("[OAuth Callback] Exchanging code for tokens...")
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", fitbitConfig.RedirectURI)
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
req.SetBasicAuth(fitbitConfig.ClientID, fitbitConfig.ClientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[OAuth Callback] ERROR: Token exchange request failed: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Printf("[OAuth Callback] Token exchange response: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("[OAuth Callback] ERROR: Token exchange failed: %s - %s\n", resp.Status, body)
|
||||
http.Error(w, fmt.Sprintf("Token exchange failed: %s - %s", resp.Status, body), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
UserID string `json:"user_id"` // Fitbit returns user_id in token response
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResult); err != nil {
|
||||
fmt.Printf("[OAuth Callback] ERROR: Failed to parse token response: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to parse token response: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Printf("[OAuth Callback] Successfully parsed tokens for user: %s\n", tokenResult.UserID)
|
||||
|
||||
fitbitUserID := tokenResult.UserID
|
||||
if fitbitUserID == "" {
|
||||
fmt.Println("[OAuth Callback] ERROR: No user_id in token response")
|
||||
http.Error(w, "No user_id in token response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user profile from Fitbit
|
||||
fmt.Println("[OAuth Callback] Fetching user profile from Fitbit...")
|
||||
userID, displayName, avatarURL, err := FetchFitbitUserProfile(tokenResult.AccessToken)
|
||||
if err != nil {
|
||||
fmt.Printf("[OAuth Callback] WARNING: Could not fetch user profile: %v\n", err)
|
||||
// Use user_id from token if profile fetch fails
|
||||
displayName = fitbitUserID
|
||||
avatarURL = ""
|
||||
} else {
|
||||
fmt.Printf("[OAuth Callback] Successfully fetched profile for: %s\n", displayName)
|
||||
// Verify user_id matches
|
||||
if userID != fitbitUserID {
|
||||
fmt.Printf("[OAuth Callback] WARNING: user_id mismatch: token=%s, profile=%s\n", fitbitUserID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update user in registry
|
||||
fmt.Println("[OAuth Callback] Creating/updating user in registry...")
|
||||
if userRegistry == nil {
|
||||
fmt.Println("[OAuth Callback] ERROR: userRegistry is nil!")
|
||||
http.Error(w, "Server configuration error: user registry not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, err = userRegistry.CreateOrUpdateUser(fitbitUserID, displayName, avatarURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[OAuth Callback] ERROR: Failed to create user: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to create user: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Println("[OAuth Callback] User created/updated successfully")
|
||||
|
||||
// Save Fitbit tokens
|
||||
fmt.Println("[OAuth Callback] Saving Fitbit tokens...")
|
||||
userConfig := &FitbitConfig{
|
||||
ClientID: fitbitConfig.ClientID,
|
||||
ClientSecret: fitbitConfig.ClientSecret,
|
||||
RedirectURI: fitbitConfig.RedirectURI,
|
||||
AccessToken: tokenResult.AccessToken,
|
||||
RefreshToken: tokenResult.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second),
|
||||
}
|
||||
if err := saveTokens(fitbitUserID, userConfig); err != nil {
|
||||
fmt.Printf("[OAuth Callback] ERROR: Failed to save tokens: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to save tokens: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[OAuth Callback] Tokens saved successfully")
|
||||
|
||||
// Create session
|
||||
fmt.Println("[OAuth Callback] Creating session...")
|
||||
session, err := CreateSession(fitbitUserID)
|
||||
if err != nil {
|
||||
fmt.Printf("[OAuth Callback] ERROR: Failed to create session: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Println("[OAuth Callback] Session created successfully")
|
||||
|
||||
// Set session cookie
|
||||
fmt.Println("[OAuth Callback] Setting session cookie...")
|
||||
SetSessionCookie(w, session)
|
||||
|
||||
fmt.Printf("[OAuth Callback] ✅ SUCCESS! User %s (%s) logged in\n", displayName, fitbitUserID)
|
||||
|
||||
// Redirect to homepage
|
||||
fmt.Println("[OAuth Callback] Redirecting to homepage")
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
}
|
||||
Reference in New Issue
Block a user