github.com/slspeek/camlistore_namedsearch@v0.0.0-20140519202248-ed6f70f7721a/pkg/importer/foursquare/foursquare.go (about)

     1  /*
     2  Copyright 2014 The Camlistore Authors
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8       http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package foursquare implements an importer for foursquare.com accounts.
    18  package foursquare
    19  
    20  import (
    21  	"fmt"
    22  	"log"
    23  	"net/http"
    24  	"net/url"
    25  	"path"
    26  	"path/filepath"
    27  	"sort"
    28  	"strconv"
    29  	"strings"
    30  	"sync"
    31  	"time"
    32  
    33  	"camlistore.org/pkg/blob"
    34  	"camlistore.org/pkg/context"
    35  	"camlistore.org/pkg/httputil"
    36  	"camlistore.org/pkg/importer"
    37  	"camlistore.org/pkg/schema"
    38  	"camlistore.org/third_party/code.google.com/p/goauth2/oauth"
    39  )
    40  
    41  const (
    42  	apiURL   = "https://api.foursquare.com/v2/"
    43  	authURL  = "https://foursquare.com/oauth2/authenticate"
    44  	tokenURL = "https://foursquare.com/oauth2/access_token"
    45  
    46  	// runCompleteVersion is a cache-busting version number of the
    47  	// importer code. It should be incremented whenever the
    48  	// behavior of this importer is updated enough to warrant a
    49  	// complete run.  Otherwise, if the importer runs to
    50  	// completion, this version number is recorded on the account
    51  	// permanode and subsequent importers can stop early.
    52  	runCompleteVersion = "1"
    53  
    54  	// Permanode attributes on account node:
    55  	acctAttrUserId           = "foursquareUserId"
    56  	acctAttrUserFirst        = "foursquareFirstName"
    57  	acctAttrUserLast         = "foursquareLastName"
    58  	acctAttrAccessToken      = "oauthAccessToken"
    59  	acctAttrCompletedVersion = "completedVersion"
    60  )
    61  
    62  func init() {
    63  	importer.Register("foursquare", &imp{
    64  		imageFileRef: make(map[string]blob.Ref),
    65  	})
    66  }
    67  
    68  var _ importer.ImporterSetupHTMLer = (*imp)(nil)
    69  
    70  type imp struct {
    71  	mu           sync.Mutex          // guards following
    72  	imageFileRef map[string]blob.Ref // url to file schema blob
    73  
    74  	importer.OAuth2 // for CallbackRequestAccount and CallbackURLParameters
    75  }
    76  
    77  func (im *imp) NeedsAPIKey() bool { return true }
    78  
    79  func (im *imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
    80  	if acctNode.Attr(acctAttrUserId) != "" && acctNode.Attr(acctAttrAccessToken) != "" {
    81  		return true, nil
    82  	}
    83  	return false, nil
    84  }
    85  
    86  func (im *imp) SummarizeAccount(acct *importer.Object) string {
    87  	ok, err := im.IsAccountReady(acct)
    88  	if err != nil {
    89  		return "Not configured; error = " + err.Error()
    90  	}
    91  	if !ok {
    92  		return "Not configured"
    93  	}
    94  	if acct.Attr(acctAttrUserFirst) == "" && acct.Attr(acctAttrUserLast) == "" {
    95  		return fmt.Sprintf("userid %s", acct.Attr(acctAttrUserId))
    96  	}
    97  	return fmt.Sprintf("userid %s (%s %s)", acct.Attr(acctAttrUserId),
    98  		acct.Attr(acctAttrUserFirst), acct.Attr(acctAttrUserLast))
    99  }
   100  
   101  func (im *imp) AccountSetupHTML(host *importer.Host) string {
   102  	base := host.ImporterBaseURL() + "foursquare"
   103  	return fmt.Sprintf(`
   104  <h1>Configuring Foursquare</h1>
   105  <p>Visit <a href='https://foursquare.com/developers/apps'>https://foursquare.com/developers/apps</a> and click "Create a new app".</p>
   106  <p>Use the following settings:</p>
   107  <ul>
   108    <li>Download / welcome page url: <b>%s</b></li>
   109    <li>Your privacy policy url: <b>%s</b></li>
   110    <li>Redirect URI(s): <b>%s</b></li>
   111  </ul>
   112  <p>Click "SAVE CHANGES".  Copy the "Client ID" and "Client Secret" into the boxes above.</p>
   113  `, base, base+"/privacy", base+"/callback")
   114  }
   115  
   116  // A run is our state for a given run of the importer.
   117  type run struct {
   118  	*importer.RunContext
   119  	im          *imp
   120  	incremental bool // whether we've completed a run in the past
   121  
   122  	mu     sync.Mutex // guards anyErr
   123  	anyErr bool
   124  }
   125  
   126  func (r *run) token() string {
   127  	return r.RunContext.AccountNode().Attr(acctAttrAccessToken)
   128  }
   129  
   130  func (im *imp) Run(ctx *importer.RunContext) error {
   131  	r := &run{
   132  		RunContext:  ctx,
   133  		im:          im,
   134  		incremental: ctx.AccountNode().Attr(acctAttrCompletedVersion) == runCompleteVersion,
   135  	}
   136  
   137  	if err := r.importCheckins(); err != nil {
   138  		return err
   139  	}
   140  
   141  	r.mu.Lock()
   142  	anyErr := r.anyErr
   143  	r.mu.Unlock()
   144  
   145  	if !anyErr {
   146  		if err := r.AccountNode().SetAttrs(acctAttrCompletedVersion, runCompleteVersion); err != nil {
   147  			return err
   148  		}
   149  	}
   150  
   151  	return nil
   152  }
   153  
   154  func (r *run) errorf(format string, args ...interface{}) {
   155  	log.Printf(format, args...)
   156  	r.mu.Lock()
   157  	defer r.mu.Unlock()
   158  	r.anyErr = true
   159  }
   160  
   161  // urlFileRef slurps urlstr from the net, writes to a file and returns its
   162  // fileref or "" on error
   163  func (r *run) urlFileRef(urlstr, filename string) string {
   164  	im := r.im
   165  	im.mu.Lock()
   166  	if br, ok := im.imageFileRef[urlstr]; ok {
   167  		im.mu.Unlock()
   168  		return br.String()
   169  	}
   170  	im.mu.Unlock()
   171  
   172  	res, err := r.Host.HTTPClient().Get(urlstr)
   173  	if err != nil {
   174  		log.Printf("couldn't get image: %v", err)
   175  		return ""
   176  	}
   177  	defer res.Body.Close()
   178  
   179  	fileRef, err := schema.WriteFileFromReader(r.Host.Target(), filename, res.Body)
   180  	if err != nil {
   181  		r.errorf("couldn't write file: %v", err)
   182  		return ""
   183  	}
   184  
   185  	im.mu.Lock()
   186  	defer im.mu.Unlock()
   187  	im.imageFileRef[urlstr] = fileRef
   188  	return fileRef.String()
   189  }
   190  
   191  type byCreatedAt []*checkinItem
   192  
   193  func (s byCreatedAt) Less(i, j int) bool { return s[i].CreatedAt < s[j].CreatedAt }
   194  func (s byCreatedAt) Len() int           { return len(s) }
   195  func (s byCreatedAt) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
   196  
   197  func (r *run) importCheckins() error {
   198  	limit := 100
   199  	offset := 0
   200  	continueRequests := true
   201  
   202  	for continueRequests {
   203  		resp := checkinsList{}
   204  		if err := r.im.doAPI(r.Context, r.token(), &resp, "users/self/checkins", "limit", strconv.Itoa(limit), "offset", strconv.Itoa(offset)); err != nil {
   205  			return err
   206  		}
   207  
   208  		itemcount := len(resp.Response.Checkins.Items)
   209  		log.Printf("foursquare: importing %d checkins (offset %d)", itemcount, offset)
   210  		if itemcount < limit {
   211  			continueRequests = false
   212  		} else {
   213  			offset += itemcount
   214  		}
   215  
   216  		checkinsNode, err := r.getTopLevelNode("checkins", "Checkins")
   217  		if err != nil {
   218  			return err
   219  		}
   220  
   221  		placesNode, err := r.getTopLevelNode("places", "Places")
   222  		if err != nil {
   223  			return err
   224  		}
   225  
   226  		sort.Sort(byCreatedAt(resp.Response.Checkins.Items))
   227  		sawOldItem := false
   228  		for _, checkin := range resp.Response.Checkins.Items {
   229  			placeNode, err := r.importPlace(placesNode, &checkin.Venue)
   230  			if err != nil {
   231  				r.errorf("Foursquare importer: error importing place %s %v", checkin.Venue.Id, err)
   232  				continue
   233  			}
   234  
   235  			_, dup, err := r.importCheckin(checkinsNode, checkin, placeNode.PermanodeRef())
   236  			if err != nil {
   237  				r.errorf("Foursquare importer: error importing checkin %s %v", checkin.Id, err)
   238  				continue
   239  			}
   240  
   241  			if dup {
   242  				sawOldItem = true
   243  			}
   244  
   245  			err = r.importPhotos(placeNode, dup)
   246  			if err != nil {
   247  				r.errorf("Foursquare importer: error importing photos for checkin %s %v", checkin.Id, err)
   248  				continue
   249  			}
   250  		}
   251  		if sawOldItem && r.incremental {
   252  			break
   253  		}
   254  	}
   255  
   256  	return nil
   257  }
   258  
   259  func (r *run) importPhotos(placeNode *importer.Object, checkinWasDup bool) error {
   260  	photosNode, err := placeNode.ChildPathObject("photos")
   261  	if err != nil {
   262  		return err
   263  	}
   264  
   265  	if err := photosNode.SetAttrs(
   266  		"title", "Photos of "+placeNode.Attr("title"),
   267  		"camliDefVis", "hide"); err != nil {
   268  		return err
   269  	}
   270  
   271  	nHave := 0
   272  	photosNode.ForeachAttr(func(key, value string) {
   273  		if strings.HasPrefix(key, "camliPath:") {
   274  			nHave++
   275  		}
   276  	})
   277  	nWant := 5
   278  	if checkinWasDup {
   279  		nWant = 1
   280  	}
   281  	if nHave >= nWant {
   282  		return nil
   283  	}
   284  
   285  	resp := photosList{}
   286  	if err := r.im.doAPI(r.Context, r.token(), &resp,
   287  		"venues/"+placeNode.Attr("foursquareId")+"/photos",
   288  		"limit", strconv.Itoa(nWant)); err != nil {
   289  		return err
   290  	}
   291  
   292  	var need []*photoItem
   293  	for _, photo := range resp.Response.Photos.Items {
   294  		attr := "camliPath:" + photo.Id + filepath.Ext(photo.Suffix)
   295  		if photosNode.Attr(attr) == "" {
   296  			need = append(need, photo)
   297  		}
   298  	}
   299  
   300  	if len(need) > 0 {
   301  		log.Printf("foursquare: importing %d photos for venue %s", len(need), placeNode.Attr("title"))
   302  		for _, photo := range need {
   303  			attr := "camliPath:" + photo.Id + filepath.Ext(photo.Suffix)
   304  			if photosNode.Attr(attr) != "" {
   305  				continue
   306  			}
   307  			url := photo.Prefix + "original" + photo.Suffix
   308  			log.Printf("foursquare: importing photo for venue %s: %s", placeNode.Attr("title"), url)
   309  			ref := r.urlFileRef(url, "")
   310  			if ref == "" {
   311  				r.errorf("Error slurping photo: %s", url)
   312  				continue
   313  			}
   314  			if err := photosNode.SetAttr(attr, ref); err != nil {
   315  				r.errorf("Error adding venue photo: %#v", err)
   316  			}
   317  		}
   318  	}
   319  
   320  	return nil
   321  }
   322  
   323  func (r *run) importCheckin(parent *importer.Object, checkin *checkinItem, placeRef blob.Ref) (checkinNode *importer.Object, dup bool, err error) {
   324  	checkinNode, err = parent.ChildPathObject(checkin.Id)
   325  	if err != nil {
   326  		return
   327  	}
   328  
   329  	title := fmt.Sprintf("Checkin at %s", checkin.Venue.Name)
   330  	dup = checkinNode.Attr("startDate") != ""
   331  	if err := checkinNode.SetAttrs(
   332  		"foursquareId", checkin.Id,
   333  		"foursquareVenuePermanode", placeRef.String(),
   334  		"camliNodeType", "foursquare.com:checkin",
   335  		"startDate", schema.RFC3339FromTime(time.Unix(checkin.CreatedAt, 0)),
   336  		"title", title); err != nil {
   337  		return nil, false, err
   338  	}
   339  	return checkinNode, dup, nil
   340  }
   341  
   342  func (r *run) importPlace(parent *importer.Object, place *venueItem) (*importer.Object, error) {
   343  	placeNode, err := parent.ChildPathObject(place.Id)
   344  	if err != nil {
   345  		return nil, err
   346  	}
   347  
   348  	catName := ""
   349  	if cat := place.primaryCategory(); cat != nil {
   350  		catName = cat.Name
   351  	}
   352  
   353  	icon := place.icon()
   354  	if err := placeNode.SetAttrs(
   355  		"foursquareId", place.Id,
   356  		"camliNodeType", "foursquare.com:venue",
   357  		"camliContentImage", r.urlFileRef(icon, path.Base(icon)),
   358  		"foursquareCategoryName", catName,
   359  		"title", place.Name,
   360  		"streetAddress", place.Location.Address,
   361  		"addressLocality", place.Location.City,
   362  		"postalCode", place.Location.PostalCode,
   363  		"addressRegion", place.Location.State,
   364  		"addressCountry", place.Location.Country,
   365  		"latitude", fmt.Sprint(place.Location.Lat),
   366  		"longitude", fmt.Sprint(place.Location.Lng)); err != nil {
   367  		return nil, err
   368  	}
   369  
   370  	return placeNode, nil
   371  }
   372  
   373  func (r *run) getTopLevelNode(path string, title string) (*importer.Object, error) {
   374  	childObject, err := r.RootNode().ChildPathObject(path)
   375  	if err != nil {
   376  		return nil, err
   377  	}
   378  
   379  	if err := childObject.SetAttr("title", title); err != nil {
   380  		return nil, err
   381  	}
   382  	return childObject, nil
   383  }
   384  
   385  func (im *imp) getUserInfo(ctx *context.Context, accessToken string) (user, error) {
   386  	var ui userInfo
   387  	if err := im.doAPI(ctx, accessToken, &ui, "users/self"); err != nil {
   388  		return user{}, err
   389  	}
   390  	if ui.Response.User.Id == "" {
   391  		return user{}, fmt.Errorf("No userid returned")
   392  	}
   393  	return ui.Response.User, nil
   394  }
   395  
   396  func (im *imp) doAPI(ctx *context.Context, accessToken string, result interface{}, apiPath string, keyval ...string) error {
   397  	if len(keyval)%2 == 1 {
   398  		panic("Incorrect number of keyval arguments")
   399  	}
   400  
   401  	form := url.Values{}
   402  	form.Set("v", "20140225") // 4sq requires this to version their API
   403  	form.Set("oauth_token", accessToken)
   404  	for i := 0; i < len(keyval); i += 2 {
   405  		form.Set(keyval[i], keyval[i+1])
   406  	}
   407  
   408  	fullURL := apiURL + apiPath
   409  	res, err := doGet(ctx, fullURL, form)
   410  	if err != nil {
   411  		return err
   412  	}
   413  	err = httputil.DecodeJSON(res, result)
   414  	if err != nil {
   415  		log.Printf("Error parsing response for %s: %v", fullURL, err)
   416  	}
   417  	return err
   418  }
   419  
   420  func doGet(ctx *context.Context, url string, form url.Values) (*http.Response, error) {
   421  	requestURL := url + "?" + form.Encode()
   422  	req, err := http.NewRequest("GET", requestURL, nil)
   423  	if err != nil {
   424  		return nil, err
   425  	}
   426  	res, err := ctx.HTTPClient().Do(req)
   427  	if err != nil {
   428  		log.Printf("Error fetching %s: %v", url, err)
   429  		return nil, err
   430  	}
   431  	if res.StatusCode != http.StatusOK {
   432  		return nil, fmt.Errorf("Get request on %s failed with: %s", requestURL, res.Status)
   433  	}
   434  	return res, nil
   435  }
   436  
   437  // auth returns a new oauth.Config
   438  func auth(ctx *importer.SetupContext) (*oauth.Config, error) {
   439  	clientId, secret, err := ctx.Credentials()
   440  	if err != nil {
   441  		return nil, err
   442  	}
   443  	return &oauth.Config{
   444  		ClientId:     clientId,
   445  		ClientSecret: secret,
   446  		AuthURL:      authURL,
   447  		TokenURL:     tokenURL,
   448  		RedirectURL:  ctx.CallbackURL(),
   449  	}, nil
   450  }
   451  
   452  func (im *imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
   453  	oauthConfig, err := auth(ctx)
   454  	if err != nil {
   455  		return err
   456  	}
   457  	oauthConfig.RedirectURL = im.RedirectURL(im, ctx)
   458  	state, err := im.RedirectState(im, ctx)
   459  	if err != nil {
   460  		return err
   461  	}
   462  	http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
   463  	return nil
   464  }
   465  
   466  func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
   467  	oauthConfig, err := auth(ctx)
   468  	if err != nil {
   469  		httputil.ServeError(w, r, fmt.Errorf("Error getting oauth config: %v", err))
   470  		return
   471  	}
   472  
   473  	if r.Method != "GET" {
   474  		http.Error(w, "Expected a GET", 400)
   475  		return
   476  	}
   477  	code := r.FormValue("code")
   478  	if code == "" {
   479  		http.Error(w, "Expected a code", 400)
   480  		return
   481  	}
   482  	transport := &oauth.Transport{Config: oauthConfig}
   483  	token, err := transport.Exchange(code)
   484  	log.Printf("Token = %#v, error %v", token, err)
   485  	if err != nil {
   486  		log.Printf("Token Exchange error: %v", err)
   487  		http.Error(w, "token exchange error", 500)
   488  		return
   489  	}
   490  
   491  	u, err := im.getUserInfo(ctx.Context, token.AccessToken)
   492  	if err != nil {
   493  		log.Printf("Couldn't get username: %v", err)
   494  		http.Error(w, "can't get username", 500)
   495  		return
   496  	}
   497  	if err := ctx.AccountNode.SetAttrs(
   498  		acctAttrUserId, u.Id,
   499  		acctAttrUserFirst, u.FirstName,
   500  		acctAttrUserLast, u.LastName,
   501  		acctAttrAccessToken, token.AccessToken,
   502  	); err != nil {
   503  		httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
   504  		return
   505  	}
   506  	http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
   507  
   508  }