X-Git-Url: https://git.octo.it/?a=blobdiff_plain;ds=sidebyside;f=gfitsync.go;h=9eadde9efb66f919d278328f0daf06a0168840f7;hb=f1acb1c2891ade2daa46e6e0f5fbafc1d7d6dd05;hp=56cb353205c249b18495962635285343134c23ad;hpb=63c0e25212c8fcd3da763b8f58cd13fc52169f31;p=kraftakt.git
diff --git a/gfitsync.go b/gfitsync.go
index 56cb353..9eadde9 100644
--- a/gfitsync.go
+++ b/gfitsync.go
@@ -2,19 +2,16 @@ package gfitsync
import (
"context"
- "crypto/hmac"
- "crypto/sha1"
- "encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
- "net/url"
+ "sync"
"time"
- legacy_context "golang.org/x/net/context"
- "golang.org/x/oauth2"
- "golang.org/x/oauth2/fitbit"
+ "github.com/octo/gfitsync/app"
+ "github.com/octo/gfitsync/fitbit"
+ "github.com/octo/gfitsync/gfit"
"google.golang.org/appengine"
"google.golang.org/appengine/datastore"
"google.golang.org/appengine/delay"
@@ -22,30 +19,14 @@ import (
"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.HandleFunc("/fitbit/setup", fitbitSetupHandler)
http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
+ http.HandleFunc("/google/setup", googleSetupHandler)
+ http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
http.Handle("/", AuthenticatedHandler(indexHandler))
}
@@ -61,13 +42,13 @@ func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
-type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *user.User) error
+type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
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)
@@ -77,14 +58,7 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
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)
+ u, err := app.NewUser(ctx, gaeUser.Email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -96,77 +70,67 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
}
}
-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)
+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
+ haveFitbitToken := err == nil
+
+ _, err = u.Token(ctx, "Google")
+ if err != nil && err != datastore.ErrNoSuchEntity {
+ return err
}
+ haveGoogleToken := err == nil
- // fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
- fmt.Fprintln(w, "
Fitbit to Google Fit sync
")
+ fmt.Fprintln(w, "Kraftakt")
+ fmt.Fprintln(w, "Kraftakt
")
- fmt.Fprintf(w, "Hello %s
\n", u.Email)
- fmt.Fprint(w, "Fitbit: ")
- if haveToken {
+ fmt.Fprintln(w, "
Kraftakt copies your Fitbit data to Google Fit, seconds after you sync.
")
+
+ fmt.Fprintf(w, "Hello %s
\n", user.Current(ctx).Email)
+ fmt.Fprintln(w, "")
+
+ fmt.Fprint(w, "- Fitbit: ")
+ if haveFitbitToken {
fmt.Fprint(w, `Authorized`)
} else {
- fmt.Fprint(w, `Not authorized (Authorize)`)
+ fmt.Fprint(w, `Not authorized (Authorize)`)
}
- fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "
")
+
+ fmt.Fprint(w, "- Google Fit: ")
+ if haveGoogleToken {
+ fmt.Fprint(w, `Authorized`)
+ } else {
+ fmt.Fprint(w, `Not authorized (Authorize)`)
+ }
+ fmt.Fprintln(w, "
")
+
+ 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 fitbitSetupHandler(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, fitbit.AuthURL(), 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 {
+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)
-
- // 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)
+ c, err := fitbit.NewClient(ctx, "-", u)
if err != nil {
return err
}
- defer res.Body.Close()
- if res.StatusCode >= 400 {
- data, _ := ioutil.ReadAll(r.Body)
- log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
- return fmt.Errorf("creating subscription failed")
+ for _, collection := range []string{"activities", "sleep"} {
+ if err := c.Subscribe(ctx, collection); err != nil {
+ return fmt.Errorf("c.Subscribe(%q) = %v", collection, err)
+ }
+ log.Infof(ctx, "Successfully subscribed to %q", collection)
}
redirectURL := r.URL
@@ -177,46 +141,21 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
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 googleSetupHandler(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
}
-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
+func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
+ if err := gfit.ParseToken(ctx, r, u); err != nil {
+ return err
}
- mac := hmac.New(sha1.New, []byte(oauthConfig.ClientSecret+"&"))
- mac.Write(payload)
- signatureWant := mac.Sum(nil)
-
- return hmac.Equal(signatureGot, signatureWant)
+ redirectURL := r.URL
+ redirectURL.Path = "/"
+ redirectURL.RawQuery = ""
+ redirectURL.Fragment = ""
+ http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
+ return nil
}
// fitbitNotifyHandler is called by Fitbit whenever there are updates to a
@@ -247,7 +186,7 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req
// 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
}
@@ -263,17 +202,18 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req
// 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 {
+ var subscriptions []fitbit.Subscription
+ if err := json.Unmarshal(payload, &subscriptions); err != nil {
return err
}
- for _, n := range notifications {
- if n.CollectionType != "activities" {
+ for _, s := range subscriptions {
+ if s.CollectionType != "activities" {
+ log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
continue
}
- if err := handleNotification(ctx, &n); err != nil {
+ if err := handleNotification(ctx, &s); err != nil {
log.Errorf(ctx, "handleNotification() = %v", err)
continue
}
@@ -282,24 +222,137 @@ func handleNotifications(ctx context.Context, payload []byte) error {
return nil
}
-func handleNotification(ctx context.Context, n *fitbitNotification) error {
- rootKey, err := datastore.DecodeKey(n.SubscriptionID)
+func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
+ u, err := app.UserByID(ctx, s.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 {
+ fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
+ if err != nil {
return err
}
- c := oauthConfig.Client(ctx, &tok)
- res, err := c.Get(n.URL())
+ var (
+ wg = &sync.WaitGroup{}
+ errs appengine.MultiError
+ summary *fitbit.ActivitySummary
+ profile *fitbit.Profile
+ )
+
+ wg.Add(1)
+ go func() {
+ var err error
+ summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
+ }
+ wg.Done()
+ }()
+
+ wg.Add(1)
+ go func() {
+ var err error
+ profile, err = fitbitClient.Profile(ctx)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
+ }
+ wg.Done()
+ }()
+
+ wg.Wait()
+ if len(errs) != 0 {
+ return errs
+ }
+
+ tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
if err != nil {
return err
}
- log.Infof(ctx, "GET %s = %v", n.URL(), res)
+ log.Debugf(ctx, "%s (%s) took %d steps on %s",
+ profile.Name, u.Email, summary.Summary.Steps, tm)
+
+ gfitClient, err := gfit.NewClient(ctx, u)
+ if err != nil {
+ return err
+ }
+
+ wg.Add(1)
+ go func() {
+ if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
+ }
+ wg.Done()
+ }()
+
+ wg.Add(1)
+ go func() {
+ if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
+ }
+ wg.Done()
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ var distanceMeters float64
+ for _, d := range summary.Summary.Distances {
+ if d.Activity != "total" {
+ continue
+ }
+ distanceMeters = 1000.0 * d.Distance
+ break
+ }
+ if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%d) = %v", distanceMeters, err))
+ return
+ }
+ }()
+
+ wg.Add(1)
+ go func() {
+ if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
+ }
+ wg.Done()
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ var activities []gfit.Activity
+ for _, a := range summary.Activities {
+ if !a.HasStartTime {
+ continue
+ }
+
+ startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
+ return
+ }
+ endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
+
+ activities = append(activities, gfit.Activity{
+ Start: startTime,
+ End: endTime,
+ Type: gfit.ParseFitbitActivity(a.Name),
+ })
+ }
+ if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
+ errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
+ return
+ }
+ }()
+
+ wg.Wait()
+
+ if len(errs) != 0 {
+ return errs
+ }
return nil
}