12 "github.com/octo/kraftakt/app"
13 "github.com/octo/kraftakt/fitbit"
14 "github.com/octo/kraftakt/gfit"
15 "google.golang.org/appengine"
16 "google.golang.org/appengine/datastore"
17 "google.golang.org/appengine/delay"
18 "google.golang.org/appengine/log"
19 "google.golang.org/appengine/user"
22 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
25 http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
26 http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
27 http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
28 http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
29 http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
30 http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
31 http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
32 http.Handle("/", AuthenticatedHandler(indexHandler))
35 // ContextHandler implements http.Handler
36 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
38 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
39 ctx := appengine.NewContext(r)
41 if err := app.LoadConfig(ctx); err != nil {
42 http.Error(w, err.Error(), http.StatusInternalServerError)
46 if err := hndl(ctx, w, r); err != nil {
47 http.Error(w, err.Error(), http.StatusInternalServerError)
52 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
54 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55 ctx := appengine.NewContext(r)
57 if err := app.LoadConfig(ctx); err != nil {
58 http.Error(w, err.Error(), http.StatusInternalServerError)
62 gaeUser := user.Current(ctx)
64 url, err := user.LoginURL(ctx, r.URL.String())
66 http.Error(w, err.Error(), http.StatusInternalServerError)
69 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
73 u, err := app.NewUser(ctx, gaeUser.Email)
75 http.Error(w, err.Error(), http.StatusInternalServerError)
79 if err := hndl(ctx, w, r, u); err != nil {
80 http.Error(w, err.Error(), http.StatusInternalServerError)
85 func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
86 _, err := u.Token(ctx, "Fitbit")
87 if err != nil && err != datastore.ErrNoSuchEntity {
90 haveFitbitToken := err == nil
92 _, err = u.Token(ctx, "Google")
93 if err != nil && err != datastore.ErrNoSuchEntity {
96 haveGoogleToken := err == nil
98 fmt.Fprintln(w, "<html><head><title>Kraftakt</title></head>")
99 fmt.Fprintln(w, "<body><h1>Kraftakt</h1>")
101 fmt.Fprintln(w, "<p><strong>Kraftakt</strong> copies your <em>Fitbit</em> data to <em>Google Fit</em>, seconds after you sync.</p>")
103 fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
104 fmt.Fprintln(w, "<ul>")
106 fmt.Fprint(w, "<li>Fitbit: ")
108 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
110 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/fitbit/setup">Authorize</a>)`)
112 fmt.Fprintln(w, "</li>")
114 fmt.Fprint(w, "<li>Google Fit: ")
116 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
118 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/google/setup">Authorize</a>)`)
120 fmt.Fprintln(w, "</li>")
122 fmt.Fprintln(w, "</ul>")
123 fmt.Fprintln(w, "</body></html>")
128 func fitbitConnectHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error {
129 http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect)
133 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
134 if err := fitbit.ParseToken(ctx, r, u); err != nil {
137 c, err := fitbit.NewClient(ctx, "-", u)
142 for _, collection := range []string{"activities", "sleep"} {
143 if err := c.Subscribe(ctx, collection); err != nil {
144 return fmt.Errorf("c.Subscribe(%q) = %v", collection, err)
146 log.Infof(ctx, "Successfully subscribed to %q", collection)
150 redirectURL.Path = "/"
151 redirectURL.RawQuery = ""
152 redirectURL.Fragment = ""
153 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
157 func googleSetupHandler(w http.ResponseWriter, r *http.Request) {
158 http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
161 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
162 if err := gfit.ParseToken(ctx, r, u); err != nil {
167 redirectURL.Path = "/"
168 redirectURL.RawQuery = ""
169 redirectURL.Fragment = ""
170 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
174 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
175 // subscription. It verifies the payload, splits it into individual
176 // notifications and adds it to the taskqueue service.
177 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
180 fitbitTimeout := 3 * time.Second
181 ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
184 // this is used when setting up a new subscriber in the UI. Once set
185 // up, this code path should not be triggered.
186 if verify := r.FormValue("verify"); verify != "" {
187 if verify == app.Config.FitbitSubscriberCode {
188 w.WriteHeader(http.StatusNoContent)
190 w.WriteHeader(http.StatusNotFound)
195 data, err := ioutil.ReadAll(r.Body)
200 // Fitbit recommendation: "If signature verification fails, you should
201 // respond with a 404"
202 if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
203 log.Warningf(ctx, "signature mismatch")
204 w.WriteHeader(http.StatusNotFound)
208 if err := delayedHandleNotifications.Call(ctx, data); err != nil {
212 w.WriteHeader(http.StatusCreated)
216 // handleNotifications parses fitbit notifications and requests the individual
217 // activities from Fitbit. It is executed asynchronously via the delay package.
218 func handleNotifications(ctx context.Context, payload []byte) error {
219 if err := app.LoadConfig(ctx); err != nil {
223 var subscriptions []fitbit.Subscription
224 if err := json.Unmarshal(payload, &subscriptions); err != nil {
228 for _, s := range subscriptions {
229 if s.CollectionType != "activities" {
230 log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
234 if err := handleNotification(ctx, &s); err != nil {
235 log.Errorf(ctx, "handleNotification() = %v", err)
243 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
244 u, err := app.UserByID(ctx, s.SubscriptionID)
249 fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
255 wg = &sync.WaitGroup{}
256 errs appengine.MultiError
257 summary *fitbit.ActivitySummary
258 profile *fitbit.Profile
264 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
266 errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
274 profile, err = fitbitClient.Profile(ctx)
276 errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
286 tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
291 log.Debugf(ctx, "%s (%s) took %d steps on %s",
292 profile.Name, u.Email, summary.Summary.Steps, tm)
294 gfitClient, err := gfit.NewClient(ctx, u)
301 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
302 errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
309 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
310 errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
319 var distanceMeters float64
320 for _, d := range summary.Summary.Distances {
321 if d.Activity != "total" {
324 distanceMeters = 1000.0 * d.Distance
327 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
328 errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
335 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
336 errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
345 var activities []gfit.Activity
346 for _, a := range summary.Activities {
351 startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
353 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
356 endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
358 activities = append(activities, gfit.Activity{
361 Type: gfit.ParseFitbitActivity(a.Name),
364 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
365 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))