All checks were successful
pedestrian-simulator / build (push) Successful in 58s
286 lines
9.1 KiB
Go
286 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"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) {
|
|
var config FitbitConfig
|
|
err := db.QueryRow("SELECT access_token, refresh_token, expires_at FROM fitbit_tokens WHERE user_id = ?", userID).
|
|
Scan(&config.AccessToken, &config.RefreshToken, &config.ExpiresAt)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("tokens not found for user %s", userID)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
func saveTokens(userID string, config *FitbitConfig) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO fitbit_tokens (user_id, access_token, refresh_token, expires_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE access_token = ?, refresh_token = ?, expires_at = ?
|
|
`, userID, config.AccessToken, config.RefreshToken, config.ExpiresAt,
|
|
config.AccessToken, config.RefreshToken, config.ExpiresAt)
|
|
return err
|
|
}
|
|
|
|
// 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 database
|
|
fmt.Println("[OAuth Callback] Creating/updating user in database...")
|
|
_, err = 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)
|
|
}
|