app.yaml: Set api_version "go1".
[kraftakt.git] / kraftakt.go
index afb5e4d..72389bd 100644 (file)
@@ -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
@@ -37,12 +56,12 @@ func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        ctx := appengine.NewContext(r)
 
        if err := app.LoadConfig(ctx); err != nil {
-               http.Error(w, err.Error(), http.StatusInternalServerError)
+               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
        }
 }
@@ -53,7 +72,7 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
        ctx := appengine.NewContext(r)
 
        if err := app.LoadConfig(ctx); err != nil {
-               http.Error(w, err.Error(), http.StatusInternalServerError)
+               internalServerError(ctx, w, fmt.Errorf("LoadConfig() = %v", err))
                return
        }
 
@@ -61,7 +80,7 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
        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)
@@ -70,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
+func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
+       var templateData struct {
+               HaveFitbit    bool
+               HaveGoogleFit bool
+               *app.User
        }
-       haveFitbitToken := err == nil
-
-       _, err = u.Token(ctx, "Google")
-       if err != nil && err != datastore.ErrNoSuchEntity {
-               return err
-       }
-       haveGoogleToken := err == nil
-
-       fmt.Fprintln(w, "<html><head><title>Kraftakt</title></head>")
-       fmt.Fprintln(w, "<body><h1>Kraftakt</h1>")
+       templateName := "main.html"
 
-       fmt.Fprintln(w, "<p><strong>Kraftakt</strong> copies your <em>Fitbit</em> data to <em>Google Fit</em>, seconds after you sync.</p>")
+       if gaeUser := user.Current(ctx); gaeUser != nil {
+               templateName = "loggedin.html"
 
-       fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
-       fmt.Fprintln(w, "<ul>")
+               u, err := app.NewUser(ctx, gaeUser.Email)
+               if err != nil {
+                       return err
+               }
+               templateData.User = u
 
-       fmt.Fprint(w, "<li>Fitbit: ")
-       if haveFitbitToken {
-               fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
-       } else {
-               fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/fitbit/setup">Authorize</a>)`)
-       }
-       fmt.Fprintln(w, "</li>")
+               _, err = u.Token(ctx, "Fitbit")
+               if err != nil && err != datastore.ErrNoSuchEntity {
+                       return err
+               }
+               templateData.HaveFitbit = (err == nil)
 
-       fmt.Fprint(w, "<li>Google Fit: ")
-       if haveGoogleToken {
-               fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
-       } else {
-               fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/google/setup">Authorize</a>)`)
+               _, err = u.Token(ctx, "Google")
+               if err != nil && err != datastore.ErrNoSuchEntity {
+                       return err
+               }
+               templateData.HaveGoogleFit = (err == nil)
        }
-       fmt.Fprintln(w, "</li>")
 
-       fmt.Fprintln(w, "</ul>")
-       fmt.Fprintln(w, "</body></html>")
+       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
        }
@@ -151,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 {
@@ -168,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.
@@ -197,7 +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.Warningf(ctx, "signature mismatch")
+               log.Errorf(ctx, "signature mismatch")
                w.WriteHeader(http.StatusNotFound)
                return nil
        }
@@ -213,6 +274,8 @@ 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
        }
@@ -222,23 +285,38 @@ func handleNotifications(ctx context.Context, payload []byte) error {
                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
        }
@@ -322,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
                }
        }()
@@ -355,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 {
@@ -371,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
+}