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/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/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: 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)) 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, *datastore.Key) 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 { 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 := 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) 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 { 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) if err != nil && err != datastore.ErrNoSuchEntity { return err } if err == nil { haveToken = true } 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.Fprint(w, "

Fitbit: ") if haveToken { fmt.Fprint(w, `Authorized`) } else { fmt.Fprint(w, `Not authorized (Authorize)`) } 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) 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 { 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) 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") } redirectURL := r.URL redirectURL.Path = "/" redirectURL.RawQuery = "" redirectURL.Fragment = "" http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect) 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. 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 subscriptions []fitbitSubscription if err := json.Unmarshal(payload, &subscriptions); err != nil { return err } for _, s := range subscriptions { if s.CollectionType != "activities" { continue } if err := handleNotification(ctx, &s); err != nil { log.Errorf(ctx, "handleNotification() = %v", err) continue } } 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) 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)) } rootKey := keys[0] key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey) var tok oauth2.Token if err := datastore.Get(ctx, key, &tok); err != nil { return err } c := fitbit.NewClient(ctx, s.OwnerID, &tok) t, err := time.Parse("2006-01-02", s.Date) if err != nil { return err } summary, err := c.ActivitySummary(t) if err != nil { return err } log.Debugf(ctx, "ActivitySummary() = %v", summary) return nil }