From: Florian Forster Date: Wed, 10 Jan 2018 20:48:25 +0000 (+0100) Subject: Move more logic into the "fitbit" and (new) "app" packages. X-Git-Url: https://git.octo.it/?p=kraftakt.git;a=commitdiff_plain;h=000f6f002f24e742f85ca20503a838c198637125 Move more logic into the "fitbit" and (new) "app" packages. --- diff --git a/app/user.go b/app/user.go new file mode 100644 index 0000000..86c033c --- /dev/null +++ b/app/user.go @@ -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 +} diff --git a/fitbit/fitbit.go b/fitbit/fitbit.go index 6c7a61d..0f1a583 100644 --- a/fitbit/fitbit.go +++ b/fitbit/fitbit.go @@ -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 +} diff --git a/gfitsync.go b/gfitsync.go index fe8049c..0007395 100644 --- a/gfitsync.go +++ b/gfitsync.go @@ -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, "

Fitbit to Google Fit sync

") - - fmt.Fprintf(w, "

Hello %s

\n", u.Email) + fmt.Fprintf(w, "

Hello %s

\n", user.Current(ctx).Email) fmt.Fprint(w, "

Fitbit: ") if haveToken { fmt.Fprint(w, `Authorized`) @@ -132,56 +88,25 @@ func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, r fmt.Fprintln(w, "

") fmt.Fprintln(w, "") - // 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 }