13 "github.com/octo/kraftakt/app"
14 "github.com/octo/kraftakt/fitbit"
15 "github.com/octo/kraftakt/gfit"
16 "google.golang.org/appengine"
17 "google.golang.org/appengine/datastore"
18 "google.golang.org/appengine/delay"
19 "google.golang.org/appengine/log"
20 "google.golang.org/appengine/user"
23 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
25 var templates *template.Template
28 http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
29 http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
30 http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
31 http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
32 http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
33 http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
35 http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
36 http.Handle("/", ContextHandler(indexHandler))
38 t, err := template.ParseGlob("templates/*.html")
45 // ContextHandler implements http.Handler
46 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
48 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
49 ctx := appengine.NewContext(r)
51 if err := app.LoadConfig(ctx); err != nil {
52 http.Error(w, err.Error(), http.StatusInternalServerError)
56 if err := hndl(ctx, w, r); err != nil {
57 http.Error(w, err.Error(), http.StatusInternalServerError)
62 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
64 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
65 ctx := appengine.NewContext(r)
67 if err := app.LoadConfig(ctx); err != nil {
68 http.Error(w, err.Error(), http.StatusInternalServerError)
72 gaeUser := user.Current(ctx)
74 url, err := user.LoginURL(ctx, r.URL.String())
76 http.Error(w, err.Error(), http.StatusInternalServerError)
79 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
83 u, err := app.NewUser(ctx, gaeUser.Email)
85 http.Error(w, err.Error(), http.StatusInternalServerError)
89 if err := hndl(ctx, w, r, u); err != nil {
90 http.Error(w, err.Error(), http.StatusInternalServerError)
95 func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
96 var templateData struct {
101 templateName := "main.html"
103 if gaeUser := user.Current(ctx); gaeUser != nil {
104 templateName = "loggedin.html"
106 u, err := app.NewUser(ctx, gaeUser.Email)
110 templateData.User = u
112 _, err = u.Token(ctx, "Fitbit")
113 if err != nil && err != datastore.ErrNoSuchEntity {
116 templateData.HaveFitbit = (err == nil)
118 _, err = u.Token(ctx, "Google")
119 if err != nil && err != datastore.ErrNoSuchEntity {
122 templateData.HaveGoogleFit = (err == nil)
125 return templates.ExecuteTemplate(w, templateName, &templateData)
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 fitbitDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
158 c, err := fitbit.NewClient(ctx, "", u)
163 var errs appengine.MultiError
164 if err := c.Unsubscribe(ctx); err != nil {
165 errs = append(errs, fmt.Errorf("Unsubscribe() = %v", err))
168 if err := c.DeleteToken(ctx); err != nil {
169 errs = append(errs, fmt.Errorf("DeleteToken() = %v", err))
176 redirectURL.Path = "/"
177 redirectURL.RawQuery = ""
178 redirectURL.Fragment = ""
179 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
183 func googleConnectHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error {
184 http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
188 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
189 if err := gfit.ParseToken(ctx, r, u); err != nil {
194 redirectURL.Path = "/"
195 redirectURL.RawQuery = ""
196 redirectURL.Fragment = ""
197 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
201 func googleDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
202 c, err := gfit.NewClient(ctx, u)
207 if err := c.DeleteToken(ctx); err != nil {
212 redirectURL.Path = "/"
213 redirectURL.RawQuery = ""
214 redirectURL.Fragment = ""
215 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
219 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
220 // subscription. It verifies the payload, splits it into individual
221 // notifications and adds it to the taskqueue service.
222 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
225 fitbitTimeout := 3 * time.Second
226 ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
229 // this is used when setting up a new subscriber in the UI. Once set
230 // up, this code path should not be triggered.
231 if verify := r.FormValue("verify"); verify != "" {
232 if verify == app.Config.FitbitSubscriberCode {
233 w.WriteHeader(http.StatusNoContent)
235 w.WriteHeader(http.StatusNotFound)
240 data, err := ioutil.ReadAll(r.Body)
245 // Fitbit recommendation: "If signature verification fails, you should
246 // respond with a 404"
247 if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
248 log.Warningf(ctx, "signature mismatch")
249 w.WriteHeader(http.StatusNotFound)
253 if err := delayedHandleNotifications.Call(ctx, data); err != nil {
257 w.WriteHeader(http.StatusCreated)
261 // handleNotifications parses fitbit notifications and requests the individual
262 // activities from Fitbit. It is executed asynchronously via the delay package.
263 func handleNotifications(ctx context.Context, payload []byte) error {
264 if err := app.LoadConfig(ctx); err != nil {
268 var subscriptions []fitbit.Subscription
269 if err := json.Unmarshal(payload, &subscriptions); err != nil {
273 for _, s := range subscriptions {
274 if s.CollectionType != "activities" {
275 log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
279 if err := handleNotification(ctx, &s); err != nil {
280 log.Errorf(ctx, "handleNotification() = %v", err)
288 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
289 u, err := app.UserByID(ctx, s.SubscriptionID)
294 fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
300 wg = &sync.WaitGroup{}
301 errs appengine.MultiError
302 summary *fitbit.ActivitySummary
303 profile *fitbit.Profile
309 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
311 errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
319 profile, err = fitbitClient.Profile(ctx)
321 errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
331 tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
336 log.Debugf(ctx, "%s (%s) took %d steps on %s",
337 profile.Name, u.Email, summary.Summary.Steps, tm)
339 gfitClient, err := gfit.NewClient(ctx, u)
346 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
347 errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
354 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
355 errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
364 var distanceMeters float64
365 for _, d := range summary.Summary.Distances {
366 if d.Activity != "total" {
369 distanceMeters = 1000.0 * d.Distance
372 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
373 errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
380 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
381 errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
390 var activities []gfit.Activity
391 for _, a := range summary.Activities {
396 startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
398 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
401 endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
403 activities = append(activities, gfit.Activity{
406 Type: gfit.ParseFitbitActivity(a.Name),
409 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
410 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))