From 89b5d50db6ae20cf598983fdc3c736c53cabaa3d Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Wed, 10 Jan 2018 11:33:54 +0100 Subject: [PATCH] Initial commit. --- app.yaml | 9 ++ deploy.sh | 13 +++ gfitsync.go | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 app.yaml create mode 100755 deploy.sh create mode 100644 gfitsync.go diff --git a/app.yaml b/app.yaml new file mode 100644 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 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 index 0000000..21f764b --- /dev/null +++ b/gfitsync.go @@ -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 +} -- 2.11.0