--- /dev/null
+package gfitsync
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "time"
+
+ legacy_context "golang.org/x/net/context"
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/fitbit"
+ "google.golang.org/appengine"
+ "google.golang.org/appengine/datastore"
+ "google.golang.org/appengine/delay"
+ "google.golang.org/appengine/log"
+ "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: fitbit.Endpoint,
+ RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant", // TODO(octo): make full URL
+ Scopes: []string{"activity"},
+}
+
+type storedToken struct {
+ Email string
+ oauth2.Token
+}
+
+func init() {
+ http.HandleFunc("/setup", setupHandler)
+ http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
+ http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
+ http.Handle("/", AuthenticatedHandler(indexHandler))
+}
+
+// ContextHandler implements http.Handler
+type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
+
+func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ ctx := appengine.NewContext(r)
+
+ if err := hndl(ctx, w, r); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *user.User) error
+
+func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ ctx := appengine.NewContext(r)
+
+ u := user.Current(ctx)
+ if u == nil {
+ url, err := user.LoginURL(ctx, r.URL.String())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+ return
+ }
+
+ err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
+ key := RootKey(ctx, u)
+ if err := datastore.Get(ctx, key, &struct{}{}); err != datastore.ErrNoSuchEntity {
+ return err // may be nil
+ }
+ _, err := datastore.Put(ctx, key, &struct{}{})
+ return err
+ }, nil)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if err := hndl(ctx, w, r, u); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func RootKey(ctx context.Context, u *user.User) *datastore.Key {
+ return datastore.NewKey(ctx, "User", u.Email, 0, nil)
+}
+
+func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error {
+ var (
+ tok oauth2.Token
+ haveToken bool
+ )
+
+ key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u))
+ err := datastore.Get(ctx, key, &tok)
+ if err != nil && err != datastore.ErrNoSuchEntity {
+ return err
+ }
+ if err == nil {
+ haveToken = true
+ }
+
+ fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
+
+ // TODO(octo): print summary to user
+ return nil
+}
+
+func setupHandler(w http.ResponseWriter, r *http.Request) {
+ url := oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) 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(ctx, u))
+ if _, err := datastore.Put(ctx, key, tok); err != nil {
+ return err
+ }
+
+ c := oauthConfig.Client(ctx, tok)
+
+ // create a subscription
+ url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json",
+ RootKey(ctx, u).Encode())
+ res, err := c.Post(url, "", nil)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ redirectURL := r.URL
+ redirectURL.Path = "/"
+ http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
+ return nil
+}
+
+type fitbitNotification struct {
+ CollectionType string `json:"collectionType"`
+ Date string `json:"date"`
+ OwnerID string `json:"ownerId"`
+ OwnerType string `json:"ownerType"`
+ SubscriptionID string `json:"subscriptionId"`
+}
+
+func (n *fitbitNotification) URLValues() url.Values {
+ return url.Values{
+ "CollectionType": []string{n.CollectionType},
+ "Date": []string{n.Date},
+ "OwnerID": []string{n.OwnerID},
+ "OwnerType": []string{n.OwnerType},
+ "SubscriptionID": []string{n.SubscriptionID},
+ }
+}
+
+func (n *fitbitNotification) URL() string {
+ return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json",
+ n.OwnerID, n.CollectionType, n.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.
+func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+ defer r.Body.Close()
+
+ fitbitTimeout := 3 * time.Second
+ ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
+ defer cancel()
+
+ // 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@" {
+ w.WriteHeader(http.StatusNoContent)
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+ return nil
+ }
+
+ data, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ return err
+ }
+
+ // Fitbit recommendation: "If signature verification fails, you should
+ // respond with a 404"
+ if !checkSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
+ w.WriteHeader(http.StatusNotFound)
+ return nil
+ }
+
+ if err := delayedHandleNotifications.Call(ctx, data); err != nil {
+ return err
+ }
+
+ w.WriteHeader(http.StatusCreated)
+ 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 notifications []fitbitNotification
+ if err := json.Unmarshal(payload, notifications); err != nil {
+ return err
+ }
+
+ for _, n := range notifications {
+ if n.CollectionType != "activities" {
+ continue
+ }
+
+ if err := handleNotification(ctx, &n); err != nil {
+ log.Errorf(ctx, "handleNotification() = %v", err)
+ continue
+ }
+ }
+
+ return nil
+}
+
+func handleNotification(ctx context.Context, n *fitbitNotification) error {
+ rootKey, err := datastore.DecodeKey(n.SubscriptionID)
+ if err != nil {
+ return err
+ }
+ key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
+
+ var tok oauth2.Token
+ if err := datastore.Get(ctx, key, &tok); err != nil {
+ return err
+ }
+
+ c := oauthConfig.Client(ctx, &tok)
+ res, err := c.Get(n.URL())
+ if err != nil {
+ return err
+ }
+
+ log.Infof(ctx, "GET %s = %v", n.URL(), res)
+ return nil
+}