This commit is contained in:
18
server/context.go
Normal file
18
server/context.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userIDKey contextKey = "userID"
|
||||
|
||||
// withUserID adds the user ID to the request context
|
||||
func withUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, userID)
|
||||
}
|
||||
|
||||
// getUserID extracts the user ID from the request context
|
||||
func getUserID(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(userIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
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)
|
||||
}
|
||||
595
server/kml.go
Normal file
595
server/kml.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KML structure for parsing
|
||||
type KML struct {
|
||||
XMLName xml.Name `xml:"kml"`
|
||||
Document KMLDocument `xml:"Document"`
|
||||
}
|
||||
|
||||
type KMLDocument struct {
|
||||
Placemarks []KMLPlacemark `xml:"Placemark"`
|
||||
}
|
||||
|
||||
type KMLPlacemark struct {
|
||||
LineString KMLLineString `xml:"LineString"`
|
||||
}
|
||||
|
||||
type KMLLineString struct {
|
||||
Coordinates string `xml:"coordinates"`
|
||||
}
|
||||
|
||||
// KML Metadata
|
||||
type KMLMetadata struct {
|
||||
Filename string `json:"filename"`
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"` // User's display name
|
||||
Distance float64 `json:"distance"` // in kilometers
|
||||
IsPublic bool `json:"is_public"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
Votes int `json:"votes"` // Net votes (calculated from voting system)
|
||||
}
|
||||
|
||||
// Global vote tracking: kmlID -> userID -> vote (+1, -1, or 0)
|
||||
type VoteRegistry struct {
|
||||
Votes map[string]map[string]int `json:"votes"` // kmlID -> (userID -> vote)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var voteRegistry *VoteRegistry
|
||||
|
||||
// InitVoteRegistry loads the vote registry from disk
|
||||
func InitVoteRegistry() {
|
||||
voteRegistry = &VoteRegistry{
|
||||
Votes: make(map[string]map[string]int),
|
||||
}
|
||||
voteRegistry.Load()
|
||||
}
|
||||
|
||||
func (vr *VoteRegistry) Load() error {
|
||||
vr.mu.Lock()
|
||||
defer vr.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile("data/kml_votes.json")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, vr)
|
||||
}
|
||||
|
||||
func (vr *VoteRegistry) Save() error {
|
||||
vr.mu.RLock()
|
||||
defer vr.mu.RUnlock()
|
||||
return vr.saveUnlocked()
|
||||
}
|
||||
|
||||
func (vr *VoteRegistry) saveUnlocked() error {
|
||||
if err := os.MkdirAll("data", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(vr, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile("data/kml_votes.json", data, 0644)
|
||||
}
|
||||
|
||||
// GetVote return the vote of a user for a KML file (-1, 0, +1)
|
||||
func (vr *VoteRegistry) GetVote(kmlID, userID string) int {
|
||||
vr.mu.RLock()
|
||||
defer vr.mu.RUnlock()
|
||||
|
||||
if userVotes, exists := vr.Votes[kmlID]; exists {
|
||||
return userVotes[userID]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SetVote sets a user's vote for a KML file
|
||||
func (vr *VoteRegistry) SetVote(kmlID, userID string, vote int) error {
|
||||
vr.mu.Lock()
|
||||
defer vr.mu.Unlock()
|
||||
|
||||
if vr.Votes[kmlID] == nil {
|
||||
vr.Votes[kmlID] = make(map[string]int)
|
||||
}
|
||||
|
||||
if vote == 0 {
|
||||
delete(vr.Votes[kmlID], userID)
|
||||
} else {
|
||||
vr.Votes[kmlID][userID] = vote
|
||||
}
|
||||
|
||||
return vr.saveUnlocked()
|
||||
}
|
||||
|
||||
// CalculateNetVotes calculates net votes for a KML file
|
||||
func (vr *VoteRegistry) CalculateNetVotes(kmlID string) int {
|
||||
vr.mu.RLock()
|
||||
defer vr.mu.RUnlock()
|
||||
|
||||
total := 0
|
||||
if userVotes, exists := vr.Votes[kmlID]; exists {
|
||||
for _, vote := range userVotes {
|
||||
total += vote
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// Haversine formula to calculate distance between two lat/lng points
|
||||
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadiusKm = 6371.0
|
||||
|
||||
dLat := (lat2 - lat1) * math.Pi / 180.0
|
||||
dLon := (lon2 - lon1) * math.Pi / 180.0
|
||||
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*math.Pi/180.0)*math.Cos(lat2*math.Pi/180.0)*
|
||||
math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
|
||||
return earthRadiusKm * c
|
||||
}
|
||||
|
||||
// ParseKMLDistance parses a KML file and calculates the total distance
|
||||
func ParseKMLDistance(kmlData []byte) (float64, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(kmlData))
|
||||
totalDistance := 0.0
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch se := token.(type) {
|
||||
case xml.StartElement:
|
||||
// We only care about coordinates within a LineString context.
|
||||
// However, since Points have only one coordinate and result in 0 anyway,
|
||||
// searching for any <coordinates> tag is both easier and robust enough.
|
||||
if se.Name.Local == "coordinates" {
|
||||
var coords string
|
||||
if err := decoder.DecodeElement(&coords, &se); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sum up distance from this LineString
|
||||
items := strings.Fields(coords)
|
||||
var prevLat, prevLon float64
|
||||
first := true
|
||||
|
||||
for _, item := range items {
|
||||
parts := strings.Split(strings.TrimSpace(item), ",")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
var lon, lat float64
|
||||
_, err1 := fmt.Sscanf(parts[0], "%f", &lon)
|
||||
_, err2 := fmt.Sscanf(parts[1], "%f", &lat)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !first {
|
||||
totalDistance += haversineDistance(prevLat, prevLon, lat, lon)
|
||||
}
|
||||
|
||||
prevLat = lat
|
||||
prevLon = lon
|
||||
first = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalDistance, nil
|
||||
}
|
||||
|
||||
// HandleKMLUpload handles KML file uploads
|
||||
func HandleKMLUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil { // 10 MB limit
|
||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("kml")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file data
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate distance
|
||||
distance, err := ParseKMLDistance(data)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to parse KML: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Save KML file
|
||||
kmlDir := fmt.Sprintf("data/users/%s/kml", userID)
|
||||
if err := os.MkdirAll(kmlDir, 0755); err != nil {
|
||||
http.Error(w, "Failed to create directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
kmlPath := filepath.Join(kmlDir, handler.Filename)
|
||||
if err := os.WriteFile(kmlPath, data, 0644); err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info
|
||||
user, _ := userRegistry.GetUser(userID)
|
||||
displayName := userID
|
||||
if user != nil {
|
||||
displayName = user.DisplayName
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
metadata := KMLMetadata{
|
||||
Filename: handler.Filename,
|
||||
UserID: userID,
|
||||
DisplayName: displayName,
|
||||
Distance: distance,
|
||||
IsPublic: false, // Private by default
|
||||
UploadedAt: time.Now(),
|
||||
Votes: 0,
|
||||
}
|
||||
|
||||
metaPath := filepath.Join(kmlDir, handler.Filename+".meta.json")
|
||||
metaData, _ := json.MarshalIndent(metadata, "", " ")
|
||||
if err := os.WriteFile(metaPath, metaData, 0644); err != nil {
|
||||
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"filename": handler.Filename,
|
||||
"distance": distance,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleKMLList lists KML files (user's own + public files)
|
||||
func HandleKMLList(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var allFiles []KMLMetadata
|
||||
|
||||
// Walk through all user directories
|
||||
usersDir := "data/users"
|
||||
entries, err := os.ReadDir(usersDir)
|
||||
if err != nil {
|
||||
// No users yet
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"my_files": []KMLMetadata{},
|
||||
"public_files": []KMLMetadata{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
ownerID := entry.Name()
|
||||
kmlDir := filepath.Join(usersDir, ownerID, "kml")
|
||||
|
||||
kmlFiles, err := os.ReadDir(kmlDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, kmlFile := range kmlFiles {
|
||||
if !strings.HasSuffix(kmlFile.Name(), ".meta.json") {
|
||||
continue
|
||||
}
|
||||
|
||||
metaPath := filepath.Join(kmlDir, kmlFile.Name())
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var meta KMLMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate current votes
|
||||
kmlID := fmt.Sprintf("%s/%s", ownerID, meta.Filename)
|
||||
meta.Votes = voteRegistry.CalculateNetVotes(kmlID)
|
||||
|
||||
// Include if: 1) owned by current user, OR 2) public
|
||||
if ownerID == userID || meta.IsPublic {
|
||||
allFiles = append(allFiles, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separate into own files and public files
|
||||
var myFiles, publicFiles []KMLMetadata
|
||||
for _, file := range allFiles {
|
||||
if file.UserID == userID {
|
||||
myFiles = append(myFiles, file)
|
||||
}
|
||||
if file.IsPublic {
|
||||
publicFiles = append(publicFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort public files by votes (highest first)
|
||||
sort.Slice(publicFiles, func(i, j int) bool {
|
||||
return publicFiles[i].Votes > publicFiles[j].Votes
|
||||
})
|
||||
|
||||
// Sort my files by upload date (newest first)
|
||||
sort.Slice(myFiles, func(i, j int) bool {
|
||||
return myFiles[i].UploadedAt.After(myFiles[j].UploadedAt)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"my_files": myFiles,
|
||||
"public_files": publicFiles,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleKMLPrivacyToggle toggles the privacy setting of a KML file
|
||||
func HandleKMLPrivacyToggle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename)
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var meta KMLMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
http.Error(w, "Failed to parse metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if meta.UserID != userID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle privacy
|
||||
meta.IsPublic = !meta.IsPublic
|
||||
|
||||
// Save updated metadata
|
||||
newData, _ := json.MarshalIndent(meta, "", " ")
|
||||
if err := os.WriteFile(metaPath, newData, 0644); err != nil {
|
||||
http.Error(w, "Failed to update metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"is_public": meta.IsPublic,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleKMLVote handles voting on KML files (toggle upvote/downvote/none)
|
||||
func HandleKMLVote(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OwnerID string `json:"owner_id"`
|
||||
Filename string `json:"filename"`
|
||||
Vote int `json:"vote"` // +1, -1, or 0 to remove vote
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate vote value
|
||||
if req.Vote != -1 && req.Vote != 0 && req.Vote != 1 {
|
||||
http.Error(w, "Invalid vote value (must be -1, 0, or 1)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
kmlID := fmt.Sprintf("%s/%s", req.OwnerID, req.Filename)
|
||||
|
||||
// Set the vote
|
||||
if err := voteRegistry.SetVote(kmlID, userID, req.Vote); err != nil {
|
||||
http.Error(w, "Failed to save vote", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate new net votes
|
||||
netVotes := voteRegistry.CalculateNetVotes(kmlID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"net_votes": netVotes,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleKMLDelete deletes a KML file with ownership verification
|
||||
func HandleKMLDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership by reading metadata
|
||||
metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename)
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var meta KMLMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
http.Error(w, "Failed to parse metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if meta.UserID != userID {
|
||||
http.Error(w, "Forbidden - you can only delete your own files", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete KML file and metadata
|
||||
kmlPath := fmt.Sprintf("data/users/%s/kml/%s", userID, req.Filename)
|
||||
os.Remove(kmlPath)
|
||||
os.Remove(metaPath)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleKMLDownload serves a KML file for downloading/viewing
|
||||
func HandleKMLDownload(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := getUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := r.URL.Query().Get("owner_id")
|
||||
filename := r.URL.Query().Get("filename")
|
||||
|
||||
if ownerID == "" || filename == "" {
|
||||
http.Error(w, "Missing owner_id or filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify permission: ownerID == userID OR file is public
|
||||
if ownerID != userID {
|
||||
metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", ownerID, filename)
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var meta KMLMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
http.Error(w, "Error reading metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !meta.IsPublic {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
kmlPath := fmt.Sprintf("data/users/%s/kml/%s", ownerID, filename)
|
||||
data, err := os.ReadFile(kmlPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.google-earth.kml+xml")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
w.Write(data)
|
||||
}
|
||||
134
server/main.go
Normal file
134
server/main.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
stepManagers = make(map[string]*StepManager) // userID -> StepManager
|
||||
smMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// getOrCreateStepManager retrieves or creates a StepManager for the given user
|
||||
func getOrCreateStepManager(userID string) *StepManager {
|
||||
smMutex.RLock()
|
||||
sm, exists := stepManagers[userID]
|
||||
smMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
return sm
|
||||
}
|
||||
|
||||
// Create new StepManager for this user
|
||||
smMutex.Lock()
|
||||
defer smMutex.Unlock()
|
||||
|
||||
// Double-check it wasn't created while we were waiting for the lock
|
||||
if sm, exists := stepManagers[userID]; exists {
|
||||
return sm
|
||||
}
|
||||
|
||||
sm = NewStepManager(userID)
|
||||
stepManagers[userID] = sm
|
||||
return sm
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize components
|
||||
InitFitbit()
|
||||
InitUserRegistry()
|
||||
InitVoteRegistry()
|
||||
|
||||
// 1. Serve Static Files (Frontend)
|
||||
fs := http.FileServer(http.Dir("frontend"))
|
||||
http.Handle("/", fs)
|
||||
|
||||
// 2. API Endpoints (all require authentication)
|
||||
http.HandleFunc("/api/status", RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := getUserID(r.Context())
|
||||
sm := getOrCreateStepManager(userID)
|
||||
|
||||
status := sm.GetStatus()
|
||||
|
||||
// Add user info to status
|
||||
user, exists := userRegistry.GetUser(userID)
|
||||
if exists && user != nil {
|
||||
status["user"] = map[string]string{
|
||||
"displayName": user.DisplayName,
|
||||
"avatarUrl": user.AvatarURL,
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[API Status] WARNING: User info not found for ID: %s (exists=%v)\n", userID, exists)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}))
|
||||
|
||||
http.HandleFunc("/api/refresh", RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := getUserID(r.Context())
|
||||
sm := getOrCreateStepManager(userID)
|
||||
sm.Sync()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
http.HandleFunc("/api/trip", RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := getUserID(r.Context())
|
||||
sm := getOrCreateStepManager(userID)
|
||||
sm.StartNewTrip()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
http.HandleFunc("/api/drain", RequireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := getUserID(r.Context())
|
||||
sm := getOrCreateStepManager(userID)
|
||||
go sm.Drain() // Async so we don't block
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// 3. KML Management Endpoints
|
||||
http.HandleFunc("/api/kml/upload", RequireAuth(HandleKMLUpload))
|
||||
http.HandleFunc("/api/kml/list", RequireAuth(HandleKMLList))
|
||||
http.HandleFunc("/api/kml/privacy", RequireAuth(HandleKMLPrivacyToggle))
|
||||
http.HandleFunc("/api/kml/vote", RequireAuth(HandleKMLVote))
|
||||
http.HandleFunc("/api/kml/delete", RequireAuth(HandleKMLDelete))
|
||||
http.HandleFunc("/api/kml/download", RequireAuth(HandleKMLDownload))
|
||||
|
||||
// 4. Fitbit OAuth Endpoints
|
||||
http.HandleFunc("/auth/fitbit", HandleFitbitAuth)
|
||||
http.HandleFunc("/auth/callback", HandleFitbitCallback)
|
||||
|
||||
// 5. Logout Endpoint
|
||||
http.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := GetSessionFromRequest(r)
|
||||
if err == nil {
|
||||
DeleteSession(session.Token)
|
||||
}
|
||||
ClearSessionCookie(w)
|
||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
|
||||
// 6. Start Server
|
||||
binding := "0.0.0.0:8080"
|
||||
fmt.Printf("Server starting on http://%s\n", binding)
|
||||
log.Fatal(http.ListenAndServe(binding, nil))
|
||||
}
|
||||
159
server/session.go
Normal file
159
server/session.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "pedestrian_simulator_session"
|
||||
sessionDuration = 30 * 24 * time.Hour // 30 days
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
FitbitUserID string `json:"fitbit_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a cryptographically secure random token
|
||||
func GenerateSessionToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateSession generates a new session for a user
|
||||
func CreateSession(fitbitUserID string) (*Session, error) {
|
||||
token, err := GenerateSessionToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session := &Session{
|
||||
Token: token,
|
||||
FitbitUserID: fitbitUserID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(sessionDuration),
|
||||
}
|
||||
|
||||
if err := SaveSession(session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// SaveSession persists a session to disk
|
||||
func SaveSession(session *Session) error {
|
||||
sessionDir := "data/sessions"
|
||||
if err := os.MkdirAll(sessionDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create sessions directory: %w", err)
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(sessionDir, session.Token+".json")
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(sessionPath, data, 0600)
|
||||
}
|
||||
|
||||
// LoadSession loads a session from disk by token
|
||||
func LoadSession(token string) (*Session, error) {
|
||||
sessionPath := filepath.Join("data/sessions", token+".json")
|
||||
data, err := os.ReadFile(sessionPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
DeleteSession(token)
|
||||
return nil, fmt.Errorf("session expired")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session from disk
|
||||
func DeleteSession(token string) error {
|
||||
sessionPath := filepath.Join("data/sessions", token+".json")
|
||||
return os.Remove(sessionPath)
|
||||
}
|
||||
|
||||
// GetSessionFromRequest extracts the session from the request cookie
|
||||
func GetSessionFromRequest(r *http.Request) (*Session, error) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return LoadSession(cookie.Value)
|
||||
}
|
||||
|
||||
// SetSessionCookie sets the session cookie on the response
|
||||
func SetSessionCookie(w http.ResponseWriter, session *Session) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: session.Token,
|
||||
Path: "/",
|
||||
Expires: session.ExpiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
// Secure: true, // Enable in production with HTTPS
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie removes the session cookie
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
// RequireAuth is middleware that ensures the user is authenticated
|
||||
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := GetSessionFromRequest(r)
|
||||
if err != nil {
|
||||
// Not authenticated - for API calls return 401, for pages redirect
|
||||
if isAPIRequest(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, "/auth/fitbit", http.StatusTemporaryRedirect)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Store user ID in request context for handlers to use
|
||||
ctx := r.Context()
|
||||
ctx = withUserID(ctx, session.FitbitUserID)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// isAPIRequest checks if the request is to an API endpoint
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
return len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api"
|
||||
}
|
||||
269
server/step_manager.go
Normal file
269
server/step_manager.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TripState struct {
|
||||
StartDate string `json:"start_date"` // YYYY-MM-DD
|
||||
StartTime time.Time `json:"start_time"` // Exact start time
|
||||
StartDayInitialSteps int `json:"start_day_initial_steps"` // Steps on the tracker when trip started
|
||||
DailyCache map[string]int `json:"daily_cache"` // Cache of steps for past days
|
||||
}
|
||||
|
||||
type StepManager struct {
|
||||
mu sync.Mutex
|
||||
userID string // Fitbit user ID
|
||||
tripState TripState
|
||||
|
||||
// Smoothing State
|
||||
previousTotalSteps int // What we last told the client (or where we started smoothing from)
|
||||
targetTotalSteps int // The actual total steps we just fetched/calculated
|
||||
lastSyncTime time.Time
|
||||
nextSyncTime time.Time
|
||||
syncInterval time.Duration
|
||||
}
|
||||
|
||||
func NewStepManager(userID string) *StepManager {
|
||||
now := time.Now()
|
||||
interval := 15 * time.Minute
|
||||
|
||||
// Default state (will be used if load fails or file missing)
|
||||
defaultState := TripState{
|
||||
StartDate: now.Format("2006-01-02"),
|
||||
StartTime: now,
|
||||
DailyCache: make(map[string]int),
|
||||
}
|
||||
|
||||
sm := &StepManager{
|
||||
userID: userID,
|
||||
tripState: defaultState,
|
||||
syncInterval: interval,
|
||||
lastSyncTime: now.Add(-interval),
|
||||
nextSyncTime: now,
|
||||
}
|
||||
|
||||
if err := sm.LoadTripState(); err != nil {
|
||||
fmt.Printf("Warning: Failed to load trip state: %v. Using new trip defaults.\n", err)
|
||||
} else {
|
||||
// Initialize total steps from the loaded state to avoid interpolating from 0
|
||||
initialTotal := sm.RecalculateTotalFromState()
|
||||
sm.previousTotalSteps = initialTotal
|
||||
sm.targetTotalSteps = initialTotal
|
||||
fmt.Printf("Initialized step counts from cache: %d\n", initialTotal)
|
||||
}
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
func (sm *StepManager) LoadTripState() error {
|
||||
tripPath := fmt.Sprintf("data/users/%s/trip.json", sm.userID)
|
||||
data, err := os.ReadFile(tripPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Normal for first run
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var loadedState TripState
|
||||
if err := json.Unmarshal(data, &loadedState); err != nil {
|
||||
return fmt.Errorf("failed to parse trip.json: %w", err)
|
||||
}
|
||||
|
||||
// Only update if valid
|
||||
sm.tripState = loadedState
|
||||
if sm.tripState.DailyCache == nil {
|
||||
sm.tripState.DailyCache = make(map[string]int)
|
||||
}
|
||||
fmt.Printf("Loaded trip state: StartDate=%s, InitialSteps=%d\n", sm.tripState.StartDate, sm.tripState.StartDayInitialSteps)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *StepManager) SaveTripState() {
|
||||
userDir := fmt.Sprintf("data/users/%s", sm.userID)
|
||||
if err := os.MkdirAll(userDir, 0755); err != nil {
|
||||
fmt.Printf("Error creating user directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
tripPath := fmt.Sprintf("%s/trip.json", userDir)
|
||||
data, _ := json.MarshalIndent(sm.tripState, "", " ")
|
||||
os.WriteFile(tripPath, data, 0644)
|
||||
}
|
||||
|
||||
func (sm *StepManager) StartNewTrip() {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
now := time.Now()
|
||||
initialSteps, err := GetDailySteps(sm.userID, now.Format("2006-01-02"))
|
||||
if err != nil {
|
||||
initialSteps = 0
|
||||
fmt.Printf("Error fetching initial steps: %v\n", err)
|
||||
}
|
||||
sm.tripState = TripState{
|
||||
StartDate: now.Format("2006-01-02"),
|
||||
StartTime: now,
|
||||
StartDayInitialSteps: initialSteps,
|
||||
DailyCache: make(map[string]int),
|
||||
}
|
||||
// On new trip, previous total is 0
|
||||
sm.previousTotalSteps = 0
|
||||
sm.targetTotalSteps = 0
|
||||
sm.SaveTripState()
|
||||
|
||||
// Trigger immediate sync to set baseline
|
||||
go sm.Sync()
|
||||
}
|
||||
|
||||
// Sync fetches data for all days in the trip using the default interval
|
||||
func (sm *StepManager) Sync() {
|
||||
sm.performSync(sm.syncInterval)
|
||||
}
|
||||
|
||||
// Drain fetches data and sets a short sync interval to fast-forward interpolation
|
||||
func (sm *StepManager) Drain() {
|
||||
sm.performSync(30 * time.Second)
|
||||
}
|
||||
|
||||
// performSync implementation
|
||||
func (sm *StepManager) performSync(interval time.Duration) {
|
||||
sm.mu.Lock()
|
||||
tripStateCopy := sm.tripState
|
||||
// Deep copy the map to avoid data races during async network calls
|
||||
newDailyCache := make(map[string]int)
|
||||
for k, v := range tripStateCopy.DailyCache {
|
||||
newDailyCache[k] = v
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
totalSteps := 0
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// Parse start date
|
||||
start, _ := time.Parse("2006-01-02", tripStateCopy.StartDate)
|
||||
end, _ := time.Parse("2006-01-02", today)
|
||||
|
||||
// Iterate from Start Date to Today
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
dateStr := d.Format("2006-01-02")
|
||||
|
||||
var steps int
|
||||
var err error
|
||||
|
||||
// Check cache first for past days
|
||||
// For today, we always fetch.
|
||||
// Ideally we might trust cache for past days, but re-checking isn't bad if we want to catch up.
|
||||
// The current logic trusts cache for past days.
|
||||
shouldFetch := (dateStr == today)
|
||||
if !shouldFetch {
|
||||
if cached, ok := newDailyCache[dateStr]; ok {
|
||||
steps = cached
|
||||
} else {
|
||||
shouldFetch = true
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetch {
|
||||
steps, err = GetDailySteps(sm.userID, dateStr)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching steps for %s: %v\n", dateStr, err)
|
||||
return // Don't proceed with sync if fetch fails
|
||||
}
|
||||
// Update the local cache
|
||||
newDailyCache[dateStr] = steps
|
||||
}
|
||||
|
||||
// Calculate contribution to total
|
||||
if dateStr == tripStateCopy.StartDate {
|
||||
// Substract the steps that were already there when we started
|
||||
contribution := steps - tripStateCopy.StartDayInitialSteps
|
||||
if contribution < 0 {
|
||||
contribution = 0
|
||||
}
|
||||
totalSteps += contribution
|
||||
} else {
|
||||
totalSteps += steps
|
||||
}
|
||||
}
|
||||
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
// Update State
|
||||
sm.tripState.DailyCache = newDailyCache
|
||||
sm.SaveTripState()
|
||||
|
||||
// Update Smoothing Targets
|
||||
sm.previousTotalSteps = sm.calculateSmoothedTokenAt(time.Now()) // Snapshot current interpolated value as new start
|
||||
sm.targetTotalSteps = totalSteps
|
||||
|
||||
sm.lastSyncTime = time.Now()
|
||||
sm.nextSyncTime = time.Now().Add(interval)
|
||||
|
||||
fmt.Printf("Sync Complete. Total Trip Steps: %d\n", sm.targetTotalSteps)
|
||||
}
|
||||
|
||||
// calculateSmoothedTokenAt returns the interpolated step count at a given time
|
||||
func (sm *StepManager) calculateSmoothedTokenAt(t time.Time) int {
|
||||
totalDuration := sm.nextSyncTime.Sub(sm.lastSyncTime)
|
||||
elapsed := t.Sub(sm.lastSyncTime)
|
||||
|
||||
if totalDuration <= 0 {
|
||||
return sm.targetTotalSteps
|
||||
}
|
||||
|
||||
progress := float64(elapsed) / float64(totalDuration)
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 1 {
|
||||
progress = 1
|
||||
}
|
||||
|
||||
// Linear interpolation from Previous -> Target
|
||||
delta := sm.targetTotalSteps - sm.previousTotalSteps
|
||||
return sm.previousTotalSteps + int(float64(delta)*progress)
|
||||
}
|
||||
|
||||
func (sm *StepManager) GetStatus() map[string]interface{} {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
// Auto-trigger sync if needed
|
||||
if time.Now().After(sm.nextSyncTime) {
|
||||
go sm.Sync() // Async sync
|
||||
}
|
||||
|
||||
currentSmoothed := sm.calculateSmoothedTokenAt(time.Now())
|
||||
|
||||
return map[string]interface{}{
|
||||
"tripSteps": currentSmoothed,
|
||||
"nextSyncTime": sm.nextSyncTime.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecalculateTotalFromState sums up the steps from the DailyCache without making external API calls
|
||||
func (sm *StepManager) RecalculateTotalFromState() int {
|
||||
total := 0
|
||||
for dateStr, steps := range sm.tripState.DailyCache {
|
||||
// YYYY-MM-DD string comparison works for chronological order
|
||||
if dateStr < sm.tripState.StartDate {
|
||||
continue
|
||||
}
|
||||
|
||||
if dateStr == sm.tripState.StartDate {
|
||||
contribution := steps - sm.tripState.StartDayInitialSteps
|
||||
if contribution < 0 {
|
||||
contribution = 0
|
||||
}
|
||||
total += contribution
|
||||
} else {
|
||||
total += steps
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
156
server/user.go
Normal file
156
server/user.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
FitbitUserID string `json:"fitbit_user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UserRegistry struct {
|
||||
Users map[string]*User `json:"users"` // Map of FitbitUserID -> User
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var userRegistry *UserRegistry
|
||||
|
||||
// InitUserRegistry loads or creates the user registry
|
||||
func InitUserRegistry() {
|
||||
userRegistry = &UserRegistry{
|
||||
Users: make(map[string]*User),
|
||||
}
|
||||
userRegistry.Load()
|
||||
}
|
||||
|
||||
// Load reads the user registry from disk
|
||||
func (ur *UserRegistry) Load() error {
|
||||
ur.mu.Lock()
|
||||
defer ur.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile("data/users.json")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // First run, no users yet
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, ur)
|
||||
}
|
||||
|
||||
// Save writes the user registry to disk
|
||||
func (ur *UserRegistry) Save() error {
|
||||
ur.mu.RLock()
|
||||
defer ur.mu.RUnlock()
|
||||
return ur.saveUnlocked()
|
||||
}
|
||||
|
||||
// saveUnlocked writes the user registry to disk without locking (caller must hold lock)
|
||||
func (ur *UserRegistry) saveUnlocked() error {
|
||||
if err := os.MkdirAll("data", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ur, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile("data/users.json", data, 0644)
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by Fitbit user ID
|
||||
func (ur *UserRegistry) GetUser(fitbitUserID string) (*User, bool) {
|
||||
ur.mu.RLock()
|
||||
defer ur.mu.RUnlock()
|
||||
|
||||
user, exists := ur.Users[fitbitUserID]
|
||||
return user, exists
|
||||
}
|
||||
|
||||
// CreateOrUpdateUser adds or updates a user in the registry
|
||||
func (ur *UserRegistry) CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) {
|
||||
ur.mu.Lock()
|
||||
defer ur.mu.Unlock()
|
||||
|
||||
user, exists := ur.Users[fitbitUserID]
|
||||
if exists {
|
||||
// Update existing user
|
||||
user.DisplayName = displayName
|
||||
user.AvatarURL = avatarURL
|
||||
} else {
|
||||
// Create new user
|
||||
user = &User{
|
||||
FitbitUserID: fitbitUserID,
|
||||
DisplayName: displayName,
|
||||
AvatarURL: avatarURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
ur.Users[fitbitUserID] = user
|
||||
}
|
||||
|
||||
// Save without locking (we already have the lock)
|
||||
if err := ur.saveUnlocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create user directory
|
||||
userDir := fmt.Sprintf("data/users/%s", fitbitUserID)
|
||||
if err := os.MkdirAll(userDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kmlDir := fmt.Sprintf("data/users/%s/kml", fitbitUserID)
|
||||
if err := os.MkdirAll(kmlDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// FetchFitbitUserProfile fetches the user's profile from Fitbit API
|
||||
func FetchFitbitUserProfile(accessToken string) (userID, displayName, avatarURL string, err error) {
|
||||
apiURL := "https://api.fitbit.com/1/user/-/profile.json"
|
||||
|
||||
req, _ := http.NewRequest("GET", apiURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
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("fitbit profile api error: %s", resp.Status)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
User struct {
|
||||
EncodedID string `json:"encodedId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Avatar string `json:"avatar"`
|
||||
Avatar150 string `json:"avatar150"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
avatar := result.User.Avatar150
|
||||
if avatar == "" {
|
||||
avatar = result.User.Avatar
|
||||
}
|
||||
|
||||
return result.User.EncodedID, result.User.DisplayName, avatar, nil
|
||||
}
|
||||
Reference in New Issue
Block a user