X-Git-Url: https://git.octo.it/?p=kraftakt.git;a=blobdiff_plain;f=kraftakt.go;h=72389bd1430b36450a071d09d8a491597d4805d7;hp=e0ad321e325d18b34d453c31893f74c3b8009b63;hb=HEAD;hpb=304308a757d192b9b80455017bda1cb82889d346 diff --git a/kraftakt.go b/kraftakt.go index e0ad321..72389bd 100644 --- a/kraftakt.go +++ b/kraftakt.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "html/template" "io/ioutil" "net/http" "sync" @@ -21,13 +22,31 @@ import ( var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications) +var templates *template.Template + func init() { - http.HandleFunc("/fitbit/setup", fitbitSetupHandler) + http.Handle("/login", AuthenticatedHandler(loginHandler)) + http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler)) http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler)) - http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler)) - http.HandleFunc("/google/setup", googleSetupHandler) + http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler)) + http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler)) http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler)) - http.Handle("/", AuthenticatedHandler(indexHandler)) + http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler)) + // unauthenticated + http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler)) + http.Handle("/", ContextHandler(indexHandler)) + + t, err := template.ParseGlob("templates/*.html") + if err != nil { + panic(err) + } + templates = t +} + +func internalServerError(ctx context.Context, w http.ResponseWriter, err error) { + log.Errorf(ctx, "%v", err) + + http.Error(w, "Internal Server Error\n\nReference: "+appengine.RequestID(ctx), http.StatusInternalServerError) } // ContextHandler implements http.Handler @@ -36,8 +55,13 @@ type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) er func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := appengine.NewContext(r) + if err := app.LoadConfig(ctx); err != nil { + internalServerError(ctx, w, fmt.Errorf("LoadConfig() = %v", err)) + return + } + if err := hndl(ctx, w, r); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + internalServerError(ctx, w, err) return } } @@ -47,11 +71,16 @@ type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Reque func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := appengine.NewContext(r) + if err := app.LoadConfig(ctx); err != nil { + internalServerError(ctx, w, fmt.Errorf("LoadConfig() = %v", err)) + return + } + gaeUser := user.Current(ctx) if gaeUser == nil { url, err := user.LoginURL(ctx, r.URL.String()) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + internalServerError(ctx, w, fmt.Errorf("LoginURL() = %v", err)) return } http.Redirect(w, r, url, http.StatusTemporaryRedirect) @@ -60,68 +89,69 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques u, err := app.NewUser(ctx, gaeUser.Email) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + internalServerError(ctx, w, fmt.Errorf("NewUser(%q) = %v", gaeUser.Email, err)) return } if err := hndl(ctx, w, r, u); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + internalServerError(ctx, w, err) return } } -func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { - _, err := u.Token(ctx, "Fitbit") - if err != nil && err != datastore.ErrNoSuchEntity { - return err - } - haveFitbitToken := err == nil - - _, err = u.Token(ctx, "Google") - if err != nil && err != datastore.ErrNoSuchEntity { - return err +func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error { + var templateData struct { + HaveFitbit bool + HaveGoogleFit bool + *app.User } - haveGoogleToken := err == nil + templateName := "main.html" - fmt.Fprintln(w, "Kraftakt") - fmt.Fprintln(w, "

Kraftakt

") + if gaeUser := user.Current(ctx); gaeUser != nil { + templateName = "loggedin.html" - fmt.Fprintln(w, "

Kraftakt copies your Fitbit data to Google Fit, seconds after you sync.

") - - fmt.Fprintf(w, "

Hello %s

