Implement Fitbit and Google Fit disconnect handlers.
[kraftakt.git] / kraftakt.go
1 package kraftakt
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "io/ioutil"
8         "net/http"
9         "sync"
10         "time"
11
12         "github.com/octo/kraftakt/app"
13         "github.com/octo/kraftakt/fitbit"
14         "github.com/octo/kraftakt/gfit"
15         "google.golang.org/appengine"
16         "google.golang.org/appengine/datastore"
17         "google.golang.org/appengine/delay"
18         "google.golang.org/appengine/log"
19         "google.golang.org/appengine/user"
20 )
21
22 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
23
24 func init() {
25         http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
26         http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
27         http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
28         http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
29         http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
30         http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
31         http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
32         http.Handle("/", AuthenticatedHandler(indexHandler))
33 }
34
35 // ContextHandler implements http.Handler
36 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
37
38 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
39         ctx := appengine.NewContext(r)
40
41         if err := app.LoadConfig(ctx); err != nil {
42                 http.Error(w, err.Error(), http.StatusInternalServerError)
43                 return
44         }
45
46         if err := hndl(ctx, w, r); err != nil {
47                 http.Error(w, err.Error(), http.StatusInternalServerError)
48                 return
49         }
50 }
51
52 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
53
54 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55         ctx := appengine.NewContext(r)
56
57         if err := app.LoadConfig(ctx); err != nil {
58                 http.Error(w, err.Error(), http.StatusInternalServerError)
59                 return
60         }
61
62         gaeUser := user.Current(ctx)
63         if gaeUser == nil {
64                 url, err := user.LoginURL(ctx, r.URL.String())
65                 if err != nil {
66                         http.Error(w, err.Error(), http.StatusInternalServerError)
67                         return
68                 }
69                 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
70                 return
71         }
72
73         u, err := app.NewUser(ctx, gaeUser.Email)
74         if err != nil {
75                 http.Error(w, err.Error(), http.StatusInternalServerError)
76                 return
77         }
78
79         if err := hndl(ctx, w, r, u); err != nil {
80                 http.Error(w, err.Error(), http.StatusInternalServerError)
81                 return
82         }
83 }
84
85 func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
86         _, err := u.Token(ctx, "Fitbit")
87         if err != nil && err != datastore.ErrNoSuchEntity {
88                 return err
89         }
90         haveFitbitToken := err == nil
91
92         _, err = u.Token(ctx, "Google")
93         if err != nil && err != datastore.ErrNoSuchEntity {
94                 return err
95         }
96         haveGoogleToken := err == nil
97
98         fmt.Fprintln(w, "<html><head><title>Kraftakt</title></head>")
99         fmt.Fprintln(w, "<body><h1>Kraftakt</h1>")
100
101         fmt.Fprintln(w, "<p><strong>Kraftakt</strong> copies your <em>Fitbit</em> data to <em>Google Fit</em>, seconds after you sync.</p>")
102
103         fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
104         fmt.Fprintln(w, "<ul>")
105
106         fmt.Fprint(w, "<li>Fitbit: ")
107         if haveFitbitToken {
108                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
109         } else {
110                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/fitbit/setup">Authorize</a>)`)
111         }
112         fmt.Fprintln(w, "</li>")
113
114         fmt.Fprint(w, "<li>Google Fit: ")
115         if haveGoogleToken {
116                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
117         } else {
118                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/google/setup">Authorize</a>)`)
119         }
120         fmt.Fprintln(w, "</li>")
121
122         fmt.Fprintln(w, "</ul>")
123         fmt.Fprintln(w, "</body></html>")
124
125         return nil
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 googleSetupHandler(w http.ResponseWriter, r *http.Request) {
158         http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
159 }
160
161 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
162         if err := gfit.ParseToken(ctx, r, u); err != nil {
163                 return err
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 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
175 // subscription. It verifies the payload, splits it into individual
176 // notifications and adds it to the taskqueue service.
177 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
178         defer r.Body.Close()
179
180         fitbitTimeout := 3 * time.Second
181         ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
182         defer cancel()
183
184         // this is used when setting up a new subscriber in the UI. Once set
185         // up, this code path should not be triggered.
186         if verify := r.FormValue("verify"); verify != "" {
187                 if verify == app.Config.FitbitSubscriberCode {
188                         w.WriteHeader(http.StatusNoContent)
189                 } else {
190                         w.WriteHeader(http.StatusNotFound)
191                 }
192                 return nil
193         }
194
195         data, err := ioutil.ReadAll(r.Body)
196         if err != nil {
197                 return err
198         }
199
200         // Fitbit recommendation: "If signature verification fails, you should
201         // respond with a 404"
202         if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
203                 log.Warningf(ctx, "signature mismatch")
204                 w.WriteHeader(http.StatusNotFound)
205                 return nil
206         }
207
208         if err := delayedHandleNotifications.Call(ctx, data); err != nil {
209                 return err
210         }
211
212         w.WriteHeader(http.StatusCreated)
213         return nil
214 }
215
216 // handleNotifications parses fitbit notifications and requests the individual
217 // activities from Fitbit. It is executed asynchronously via the delay package.
218 func handleNotifications(ctx context.Context, payload []byte) error {
219         if err := app.LoadConfig(ctx); err != nil {
220                 return err
221         }
222
223         var subscriptions []fitbit.Subscription
224         if err := json.Unmarshal(payload, &subscriptions); err != nil {
225                 return err
226         }
227
228         for _, s := range subscriptions {
229                 if s.CollectionType != "activities" {
230                         log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
231                         continue
232                 }
233
234                 if err := handleNotification(ctx, &s); err != nil {
235                         log.Errorf(ctx, "handleNotification() = %v", err)
236                         continue
237                 }
238         }
239
240         return nil
241 }
242
243 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
244         u, err := app.UserByID(ctx, s.SubscriptionID)
245         if err != nil {
246                 return err
247         }
248
249         fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
250         if err != nil {
251                 return err
252         }
253
254         var (
255                 wg      = &sync.WaitGroup{}
256                 errs    appengine.MultiError
257                 summary *fitbit.ActivitySummary
258                 profile *fitbit.Profile
259         )
260
261         wg.Add(1)
262         go func() {
263                 var err error
264                 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
265                 if err != nil {
266                         errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
267                 }
268                 wg.Done()
269         }()
270
271         wg.Add(1)
272         go func() {
273                 var err error
274                 profile, err = fitbitClient.Profile(ctx)
275                 if err != nil {
276                         errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
277                 }
278                 wg.Done()
279         }()
280
281         wg.Wait()
282         if len(errs) != 0 {
283                 return errs
284         }
285
286         tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
287         if err != nil {
288                 return err
289         }
290
291         log.Debugf(ctx, "%s (%s) took %d steps on %s",
292                 profile.Name, u.Email, summary.Summary.Steps, tm)
293
294         gfitClient, err := gfit.NewClient(ctx, u)
295         if err != nil {
296                 return err
297         }
298
299         wg.Add(1)
300         go func() {
301                 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
302                         errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
303                 }
304                 wg.Done()
305         }()
306
307         wg.Add(1)
308         go func() {
309                 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
310                         errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
311                 }
312                 wg.Done()
313         }()
314
315         wg.Add(1)
316         go func() {
317                 defer wg.Done()
318
319                 var distanceMeters float64
320                 for _, d := range summary.Summary.Distances {
321                         if d.Activity != "total" {
322                                 continue
323                         }
324                         distanceMeters = 1000.0 * d.Distance
325                         break
326                 }
327                 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
328                         errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
329                         return
330                 }
331         }()
332
333         wg.Add(1)
334         go func() {
335                 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
336                         errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
337                 }
338                 wg.Done()
339         }()
340
341         wg.Add(1)
342         go func() {
343                 defer wg.Done()
344
345                 var activities []gfit.Activity
346                 for _, a := range summary.Activities {
347                         if !a.HasStartTime {
348                                 continue
349                         }
350
351                         startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
352                         if err != nil {
353                                 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
354                                 return
355                         }
356                         endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
357
358                         activities = append(activities, gfit.Activity{
359                                 Start: startTime,
360                                 End:   endTime,
361                                 Type:  gfit.ParseFitbitActivity(a.Name),
362                         })
363                 }
364                 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
365                         errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
366                         return
367                 }
368         }()
369
370         wg.Wait()
371
372         if len(errs) != 0 {
373                 return errs
374         }
375         return nil
376 }