|
|
|
@ -30,81 +30,107 @@ import (
|
|
|
|
|
"camlistore.org/pkg/context"
|
|
|
|
|
"camlistore.org/pkg/httputil"
|
|
|
|
|
"camlistore.org/pkg/importer"
|
|
|
|
|
"camlistore.org/pkg/jsonconfig"
|
|
|
|
|
"camlistore.org/pkg/schema"
|
|
|
|
|
"camlistore.org/third_party/github.com/garyburd/go-oauth/oauth"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
apiURL = "https://api.twitter.com/1.1/"
|
|
|
|
|
apiURL = "https://api.twitter.com/1.1/"
|
|
|
|
|
temporaryCredentialRequestURL = "https://api.twitter.com/oauth/request_token"
|
|
|
|
|
resourceOwnerAuthorizationURL = "https://api.twitter.com/oauth/authorize"
|
|
|
|
|
tokenRequestURL = "https://api.twitter.com/oauth/access_token"
|
|
|
|
|
userInfoAPIPath = "account/verify_credentials.json"
|
|
|
|
|
|
|
|
|
|
// Permanode attributes on account node:
|
|
|
|
|
acctAttrUserID = "twitterUserID"
|
|
|
|
|
acctAttrScreenName = "twitterScreenName"
|
|
|
|
|
acctAttrUserFirst = "twitterFirstName"
|
|
|
|
|
acctAttrUserLast = "twitterLastName"
|
|
|
|
|
acctAttrAccessToken = "oauthAccessToken"
|
|
|
|
|
|
|
|
|
|
tweetRequestLimit = 200 // max number of tweets we can get in a user_timeline request
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
importer.Register("twitter", &imp{})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ importer.ImporterSetupHTMLer = (*imp)(nil)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
oauthClient = &oauth.Client{
|
|
|
|
|
TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token",
|
|
|
|
|
ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authorize",
|
|
|
|
|
TokenRequestURI: "https://api.twitter.com/oauth/access_token",
|
|
|
|
|
TemporaryCredentialRequestURI: temporaryCredentialRequestURL,
|
|
|
|
|
ResourceOwnerAuthorizationURI: resourceOwnerAuthorizationURL,
|
|
|
|
|
TokenRequestURI: tokenRequestURL,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
importer.Register("twitter", newFromConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type imp struct {
|
|
|
|
|
host *importer.Host
|
|
|
|
|
userid string // empty if the user isn't authenticated
|
|
|
|
|
cred *oauth.Credentials
|
|
|
|
|
// cred are the various credentials passed around during OAUTH. First the temporary
|
|
|
|
|
// ones, then the access token and secret.
|
|
|
|
|
cred *oauth.Credentials
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newFromConfig(cfg jsonconfig.Obj, host *importer.Host) (importer.Importer, error) {
|
|
|
|
|
apiKey := cfg.RequiredString("apiKey")
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(apiKey, ":")
|
|
|
|
|
if len(parts) != 2 {
|
|
|
|
|
return nil, fmt.Errorf("Twitter importer: Invalid apiKey configuration: %q", apiKey)
|
|
|
|
|
}
|
|
|
|
|
func (im *imp) NeedsAPIKey() bool { return true }
|
|
|
|
|
|
|
|
|
|
oauthClient.Credentials = oauth.Credentials{
|
|
|
|
|
Token: parts[0],
|
|
|
|
|
Secret: parts[1],
|
|
|
|
|
func (im *imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
|
|
|
|
|
if acctNode.Attr(acctAttrUserID) != "" && acctNode.Attr(acctAttrAccessToken) != "" {
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &imp{
|
|
|
|
|
host: host,
|
|
|
|
|
cred: &oauthClient.Credentials,
|
|
|
|
|
}, nil
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) CanHandleURL(url string) bool { return false }
|
|
|
|
|
func (im *imp) ImportURL(url string) error { panic("unused") }
|
|
|
|
|
|
|
|
|
|
func (im *imp) Prefix() string {
|
|
|
|
|
// This should only get called when we're importing, so it's OK to
|
|
|
|
|
// assume we're authenticated.
|
|
|
|
|
return fmt.Sprintf("twitter:%s", im.userid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) String() string {
|
|
|
|
|
// We use this in logging when we're not authenticated, so it should do
|
|
|
|
|
// something reasonable in that case.
|
|
|
|
|
userId := "<unauthenticated>"
|
|
|
|
|
if im.userid != "" {
|
|
|
|
|
userId = im.userid
|
|
|
|
|
func (im *imp) SummarizeAccount(acct *importer.Object) string {
|
|
|
|
|
ok, err := im.IsAccountReady(acct)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "Not configured; error = " + err.Error()
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("twitter:%s", userId)
|
|
|
|
|
if !ok {
|
|
|
|
|
return "Not configured"
|
|
|
|
|
}
|
|
|
|
|
if acct.Attr(acctAttrUserFirst) == "" && acct.Attr(acctAttrUserLast) == "" {
|
|
|
|
|
return fmt.Sprintf("@%s", acct.Attr(acctAttrScreenName))
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("@%s (%s %s)", acct.Attr(acctAttrScreenName),
|
|
|
|
|
acct.Attr(acctAttrUserFirst), acct.Attr(acctAttrUserLast))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) Run(ctx *context.Context) error {
|
|
|
|
|
log.Print("Twitter running...")
|
|
|
|
|
func (im *imp) AccountSetupHTML(host *importer.Host) string {
|
|
|
|
|
base := host.ImporterBaseURL() + "twitter"
|
|
|
|
|
return fmt.Sprintf(`
|
|
|
|
|
<h1>Configuring Twitter</h1>
|
|
|
|
|
<p>Visit <a href='https://apps.twitter.com/'>https://apps.twitter.com/</a> and click "Create New App".</p>
|
|
|
|
|
<p>Use the following settings:</p>
|
|
|
|
|
<ul>
|
|
|
|
|
<li>Name: Does not matter. (camlistore-importer).</li>
|
|
|
|
|
<li>Description: Does not matter. (imports twitter data into camlistore).</li>
|
|
|
|
|
<li>Website: <b>%s</b></li>
|
|
|
|
|
<li>Callback URL: <b>%s</b></li>
|
|
|
|
|
</ul>
|
|
|
|
|
<p>Click "Create your Twitter application".You should be redirected to the Application Management page of your newly created application.
|
|
|
|
|
</br>Go to the API Keys tab. Copy the "API key" and "API secret" into the "Client ID" and "Client Secret" boxes above.</p>
|
|
|
|
|
`, base, base+"/callback")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := im.importTweets(ctx); err != nil {
|
|
|
|
|
// A run is our state for a given run of the importer.
|
|
|
|
|
type run struct {
|
|
|
|
|
*importer.RunContext
|
|
|
|
|
im *imp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) Run(ctx *importer.RunContext) error {
|
|
|
|
|
r := &run{
|
|
|
|
|
RunContext: ctx,
|
|
|
|
|
im: im,
|
|
|
|
|
}
|
|
|
|
|
userID := ctx.AccountNode().Attr(acctAttrUserID)
|
|
|
|
|
if userID == "" {
|
|
|
|
|
return errors.New("UserID hasn't been set by account setup.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := r.importTweets(userID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -114,22 +140,25 @@ type tweetItem struct {
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) importTweets(ctx *context.Context) error {
|
|
|
|
|
func (r *run) importTweets(userID string) error {
|
|
|
|
|
maxId := ""
|
|
|
|
|
continueRequests := true
|
|
|
|
|
|
|
|
|
|
for continueRequests {
|
|
|
|
|
if ctx.IsCanceled() {
|
|
|
|
|
if r.Context.IsCanceled() {
|
|
|
|
|
log.Printf("Twitter importer: interrupted")
|
|
|
|
|
return context.ErrCanceled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var resp []*tweetItem
|
|
|
|
|
if err := im.doAPI(&resp, "statuses/user_timeline.json", "count", strconv.Itoa(tweetRequestLimit), "max_id", maxId); err != nil {
|
|
|
|
|
if err := r.im.doAPI(r.Context, &resp, "statuses/user_timeline.json",
|
|
|
|
|
"user_id", userID,
|
|
|
|
|
"count", strconv.Itoa(tweetRequestLimit),
|
|
|
|
|
"max_id", maxId); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tweetsNode, err := im.getTopLevelNode("tweets", "Tweets")
|
|
|
|
|
tweetsNode, err := r.getTopLevelNode("tweets", "Tweets")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
@ -144,11 +173,11 @@ func (im *imp) importTweets(ctx *context.Context) error {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tweet := range resp {
|
|
|
|
|
if ctx.IsCanceled() {
|
|
|
|
|
if r.Context.IsCanceled() {
|
|
|
|
|
log.Printf("Twitter importer: interrupted")
|
|
|
|
|
return context.ErrCanceled
|
|
|
|
|
}
|
|
|
|
|
err = im.importTweet(tweetsNode, tweet)
|
|
|
|
|
err = r.importTweet(tweetsNode, tweet)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Twitter importer: error importing tweet %s %v", tweet.Id, err)
|
|
|
|
|
continue
|
|
|
|
@ -159,7 +188,7 @@ func (im *imp) importTweets(ctx *context.Context) error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) importTweet(parent *importer.Object, tweet *tweetItem) error {
|
|
|
|
|
func (r *run) importTweet(parent *importer.Object, tweet *tweetItem) error {
|
|
|
|
|
tweetNode, err := parent.ChildPathObject(tweet.Id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
@ -169,8 +198,7 @@ func (im *imp) importTweet(parent *importer.Object, tweet *tweetItem) error {
|
|
|
|
|
|
|
|
|
|
createdTime, err := time.Parse(time.RubyDate, tweet.CreatedAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Twitter importer: error parsing time %s %v", tweet.Id, err)
|
|
|
|
|
return err
|
|
|
|
|
return fmt.Errorf("could not parse time %q: %v", tweet.CreatedAt, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: import photos referenced in tweets
|
|
|
|
@ -182,136 +210,149 @@ func (im *imp) importTweet(parent *importer.Object, tweet *tweetItem) error {
|
|
|
|
|
"title", title)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// utility
|
|
|
|
|
|
|
|
|
|
func (im *imp) getTopLevelNode(path string, title string) (*importer.Object, error) {
|
|
|
|
|
root, err := im.getRootNode()
|
|
|
|
|
func (r *run) getTopLevelNode(path string, title string) (*importer.Object, error) {
|
|
|
|
|
tweets, err := r.RootNode().ChildPathObject(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
photos, err := root.ChildPathObject(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err := tweets.SetAttr("title", title); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := photos.SetAttr("title", title); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return photos, nil
|
|
|
|
|
return tweets, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) getRootNode() (*importer.Object, error) {
|
|
|
|
|
root, err := im.host.RootObject()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// TODO(mpl): move to an api.go when we it gets bigger.
|
|
|
|
|
|
|
|
|
|
title := fmt.Sprintf("Twitter (%s)", im.userid)
|
|
|
|
|
if err := root.SetAttr("title", title); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return root, nil
|
|
|
|
|
type userInfo struct {
|
|
|
|
|
ID string `json:"id_str"`
|
|
|
|
|
ScreenName string `json:"screen_name"`
|
|
|
|
|
Name string `json:"name,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// twitter api builders
|
|
|
|
|
func (im *imp) getUserInfo(ctx *context.Context) (userInfo, error) {
|
|
|
|
|
var ui userInfo
|
|
|
|
|
if err := im.doAPI(ctx, &ui, userInfoAPIPath); err != nil {
|
|
|
|
|
return ui, err
|
|
|
|
|
}
|
|
|
|
|
if ui.ID == "" {
|
|
|
|
|
return ui, fmt.Errorf("No userid returned")
|
|
|
|
|
}
|
|
|
|
|
return ui, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) doAPI(result interface{}, apiPath string, keyval ...string) error {
|
|
|
|
|
func (im *imp) doAPI(ctx *context.Context, result interface{}, apiPath string, keyval ...string) error {
|
|
|
|
|
if len(keyval)%2 == 1 {
|
|
|
|
|
panic("Incorrect number of keyval arguments")
|
|
|
|
|
panic("Incorrect number of keyval arguments. must be even.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if im.cred == nil {
|
|
|
|
|
return fmt.Errorf("No authentication creds")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if im.userid == "" {
|
|
|
|
|
return fmt.Errorf("No user id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
form := url.Values{}
|
|
|
|
|
form.Set("user_id", im.userid)
|
|
|
|
|
for i := 0; i < len(keyval); i += 2 {
|
|
|
|
|
if keyval[i+1] != "" {
|
|
|
|
|
form.Set(keyval[i], keyval[i+1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := im.doGet(apiURL+apiPath, form)
|
|
|
|
|
fullURL := apiURL + apiPath
|
|
|
|
|
res, err := im.doGet(ctx, fullURL, form)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
err = httputil.DecodeJSON(res, result)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("Error parsing response for %s: %s", apiURL, err)
|
|
|
|
|
return fmt.Errorf("could not parse response for %s: %v", fullURL, err)
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) doGet(url string, form url.Values) (*http.Response, error) {
|
|
|
|
|
func (im *imp) doGet(ctx *context.Context, url string, form url.Values) (*http.Response, error) {
|
|
|
|
|
if im.cred == nil {
|
|
|
|
|
return nil, errors.New("Not logged in. Go to /importer-twitter/login.")
|
|
|
|
|
return nil, errors.New("No OAUTH credentials. Not logged in?")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := oauthClient.Get(im.host.HTTPClient(), im.cred, url, form)
|
|
|
|
|
res, err := oauthClient.Get(ctx.HTTPClient(), im.cred, url, form)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
return nil, fmt.Errorf("Error fetching %s: %v", url, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
|
|
|
return nil, fmt.Errorf("Auth request failed with: %s", res.Status)
|
|
|
|
|
return nil, fmt.Errorf("Get request on %s failed with: %s", url, res.Status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// auth endpoints
|
|
|
|
|
|
|
|
|
|
func (im *imp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if strings.HasSuffix(r.URL.Path, "/login") {
|
|
|
|
|
im.serveLogin(w, r)
|
|
|
|
|
} else if strings.HasSuffix(r.URL.Path, "/callback") {
|
|
|
|
|
im.serveCallback(w, r)
|
|
|
|
|
} else {
|
|
|
|
|
httputil.BadRequestError(w, "Unknown path: %s", r.URL.Path)
|
|
|
|
|
func auth(ctx *importer.SetupContext) (*oauth.Credentials, error) {
|
|
|
|
|
clientId, secret, err := ctx.Credentials()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &oauth.Credentials{
|
|
|
|
|
Token: clientId,
|
|
|
|
|
Secret: secret,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) serveLogin(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
callback := im.host.BaseURL + "callback"
|
|
|
|
|
tempCred, err := oauthClient.RequestTemporaryCredentials(im.host.HTTPClient(), callback, nil)
|
|
|
|
|
func (im *imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
|
|
|
|
|
cred, err := auth(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Twitter importer: Error getting temp cred: %s", err))
|
|
|
|
|
return
|
|
|
|
|
err = fmt.Errorf("Error getting API credentials: %v", err)
|
|
|
|
|
httputil.ServeError(w, r, err)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
oauthClient.Credentials = *cred
|
|
|
|
|
tempCred, err := oauthClient.RequestTemporaryCredentials(ctx.HTTPClient(), ctx.CallbackURL(), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
err = fmt.Errorf("Error getting temp cred: %v", err)
|
|
|
|
|
httputil.ServeError(w, r, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
im.cred = tempCred
|
|
|
|
|
|
|
|
|
|
authURL := oauthClient.AuthorizationURL(tempCred, nil)
|
|
|
|
|
http.Redirect(w, r, authURL, 302)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (im *imp) serveCallback(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
|
|
|
|
|
if im.cred.Token != r.FormValue("oauth_token") {
|
|
|
|
|
httputil.BadRequestError(w, "Twitter importer: unexpected oauth_token")
|
|
|
|
|
log.Printf("unexpected oauth_token: got %v, want %v", r.FormValue("oauth_token"), im.cred.Token)
|
|
|
|
|
httputil.BadRequestError(w, "unexpected oauth_token")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tokenCred, vals, err := oauthClient.RequestToken(im.host.HTTPClient(), im.cred, r.FormValue("oauth_verifier"))
|
|
|
|
|
tokenCred, vals, err := oauthClient.RequestToken(ctx.Context.HTTPClient(), im.cred, r.FormValue("oauth_verifier"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Twitter importer: error getting request token: %s ", err))
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Error getting request token: %v ", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userid := vals.Get("user_id")
|
|
|
|
|
if userid == "" {
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Couldn't get user id: %v", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
im.cred = tokenCred
|
|
|
|
|
|
|
|
|
|
userid := vals.Get("user_id")
|
|
|
|
|
if userid == "" {
|
|
|
|
|
log.Printf("Couldn't get user id: %v", err)
|
|
|
|
|
http.Error(w, "can't get user id", 500)
|
|
|
|
|
u, err := im.getUserInfo(ctx.Context)
|
|
|
|
|
if err != nil {
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Couldn't get user info: %v", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
im.userid = userid
|
|
|
|
|
|
|
|
|
|
http.Redirect(w, r, im.host.BaseURL+"?mode=start", 302)
|
|
|
|
|
firstName, lastName := "", ""
|
|
|
|
|
if u.Name != "" {
|
|
|
|
|
if pieces := strings.Fields(u.Name); len(pieces) == 2 {
|
|
|
|
|
firstName = pieces[0]
|
|
|
|
|
lastName = pieces[1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.AccountNode.SetAttrs(
|
|
|
|
|
acctAttrUserID, u.ID,
|
|
|
|
|
acctAttrUserFirst, firstName,
|
|
|
|
|
acctAttrUserLast, lastName,
|
|
|
|
|
acctAttrScreenName, u.ScreenName,
|
|
|
|
|
acctAttrAccessToken, tokenCred.Token,
|
|
|
|
|
); err != nil {
|
|
|
|
|
httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|