Move more logic into the "fitbit" and (new) "app" packages.
authorFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 20:48:25 +0000 (21:48 +0100)
committerFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 20:49:08 +0000 (21:49 +0100)
app/user.go [new file with mode: 0644]
fitbit/fitbit.go
gfitsync.go

diff --git a/app/user.go b/app/user.go
new file mode 100644 (file)
index 0000000..86c033c
--- /dev/null
@@ -0,0 +1,81 @@
+package app
+
+import (
+       "context"
+       "fmt"
+
+       "github.com/google/uuid"
+       legacy_context "golang.org/x/net/context"
+       "golang.org/x/oauth2"
+       "google.golang.org/appengine/datastore"
+)
+
+type User struct {
+       key *datastore.Key
+}
+
+type dbUser struct {
+       ID string
+}
+
+func NewUser(ctx context.Context, email string) (*User, error) {
+       err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
+               key := datastore.NewKey(ctx, "User", email, 0, nil)
+               if err := datastore.Get(ctx, key, &dbUser{}); err != datastore.ErrNoSuchEntity {
+                       return err // may be nil
+               }
+
+               _, err := datastore.Put(ctx, key, &dbUser{
+                       ID: uuid.New().String(),
+               })
+               return err
+       }, nil)
+       if err != nil {
+               return nil, err
+       }
+
+       return &User{
+               key: datastore.NewKey(ctx, "User", email, 0, nil),
+       }, nil
+}
+
+func UserByID(ctx context.Context, id string) (*User, error) {
+       q := datastore.NewQuery("User").Filter("ID=", id).KeysOnly()
+       keys, err := q.GetAll(ctx, nil)
+       if err != nil {
+               return nil, fmt.Errorf("datastore.Query.GetAll(): %v", err)
+       }
+       if len(keys) != 1 {
+               return nil, fmt.Errorf("len(keys) = %d, want 1", len(keys))
+       }
+
+       return &User{
+               key: keys[0],
+       }, nil
+}
+
+func (u *User) ID(ctx context.Context) (string, error) {
+       var db dbUser
+       if err := datastore.Get(ctx, u.key, &db); err != nil {
+               return "", err
+       }
+
+       return db.ID, nil
+}
+
+func (u *User) Token(ctx context.Context, svc string) (*oauth2.Token, error) {
+       key := datastore.NewKey(ctx, "Token", svc, 0, u.key)
+
+       var tok oauth2.Token
+       if err := datastore.Get(ctx, key, &tok); err != nil {
+               return nil, err
+       }
+
+       return &tok, nil
+}
+
+func (u *User) SetToken(ctx context.Context, svc string, tok *oauth2.Token) error {
+       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, u.key)
+       _, err := datastore.Put(ctx, key, tok)
+       return err
+}
index 6c7a61d..0f1a583 100644 (file)
@@ -2,13 +2,20 @@ package fitbit
 
 import (
        "context"
+       "crypto/hmac"
+       "crypto/sha1"
+       "encoding/base64"
        "encoding/json"
        "fmt"
+       "io/ioutil"
        "net/http"
+       "net/url"
        "time"
 
+       "github.com/octo/gfitsync/app"
        "golang.org/x/oauth2"
        oauth2fitbit "golang.org/x/oauth2/fitbit"
+       "google.golang.org/appengine/log"
 )
 
 var oauth2Config = &oauth2.Config{
@@ -19,6 +26,44 @@ var oauth2Config = &oauth2.Config{
        Scopes:       []string{"activity"},
 }
 
+const csrfToken = "@CSRFTOKEN@"
+
+func AuthURL() string {
+       return oauth2Config.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
+}
+
+func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
+       if state := r.FormValue("state"); state != csrfToken {
+               return fmt.Errorf("invalid state parameter: %q", state)
+       }
+
+       tok, err := oauth2Config.Exchange(ctx, r.FormValue("code"))
+       if err != nil {
+               return err
+       }
+
+       return u.SetToken(ctx, "Fitbit", tok)
+}
+
+func CheckSignature(ctx context.Context, payload []byte, rawSig string) bool {
+       base64Sig, err := url.QueryUnescape(rawSig)
+       if err != nil {
+               log.Errorf(ctx, "QueryUnescape(%q) = %v", rawSig, err)
+               return false
+       }
+       signatureGot, err := base64.StdEncoding.DecodeString(base64Sig)
+       if err != nil {
+               log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", base64Sig, err)
+               return false
+       }
+
+       mac := hmac.New(sha1.New, []byte(oauth2Config.ClientSecret+"&"))
+       mac.Write(payload)
+       signatureWant := mac.Sum(nil)
+
+       return hmac.Equal(signatureGot, signatureWant)
+}
+
 type Activity struct {
        ActivityID       int     `json:"activityId"`
        ActivityParentID int     `json:"activityParentId"`
@@ -63,21 +108,40 @@ type ActivitySummary struct {
        } `json:"summary"`
 }
 
