X-Git-Url: https://git.octo.it/?a=blobdiff_plain;f=gfitsync.go;h=9eadde9efb66f919d278328f0daf06a0168840f7;hb=4c298110b1564f337cd0ceaad8e94fd4d4f7741f;hp=614d513e374384314611581f24cfb6b2c3dc8927;hpb=6023f6eda8e7bc9549966b4e844e35153158335e;p=kraftakt.git diff --git a/gfitsync.go b/gfitsync.go index 614d513..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,66 +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, "Kraftakt") + fmt.Fprintln(w, "

Kraftakt

") - // TODO(octo): print summary to user - return nil -} + fmt.Fprintln(w, "

Kraftakt copies your Fitbit data to Google Fit, seconds after you sync.

") -func setupHandler(w http.ResponseWriter, r *http.Request) { - url := oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline) - http.Redirect(w, r, url, http.StatusTemporaryRedirect) -} + fmt.Fprintf(w, "

Hello %s

\n", user.Current(ctx).Email) + fmt.Fprintln(w, "") + fmt.Fprintln(w, "") + + return nil +} - c := oauthConfig.Client(ctx, tok) +func fitbitSetupHandler(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect) +} - // 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) +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, 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 @@ -166,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 @@ -236,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 } @@ -252,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 } @@ -271,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 + } + + 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 } - c := oauthConfig.Client(ctx, &tok) - res, err := c.Get(n.URL()) + 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 } - log.Infof(ctx, "GET %s = %v", n.URL(), res) + 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 }