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