+type Subscription struct {
+       CollectionType string `json:"collectionType"`
+       Date           string `json:"date"`
+       OwnerID        string `json:"ownerId"`
+       OwnerType      string `json:"ownerType"`
+       SubscriptionID string `json:"subscriptionId"`
+}
+
 type Client struct {
-       userID string
-       client *http.Client
+       fitbitUserID string
+       appUser      *app.User
+       client       *http.Client
 }
 
-func NewClient(ctx context.Context, userID string, tok *oauth2.Token) *Client {
-       return &Client{
-               userID: userID,
-               client: oauth2Config.Client(ctx, tok),
+func NewClient(ctx context.Context, fitbitUserID string, u *app.User) (*Client, error) {
+       tok, err := u.Token(ctx, "Fitbit")
+       if err != nil {
+               return nil, err
+       }
+
+       if fitbitUserID == "" {
+               fitbitUserID = "-"
        }
+
+       return &Client{
+               fitbitUserID: fitbitUserID,
+               appUser:      u,
+               client:       oauth2Config.Client(ctx, tok),
+       }, nil
 }
 
 func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) {
        url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activity/date/%s.json",
-               c.userID, t.Format("2006-01-02"))
+               c.fitbitUserID, t.Format("2006-01-02"))
 
        res, err := c.client.Get(url)
        if err != nil {
@@ -92,3 +156,26 @@ func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) {
 
        return &summary, nil
 }
+
+func (c *Client) Subscribe(ctx context.Context, collection string) error {
+       subscriberID, err := c.appUser.ID(ctx)
+       if err != nil {
+               return err
+       }
+
+       url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/apiSubscriptions/%s.json",
+               c.fitbitUserID, collection, subscriberID)
+       res, err := c.client.Post(url, "", nil)
+       if err != nil {
+               return err
+       }
+       defer res.Body.Close()
+
+       if res.StatusCode >= 400 {
+               data, _ := ioutil.ReadAll(res.Body)
+               log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
+               return fmt.Errorf("creating subscription failed")
+       }
+
+       return nil
+}
index fe8049c..0007395 100644 (file)
@@ -2,21 +2,14 @@ package gfitsync
 
 import (
        "context"
-       "crypto/hmac"
-       "crypto/sha1"
-       "encoding/base64"
        "encoding/json"
        "fmt"
        "io/ioutil"
        "net/http"
-       "net/url"
        "time"
 
-       "github.com/google/uuid"
+       "github.com/octo/gfitsync/app"
        "github.com/octo/gfitsync/fitbit"
-       legacy_context "golang.org/x/net/context"
-       "golang.org/x/oauth2"
-       oauth2fitbit "golang.org/x/oauth2/fitbit"
        "google.golang.org/appengine"
        "google.golang.org/appengine/datastore"
        "google.golang.org/appengine/delay"
@@ -24,21 +17,8 @@ import (
        "google.golang.org/appengine/user"
 )
 
-const csrfToken = "@CSRFTOKEN@"
-
-// var delayedHandleNotifications = delay.Func("handleNotifications", func(ctx legacy_context.Context, payload []byte) error {
-//     return handleNotifications(ctx, payload)
-// })
 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
 
-var oauthConfig = &oauth2.Config{
-       ClientID:     "@FITBIT_CLIENT_ID@",
-       ClientSecret: "@FITBIT_CLIENT_SECRET@",
-       Endpoint:     oauth2fitbit.Endpoint,
-       RedirectURL:  "https://fitbit-gfit-sync.appspot.com/fitbit/grant", // TODO(octo): make full URL
-       Scopes:       []string{"activity"},
-}
-
 func init() {
        http.HandleFunc("/setup", setupHandler)
        http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
@@ -58,7 +38,7 @@ func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        }
 }
 
