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