25df282ed03fac72a919ef8b170a1ad7283b491d
[kraftakt.git] / gfitsync.go
1 package gfitsync
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "io/ioutil"
8         "net/http"
9         "time"
10
11         "github.com/octo/gfitsync/app"
12         "github.com/octo/gfitsync/fitbit"
13         "google.golang.org/appengine"
14         "google.golang.org/appengine/datastore"
15         "google.golang.org/appengine/delay"
16         "google.golang.org/appengine/log"
17         "google.golang.org/appengine/user"
18 )
19
20 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
21
22 func init() {
23         http.HandleFunc("/setup", setupHandler)
24         http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
25         http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
26         http.Handle("/", AuthenticatedHandler(indexHandler))
27 }
28
29 // ContextHandler implements http.Handler
30 type ContextHandler func(context.Context, http.ResponseWriter, *http.Request) error
31
32 func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
33         ctx := appengine.NewContext(r)
34
35         if err := hndl(ctx, w, r); err != nil {
36                 http.Error(w, err.Error(), http.StatusInternalServerError)
37                 return
38         }
39 }
40
41 type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
42
43 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44         ctx := appengine.NewContext(r)
45
46         gaeUser := user.Current(ctx)
47         if gaeUser == nil {
48                 url, err := user.LoginURL(ctx, r.URL.String())
49                 if err != nil {
50                         http.Error(w, err.Error(), http.StatusInternalServerError)
51                         return
52                 }
53                 http.Redirect(w, r, url, http.StatusTemporaryRedirect)
54                 return
55         }
56
57         u, err := app.NewUser(ctx, gaeUser.Email)
58         if err != nil {
59                 http.Error(w, err.Error(), http.StatusInternalServerError)
60                 return
61         }
62
63         if err := hndl(ctx, w, r, u); err != nil {
64                 http.Error(w, err.Error(), http.StatusInternalServerError)
65                 return
66         }
67 }
68
69 func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
70         _, err := u.Token(ctx, "Fitbit")
71         if err != nil && err != datastore.ErrNoSuchEntity {
72                 return err
73         }
74         haveToken := err == nil
75
76         fmt.Fprintln(w, "<html><body><h1>Fitbit to Google Fit sync</h1>")
77         fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
78         fmt.Fprint(w, "<p>Fitbit: ")
79         if haveToken {
80                 fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
81         } else {
82                 fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/setup">Authorize</a>)`)
83         }
84         fmt.Fprintln(w, "</p>")
85         fmt.Fprintln(w, "</body></html>")
86
87         return nil
88 }
89
90 func setupHandler(w http.ResponseWriter, r *http.Request) {
91         http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect)
92 }
93
94 func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
95         if err := fitbit.ParseToken(ctx, r, u); err != nil {
96                 return err
97         }
98         c, err := fitbit.NewClient(ctx, "-", u)
99         if err != nil {
100                 return err
101         }
102
103         if err := c.Subscribe(ctx, "activities"); err != nil {
104                 return fmt.Errorf("c.Subscribe() = %v", err)
105         }
106
107         redirectURL := r.URL
108         redirectURL.Path = "/"
109         redirectURL.RawQuery = ""
110         redirectURL.Fragment = ""
111         http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
112         return nil
113 }
114
115 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
116 // subscription. It verifies the payload, splits it into individual
117 // notifications and adds it to the taskqueue service.
118 func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
119         defer r.Body.Close()
120
121         fitbitTimeout := 3 * time.Second
122         ctx, cancel := context.WithTimeout(ctx, fitbitTimeout)
123         defer cancel()
124
125         // this is used when setting up a new subscriber in the UI. Once set
126         // up, this code path should not be triggered.
127         if verify := r.FormValue("verify"); verify != "" {
128                 if verify == "@FITBIT_SUBSCRIBER_CODE@" {
129                         w.WriteHeader(http.StatusNoContent)
130                 } else {
131                         w.WriteHeader(http.StatusNotFound)
132                 }
133                 return nil
134         }
135
136         data, err := ioutil.ReadAll(r.Body)
137         if err != nil {
138                 return err
139         }
140
141         // Fitbit recommendation: "If signature verification fails, you should
142         // respond with a 404"
143         if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
144                 w.WriteHeader(http.StatusNotFound)
145                 return nil
146         }
147
148         if err := delayedHandleNotifications.Call(ctx, data); err != nil {
149                 return err
150         }
151
152         w.WriteHeader(http.StatusCreated)
153         return nil
154 }
155
156 // handleNotifications parses fitbit notifications and requests the individual
157 // activities from Fitbit. It is executed asynchronously via the delay package.
158 func handleNotifications(ctx context.Context, payload []byte) error {
159         var subscriptions []fitbit.Subscription
160         if err := json.Unmarshal(payload, &subscriptions); err != nil {
161                 return err
162         }
163
164         for _, s := range subscriptions {
165                 if s.CollectionType != "activities" {
166                         continue
167                 }
168
169                 if err := handleNotification(ctx, &s); err != nil {
170                         log.Errorf(ctx, "handleNotification() = %v", err)
171                         continue
172                 }
173         }
174
175         return nil
176 }
177
178 func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
179         u, err := app.UserByID(ctx, s.SubscriptionID)
180         if err != nil {
181                 return err
182         }
183         c, err := fitbit.NewClient(ctx, s.OwnerID, u)
184         if err != nil {
185                 return err
186         }
187
188         tm, err := time.Parse("2006-01-02", s.Date)
189         if err != nil {
190                 return err
191         }
192
193         summary, err := c.ActivitySummary(tm)
194         if err != nil {
195                 return err
196         }
197
198         log.Debugf(ctx, "ActivitySummary for %s = %+v", u.Email, summary)
199         return nil
200 }