-type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *datastore.Key) error
+type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
 
 type User struct {
        ID string
@@ -67,8 +47,8 @@ type User struct {
 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        ctx := appengine.NewContext(r)
 
-       u := user.Current(ctx)
-       if u == nil {
+       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)
@@ -78,51 +58,27 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
                return
        }
 
-       err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
-               key := datastore.NewKey(ctx, "User", u.Email, 0, nil)
-
-               if err := datastore.Get(ctx, key, &User{}); err != datastore.ErrNoSuchEntity {
-                       return err // may be nil
-               }
-
-               _, err := datastore.Put(ctx, key, &User{
-                       ID: uuid.New().String(),
-               })
-               return err
-       }, nil)
+       u, err := app.NewUser(ctx, gaeUser.Email)
        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
        }
 
-       rootKey := datastore.NewKey(ctx, "User", u.Email, 0, nil)
-       if err := hndl(ctx, w, r, rootKey); err != nil {
+       if err := hndl(ctx, w, r, u); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
        }
 }
 
-func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
-       var (
-               tok       oauth2.Token
-               haveToken bool
-       )
-
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
-       err := datastore.Get(ctx, key, &tok)
+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
        }
-       if err == nil {
-               haveToken = true
-       }
+       haveToken := err == nil
 
-       u := user.Current(ctx)
-
-       // fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
        fmt.Fprintln(w, "<html><body><h1>Fitbit to Google Fit sync</h1>")
-
-       fmt.Fprintf(w, "<p>Hello %s</p>\n", u.Email)
+       fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
        fmt.Fprint(w, "<p>Fitbit: ")
        if haveToken {
                fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
@@ -132,56 +88,25 @@ func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, r
        fmt.Fprintln(w, "</p>")
        fmt.Fprintln(w, "</body></html>")
 
-       // TODO(octo): print summary to user
        return nil
 }
 
 func setupHandler(w http.ResponseWriter, r *http.Request) {
-       url := oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
+       url := fitbit.AuthURL()
        http.Redirect(w, r, url, http.StatusTemporaryRedirect)
 }
 
-func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
-       if state := r.FormValue("state"); state != csrfToken {
-               return fmt.Errorf("invalid state parameter: %q", state)
-       }
-
-       tok, err := oauthConfig.Exchange(ctx, r.FormValue("code"))
-       if err != nil {
-               return err
-       }
-
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
-       if _, err := datastore.Put(ctx, key, tok); err != 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 := oauthConfig.Client(ctx, tok)
-
-       var u User
-       if err := datastore.Get(ctx, rootKey, &u); err != nil {
-               return err
-       }
-
-       // create a subscription
-       url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json", u.ID)
-       res, err := c.Post(url, "", nil)
+       c, err := fitbit.NewClient(ctx, "-", u)
        if err != nil {
                return err
        }
-       defer res.Body.Close()
 
