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