--- /dev/null
+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
+}
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{
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"`
} `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 {
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
+}
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"
"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))
}
}
-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
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)
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>`)
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
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.
// 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
}
// 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
}
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
}