1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16
17
18 package swarm
19
20 import (
21 "context"
22 "fmt"
23 "log"
24 "net/http"
25 "net/url"
26 "path"
27 "path/filepath"
28 "sort"
29 "strconv"
30 "strings"
31 "sync"
32 "time"
33
34 "perkeep.org/internal/httputil"
35 "perkeep.org/pkg/blob"
36 "perkeep.org/pkg/importer"
37 "perkeep.org/pkg/schema"
38 "perkeep.org/pkg/schema/nodeattr"
39
40 "go4.org/ctxutil"
41 "golang.org/x/oauth2"
42 )
43
44 const (
45 apiURL = "https://api.foursquare.com/v2/"
46 authURL = "https://foursquare.com/oauth2/authenticate"
47 tokenURL = "https://foursquare.com/oauth2/access_token"
48
49 apiVersion = "20140225"
50 checkinsAPIPath = "users/self/checkins"
51
52
53
54
55
56
57
58 runCompleteVersion = "2"
59
60
61 acctAttrUserId = "foursquareUserId"
62 acctAttrUserFirst = "foursquareFirstName"
63 acctAttrUserLast = "foursquareLastName"
64 acctAttrAccessToken = "oauthAccessToken"
65
66 checkinsRequestLimit = 100
67 photosRequestLimit = 5
68
69 attrFoursquareId = "foursquareId"
70 attrFoursquareVenuePermanode = "foursquareVenuePermanode"
71 attrFoursquareCategoryName = "foursquareCategoryName"
72 )
73
74 func init() {
75 importer.Register("swarm", &imp{
76 imageFileRef: make(map[string]blob.Ref),
77 })
78 }
79
80 var _ importer.ImporterSetupHTMLer = (*imp)(nil)
81
82 type imp struct {
83 mu sync.Mutex
84 imageFileRef map[string]blob.Ref
85
86 importer.OAuth2
87 }
88
89 func (*imp) Properties() importer.Properties {
90 return importer.Properties{
91 Title: "Swarm",
92 Description: "import check-ins and venues from Foursquare Swarm (swarmapp.com)",
93 SupportsIncremental: true,
94 NeedsAPIKey: true,
95 PermanodeImporterType: "foursquare",
96 }
97 }
98
99 func (im *imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
100 if acctNode.Attr(acctAttrUserId) != "" && acctNode.Attr(acctAttrAccessToken) != "" {
101 return true, nil
102 }
103 return false, nil
104 }
105
106 func (im *imp) SummarizeAccount(acct *importer.Object) string {
107 ok, err := im.IsAccountReady(acct)
108 if err != nil {
109 return "Not configured; error = " + err.Error()
110 }
111 if !ok {
112 return "Not configured"
113 }
114 if acct.Attr(acctAttrUserFirst) == "" && acct.Attr(acctAttrUserLast) == "" {
115 return fmt.Sprintf("userid %s", acct.Attr(acctAttrUserId))
116 }
117 return fmt.Sprintf("userid %s (%s %s)", acct.Attr(acctAttrUserId),
118 acct.Attr(acctAttrUserFirst), acct.Attr(acctAttrUserLast))
119 }
120
121 func (im *imp) AccountSetupHTML(host *importer.Host) string {
122 base := host.ImporterBaseURL() + "swarm"
123 return fmt.Sprintf(`
124 <h1>Configuring Foursquare</h1>
125 <p>Visit <a href='https://foursquare.com/developers/apps'>https://foursquare.com/developers/apps</a> and click "Create a new app".</p>
126 <p>Use the following settings:</p>
127 <ul>
128 <li>Download / welcome page url: <b>%s</b></li>
129 <li>Your privacy policy url: <b>%s</b></li>
130 <li>Redirect URI(s): <b>%s</b></li>
131 </ul>
132 <p>Click "SAVE CHANGES". Copy the "Client ID" and "Client Secret" into the boxes above.</p>
133 `, base, base+"/privacy", base+"/callback")
134 }
135
136
137 type run struct {
138 *importer.RunContext
139 im *imp
140 incremental bool
141
142 mu sync.Mutex
143 anyErr bool
144 }
145
146 func (r *run) token() string {
147 return r.RunContext.AccountNode().Attr(acctAttrAccessToken)
148 }
149
150 func (im *imp) Run(ctx *importer.RunContext) error {
151 r := &run{
152 RunContext: ctx,
153 im: im,
154 incremental: ctx.AccountNode().Attr(importer.AcctAttrCompletedVersion) == runCompleteVersion,
155 }
156
157 if err := r.importCheckins(); err != nil {
158 return err
159 }
160
161 r.mu.Lock()
162 anyErr := r.anyErr
163 r.mu.Unlock()
164
165 if !anyErr {
166 if err := r.AccountNode().SetAttrs(importer.AcctAttrCompletedVersion, runCompleteVersion); err != nil {
167 return err
168 }
169 }
170
171 return nil
172 }
173
174 func (r *run) errorf(format string, args ...interface{}) {
175 log.Printf(format, args...)
176 r.mu.Lock()
177 defer r.mu.Unlock()
178 r.anyErr = true
179 }
180
181
182
183 func (r *run) urlFileRef(urlstr, filename string) string {
184 im := r.im
185 im.mu.Lock()
186 if br, ok := im.imageFileRef[urlstr]; ok {
187 im.mu.Unlock()
188 return br.String()
189 }
190 im.mu.Unlock()
191
192 if urlstr == "" {
193 return ""
194 }
195 res, err := ctxutil.Client(r.Context()).Get(urlstr)
196 if err != nil {
197 log.Printf("swarm: couldn't fetch image %q: %v", urlstr, err)
198 return ""
199 }
200 defer res.Body.Close()
201
202 fileRef, err := schema.WriteFileFromReader(r.Context(), r.Host.Target(), filename, res.Body)
203 if err != nil {
204 r.errorf("couldn't write file: %v", err)
205 return ""
206 }
207
208 im.mu.Lock()
209 defer im.mu.Unlock()
210 im.imageFileRef[urlstr] = fileRef
211 return fileRef.String()
212 }
213
214 type byCreatedAt []*checkinItem
215
216 func (s byCreatedAt) Less(i, j int) bool {
217 return s[i].CreatedAt < s[j].CreatedAt
218 }
219 func (s byCreatedAt) Len() int {
220 return len(s)
221 }
222 func (s byCreatedAt) Swap(i, j int) {
223 s[i], s[j] = s[j], s[i]
224 }
225
226 func (r *run) importCheckins() error {
227 limit := checkinsRequestLimit
228 offset := 0
229 continueRequests := true
230
231 for continueRequests {
232 resp := checkinsList{}
233 if err := r.im.doUserAPI(r.Context(), r.token(), &resp, checkinsAPIPath, "limit", strconv.Itoa(limit), "offset", strconv.Itoa(offset)); err != nil {
234 return err
235 }
236
237 itemcount := len(resp.Response.Checkins.Items)
238 log.Printf("swarm: importing %d checkins (offset %d)", itemcount, offset)
239 if itemcount < limit {
240 continueRequests = false
241 } else {
242 offset += itemcount
243 }
244
245 checkinsNode, err := r.getTopLevelNode("checkins", "Checkins")
246 if err != nil {
247 return err
248 }
249
250 placesNode, err := r.getTopLevelNode("places", "Places")
251 if err != nil {
252 return err
253 }
254
255 pplNode, err := r.getTopLevelNode("people", "People")
256 if err != nil {
257 return err
258 }
259
260 sort.Sort(byCreatedAt(resp.Response.Checkins.Items))
261 sawOldItem := false
262 for _, checkin := range resp.Response.Checkins.Items {
263 placeNode, err := r.importPlace(placesNode, &checkin.Venue)
264 if err != nil {
265 r.errorf("Foursquare importer: error importing place %s: %v", checkin.Venue.Id, err)
266 continue
267 }
268
269 companionRefs, err := r.importCompanions(pplNode, checkin.With)
270 if err != nil {
271 r.errorf("Foursquare importer: error importing companions for checkin %s: %v", checkin.Id, err)
272 continue
273 }
274
275 _, dup, err := r.importCheckin(checkinsNode, checkin, placeNode.PermanodeRef(), companionRefs)
276 if err != nil {
277 r.errorf("Foursquare importer: error importing checkin %s: %v", checkin.Id, err)
278 continue
279 }
280
281 if dup {
282 sawOldItem = true
283 }
284
285 err = r.importPhotos(placeNode, dup)
286 if err != nil {
287 r.errorf("Foursquare importer: error importing photos for checkin %s: %v", checkin.Id, err)
288 continue
289 }
290 }
291 if sawOldItem && r.incremental {
292 break
293 }
294 }
295
296 return nil
297 }
298
299 func (r *run) importPhotos(placeNode *importer.Object, checkinWasDup bool) error {
300 photosNode, err := placeNode.ChildPathObject("photos")
301 if err != nil {
302 return err
303 }
304
305 if err := photosNode.SetAttrs(
306 nodeattr.Title, "Photos of "+placeNode.Attr("title"),
307 nodeattr.DefaultVisibility, "hide"); err != nil {
308 return err
309 }
310
311 nHave := 0
312 photosNode.ForeachAttr(func(key, value string) {
313 if strings.HasPrefix(key, "camliPath:") {
314 nHave++
315 }
316 })
317 nWant := photosRequestLimit
318 if checkinWasDup {
319 nWant = 1
320 }
321 if nHave >= nWant {
322 return nil
323 }
324
325 clientID, clientSecret, err := r.Credentials()
326 if err != nil {
327 return err
328 }
329
330 resp := photosList{}
331 if err = r.im.doCredAPI(r.Context(), clientID, clientSecret, &resp,
332 "venues/"+placeNode.Attr(attrFoursquareId)+"/photos",
333 "limit", strconv.Itoa(nWant)); err != nil {
334 return err
335 }
336
337 var need []*photoItem
338 for _, photo := range resp.Response.Photos.Items {
339 attr := "camliPath:" + photo.Id + filepath.Ext(photo.Suffix)
340 if photosNode.Attr(attr) == "" {
341 need = append(need, photo)
342 }
343 }
344
345 if len(need) > 0 {
346 venueTitle := placeNode.Attr(nodeattr.Title)
347 log.Printf("swarm: importing %d photos for venue %s", len(need), venueTitle)
348 for _, photo := range need {
349 attr := "camliPath:" + photo.Id + filepath.Ext(photo.Suffix)
350 if photosNode.Attr(attr) != "" {
351 continue
352 }
353 url := photo.Prefix + "original" + photo.Suffix
354 log.Printf("swarm: importing photo for venue %s: %s", venueTitle, url)
355 ref := r.urlFileRef(url, "")
356 if ref == "" {
357 r.errorf("Error slurping photo: %s", url)
358 continue
359 }
360 if err := photosNode.SetAttr(attr, ref); err != nil {
361 r.errorf("Error adding venue photo: %#v", err)
362 }
363 }
364 }
365
366 return nil
367 }
368
369 func (r *run) importCheckin(parent *importer.Object, checkin *checkinItem, placeRef blob.Ref, companionRefs []string) (checkinNode *importer.Object, dup bool, err error) {
370 checkinNode, err = parent.ChildPathObject(checkin.Id)
371 if err != nil {
372 return
373 }
374
375 title := fmt.Sprintf("Checkin at %s", checkin.Venue.Name)
376 dup = checkinNode.Attr(nodeattr.StartDate) != ""
377 if err := checkinNode.SetAttrs(
378 attrFoursquareId, checkin.Id,
379 attrFoursquareVenuePermanode, placeRef.String(),
380 nodeattr.Type, "foursquare.com:checkin",
381 nodeattr.StartDate, schema.RFC3339FromTime(time.Unix(checkin.CreatedAt, 0)),
382 nodeattr.Title, title); err != nil {
383 return nil, false, err
384 }
385
386 if err := checkinNode.SetAttrValues("with", companionRefs); err != nil {
387 return nil, false, err
388 }
389
390 return checkinNode, dup, nil
391 }
392
393 func (r *run) importCompanions(parent *importer.Object, companions []*user) (companionRefs []string, err error) {
394 for _, user := range companions {
395 personNode, err := parent.ChildPathObject(user.Id)
396 if err != nil {
397 return nil, err
398 }
399 attrs := []string{
400 attrFoursquareId, user.Id,
401 nodeattr.Type, "foursquare.com:person",
402 nodeattr.Title, user.FirstName + " " + user.LastName,
403 nodeattr.GivenName, user.FirstName,
404 nodeattr.FamilyName, user.LastName,
405 }
406 if icon := user.icon(); icon != "" {
407 attrs = append(attrs, nodeattr.CamliContentImage, r.urlFileRef(icon, path.Base(icon)))
408 }
409 if err := personNode.SetAttrs(attrs...); err != nil {
410 return nil, err
411 }
412 companionRefs = append(companionRefs, personNode.PermanodeRef().String())
413 }
414 return companionRefs, nil
415 }
416
417 func (r *run) importPlace(parent *importer.Object, place *venueItem) (*importer.Object, error) {
418 placeNode, err := parent.ChildPathObject(place.Id)
419 if err != nil {
420 return nil, err
421 }
422
423 catName := ""
424 if cat := place.primaryCategory(); cat != nil {
425 catName = cat.Name
426 }
427
428 attrs := []string{
429 attrFoursquareId, place.Id,
430 nodeattr.Type, "foursquare.com:venue",
431 attrFoursquareCategoryName, catName,
432 nodeattr.Title, place.Name,
433 }
434 if icon := place.icon(); icon != "" {
435 attrs = append(attrs,
436 nodeattr.CamliContentImage, r.urlFileRef(icon, path.Base(icon)))
437 }
438 if place.Location != nil {
439 attrs = append(attrs,
440 nodeattr.StreetAddress, place.Location.Address,
441 nodeattr.AddressLocality, place.Location.City,
442 nodeattr.PostalCode, place.Location.PostalCode,
443 nodeattr.AddressRegion, place.Location.State,
444 nodeattr.AddressCountry, place.Location.Country,
445 nodeattr.Latitude, fmt.Sprint(place.Location.Lat),
446 nodeattr.Longitude, fmt.Sprint(place.Location.Lng))
447 }
448 if err := placeNode.SetAttrs(attrs...); err != nil {
449 return nil, err
450 }
451
452 return placeNode, nil
453 }
454
455 func (r *run) getTopLevelNode(path string, title string) (*importer.Object, error) {
456 childObject, err := r.RootNode().ChildPathObject(path)
457 if err != nil {
458 return nil, err
459 }
460
461 if err := childObject.SetAttr(nodeattr.Title, title); err != nil {
462 return nil, err
463 }
464 return childObject, nil
465 }
466
467 func (im *imp) getUserInfo(ctx context.Context, accessToken string) (user, error) {
468 var ui userInfo
469 if err := im.doUserAPI(ctx, accessToken, &ui, "users/self"); err != nil {
470 return user{}, err
471 }
472 if ui.Response.User.Id == "" {
473 return user{}, fmt.Errorf("No userid returned")
474 }
475 return ui.Response.User, nil
476 }
477
478
479
480 func (im *imp) doUserAPI(ctx context.Context, accessToken string, result interface{}, apiPath string, keyval ...string) error {
481 form := url.Values{}
482 form.Set("oauth_token", accessToken)
483 return im.doAPI(ctx, form, result, apiPath, keyval...)
484 }
485
486
487
488
489
490 func (im *imp) doCredAPI(ctx context.Context, clientID, clientSecret string, result interface{}, apiPath string, keyval ...string) error {
491 form := url.Values{}
492 form.Set("client_id", clientID)
493 form.Set("client_secret", clientSecret)
494 return im.doAPI(ctx, form, result, apiPath, keyval...)
495 }
496
497 func (im *imp) doAPI(ctx context.Context, form url.Values, result interface{}, apiPath string, keyval ...string) error {
498 if len(keyval)%2 == 1 {
499 panic("Incorrect number of keyval arguments")
500 }
501
502 form.Set("v", apiVersion)
503 for i := 0; i < len(keyval); i += 2 {
504 form.Set(keyval[i], keyval[i+1])
505 }
506
507 fullURL := apiURL + apiPath
508 res, err := doGet(ctx, fullURL, form)
509 if err != nil {
510 return err
511 }
512 err = httputil.DecodeJSON(res, result)
513 if err != nil {
514 log.Printf("Error parsing response for %s: %v", fullURL, err)
515 }
516 return err
517 }
518
519 func doGet(ctx context.Context, url string, form url.Values) (*http.Response, error) {
520 requestURL := url + "?" + form.Encode()
521 req, err := http.NewRequest("GET", requestURL, nil)
522 if err != nil {
523 return nil, err
524 }
525 res, err := ctxutil.Client(ctx).Do(req)
526 if err != nil {
527 log.Printf("Error fetching %s: %v", url, err)
528 return nil, err
529 }
530 if res.StatusCode != http.StatusOK {
531 return nil, fmt.Errorf("Get request on %s failed with: %s", requestURL, res.Status)
532 }
533 return res, nil
534 }
535
536
537 func auth(ctx *importer.SetupContext) (*oauth2.Config, error) {
538 clientID, secret, err := ctx.Credentials()
539 if err != nil {
540 return nil, err
541 }
542 return &oauth2.Config{
543 ClientID: clientID,
544 ClientSecret: secret,
545 Endpoint: oauth2.Endpoint{
546 AuthURL: authURL,
547 TokenURL: tokenURL,
548 },
549 RedirectURL: ctx.CallbackURL(),
550
551 }, nil
552 }
553
554 func (im *imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
555 oauthConfig, err := auth(ctx)
556 if err != nil {
557 return err
558 }
559 oauthConfig.RedirectURL = im.RedirectURL(im, ctx)
560 state, err := im.RedirectState(im, ctx)
561 if err != nil {
562 return err
563 }
564 http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
565 return nil
566 }
567
568 func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
569 oauthConfig, err := auth(ctx)
570 if err != nil {
571 httputil.ServeError(w, r, fmt.Errorf("Error getting oauth config: %v", err))
572 return
573 }
574
575 if r.Method != "GET" {
576 http.Error(w, "Expected a GET", http.StatusBadRequest)
577 return
578 }
579 code := r.FormValue("code")
580 if code == "" {
581 http.Error(w, "Expected a code", http.StatusBadRequest)
582 return
583 }
584 token, err := oauthConfig.Exchange(ctx, code)
585 log.Printf("Token = %#v, error %v", token, err)
586 if err != nil {
587 log.Printf("Token Exchange error: %v", err)
588 http.Error(w, "token exchange error", 500)
589 return
590 }
591
592 u, err := im.getUserInfo(ctx.Context, token.AccessToken)
593 if err != nil {
594 log.Printf("Couldn't get username: %v", err)
595 http.Error(w, "can't get username", 500)
596 return
597 }
598 if err := ctx.AccountNode.SetAttrs(
599 acctAttrUserId, u.Id,
600 acctAttrUserFirst, u.FirstName,
601 acctAttrUserLast, u.LastName,
602 acctAttrAccessToken, token.AccessToken,
603 ); err != nil {
604 httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
605 return
606 }
607 http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
608
609 }