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