-       if res.StatusCode == http.StatusConflict {
-               var n fitbitSubscription
-               if err := json.NewDecoder(res.Body).Decode(&n); err != nil {
-                       return err
-               }
-               log.Warningf(ctx, "conflict with existing subscription %v", n)
-       }
-
-       if res.StatusCode >= 400 {
-               data, _ := ioutil.ReadAll(res.Body)
-               log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
-               return fmt.Errorf("creating subscription failed")
+       if err := c.Subscribe(ctx, "activities"); err != nil {
+               return fmt.Errorf("c.Subscribe() = %v", err)
        }
 
        redirectURL := r.URL
@@ -192,49 +117,6 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
        return nil
 }
 
-type fitbitSubscription struct {
-       CollectionType string `json:"collectionType"`
-       Date           string `json:"date"`
-       OwnerID        string `json:"ownerId"`
-       OwnerType      string `json:"ownerType"`
-       SubscriptionID string `json:"subscriptionId"`
-}
-
-func (s *fitbitSubscription) URLValues() url.Values {
-       return url.Values{
-               "CollectionType": []string{s.CollectionType},
-               "Date":           []string{s.Date},
-               "OwnerID":        []string{s.OwnerID},
-               "OwnerType":      []string{s.OwnerType},
-               "SubscriptionID": []string{s.SubscriptionID},
-       }
-}
-
-func (s *fitbitSubscription) URL() string {
-       // daily summary: GET https://api.fitbit.com/1/user/[user-id]/activities/date/[date].json
-       return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json",
-               s.OwnerID, s.CollectionType, s.Date)
-}
-
-func checkSignature(ctx context.Context, payload []byte, rawSig string) bool {
-       base64Sig, err := url.QueryUnescape(rawSig)
-       if err != nil {
-               log.Errorf(ctx, "QueryUnescape(%q) = %v", rawSig, err)
-               return false
-       }
-       signatureGot, err := base64.StdEncoding.DecodeString(base64Sig)
-       if err != nil {
-               log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", base64Sig, err)
-               return false
-       }
-
-       mac := hmac.New(sha1.New, []byte(oauthConfig.ClientSecret+"&"))
-       mac.Write(payload)
-       signatureWant := mac.Sum(nil)
-
-       return hmac.Equal(signatureGot, signatureWant)
-}
-
 // 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.
@@ -263,7 +145,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 !checkSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
+       if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
                w.WriteHeader(http.StatusNotFound)
                return nil
        }
@@ -279,7 +161,7 @@ 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 {
-       var subscriptions []fitbitSubscription
+       var subscriptions []fitbit.Subscription
        if err := json.Unmarshal(payload, &subscriptions); err != nil {
                return err
        }
@@ -298,36 +180,26 @@ func handleNotifications(ctx context.Context, payload []byte) error {
        return nil
 }
 
-func handleNotification(ctx context.Context, s *fitbitSubscription) error {
-       q := datastore.NewQuery("User").Filter("ID=", s.SubscriptionID).KeysOnly()
-       keys, err := q.GetAll(ctx, nil)
+func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
+       u, err := app.UserByID(ctx, s.SubscriptionID)
        if err != nil {
-               return fmt.Errorf("datastore.Query.GetAll(): %v", err)
-       }
-       if len(keys) != 1 {
-               return fmt.Errorf("len(keys) = %d, want 1", len(keys))
+               return err
        }
-
-       rootKey := keys[0]
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
-
-       var tok oauth2.Token
-       if err := datastore.Get(ctx, key, &tok); err != nil {
+       c, err := fitbit.NewClient(ctx, s.OwnerID, u)
+       if err != nil {
                return err
        }
 
-       c := fitbit.NewClient(ctx, s.OwnerID, &tok)
-
-       t, err := time.Parse("2006-01-02", s.Date)
+       tm, err := time.Parse("2006-01-02", s.Date)
        if err != nil {
                return err
        }
 
-       summary, err := c.ActivitySummary(t)
+       summary, err := c.ActivitySummary(tm)
        if err != nil {
                return err
        }
 
-       log.Debugf(ctx, "ActivitySummary() = %v", summary)
+       log.Debugf(ctx, "ActivitySummary(%q) = %+v", s.OwnerID, summary)
        return nil
 }