13 "github.com/octo/kraftakt/app"
14 "github.com/octo/kraftakt/fitbit"
15 "github.com/octo/kraftakt/gfit"
16 "google.golang.org/appengine"
17 "google.golang.org/appengine/datastore"
18 "google.golang.org/appengine/delay"
19 "google.golang.org/appengine/log"
20 "google.golang.org/appengine/user"
23 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
25 var templates *template.Template
28 http.Handle("/login", AuthenticatedHandler(loginHandler))
29 http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
30 http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
31 http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
32 http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
33 http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
34 http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
36 http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
37 http.Handle("/", ContextHandler(indexHandler))
39 t, err := template.ParseGlob("templates/*.html")
46 // ContextHandler implements http.Handler
47 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
49 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
50 ctx := appengine.NewContext(r)
52 if err := app.LoadConfig(ctx); err != nil {
53 http.Error(w, err.Error(), http.StatusInternalServerError)
57 if err := hndl(ctx, w, r); err != nil {
58 http.Error(w, err.Error(), http.StatusInternalServerError)
63 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
65 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
66 ctx := appengine.NewContext(r)
68 if err := app.LoadConfig(ctx); err != nil {
69 http.Error(w, err.Error(), http.StatusInternalServerError)
73 gaeUser := user.Current(ctx)
75 url, err := user.LoginURL(ctx, r.URL.String())
77 http.Error(w, err.Error(), http.StatusInternalServerError)
80 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
84 u, err := app.NewUser(ctx, gaeUser.Email)
86 http.Error(w, err.Error(), http.StatusInternalServerError)
90 if err := hndl(ctx, w, r, u); err != nil {
91 http.Error(w, err.Error(), http.StatusInternalServerError)
96 func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
97 var templateData struct {
102 templateName := "main.html"
104 if gaeUser := user.Current(ctx); gaeUser != nil {
105 templateName = "loggedin.html"
107 u, err := app.NewUser(ctx, gaeUser.Email)
111 templateData.User = u
113 _, err = u.Token(ctx, "Fitbit")
114 if err != nil && err != datastore.ErrNoSuchEntity {
117 templateData.HaveFitbit = (err == nil)
119 _, err = u.Token(ctx, "Google")
120 if err != nil && err != datastore.ErrNoSuchEntity {
123 templateData.HaveGoogleFit = (err == nil)
126 return templates.ExecuteTemplate(w, templateName, &templateData)
129 func loginHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error {
130 // essentially a nop; all the heavy lifting (i.e. logging in) has been done by the AuthenticatedHandler wrapper.
132 redirectURL.Path = "/"
133 redirectURL.RawQuery = ""
134 redirectURL.Fragment = ""
135 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
139 func fitbitConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
140 c, err := fitbit.NewClient(ctx, "", u)
145 http.Redirect(w, r, c.AuthURL(ctx), http.StatusTemporaryRedirect)
149 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
150 if err := fitbit.ParseToken(ctx, r, u); err != nil {
153 c, err := fitbit.NewClient(ctx, "", u)
158 for _, collection := range []string{"activities", "sleep"} {
159 if err := c.Subscribe(ctx, collection); err != nil {
160 return fmt.Errorf("c.Subscribe(%q) = %v", collection, err)
162 log.Infof(ctx, "Successfully subscribed to %q", collection)
166 redirectURL.Path = "/"
167 redirectURL.RawQuery = ""
168 redirectURL.Fragment = ""
169 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
173 func fitbitDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
174 c, err := fitbit.NewClient(ctx, "", u)
179 var errs appengine.MultiError
181 for _, collection := range []string{"activities", "sleep"} {
182 if err := c.Unsubscribe(ctx, collection); err != nil {
183 errs = append(errs, fmt.Errorf("Unsubscribe(%q) = %v", collection, err))
186 log.Infof(ctx, "Successfully unsubscribed from %q", collection)
189 if err := c.DeleteToken(ctx); err != nil {
190 errs = append(errs, fmt.Errorf("DeleteToken() = %v", err))
197 redirectURL.Path = "/"
198 redirectURL.RawQuery = ""
199 redirectURL.Fragment = ""
200 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
204 func googleConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
205 c, err := gfit.NewClient(ctx, u)
210 http.Redirect(w, r, c.AuthURL(ctx), http.StatusTemporaryRedirect)
214 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
215 if err := gfit.ParseToken(ctx, r, u); err != nil {
220 redirectURL.Path = "/"
221 redirectURL.RawQuery = ""
222 redirectURL.Fragment = ""
223 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
227 func googleDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
228 c, err := gfit.NewClient(ctx, u)
233 if err := c.DeleteToken(ctx); err != nil {
238 redirectURL.Path = "/"
239 redirectURL.RawQuery = ""
240 redirectURL.Fragment = ""
241 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
245 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
246 // subscription. It verifies the payload, splits it into individual
247 // notifications and adds it to the taskqueue service.
248 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
251 fitbitTimeout := 3 * time.Second
252 ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
255 // this is used when setting up a new subscriber in the UI. Once set
256 // up, this code path should not be triggered.
257 if verify := r.FormValue("verify"); verify != "" {
258 if verify == app.Config.FitbitSubscriberCode {
259 w.WriteHeader(http.StatusNoContent)
261 w.WriteHeader(http.StatusNotFound)
266 data, err := ioutil.ReadAll(r.Body)
271 // Fitbit recommendation: "If signature verification fails, you should
272 // respond with a 404"
273 if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
274 log.Warningf(ctx, "signature mismatch")
275 w.WriteHeader(http.StatusNotFound)
279 if err := delayedHandleNotifications.Call(ctx, data); err != nil {
283 w.WriteHeader(http.StatusCreated)
287 // handleNotifications parses fitbit notifications and requests the individual
288 // activities from Fitbit. It is executed asynchronously via the delay package.
289 func handleNotifications(ctx context.Context, payload []byte) error {
290 if err := app.LoadConfig(ctx); err != nil {
294 var subscriptions []fitbit.Subscription
295 if err := json.Unmarshal(payload, &subscriptions); err != nil {
299 for _, s := range subscriptions {
300 if s.CollectionType != "activities" {
301 log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
305 if err := handleNotification(ctx, &s); err != nil {
306 log.Errorf(ctx, "handleNotification() = %v", err)
314 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
315 u, err := app.UserByID(ctx, s.SubscriptionID)
320 fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
326 wg = &sync.WaitGroup{}
327 errs appengine.MultiError
328 summary *fitbit.ActivitySummary
329 profile *fitbit.Profile
335 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
337 errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
345 profile, err = fitbitClient.Profile(ctx)
347 errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
357 tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
362 log.Debugf(ctx, "%s (%s) took %d steps on %s",
363 profile.Name, u.Email, summary.Summary.Steps, tm)
365 gfitClient, err := gfit.NewClient(ctx, u)
372 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
373 errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
380 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
381 errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
390 var distanceMeters float64
391 for _, d := range summary.Summary.Distances {
392 if d.Activity != "total" {
395 distanceMeters = 1000.0 * d.Distance
398 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
399 errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
406 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
407 errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
416 var activities []gfit.Activity
417 for _, a := range summary.Activities {
422 startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
424 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
427 endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
429 activities = append(activities, gfit.Activity{
432 Type: gfit.ParseFitbitActivity(a.Name),
435 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
436 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))