Initial commit.
authorFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 10:33:54 +0000 (11:33 +0100)
committerFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 10:33:54 +0000 (11:33 +0100)
app.yaml [new file with mode: 0644]
deploy.sh [new file with mode: 0755]
gfitsync.go [new file with mode: 0644]

diff --git a/app.yaml b/app.yaml
new file mode 100644 (file)
index 0000000..b9e9d44
--- /dev/null
+++ b/app.yaml
@@ -0,0 +1,9 @@
+runtime: go
+api_version: go1.8
+
+automatic_scaling:
+  max_idle_instances: 1
+
+handlers:
+- url: /.*
+  script: _go_app
diff --git a/deploy.sh b/deploy.sh
new file mode 100755 (executable)
index 0000000..c023bda
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+declare -r ACCT='octo@verplant.org'
+declare -r PROJ='fitbit-gfit-sync'
+
+declare -r VERSION="v$(date +%s)"
+
+export CLOUDSDK_CORE_DISABLE_PROMPTS=1
+
+#gcloud app deploy --account="${ACCT}" --project="${PROJ}" --version="${VERSION}" --verbosity=info
+gcloud app deploy --account="${ACCT}" --project="${PROJ}" --version="${VERSION}"
diff --git a/gfitsync.go b/gfitsync.go
new file mode 100644 (file)
index 0000000..21f764b
--- /dev/null
@@ -0,0 +1,286 @@
+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
+}