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("/login", AuthenticatedHandler(loginHandler))
29 http.Handle("/fitbit/connect", AuthenticatedHandler(fitbitConnectHandler))
30 http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
31 http.Handle("/fitbit/disconnect", AuthenticatedHandler(fitbitDisconnectHandler))
32 http.Handle("/google/connect", AuthenticatedHandler(googleConnectHandler))
33 http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
34 http.Handle("/google/disconnect", AuthenticatedHandler(googleDisconnectHandler))
36 http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
37 http.Handle("/", ContextHandler(indexHandler))
39 t, err := template.ParseGlob("templates/*.html")
46 func internalServerError(ctx context.Context, w http.ResponseWriter, err error) {
47 log.Errorf(ctx, "%v", err)
49 http.Error(w, "Internal Server Error\n\nReference: "+appengine.RequestID(ctx), http.StatusInternalServerError)
52 // ContextHandler implements http.Handler
53 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
55 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56 ctx := appengine.NewContext(r)
58 if err := app.LoadConfig(ctx); err != nil {
59 internalServerError(ctx, w, fmt.Errorf("LoadConfig() = %v", err))
63 if err := hndl(ctx, w, r); err != nil {
64 internalServerError(ctx, w, err)
69 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
71 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
72 ctx := appengine.NewContext(r)
74 if err := app.LoadConfig(ctx); err != nil {
75 internalServerError(ctx, w, fmt.Errorf("LoadConfig() = %v", err))
79 gaeUser := user.Current(ctx)
81 url, err := user.LoginURL(ctx, r.URL.String())
83 internalServerError(ctx, w, fmt.Errorf("LoginURL() = %v", err))
86 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
90 u, err := app.NewUser(ctx, gaeUser.Email)
92 internalServerError(ctx, w, fmt.Errorf("NewUser(%q) = %v", gaeUser.Email, err))
96 if err := hndl(ctx, w, r, u); err != nil {
97 internalServerError(ctx, w, err)
102 func indexHandler(ctx context.Context, w http.ResponseWriter, _ *http.Request) error {
103 var templateData struct {
108 templateName := "main.html"
110 if gaeUser := user.Current(ctx); gaeUser != nil {
111 templateName = "loggedin.html"
113 u, err := app.NewUser(ctx, gaeUser.Email)
117 templateData.User = u
119 _, err = u.Token(ctx, "Fitbit")
120 if err != nil && err != datastore.ErrNoSuchEntity {
123 templateData.HaveFitbit = (err == nil)
125 _, err = u.Token(ctx, "Google")
126 if err != nil && err != datastore.ErrNoSuchEntity {
129 templateData.HaveGoogleFit = (err == nil)
132 return templates.ExecuteTemplate(w, templateName, &templateData)
135 func loginHandler(_ context.Context, w http.ResponseWriter, r *http.Request, _ *app.User) error {
136 // essentially a nop; all the heavy lifting (i.e. logging in) has been done by the AuthenticatedHandler wrapper.
138 redirectURL.Path = "/"
139 redirectURL.RawQuery = ""
140 redirectURL.Fragment = ""
141 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
145 func fitbitConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
146 http.Redirect(w, r, fitbit.AuthURL(ctx, u), http.StatusTemporaryRedirect)
150 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
151 if err := fitbit.ParseToken(ctx, r, u); err != nil {
154 c, err := fitbit.NewClient(ctx, "", u)
159 for _, collection := range []string{"activities", "sleep"} {
160 if err := c.Subscribe(ctx, collection); err != nil {
161 return fmt.Errorf("c.Subscribe(%q) = %v", collection, err)
163 log.Infof(ctx, "Successfully subscribed to %q", collection)
167 redirectURL.Path = "/"
168 redirectURL.RawQuery = ""
169 redirectURL.Fragment = ""
170 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
174 func fitbitDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
175 c, err := fitbit.NewClient(ctx, "", u)
180 if err := c.UnsubscribeAll(ctx); err != nil {
181 return fmt.Errorf("UnsubscribeAll() = %v", err)
184 if err := c.DeleteToken(ctx); err != nil {
189 redirectURL.Path = "/"
190 redirectURL.RawQuery = ""
191 redirectURL.Fragment = ""
192 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
196 func googleConnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
197 http.Redirect(w, r, gfit.AuthURL(ctx, u), http.StatusTemporaryRedirect)
201 func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
202 if err := gfit.ParseToken(ctx, r, u); err != nil {
207 redirectURL.Path = "/"
208 redirectURL.RawQuery = ""
209 redirectURL.Fragment = ""
210 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
214 func googleDisconnectHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
215 c, err := gfit.NewClient(ctx, u)
220 if err := c.DeleteToken(ctx); err != nil {
225 redirectURL.Path = "/"
226 redirectURL.RawQuery = ""
227 redirectURL.Fragment = ""
228 http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
232 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
233 // subscription. It verifies the payload, splits it into individual
234 // notifications and adds it to the taskqueue service.
235 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
238 fitbitTimeout := 3 * time.Second
239 ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
242 // this is used when setting up a new subscriber in the UI. Once set
243 // up, this code path should not be triggered.
244 if verify := r.FormValue("verify"); verify != "" {
245 if verify == app.Config.FitbitSubscriberCode {
246 w.WriteHeader(http.StatusNoContent)
248 w.WriteHeader(http.StatusNotFound)
253 data, err := ioutil.ReadAll(r.Body)
258 // Fitbit recommendation: "If signature verification fails, you should
259 // respond with a 404"
260 if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
261 log.Errorf(ctx, "signature mismatch")
262 w.WriteHeader(http.StatusNotFound)
266 if err := delayedHandleNotifications.Call(ctx, data); err != nil {
270 w.WriteHeader(http.StatusCreated)
274 // handleNotifications parses fitbit notifications and requests the individual
275 // activities from Fitbit. It is executed asynchronously via the delay package.
276 func handleNotifications(ctx context.Context, payload []byte) error {
277 log.Debugf(ctx, "NOTIFY -> %s", payload)
279 if err := app.LoadConfig(ctx); err != nil {
283 var subscriptions []fitbit.Subscription
284 if err := json.Unmarshal(payload, &subscriptions); err != nil {
288 wg := &sync.WaitGroup{}
290 for _, s := range subscriptions {
291 switch s.CollectionType {
294 go func(s fitbit.Subscription) {
296 if err := activitiesNotification(ctx, &s); err != nil {
297 log.Warningf(ctx, "activitiesNotification() = %v", err)
302 go func(s fitbit.Subscription) {
304 if err := sleepNotification(ctx, &s); err != nil {
305 log.Warningf(ctx, "sleepNotification() = %v", err)
309 log.Warningf(ctx, "ignoring collection type %q", s.CollectionType)
318 func activitiesNotification(ctx context.Context, s *fitbit.Subscription) error {
319 u, err := fitbit.UserFromSubscriberID(ctx, s.SubscriptionID)
324 fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
330 wg = &sync.WaitGroup{}
331 errs appengine.MultiError
332 summary *fitbit.ActivitySummary
333 profile *fitbit.Profile
339 summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
341 errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
349 profile, err = fitbitClient.Profile(ctx)
351 errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
361 tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
366 log.Debugf(ctx, "%s (%s) took %d steps on %s",
367 profile.Name, u.Email, summary.Summary.Steps, tm)
369 gfitClient, err := gfit.NewClient(ctx, u)
376 if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
377 errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
384 if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
385 errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
394 var distanceMeters float64
395 for _, d := range summary.Summary.Distances {
396 if d.Activity != "total" {
399 distanceMeters = 1000.0 * d.Distance
402 if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
403 errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%g) = %v", distanceMeters, err))
410 if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
411 errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
420 var activities []gfit.Activity
421 for _, a := range summary.Activities {
426 startTime, err := time.ParseInLocation("2006-01-02T15:04", a.StartDate+"T"+a.StartTime, profile.Timezone)
428 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
431 endTime := startTime.Add(time.Duration(a.Duration) * time.Millisecond)
433 activities = append(activities, gfit.Activity{
439 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
440 errs = append(errs, fmt.Errorf("gfitClient.SetActivities() = %v", err))
453 func sleepNotification(ctx context.Context, s *fitbit.Subscription) error {
454 u, err := fitbit.UserFromSubscriberID(ctx, s.SubscriptionID)
460 wg = &sync.WaitGroup{}
461 gfitClient *gfit.Client
467 gfitClient, gfitErr = gfit.NewClient(ctx, u)
471 fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
476 profile, err := fitbitClient.Profile(ctx)
481 tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
486 sleep, err := fitbitClient.Sleep(ctx, tm)
490 log.Debugf(ctx, "fitbitClient.Sleep(%v) returned %d sleep stages", tm, len(sleep.Stages))
492 var activities []gfit.Activity
493 for _, stg := range sleep.Stages {
495 Start: stg.StartTime,
499 case fitbit.SleepLevelDeep:
500 a.Type = "Deep sleep"
501 case fitbit.SleepLevelLight:
502 a.Type = "Light sleep"
503 case fitbit.SleepLevelREM:
505 case fitbit.SleepLevelWake:
506 a.Type = "Awake (during sleep cycle)"
508 log.Warningf(ctx, "unexpected sleep level %v", stg.Level)
512 activities = append(activities, a)
520 log.Debugf(ctx, "passing %d activities to gfitClient.SetActivities()", len(activities))
521 if err := gfitClient.SetActivities(ctx, activities, tm); err != nil {
522 return fmt.Errorf("SetActivities() = %v", err)