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