aboutsummaryrefslogtreecommitdiff
path: root/web/strava.go
blob: 1e498f5dd105987aa74da53d540f90b72a3ecea4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144

package web

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"time"

	"github.com/martinlehoux/biking_home/config"
	"github.com/martinlehoux/biking_home/strava"
)

func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) {
	appConfig, err := s.loadConfig()
	if err != nil {
		http.Error(w, "failed to load configuration", http.StatusInternalServerError)
		return
	}
	clientID := appConfig.Strava.ClientID
	clientSecret := appConfig.Strava.ClientSecret
	if clientID == "" || clientSecret == "" {
		http.Error(w, "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required", http.StatusInternalServerError)
		return
	}
	state, err := newOAuthState()
	if err != nil {
		http.Error(w, "failed to start OAuth flow", http.StatusInternalServerError)
		return
	}
	returnTo := safeReturnTo(r.URL.Query().Get("return_to"))
	s.oauthMu.Lock()
	s.oauthState = state
	s.returnToURL = returnTo
	s.oauthMu.Unlock()
	redirectURI := appConfig.Server.PublicURL + "/strava/callback"
	http.Redirect(w, r, strava.AuthorizeURLWithState(clientID, redirectURI, state), http.StatusFound)
}

func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) {
	if callbackError := r.URL.Query().Get("error"); callbackError != "" {
		http.Error(w, "Strava authorization was denied: "+callbackError, http.StatusBadRequest)
		return
	}
	returnTo, ok := s.consumeOAuthState(r.URL.Query().Get("state"))
	if !ok {
		http.Error(w, "invalid OAuth state", http.StatusBadRequest)
		return
	}
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "missing authorization code", http.StatusBadRequest)
		return
	}
	appConfig, err := s.loadConfig()
	if err != nil {
		http.Error(w, "failed to load configuration", http.StatusInternalServerError)
		return
	}
	redirectURI := appConfig.Server.PublicURL + "/strava/callback"
	token, err := strava.ExchangeCode(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, code, redirectURI)
	if err != nil {
		http.Error(w, "failed to exchange Strava authorization code", http.StatusBadGateway)
		return
	}
	appConfig.Strava.AccessToken = token.AccessToken
	appConfig.Strava.RefreshToken = token.RefreshToken
	appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix()
	if err := config.Save(s.configPath, appConfig); err != nil {
		http.Error(w, "failed to store Strava token", http.StatusInternalServerError)
		return
	}
	slog.Info("Strava authorization completed")
	http.Redirect(w, r, returnTo, http.StatusSeeOther)
}

func (s *Server) stravaClient() (*strava.Client, bool, error) {
	appConfig, err := s.loadConfig()
	if err != nil {
		return nil, false, fmt.Errorf("load configuration: %w", err)
	}
	return newStravaClient(appConfig)
}

func (s *Server) loadConfig() (config.Config, error) {
	return config.Load(s.configPath)
}

func newStravaClient(appConfig config.Config) (*strava.Client, bool, error) {
	if appConfig.Strava.ClientID == "" || appConfig.Strava.ClientSecret == "" {
		return nil, false, errors.New("STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required")
	}
	if appConfig.Strava.AccessToken == "" || appConfig.Strava.RefreshToken == "" {
		return nil, false, nil
	}
	var expiresAt time.Time
	if appConfig.Strava.ExpiresAt != 0 {
		expiresAt = time.Unix(appConfig.Strava.ExpiresAt, 0)
	}
	return strava.NewClient(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, strava.Token{
		AccessToken:  appConfig.Strava.AccessToken,
		RefreshToken: appConfig.Strava.RefreshToken,
		ExpiresAt:    expiresAt,
	}), true, nil
}

func (s *Server) saveStravaToken(client *strava.Client) error {
	appConfig, err := s.loadConfig()
	if err != nil {
		return err
	}
	token := client.Tokens()
	appConfig.Strava.AccessToken = token.AccessToken
	appConfig.Strava.RefreshToken = token.RefreshToken
	appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix()
	return config.Save(s.configPath, appConfig)
}

func (s *Server) hasStravaToken() bool {
	_, authorized, err := s.stravaClient()
	return err == nil && authorized
}

func (s *Server) consumeOAuthState(state string) (string, bool) {
	s.oauthMu.Lock()
	defer s.oauthMu.Unlock()
	if state == "" || state != s.oauthState {
		return "", false
	}
	returnTo := s.returnToURL
	s.oauthState = ""
	s.returnToURL = ""
	return returnTo, true
}

func newOAuthState() (string, error) {
	data := make([]byte, 32)
	if _, err := rand.Read(data); err != nil {
		return "", err
	}
	return hex.EncodeToString(data), nil
}