More trial-and-error fixes.
[kraftakt.git] / gfitsync.go
1 package gfitsync
2
3 import (
4         "context"
5         "crypto/hmac"
6         "crypto/sha1"
7         "encoding/base64"
8         "encoding/json"
9         "fmt"
10         "io/ioutil"
11         "net/http"
12         "net/url"
13         "time"
14
15         "github.com/google/uuid"
16         "github.com/octo/gfitsync/fitbit"
17         legacy_context "golang.org/x/net/context"
18         "golang.org/x/oauth2"
19         oauth2fitbit "golang.org/x/oauth2/fitbit"
20         "google.golang.org/appengine"
21         "google.golang.org/appengine/datastore"
22         "google.golang.org/appengine/delay"
23         "google.golang.org/appengine/log"
24         "google.golang.org/appengine/user"
25 )
26
27 const csrfToken = "@CSRFTOKEN@"
28
29 // var delayedHandleNotifications = delay.Func("handleNotifications", func(ctx legacy_context.Context, payload []byte) error {
30 //      return handleNotifications(ctx, payload)
31 // })
32 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
33
34 var oauthConfig = &oauth2.Config{
35         ClientID:     "@FITBIT_CLIENT_ID@",
36         ClientSecret: "@FITBIT_CLIENT_SECRET@",
37         Endpoint:     oauth2fitbit.Endpoint,
38         RedirectURL:  "https://fitbit-gfit-sync.appspot.com/fitbit/grant", // TODO(octo): make full URL
39         Scopes:       []string{"activity"},
40 }
41
42 func init() {
43         http.HandleFunc("/setup", setupHandler)
44         http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
45         http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
46         http.Handle("/", AuthenticatedHandler(indexHandler))
47 }
48
49 // ContextHandler implements http.Handler
50 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
51
52 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
53         ctx := appengine.NewContext(r)
54
55         if err := hndl(ctx, w, r); err != nil {
56                 http.Error(w, err.Error(), http.StatusInternalServerError)
57                 return
58         }
59 }
60
61 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *datastore.Key) error
62
63 type User struct {
64         ID string
65 }
66
67 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
68         ctx := appengine.NewContext(r)
69
70         u := user.Current(ctx)
71         if u == nil {
72                 url, err := user.LoginURL(ctx, r.URL.String())
73                 if err != nil {
74                         http.Error(w, err.Error(), http.StatusInternalServerError)
75                         return
76                 }
77                 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
78                 return
79         }
80
81         err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
82                 key := datastore.NewKey(ctx, "User", u.Email, 0, nil)
83
84                 if err := datastore.Get(ctx, key, &User{}); err != datastore.ErrNoSuchEntity {
85                         return err // may be nil
86                 }
87
88                 _, err := datastore.Put(ctx, key, &User{
89                         ID: uuid.New().String(),
90                 })
91                 return err
92         }, nil)
93         if err != nil {
94                 http.Error(w, err.Error(), http.StatusInternalServerError)
95                 return
96         }
97
98         rootKey := datastore.NewKey(ctx, "User", u.Email, 0, nil)
99         if err := hndl(ctx, w, r, rootKey); err != nil {
100                 http.Error(w, err.Error(), http.StatusInternalServerError)
101                 return
102         }
103 }
104
105 func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
106         var (
107                 tok       oauth2.Token
108                 haveToken bool
109         )
110
111         key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
112         err := datastore.Get(ctx, key, &tok)
113         if err != nil && err != datastore.ErrNoSuchEntity {
114                 return err
115         }
116         if err == nil {
117                 haveToken = true
118         }
119
120         u := user.Current(ctx)
121
122         // fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
123         fmt.Fprintln(w, "<html><body><h1>Fitbit to Google Fit sync</h1>")
124
125         fmt.Fprintf(w, "<p>Hello %s</p>\n", u.Email)
126         fmt.Fprint(w, "<p>Fitbit: ")
127         if haveToken {
128                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
129         } else {
130                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/setup">Authorize</a>)`)
131         }
132         fmt.Fprintln(w, "</p>")
133         fmt.Fprintln(w, "</body></html>")
134
135         // TODO(octo): print summary to user
136         return nil
137 }
138
139 func setupHandler(w http.ResponseWriter, r *http.Request) {
140         url := oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
141         http.Redirect(w, r, url, http.StatusTemporaryRedirect)
142 }
143
144 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
145         if state := r.FormValue("state"); state != csrfToken {
146                 return fmt.Errorf("invalid state parameter: %q", state)
147         }
148
149         tok, err := oauthConfig.Exchange(ctx, r.FormValue("code"))
150         if err != nil {
151                 return err
152         }
153
154         key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
155         if _, err := datastore.Put(ctx, key, tok); err != nil {
156                 return err
157         }
158         c := oauthConfig.Client(ctx, tok)
159
160         var u User
161         if err := datastore.Get(ctx, rootKey, &u); err != nil {
162                 return err
163         }
164
165         // create a subscription
166         url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json", u.ID)
167         res, err := c.Post(url, "", nil)
168         if err != nil {
169                 return err
170         }
171         defer res.Body.Close()
172
173         if res.StatusCode == http.StatusConflict {
174                 var n fitbitSubscription
175                 if err := json.NewDecoder(res.Body).Decode(&n); err != nil {
176                         return err
177                 }
178                 log.Warningf(ctx, "conflict with existing subscription %v", n)
179         }
180
181         if res.StatusCode >= 400 {
182                 data, _ := ioutil.ReadAll(res.Body)
183                 log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
184                 return fmt.Errorf("creating subscription failed")
185         }
186
187         redirectURL := r.URL
188         redirectURL.Path = "/"
189         redirectURL.RawQuery = ""
190         redirectURL.Fragment = ""
191         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
192         return nil
193 }
194
195 type fitbitSubscription struct {
196         CollectionType string `json:"collectionType"`
197         Date           string `json:"date"`
198         OwnerID        string `json:"ownerId"`
199         OwnerType      string `json:"ownerType"`
200         SubscriptionID string `json:"subscriptionId"`
201 }
202
203 func (s *fitbitSubscription) URLValues() url.Values {
204         return url.Values{
205                 "CollectionType": []string{s.CollectionType},
206                 "Date":           []string{s.Date},
207                 "OwnerID":        []string{s.OwnerID},
208                 "OwnerType":      []string{s.OwnerType},
209                 "SubscriptionID": []string{s.SubscriptionID},
210         }
211 }
212
213 func (s *fitbitSubscription) URL() string {
214         // daily summary: GET https://api.fitbit.com/1/user/[user-id]/activities/date/[date].json
215         return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json",
216                 s.OwnerID, s.CollectionType, s.Date)
217 }
218
219 func checkSignature(ctx context.Context, payload []byte, rawSig string) bool {
220         base64Sig, err := url.QueryUnescape(rawSig)
221         if err != nil {
222                 log.Errorf(ctx, "QueryUnescape(%q) = %v", rawSig, err)
223                 return false
224         }
225         signatureGot, err := base64.StdEncoding.DecodeString(base64Sig)
226         if err != nil {
227                 log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", base64Sig, err)
228                 return false
229         }
230
231         mac := hmac.New(sha1.New, []byte(oauthConfig.ClientSecret+"&"))
232         mac.Write(payload)
233         signatureWant := mac.Sum(nil)
234
235         return hmac.Equal(signatureGot, signatureWant)
236 }
237
238 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
239 // subscription. It verifies the payload, splits it into individual
240 // notifications and adds it to the taskqueue service.
241 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
242         defer r.Body.Close()
243
244         fitbitTimeout := 3 * time.Second
245         ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
246         defer cancel()
247
248         // this is used when setting up a new subscriber in the UI. Once set
249         // up, this code path should not be triggered.
250         if verify := r.FormValue("verify"); verify != "" {
251                 if verify == "@FITBIT_SUBSCRIBER_CODE@" {
252                         w.WriteHeader(http.StatusNoContent)
253                 } else {
254                         w.WriteHeader(http.StatusNotFound)
255                 }
256                 return nil
257         }
258
259         data, err := ioutil.ReadAll(r.Body)
260         if err != nil {
261                 return err
262         }
263
264         // Fitbit recommendation: "If signature verification fails, you should
265         // respond with a 404"
266         if !checkSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
267                 w.WriteHeader(http.StatusNotFound)
268                 return nil
269         }
270
271         if err := delayedHandleNotifications.Call(ctx, data); err != nil {
272                 return err
273         }
274
275         w.WriteHeader(http.StatusCreated)
276         return nil
277 }
278
279 // handleNotifications parses fitbit notifications and requests the individual
280 // activities from Fitbit. It is executed asynchronously via the delay package.
281 func handleNotifications(ctx context.Context, payload []byte) error {
282         var subscriptions []fitbitSubscription
283         if err := json.Unmarshal(payload, &subscriptions); err != nil {
284                 return err
285         }
286
287         for _, s := range subscriptions {
288                 if s.CollectionType != "activities" {
289                         continue
290                 }
291
292                 if err := handleNotification(ctx, &s); err != nil {
293                         log.Errorf(ctx, "handleNotification() = %v", err)
294                         continue
295                 }
296         }
297
298         return nil
299 }
300
301 func handleNotification(ctx context.Context, s *fitbitSubscription) error {
302         q := datastore.NewQuery("User").Filter("ID=", s.SubscriptionID).KeysOnly()
303         keys, err := q.GetAll(ctx, nil)
304         if err != nil {
305                 return fmt.Errorf("datastore.Query.GetAll(): %v", err)
306         }
307         if len(keys) != 1 {
308                 return fmt.Errorf("len(keys) = %d, want 1", len(keys))
309         }
310
311         rootKey := keys[0]
312         key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
313
314         var tok oauth2.Token
315         if err := datastore.Get(ctx, key, &tok); err != nil {
316                 return err
317         }
318
319         c := fitbit.NewClient(ctx, s.OwnerID, &tok)
320
321         t, err := time.Parse("2006-01-02", s.Date)
322         if err != nil {
323                 return err
324         }
325
326         summary, err := c.ActivitySummary(t)
327         if err != nil {
328                 return err
329         }
330
331         log.Debugf(ctx, "ActivitySummary() = %v", summary)
332         return nil
333 }