\n", user.Current(ctx).Email) - fmt.Fprintln(w, "") - fmt.Fprintln(w, "") + return templates.ExecuteTemplate(w, templateName, &templateData) +} +func loginHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error { + // essentially a nop; all the heavy lifting (i.e. logging in) has been done by the AuthenticatedHandler wrapper. + redirectURL := r.URL + redirectURL.Path = "/" + redirectURL.RawQuery = "" + redirectURL.Fragment = "" + http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect) return nil } -func fitbitSetupHandler(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect) +func fitbitConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { + http.Redirect(w, r, fitbit.AuthURL(ctx, u), http.StatusTemporaryRedirect) + return nil } func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { if err := fitbit.ParseToken(ctx, r, u); err != nil { return err } - c, err := fitbit.NewClient(ctx, "-", u) + c, err := fitbit.NewClient(ctx, "", u) if err != nil { return err } @@ -141,8 +171,31 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ return nil } -func googleSetupHandler(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect) +func fitbitDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { + c, err := fitbit.NewClient(ctx, "", u) + if err != nil { + return err + } + + if err := c.UnsubscribeAll(ctx); err != nil { + return fmt.Errorf("UnsubscribeAll() = %v", err) + } + + if err := c.DeleteToken(ctx); err != nil { + return err + } + + redirectURL := r.URL + redirectURL.Path = "/" + redirectURL.RawQuery = "" + redirectURL.Fragment = "" + http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect) + return nil +} + +func googleConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { + http.Redirect(w, r, gfit.AuthURL(ctx, u), http.StatusTemporaryRedirect) + return nil } func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { @@ -158,6 +211,24 @@ func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ return nil } +func googleDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error { + c, err := gfit.NewClient(ctx, u) + if err != nil { + return err + } + + if err := c.DeleteToken(ctx); err != nil { + return err + } + + redirectURL := r.URL + redirectURL.Path = "/" + redirectURL.RawQuery = "" + redirectURL.Fragment = "" + http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect) + return nil +} + // fitbitNotifyHandler is called by Fitbit whenever there are updates to a // subscription. It verifies the payload, splits it into individual // notifications and adds it to the taskqueue service. @@ -171,7 +242,7 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req // this is used when setting up a new subscriber in the UI. Once set // up, this code path should not be triggered. if verify := r.FormValue("verify"); verify != "" { - if verify == "@FITBIT_SUBSCRIBER_CODE@" { + if verify == app.Config.FitbitSubscriberCode { w.WriteHeader(http.StatusNoContent) } else { w.WriteHeader(http.StatusNotFound) @@ -187,6 +258,7 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req // Fitbit recommendation: "If signature verification fails, you should // respond with a 404" if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) { + log.Errorf(ctx, "signature mismatch") w.WriteHeader(http.StatusNotFound) return nil } @@ -202,28 +274,49 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req // handleNotifications parses fitbit notifications and requests the individual // activities from Fitbit. It is executed asynchronously via the delay package. func handleNotifications(ctx context.Context, payload []byte) error { + log.Debugf(ctx, "NOTIFY -> %s", payload) + + if err := app.LoadConfig(ctx); err != nil { + return err + } + var subscriptions []fitbit.Subscription if err := json.Unmarshal(payload, &subscriptions); err != nil { return err } + wg := &sync.WaitGroup{} + for _, s := range subscriptions { - if s.CollectionType != "activities" { + switch s.CollectionType { + case "activities": + wg.Add(1) + go func(s fitbit.Subscription) { + defer wg.Done() + if err := activitiesNotification(ctx, &s); err != nil { + log.Warningf(ctx, "activitiesNotification() = %v", err) + } + }(s) // copies s + case "sleep": + wg.Add(1) + go func(s fitbit.Subscription) { + defer wg.Done() + if err := sleepNotification(ctx, &s); err != nil { + log.Warningf(ctx, "sleepNotification() = %v", err) + } + }(s) // copies s + default: log.Warningf(ctx, "ignoring collection type %q", s.CollectionType) - continue - } - if err := handleNotification(ctx, &s); err != nil { - log.Errorf(ctx, "handleNotification() = %v", err) - continue } } + wg.Wait() return nil } -func handleNotification(ctx context.Context, s *fitbit.Subscription) error { - u, err := app.UserByID(ctx, s.SubscriptionID) +func activitiesNotification(ctx context.Context, s *fitbit.Subscription) error { + u, err := fitbit.UserFromSubscriberID(ctx, s.SubscriptionID) if err != nil { return err } @@ -307,7 +400,7 @@ func handleNotification(ctx context.Context, s *fitbit.Subscription) error { break } if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil { - errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%d) = %v", distanceMeters, err)) + errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err)) return } }() @@ -340,7 +433,7 @@ func handleNotification(ctx context.Context, s *fitbit.Subscription) error { activities = append(activities, gfit.Activity{ Start: startTime, End: endTime, - Type: gfit.ParseFitbitActivity(a.Name), + Type: a.Name, }) } if err := gfitClient.SetActivities(ctx, activities, tm); err != nil { @@ -356,3 +449,78 @@ func handleNotification(ctx context.Context, s *fitbit.Subscription) error { } return nil } + +func sleepNotification(ctx context.Context, s *fitbit.Subscription) error { + u, err := fitbit.UserFromSubscriberID(ctx, s.SubscriptionID) + if err != nil { + return err + } + + var ( + wg = &sync.WaitGroup{} + gfitClient *gfit.Client + gfitErr error + ) + + wg.Add(1) + go func() { + gfitClient, gfitErr = gfit.NewClient(ctx, u) + wg.Done() + }() + + fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u) + if err != nil { + return err + } + + profile, err := fitbitClient.Profile(ctx) + if err != nil { + return err + } + + tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone) + if err != nil { + return err + } + + sleep, err := fitbitClient.Sleep(ctx, tm) + if err != nil { + return err + } + log.Debugf(ctx, "fitbitClient.Sleep(%v) returned %d sleep stages", tm, len(sleep.Stages)) + + var activities []gfit.Activity + for _, stg := range sleep.Stages { + a := gfit.Activity{ + Start: stg.StartTime, + End: stg.EndTime, + } + switch stg.Level { + case fitbit.SleepLevelDeep: + a.Type = "Deep sleep" + case fitbit.SleepLevelLight: + a.Type = "Light sleep" + case fitbit.SleepLevelREM: + a.Type = "REM sleep" + case fitbit.SleepLevelWake: + a.Type = "Awake (during sleep cycle)" + default: + log.Warningf(ctx, "unexpected sleep level %v", stg.Level) + continue + } + + activities = append(activities, a) + } + + wg.Wait() + if gfitErr != nil { + return gfitErr + } + + log.Debugf(ctx, "passing %d activities to gfitClient.SetActivities()", len(activities)) + if err := gfitClient.SetActivities(ctx, activities, tm); err != nil { + return fmt.Errorf("SetActivities() = %v", err) + } + + return nil +}