Replace CSRF token with tokens based on the user's ID.
[kraftakt.git] / kraftakt.go
1 package kraftakt
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "html/template"
8         "io/ioutil"
9         "net/http"
10         "sync"
11         "time"
12
13         "github.com/octo/kraftakt/app"
14         "github.com/octo/kraftakt/fitbit"
15         "github.com/octo/kraftakt/gfit"
16         "google.golang.org/appengine"
17         "google.golang.org/appengine/datastore"
18         "google.golang.org/appengine/delay"
19         "google.golang.org/appengine/log"
20         "google.golang.org/appengine/user"
21 )
22
23 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
24
25 var templates *template.Template
26
27 func init() {
28         http.Handle("/login", AuthenticatedHandler(loginHandler))
29         http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
30         http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
31         http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
32         http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
33         http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
34         http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
35         // unauthenticated
36         http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
37         http.Handle("/", ContextHandler(indexHandler))
38
39         t, err := template.ParseGlob("templates/*.html")
40         if err != nil {
41                 panic(err)
42         }
43         templates = t
44 }
45
46 // ContextHandler implements http.Handler
47 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
48
49 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
50         ctx := appengine.NewContext(r)
51
52         if err := app.LoadConfig(ctx); err != nil {
53                 http.Error(w, err.Error(), http.StatusInternalServerError)
54                 return
55         }
56
57         if err := hndl(ctx, w, r); err != nil {
58                 http.Error(w, err.Error(), http.StatusInternalServerError)
59                 return
60         }
61 }
62
63 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
64
65 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
66         ctx := appengine.NewContext(r)
67
68         if err := app.LoadConfig(ctx); err != nil {
69                 http.Error(w, err.Error(), http.StatusInternalServerError)
70                 return
71         }
72
73         gaeUser := user.Current(ctx)
74         if gaeUser == nil {
75                 url, err := user.LoginURL(ctx, r.URL.String())
76                 if err != nil {
77                         http.Error(w, err.Error(), http.StatusInternalServerError)
78                         return
79                 }
80                 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
81                 return
82         }
83
84         u, err := app.NewUser(ctx, gaeUser.Email)
85         if err != nil {
86                 http.Error(w, err.Error(), http.StatusInternalServerError)
87                 return
88         }
89
90         if err := hndl(ctx, w, r, u); err != nil {
91                 http.Error(w, err.Error(), http.StatusInternalServerError)
92                 return
93         }
94 }
95
96 func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
97         var templateData struct {
98                 HaveFitbit    bool
99                 HaveGoogleFit bool
100                 *app.User
101         }
102         templateName := "main.html"
103
104         if gaeUser := user.Current(ctx); gaeUser != nil {
105                 templateName = "loggedin.html"
106
107                 u, err := app.NewUser(ctx, gaeUser.Email)
108                 if err != nil {
109                         return err
110                 }
111                 templateData.User = u
112
113                 _, err = u.Token(ctx, "Fitbit")
114                 if err != nil && err != datastore.ErrNoSuchEntity {
115                         return err
116                 }
117                 templateData.HaveFitbit = (err == nil)
118
119                 _, err = u.Token(ctx, "Google")
120                 if err != nil && err != datastore.ErrNoSuchEntity {
121                         return err
122                 }
123                 templateData.HaveGoogleFit = (err == nil)
124         }
125
126         return templates.ExecuteTemplate(w, templateName, &templateData)
127 }
128
129 func loginHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error {
130         // essentially a nop; all the heavy lifting (i.e. logging in) has been done by the AuthenticatedHandler wrapper.
131         redirectURL := r.URL
132         redirectURL.Path = "/"
133         redirectURL.RawQuery = ""
134         redirectURL.Fragment = ""
135         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
136         return nil
137 }
138
139 func fitbitConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
140         c, err := fitbit.NewClient(ctx, "", u)
141         if err != nil {
142                 return err
143         }
144
145         http.Redirect(w, r, c.AuthURL(ctx), http.StatusTemporaryRedirect)
146         return nil
147 }
148
149 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
150         if err := fitbit.ParseToken(ctx, r, u); err != nil {
151                 return err
152         }
153         c, err := fitbit.NewClient(ctx, "", u)
154         if err != nil {
155                 return err
156         }
157
158         for _, collection := range []string{"activities", "sleep"} {
159                 if err := c.Subscribe(ctx, collection); err != nil {
160                         return fmt.Errorf("c.Subscribe(%q) = %v", collection, err)
161                 }
162                 log.Infof(ctx, "Successfully subscribed to %q", collection)
163         }
164
165         redirectURL := r.URL
166         redirectURL.Path = "/"
167         redirectURL.RawQuery = ""
168         redirectURL.Fragment = ""
169         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
170         return nil
171 }
172
173 func fitbitDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
174         c, err := fitbit.NewClient(ctx, "", u)
175         if err != nil {
176                 return err
177         }
178
179         var errs appengine.MultiError
180
181         for _, collection := range []string{"activities", "sleep"} {
182                 if err := c.Unsubscribe(ctx, collection); err != nil {
183                         errs = append(errs, fmt.Errorf("Unsubscribe(%q) = %v", collection, err))
184                         continue
185                 }
186                 log.Infof(ctx, "Successfully unsubscribed from %q", collection)
187         }
188
189         if err := c.DeleteToken(ctx); err != nil {
190                 errs = append(errs, fmt.Errorf("DeleteToken() = %v", err))
191         }
192         if len(errs) != 0 {
193                 return errs
194         }
195
196         redirectURL := r.URL
197         redirectURL.Path = "/"
198         redirectURL.RawQuery = ""
199         redirectURL.Fragment = ""
200         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
201         return nil
202 }
203
204 func googleConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
205         c, err := gfit.NewClient(ctx, u)
206         if err != nil {
207                 return err
208         }
209
210         http.Redirect(w, r, c.AuthURL(ctx), http.StatusTemporaryRedirect)
211         return nil
212 }
213
214 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
215         if err := gfit.ParseToken(ctx, r, u); err != nil {
216                 return err
217         }
218
219         redirectURL := r.URL
220         redirectURL.Path = "/"
221         redirectURL.RawQuery = ""
222         redirectURL.Fragment = ""
223         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
224         return nil
225 }
226
227 func googleDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
228         c, err := gfit.NewClient(ctx, u)
229         if err != nil {
230                 return err
231         }
232
233         if err := c.DeleteToken(ctx); err != nil {
234                 return err
235         }
236
237         redirectURL := r.URL
238         redirectURL.Path = "/"
239         redirectURL.RawQuery = ""
240         redirectURL.Fragment = ""
241         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
242         return nil
243 }
244
245 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
246 // subscription. It verifies the payload, splits it into individual
247 // notifications and adds it to the taskqueue service.
248 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
249         defer r.Body.Close()
250
251         fitbitTimeout := 3 * time.Second
252         ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
253         defer cancel()
254
255         // this is used when setting up a new subscriber in the UI. Once set
256         // up, this code path should not be triggered.
257         if verify := r.FormValue("verify"); verify != "" {
258                 if verify == app.Config.FitbitSubscriberCode {
259                         w.WriteHeader(http.StatusNoContent)
260                 } else {
261                         w.WriteHeader(http.StatusNotFound)
262                 }
263                 return nil
264         }
265
266         data, err := ioutil.ReadAll(r.Body)
267         if err != nil {
268                 return err
269         }
270
271         // Fitbit recommendation: "If signature verification fails, you should
272         // respond with a 404"
273         if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
274                 log.Warningf(ctx, "signature mismatch")
275                 w.WriteHeader(http.StatusNotFound)
276                 return nil
277         }
278
279         if err := delayedHandleNotifications.Call(ctx, data); err != nil {
280                 return err
281         }
282
283         w.WriteHeader(http.StatusCreated)
284         return nil
285 }
286
287 // handleNotifications parses fitbit notifications and requests the individual
288 // activities from Fitbit. It is executed asynchronously via the delay package.
289 func handleNotifications(ctx context.Context, payload []byte) error {
290         if err := app.LoadConfig(ctx); err != nil {
291                 return err
292         }
293
294         var subscriptions []fitbit.Subscription
295         if err := json.Unmarshal(payload, &subscriptions); err != nil {
296                 return err
297         }
298
299         for _, s := range subscriptions {
300                 if s.CollectionType != "activities" {
301                         log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
302                         continue
303                 }
304
305                 if err := handleNotification(ctx, &s); err != nil {
306                         log.Errorf(ctx, "handleNotification() = %v", err)
307                         continue
308                 }
309         }
310
311         return nil
312 }
313
314 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
315         u, err := app.UserByID(ctx, s.SubscriptionID)
316         if err != nil {
317                 return err
318         }
319
320         fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
321         if err != nil {
322                 return err
323         }
324
325         var (
326                 wg      = &sync.WaitGroup{}
327                 errs    appengine.MultiError
328                 summary *fitbit.ActivitySummary
329                 profile *fitbit.Profile
330         )
331
332         wg.Add(1)
333         go func() {
334                 var err error
335                 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
336                 if err != nil {
337                         errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
338                 }
339                 wg.Done()
340         }()
341
342         wg.Add(1)
343         go func() {
344                 var err error
345                 profile, err = fitbitClient.Profile(ctx)
346                 if err != nil {
347                         errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
348                 }
349                 wg.Done()
350         }()
351
352         wg.Wait()
353         if len(errs) != 0 {
354                 return errs
355         }
356
357         tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
358         if err != nil {
359                 return err
360         }
361
362         log.Debugf(ctx, "%s (%s) took %d steps on %s",
363                 profile.Name, u.Email, summary.Summary.Steps, tm)
364
365         gfitClient, err := gfit.NewClient(ctx, u)
366         if err != nil {
367                 return err
368         }
369
370         wg.Add(1)
371         go func() {
372                 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
373                         errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
374                 }
375                 wg.Done()
376         }()
377
378         wg.Add(1)
379         go func() {
380                 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
381                         errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
382                 }
383                 wg.Done()
384         }()
385
386         wg.Add(1)
387         go func() {
388                 defer wg.Done()
389
390                 var distanceMeters float64
391                 for _, d := range summary.Summary.Distances {
392                         if d.Activity != "total" {
393                                 continue
394                         }
395                         distanceMeters = 1000.0 * d.Distance
396                         break
397                 }
398                 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
399                         errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
400                         return
401                 }
402         }()
403
404         wg.Add(1)
405         go func() {
406                 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
407                         errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
408                 }
409                 wg.Done()
410         }()
411
412         wg.Add(1)
413         go func() {
414                 defer wg.Done()
415
416                 var activities []gfit.Activity
417                 for _, a := range summary.Activities {
418                         if !a.HasStartTime {
419                                 continue
420                         }
421
422                         startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
423                         if err != nil {
424                                 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
425                                 return
426                         }
427                         endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
428
429                         activities = append(activities, gfit.Activity{
430                                 Start: startTime,
431                                 End:   endTime,
432                                 Type:  gfit.ParseFitbitActivity(a.Name),
433                         })
434                 }
435                 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
436                         errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
437                         return
438                 }
439         }()
440
441         wg.Wait()
442
443         if len(errs) != 0 {
444                 return errs
445         }
446         return nil
447 }