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