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