28c0685533854377b04ca6f2b1295a04cf53d823
[kraftakt.git] / gfitsync.go
1 package gfitsync
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "io/ioutil"
8         "net/http"
9         "time"
10
11         "github.com/octo/gfitsync/app"
12         "github.com/octo/gfitsync/fitbit"
13         "github.com/octo/gfitsync/gfit"
14         "google.golang.org/appengine"
15         "google.golang.org/appengine/datastore"
16         "google.golang.org/appengine/delay"
17         "google.golang.org/appengine/log"
18         "google.golang.org/appengine/user"
19 )
20
21 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
22
23 func init() {
24         http.HandleFunc("/fitbit/setup", fitbitSetupHandler)
25         http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
26         http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
27         http.HandleFunc("/google/setup", googleSetupHandler)
28         http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
29         http.Handle("/", AuthenticatedHandler(indexHandler))
30 }
31
32 // ContextHandler implements http.Handler
33 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
34
35 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36         ctx := appengine.NewContext(r)
37
38         if err := hndl(ctx, w, r); err != nil {
39                 http.Error(w, err.Error(), http.StatusInternalServerError)
40                 return
41         }
42 }
43
44 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
45
46 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
47         ctx := appengine.NewContext(r)
48
49         gaeUser := user.Current(ctx)
50         if gaeUser == nil {
51                 url, err := user.LoginURL(ctx, r.URL.String())
52                 if err != nil {
53                         http.Error(w, err.Error(), http.StatusInternalServerError)
54                         return
55                 }
56                 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
57                 return
58         }
59
60         u, err := app.NewUser(ctx, gaeUser.Email)
61         if err != nil {
62                 http.Error(w, err.Error(), http.StatusInternalServerError)
63                 return
64         }
65
66         if err := hndl(ctx, w, r, u); err != nil {
67                 http.Error(w, err.Error(), http.StatusInternalServerError)
68                 return
69         }
70 }
71
72 func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
73         _, err := u.Token(ctx, "Fitbit")
74         if err != nil && err != datastore.ErrNoSuchEntity {
75                 return err
76         }
77         haveFitbitToken := err == nil
78
79         _, err = u.Token(ctx, "Google")
80         if err != nil && err != datastore.ErrNoSuchEntity {
81                 return err
82         }
83         haveGoogleToken := err == nil
84
85         fmt.Fprintln(w, "<html><head><title>Kraftakt</title></head>")
86         fmt.Fprintln(w, "<body><h1>Kraftakt</h1>")
87
88         fmt.Fprintln(w, "<p><strong>Kraftakt</strong> copies your <em>Fitbit</em> data to <em>Google Fit</em>, seconds after you sync.</p>")
89
90         fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
91         fmt.Fprintln(w, "<ul>")
92
93         fmt.Fprint(w, "<li>Fitbit: ")
94         if haveFitbitToken {
95                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
96         } else {
97                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/fitbit/setup">Authorize</a>)`)
98         }
99         fmt.Fprintln(w, "</li>")
100
101         fmt.Fprint(w, "<li>Google Fit: ")
102         if haveGoogleToken {
103                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
104         } else {
105                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/google/setup">Authorize</a>)`)
106         }
107         fmt.Fprintln(w, "</li>")
108
109         fmt.Fprintln(w, "</ul>")
110         fmt.Fprintln(w, "</body></html>")
111
112         return nil
113 }
114
115 func fitbitSetupHandler(w http.ResponseWriter, r *http.Request) {
116         http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect)
117 }
118
119 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
120         if err := fitbit.ParseToken(ctx, r, u); err != nil {
121                 return err
122         }
123         c, err := fitbit.NewClient(ctx, "-", u)
124         if err != nil {
125                 return err
126         }
127
128         if err := c.Subscribe(ctx, "activities"); err != nil {
129                 return fmt.Errorf("c.Subscribe() = %v", err)
130         }
131
132         redirectURL := r.URL
133         redirectURL.Path = "/"
134         redirectURL.RawQuery = ""
135         redirectURL.Fragment = ""
136         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
137         return nil
138 }
139
140 func googleSetupHandler(w http.ResponseWriter, r *http.Request) {
141         http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
142 }
143
144 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
145         if err := gfit.ParseToken(ctx, r, u); err != nil {
146                 return err
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 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
158 // subscription. It verifies the payload, splits it into individual
159 // notifications and adds it to the taskqueue service.
160 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
161         defer r.Body.Close()
162
163         fitbitTimeout := 3 * time.Second
164         ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
165         defer cancel()
166
167         // this is used when setting up a new subscriber in the UI. Once set
168         // up, this code path should not be triggered.
169         if verify := r.FormValue("verify"); verify != "" {
170                 if verify == "@FITBIT_SUBSCRIBER_CODE@" {
171                         w.WriteHeader(http.StatusNoContent)
172                 } else {
173                         w.WriteHeader(http.StatusNotFound)
174                 }
175                 return nil
176         }
177
178         data, err := ioutil.ReadAll(r.Body)
179         if err != nil {
180                 return err
181         }
182
183         // Fitbit recommendation: "If signature verification fails, you should
184         // respond with a 404"
185         if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
186                 w.WriteHeader(http.StatusNotFound)
187                 return nil
188         }
189
190         if err := delayedHandleNotifications.Call(ctx, data); err != nil {
191                 return err
192         }
193
194         w.WriteHeader(http.StatusCreated)
195         return nil
196 }
197
198 // handleNotifications parses fitbit notifications and requests the individual
199 // activities from Fitbit. It is executed asynchronously via the delay package.
200 func handleNotifications(ctx context.Context, payload []byte) error {
201         var subscriptions []fitbit.Subscription
202         if err := json.Unmarshal(payload, &subscriptions); err != nil {
203                 return err
204         }
205
206         for _, s := range subscriptions {
207                 if s.CollectionType != "activities" {
208                         continue
209                 }
210
211                 if err := handleNotification(ctx, &s); err != nil {
212                         log.Errorf(ctx, "handleNotification() = %v", err)
213                         continue
214                 }
215         }
216
217         return nil
218 }
219
220 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
221         u, err := app.UserByID(ctx, s.SubscriptionID)
222         if err != nil {
223                 return err
224         }
225
226         fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
227         if err != nil {
228                 return err
229         }
230
231         profile, err := fitbitClient.Profile(ctx)
232         if err != nil {
233                 return err
234         }
235         log.Debugf(ctx, "profile = %+v", profile)
236
237         tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
238         if err != nil {
239                 return err
240         }
241
242         summary, err := fitbitClient.ActivitySummary(tm)
243         if err != nil {
244                 return err
245         }
246         log.Debugf(ctx, "%s (%s) took %d steps on %s",
247                 profile.Name, u.Email, summary.Summary.Steps, s.Date)
248
249         gfitClient, err := gfit.NewClient(ctx, u)
250         if err != nil {
251                 return err
252         }
253
254         if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
255                 return fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err)
256         }
257
258         if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
259                 return fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err)
260         }
261
262         var distanceMeters float64
263         for _, d := range summary.Summary.Distances {
264                 if d.Activity != "total" {
265                         continue
266                 }
267                 distanceMeters = 1000.0 * d.Distance
268                 break
269         }
270         if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
271                 return fmt.Errorf("gfitClient.SetDistance(%d) = %v", distanceMeters, err)
272         }
273
274         if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
275                 return fmt.Errorf("gfitClient.SetHeartRate() = %v", err)
276         }
277
278         return nil
279 }