-
Notifications
You must be signed in to change notification settings - Fork 0
/
api.go
295 lines (256 loc) · 7.52 KB
/
api.go
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
package strava
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/nikolaydubina/calendarheatmap/charts"
log "github.com/sirupsen/logrus"
)
// https://developers.strava.com/docs/getting-started
// One time setup:
// In a browser open
// https://www.strava.com/oauth/authorize?client_id=[REPLACE_WITH_YOUR_CLIENT_ID]&response_type=code&redirect_uri=http://localhost/exchange_token&approval_prompt=force&scope=profile:read_all,activity:read_all
// and grab code=??? from reply after approval.
// That code is AUTHORIZATIONCODE
// Then do
// curl -X POST https://www.strava.com/oauth/token \
// -F client_id=YOURCLIENTID \
// -F client_secret=YOURCLIENTSECRET \
// -F grant_type=authorization_code \
// -F code=AUTHORIZATIONCODE
// If tokens expired
// curl -X POST https://www.strava.com/oauth/token \
// -F client_id=YOURCLIENTID \
// -F client_secret=YOURCLIENTSECRET \
// -F grant_type=refresh_token \
// -F refresh_token=REFRESHTOKEN \
// curl -X GET \
// https://www.strava.com/api/v3/athlete \
// -H 'Authorization: Bearer YOURACCESSTOKEN'
const (
STRAVA_URL = "www.strava.com"
)
type oAuth2Response struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
// other fields are not relevant yet.
}
type AppClient struct {
ID string
Secret string
}
func (app *AppClient) HandleChart(w http.ResponseWriter, r *http.Request) {
access, err := r.Cookie("access-token")
if err != nil {
// If not authenticated, draw an empty chart.
cfg := DefaultConfig
err = charts.WriteHeatmap(cfg, w)
if err != nil {
log.Errorf("write empty heatmap: %v", err)
}
return
}
// Query within current year.
now := time.Now()
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
to := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, now.Location())
counts, err := GetActivities(access.Value, from, to)
if err != nil {
log.Errorf("GetActivities: %v", err)
return
}
cfg := DefaultConfig
cfg.Counts = counts
err = charts.WriteHeatmap(cfg, w)
if err != nil {
log.Errorf("WriteHeatmap: %v", err)
}
}
func (app *AppClient) AuthInitialRedirectURL(r *http.Request) *url.URL {
authURL := &url.URL{
Scheme: "https",
Host: STRAVA_URL,
Path: "/oauth/authorize",
}
q := authURL.Query()
// Back to this handler.
redirect := &url.URL{Scheme: "http", Host: r.Host, Path: r.URL.Path}
q.Add("redirect_uri", redirect.String())
q.Add("client_id", app.ID)
q.Add("response_type", "code")
q.Add("scope", "profile:read_all,activity:read_all")
authURL.RawQuery = q.Encode()
log.Infof("auth querying: %s", authURL.String())
return authURL
}
func (app *AppClient) AuthRetrieveTokens(code string) (*oAuth2Response, error) {
tokenURL := &url.URL{Scheme: "https", Host: STRAVA_URL, Path: "/oauth/token"}
form := url.Values{}
form.Add("client_id", app.ID)
form.Add("client_secret", app.Secret)
form.Add("grant_type", "authorization_code")
form.Add("code", code)
resp, err := http.PostForm(tokenURL.String(), form)
if err != nil {
return nil, fmt.Errorf("token POST: %w", err)
}
// decode
var tokenResp oAuth2Response
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
if err != nil {
return nil, fmt.Errorf("json decode: %w", err)
}
if tokenResp.ExpiresIn < 60 {
log.Error("token will expire in less than a minute")
}
return &tokenResp, nil
}
func (app *AppClient) HandleAuth(w http.ResponseWriter, r *http.Request) {
// No tokens, do the auth dance, then redirect back to this handler to try again.
q := r.URL.Query()
if !q.Has("code") {
// No "code" in URL means start fresh: redirect user to Strava for authentication.
http.Redirect(w, r, app.AuthInitialRedirectURL(r).String(), http.StatusTemporaryRedirect)
return
}
// Populated "code" from Strava redirect, use it to get actual tokens.
// TODO: Double check scope.
tokenResp, err := app.AuthRetrieveTokens(q.Get("code"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
// TODO: Use gorilla securecookie or similar.
http.SetCookie(w, &http.Cookie{
Name: "access-token",
Value: tokenResp.AccessToken,
HttpOnly: true,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh-token",
Value: tokenResp.RefreshToken,
HttpOnly: true,
})
// Back to main page with tokens in cookies.
http.Redirect(w, r, "..", http.StatusFound)
}
// apiCall wraps the ...
func apiCall(method, path string, headers, params map[string]string) (*http.Response, error) {
url := &url.URL{
Scheme: "https",
Host: STRAVA_URL,
Path: path,
}
req, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
q := req.URL.Query()
for k, v := range params {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()
// request - let caller check error and defer close
log.Infof("API %s %s", method, req.URL.String())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return resp, err
}
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
log.Debug(string(raw))
resp.Body.Close()
return nil, fmt.Errorf("HTTP request not ok: %s", resp.Status)
}
return resp, nil
}
func GetAccessToken(apikey string) (string, error) {
secret := base64.URLEncoding.EncodeToString([]byte(apikey))
headers := map[string]string{
"Authorization": "Basic " + secret,
"Content-Type": "application/x-www-form-urlencoded",
}
params := map[string]string{
"format": "json",
"grant_type": "client_credentials",
}
resp, err := apiCall(http.MethodPost, "token", headers, params)
if err != nil {
return "", err
}
defer resp.Body.Close()
// decode
var authResp oAuth2Response
err = json.NewDecoder(resp.Body).Decode(&authResp)
if err != nil {
return "", err
}
if authResp.ExpiresIn < 60 {
return "", errors.New("token will expire in less than a minute")
}
return authResp.AccessToken, nil
}
// GetActivities exhaustively in the after/before range.
func GetActivities(token string, after, before time.Time) (map[string]int, error) {
// The output is aggregated in this map.
counts := make(map[string]int)
headers := map[string]string{
"Authorization": "Bearer " + token,
}
// Requests are paged since there is a limit on activities per response.
page := 0
const PER_PAGE = 100 // Activities per response page, 200 is maximum in the API spec.
for {
page++
params := map[string]string{
"page": strconv.Itoa(page),
"per_page": strconv.Itoa(PER_PAGE),
"after": strconv.Itoa(int(after.Unix())),
"before": strconv.Itoa(int(before.Unix())),
}
resp, err := apiCall(http.MethodGet, "/api/v3/athlete/activities", headers, params)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Decode JSON with some anon. structs.
activities := []struct {
Name string
// Avoid elapsed_time since a training can be paused
// and resumed hours later.
Seconds int `json:"moving_time"`
Distance float64
StartDate time.Time `json:"start_date"` // UTC
Type string
}{}
dec := json.NewDecoder(resp.Body)
err = dec.Decode(&activities)
if err != nil {
return nil, err
}
log.Debugf("Got %d activities\n", len(activities))
// Populate dict on form that charts expect.
for _, v := range activities {
key := v.StartDate.Format("2006-01-02")
// Exclude outliers.
if v.Seconds > 60*60*24 {
continue
}
// Could be multiple activities on the same day.
counts[key] += v.Seconds / 60
}
if len(activities) < PER_PAGE {
break
}
}
return counts, nil
}