diff options
-rw-r--r-- | actions/twitterauthorization.php | 154 | ||||
-rw-r--r-- | actions/twittersettings.php | 111 | ||||
-rw-r--r-- | classes/Foreign_link.php | 1 | ||||
-rw-r--r-- | classes/laconica.ini | 1 | ||||
-rw-r--r-- | db/laconica.sql | 3 | ||||
-rw-r--r-- | lib/common.php | 3 | ||||
-rw-r--r-- | lib/mail.php | 19 | ||||
-rw-r--r-- | lib/router.php | 4 | ||||
-rw-r--r-- | lib/twitter.php | 117 | ||||
-rw-r--r-- | lib/twitteroauthclient.php | 128 |
10 files changed, 397 insertions, 144 deletions
diff --git a/actions/twitterauthorization.php b/actions/twitterauthorization.php new file mode 100644 index 000000000..519427dac --- /dev/null +++ b/actions/twitterauthorization.php @@ -0,0 +1,154 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Class for doing OAuth authentication against Twitter + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category TwitterauthorizationAction + * @package Laconica + * @author Zach Copely <zach@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +class TwitterauthorizationAction extends Action +{ + + function prepare($args) + { + parent::prepare($args); + + $this->oauth_token = $this->arg('oauth_token'); + + return true; + } + + function handle($args) + { + parent::handle($args); + + if (!common_logged_in()) { + $this->clientError(_('Not logged in.'), 403); + } + + $user = common_current_user(); + $flink = Foreign_link::getByUserID($user->id, TWITTER_SERVICE); + + // If there's already a foreign link record, it means we already + // have an access token, and this is unecessary. So go back. + + if (isset($flink)) { + common_redirect(common_local_url('twittersettings')); + } + + // $this->oauth_token is only populated once Twitter authorizes our + // request token. If it's empty we're at the beginning of the auth + // process + + if (empty($this->oauth_token)) { + + try { + + // Get a new request token and authorize it + + $client = new TwitterOAuthClient(); + $req_tok = $client->getRequestToken(); + + // Sock the request token away in the session temporarily + + $_SESSION['twitter_request_token'] = $req_tok->key; + $_SESSION['twitter_request_token_secret'] = $req_tok->key; + + $auth_link = $client->getAuthorizeLink($req_tok); + + } catch (TwitterOAuthClientException $e) { + $msg = sprintf('OAuth client cURL error - code: %1s, msg: %2s', + $e->getCode(), $e->getMessage()); + $this->serverError(_('Couldn\'t link your Twitter account.')); + } + + common_redirect($auth_link); + + } else { + + // Check to make sure Twitter returned the same request + // token we sent them + + if ($_SESSION['twitter_request_token'] != $this->oauth_token) { + $this->serverError(_('Couldn\'t link your Twitter account.')); + } + + try { + + $client = new TwitterOAuthClient($_SESSION['twitter_request_token'], + $_SESSION['twitter_request_token_secret']); + + // Exchange the request token for an access token + + $atok = $client->getAccessToken(); + + // Save the access token and Twitter user info + + $client = new TwitterOAuthClient($atok->key, $atok->secret); + + $twitter_user = $client->verify_credentials(); + + } catch (OAuthClientException $e) { + $msg = sprintf('OAuth client cURL error - code: %1s, msg: %2s', + $e->getCode(), $e->getMessage()); + $this->serverError(_('Couldn\'t link your Twitter account.')); + } + + $user = common_current_user(); + + $flink = new Foreign_link(); + + $flink->user_id = $user->id; + $flink->foreign_id = $twitter_user->id; + $flink->service = TWITTER_SERVICE; + $flink->token = $atok->key; + $flink->credentials = $atok->secret; + $flink->created = common_sql_now(); + + $flink->set_flags(true, false, false, false); + + $flink_id = $flink->insert(); + + if (empty($flink_id)) { + common_log_db_error($flink, 'INSERT', __FILE__); + $this->serverError(_('Couldn\'t link your Twitter account.')); + } + + save_twitter_user($twitter_user->id, $twitter_user->screen_name); + + // clean up the the mess we made in the session + + unset($_SESSION['twitter_request_token']); + unset($_SESSION['twitter_request_token_secret']); + + common_redirect(common_local_url('twittersettings')); + } + } + +} + diff --git a/actions/twittersettings.php b/actions/twittersettings.php index 2b742788e..acc9fb935 100644 --- a/actions/twittersettings.php +++ b/actions/twittersettings.php @@ -69,9 +69,8 @@ class TwittersettingsAction extends ConnectSettingsAction function getInstructions() { - return _('Add your Twitter account to automatically send '. - ' your notices to Twitter, ' . - 'and subscribe to Twitter friends already here.'); + return _('Connect your Twitter account to share your updates ' . + 'with your Twitter friends and vice-versa.'); } /** @@ -93,7 +92,7 @@ class TwittersettingsAction extends ConnectSettingsAction $flink = Foreign_link::getByUserID($user->id, TWITTER_SERVICE); - if ($flink) { + if (!empty($flink)) { $fuser = $flink->getForeignUser(); } @@ -102,73 +101,61 @@ class TwittersettingsAction extends ConnectSettingsAction 'class' => 'form_settings', 'action' => common_local_url('twittersettings'))); - $this->elementStart('fieldset', array('id' => 'settings_twitter_account')); - $this->element('legend', null, _('Twitter Account')); + $this->hidden('token', common_session_token()); - if ($fuser) { + + + if (empty($fuser)) { + + $this->elementStart('fieldset', array('id' => 'settings_twitter_account')); $this->elementStart('ul', 'form_data'); - $this->elementStart('li', array('id' => 'settings_twitter_remove')); - $this->element('span', 'twitter_user', $fuser->nickname); - $this->element('a', array('href' => $fuser->uri), $fuser->uri); - $this->element('p', 'form_note', - _('Current verified Twitter account.')); - $this->hidden('flink_foreign_id', $flink->foreign_id); - $this->elementEnd('li'); + $this->elementStart('li', array('id' => 'settings_twitter_login_button')); + $this->element('a', array('href' => common_local_url('twitterauthorization')), + 'Connect my Twitter account'); + $this->elementEnd('li'); $this->elementEnd('ul'); - $this->submit('remove', _('Remove')); + $this->elementEnd('fieldset'); + } else { + + $this->elementStart('fieldset', + array('id' => 'settings_twitter_preferences')); + $this->element('legend', null, _('Preferences')); + $this->elementStart('ul', 'form_data'); - $this->elementStart('li', array('id' => 'settings_twitter_login')); - $this->input('twitter_username', _('Twitter user name'), - ($this->arg('twitter_username')) ? - $this->arg('twitter_username') : - $profile->nickname, - _('No spaces, please.')); // hey, it's what Twitter says + $this->elementStart('li'); + $this->checkbox('noticesend', + _('Automatically send my notices to Twitter.'), + ($flink) ? + ($flink->noticesync & FOREIGN_NOTICE_SEND) : + true); $this->elementEnd('li'); $this->elementStart('li'); - $this->password('twitter_password', _('Twitter password')); - $this->elementend('li'); - $this->elementEnd('ul'); - } - $this->elementEnd('fieldset'); + $this->checkbox('replysync', + _('Send local "@" replies to Twitter.'), + ($flink) ? + ($flink->noticesync & FOREIGN_NOTICE_SEND_REPLY) : + true); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->checkbox('friendsync', + _('Subscribe to my Twitter friends here.'), + ($flink) ? + ($flink->friendsync & FOREIGN_FRIEND_RECV) : + false); + $this->elementEnd('li'); - $this->elementStart('fieldset', - array('id' => 'settings_twitter_preferences')); - $this->element('legend', null, _('Preferences')); - - $this->elementStart('ul', 'form_data'); - $this->elementStart('li'); - $this->checkbox('noticesend', - _('Automatically send my notices to Twitter.'), - ($flink) ? - ($flink->noticesync & FOREIGN_NOTICE_SEND) : - true); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->checkbox('replysync', - _('Send local "@" replies to Twitter.'), - ($flink) ? - ($flink->noticesync & FOREIGN_NOTICE_SEND_REPLY) : - true); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->checkbox('friendsync', - _('Subscribe to my Twitter friends here.'), - ($flink) ? - ($flink->friendsync & FOREIGN_FRIEND_RECV) : - false); - $this->elementEnd('li'); - - if (common_config('twitterbridge','enabled')) { + if (common_config('twitterbridge','enabled')) { $this->elementStart('li'); $this->checkbox('noticerecv', - _('Import my Friends Timeline.'), - ($flink) ? - ($flink->noticesync & FOREIGN_NOTICE_RECV) : - false); + _('Import my Friends Timeline.'), + ($flink) ? + ($flink->noticesync & FOREIGN_NOTICE_RECV) : + false); $this->elementEnd('li'); - } else { + // preserve setting even if bidrection bridge toggled off + if ($flink && ($flink->noticesync & FOREIGN_NOTICE_RECV)) { $this->hidden('noticerecv', true, 'noticerecv'); } @@ -181,11 +168,13 @@ class TwittersettingsAction extends ConnectSettingsAction } else { $this->submit('add', _('Add')); } + $this->elementEnd('fieldset'); + } - $this->showTwitterSubscriptions(); + $this->showTwitterSubscriptions(); - $this->elementEnd('form'); + $this->elementEnd('form'); } /** diff --git a/classes/Foreign_link.php b/classes/Foreign_link.php index c0b356ece..a3a159eb5 100644 --- a/classes/Foreign_link.php +++ b/classes/Foreign_link.php @@ -14,6 +14,7 @@ class Foreign_link extends Memcached_DataObject public $foreign_id; // bigint(8) primary_key not_null unsigned public $service; // int(4) primary_key not_null public $credentials; // varchar(255) + public $token; // varchar(255) public $noticesync; // tinyint(1) not_null default_1 public $friendsync; // tinyint(1) not_null default_2 public $profilesync; // tinyint(1) not_null default_1 diff --git a/classes/laconica.ini b/classes/laconica.ini index 766bed75d..85d5f528d 100644 --- a/classes/laconica.ini +++ b/classes/laconica.ini @@ -127,6 +127,7 @@ user_id = 129 foreign_id = 129 service = 129 credentials = 2 +token = 2 noticesync = 145 friendsync = 145 profilesync = 145 diff --git a/db/laconica.sql b/db/laconica.sql index 2c04f680a..8b1152cbd 100644 --- a/db/laconica.sql +++ b/db/laconica.sql @@ -291,7 +291,8 @@ create table foreign_link ( user_id int comment 'link to user on this system, if exists' references user (id), foreign_id bigint unsigned comment 'link to user on foreign service, if exists' references foreign_user(id), service int not null comment 'foreign key to service' references foreign_service(id), - credentials varchar(255) comment 'authc credentials, typically a password', + credentials varchar(255) comment 'auth credentials, typically a password or token secret', + token varchar(255) comment 'access token', noticesync tinyint not null default 1 comment 'notice synchronization, bit 1 = sync outgoing, bit 2 = sync incoming, bit 3 = filter local replies', friendsync tinyint not null default 2 comment 'friend synchronization, bit 1 = sync outgoing, bit 2 = sync incoming', profilesync tinyint not null default 1 comment 'profile synchronization, bit 1 = sync outgoing, bit 2 = sync incoming', diff --git a/lib/common.php b/lib/common.php index b3d301862..becd250b0 100644 --- a/lib/common.php +++ b/lib/common.php @@ -188,6 +188,9 @@ $config = 'integration' => array('source' => 'Laconica', # source attribute for Twitter 'taguri' => $_server.',2009'), # base for tag URIs + 'twitter' => + array('consumer_key' => null, + 'consumer_secret' => null), 'memcached' => array('enabled' => false, 'server' => 'localhost', diff --git a/lib/mail.php b/lib/mail.php index 0050ad810..16c1b0f30 100644 --- a/lib/mail.php +++ b/lib/mail.php @@ -645,13 +645,14 @@ function mail_twitter_bridge_removed($user) $subject = sprintf(_('Your Twitter bridge has been disabled.')); - $body = sprintf(_("Hi, %1\$s. We're sorry to inform you that your " . - 'link to Twitter has been disabled. Your Twitter credentials ' . - 'have either changed (did you recently change your Twitter ' . - 'password?) or you have otherwise revoked our access to your ' . - "Twitter account.\n\n" . - 'You can re-enable your Twitter bridge by visiting your ' . - "Twitter settings page:\n\n\t%2\$s\n\n" . + $site_name = common_config('site', 'name'); + + $body = sprintf(_('Hi, %1$s. We\'re sorry to inform you that your ' . + 'link to Twitter has been disabled. We no longer seem to have ' . + 'permission to update your Twitter status. (Did you revoke ' . + '%3$s\'s access?)' . "\n\n" . + 'You can re-enable your Twitter bridge by visiting your ' . + "Twitter settings page:\n\n\t%2\$s\n\n" . "Regards,\n%3\$s\n"), $profile->getBestName(), common_local_url('twittersettings'), @@ -679,11 +680,11 @@ function mail_facebook_app_removed($user) $site_name = common_config('site', 'name'); $subject = sprintf( - _('Your %1\$s Facebook application access has been disabled.', + _('Your %1$s Facebook application access has been disabled.', $site_name)); $body = sprintf(_("Hi, %1\$s. We're sorry to inform you that we are " . - 'unable to update your Facebook status from %2\$s, and have disabled ' . + 'unable to update your Facebook status from %2$s, and have disabled ' . 'the Facebook application for your account. This may be because ' . 'you have removed the Facebook application\'s authorization, or ' . 'have deleted your Facebook account. You can re-enable the ' . diff --git a/lib/router.php b/lib/router.php index 19839b997..6651773c0 100644 --- a/lib/router.php +++ b/lib/router.php @@ -88,6 +88,10 @@ class Router $m->connect('doc/:title', array('action' => 'doc')); + // Twitter + + $m->connect('twitter/authorization', array('action' => 'twitterauthorization')); + // facebook $m->connect('facebook', array('action' => 'facebookhome')); diff --git a/lib/twitter.php b/lib/twitter.php index 47af32e61..2369ac267 100644 --- a/lib/twitter.php +++ b/lib/twitter.php @@ -360,104 +360,72 @@ function is_twitter_bound($notice, $flink) { function broadcast_twitter($notice) { - $flink = Foreign_link::getByUserID($notice->profile_id, TWITTER_SERVICE); if (is_twitter_bound($notice, $flink)) { - $fuser = $flink->getForeignUser(); - $twitter_user = $fuser->nickname; - $twitter_password = $flink->credentials; - $uri = 'http://www.twitter.com/statuses/update.json'; + $user = $flink->getUser(); // XXX: Hack to get around PHP cURL's use of @ being a a meta character $statustxt = preg_replace('/^@/', ' @', $notice->content); - $options = array( - CURLOPT_USERPWD => "$twitter_user:$twitter_password", - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => - array( - 'status' => $statustxt, - 'source' => common_config('integration', 'source') - ), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FAILONERROR => true, - CURLOPT_HEADER => false, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_USERAGENT => "Laconica", - CURLOPT_CONNECTTIMEOUT => 120, // XXX: How long should this be? - CURLOPT_TIMEOUT => 120, - - # Twitter is strict about accepting invalid "Expect" headers - CURLOPT_HTTPHEADER => array('Expect:') - ); - - $ch = curl_init($uri); - curl_setopt_array($ch, $options); - $data = curl_exec($ch); - $errmsg = curl_error($ch); - $errno = curl_errno($ch); + $client = new TwitterOAuthClient($flink->token, $flink->credentials); - if (!empty($errmsg)) { - common_debug("cURL error ($errno): $errmsg - " . - "trying to send notice for $twitter_user.", - __FILE__); + $status = null; - $user = $flink->getUser(); + try { + $status = $client->statuses_update($statustxt); + } catch (OAuthClientCurlException $e) { - if ($errmsg == 'The requested URL returned error: 401') { - common_debug(sprintf('User %s (user id: %s) ' . - 'has bad Twitter credentials!', - $user->nickname, $user->id)); + if ($e->getMessage() == 'The requested URL returned error: 401') { - // Bad credentials we need to delete the foreign_link - // to Twitter and inform the user. + $errmsg = sprintf('User %1$s (user id: %2$s) has an invalid ' . + 'Twitter OAuth access token.', + $user->nickname, $user->id); + common_log(LOG_WARNING, $errmsg); - remove_twitter_link($flink); + // Bad auth token! We need to delete the foreign_link + // to Twitter and inform the user. - return true; + remove_twitter_link($flink); + return true; - } else { + } else { - // Some other error happened, so we should try to - // send again later + // Some other error happened, so we should probably + // try to send again later. - return false; - } + $errmsg = sprintf('cURL error trying to send notice to Twitter ' . + 'for user %1$s (user id: %2$s) - ' . + 'code: %3$s message: $4$s.', + $user->nickname, $user->id, + $e->getCode(), $e->getMessage()); + common_log(LOG_WARNING, $errmsg); + return false; } + } - curl_close($ch); - - if (empty($data)) { - common_debug("No data returned by Twitter's " . - "API trying to send update for $twitter_user", - __FILE__); + if (empty($status)) { - // XXX: Not sure this represents a failure to send, but it - // probably does + // This could represent a failure posting, + // or the Twitter API might just be behaving flakey. - return false; + $errmsg = sprint('No data returned by Twitter API when ' . + 'trying to send update for %1$s (user id %2$s).', + $user->nickname, $user->id); + common_log(LOG_WARNING, $errmsg); - } else { - - // Twitter should return a status - $status = json_decode($data); + return false; + } - if (empty($status)) { - common_debug("Unexpected data returned by Twitter " . - " API trying to send update for $twitter_user", - __FILE__); + // Notice crossed the great divide - // XXX: Again, this could represent a failure posting - // or the Twitter API might just be behaving flakey. - // We're treating it as a failure to post. + $msg = sprintf('Twitter bridge posted notice %s to Twitter.', + $notice->id); + common_log(LOG_INFO, $msg); - return false; - } - } } return true; @@ -480,17 +448,20 @@ function remove_twitter_link($flink) // Notify the user that her Twitter bridge is down + if (isset($user->email)) { + $result = mail_twitter_bridge_removed($user); if (!$result) { $msg = 'Unable to send email to notify ' . - "$user->nickname (user id: $user->id) " . - 'that their Twitter bridge link was ' . + "$user->nickname (user id: $user->id) " . + 'that their Twitter bridge link was ' . 'removed!'; common_log(LOG_WARNING, $msg); } + } } diff --git a/lib/twitteroauthclient.php b/lib/twitteroauthclient.php new file mode 100644 index 000000000..63ffe1c7c --- /dev/null +++ b/lib/twitteroauthclient.php @@ -0,0 +1,128 @@ +<?php + +require_once('OAuth.php'); + +class OAuthClientCurlException extends Exception { } + +class TwitterOAuthClient +{ + public static $requestTokenURL = 'https://twitter.com/oauth/request_token'; + public static $authorizeURL = 'https://twitter.com/oauth/authorize'; + public static $accessTokenURL = 'https://twitter.com/oauth/access_token'; + + function __construct($oauth_token = null, $oauth_token_secret = null) + { + $this->sha1_method = new OAuthSignatureMethod_HMAC_SHA1(); + $consumer_key = common_config('twitter', 'consumer_key'); + $consumer_secret = common_config('twitter', 'consumer_secret'); + $this->consumer = new OAuthConsumer($consumer_key, $consumer_secret); + $this->token = null; + + if (isset($oauth_token) && isset($oauth_token_secret)) { + $this->token = new OAuthToken($oauth_token, $oauth_token_secret); + } + } + + function getRequestToken() + { + $response = $this->oAuthGet(TwitterOAuthClient::$requestTokenURL); + parse_str($response); + $token = new OAuthToken($oauth_token, $oauth_token_secret); + return $token; + } + + function getAuthorizeLink($request_token) + { + // Not sure Twitter actually looks at oauth_callback + + return TwitterOAuthClient::$authorizeURL . + '?oauth_token=' . $request_token->key . '&oauth_callback=' . + urlencode(common_local_url('twitterauthorization')); + } + + function getAccessToken() + { + $response = $this->oAuthPost(TwitterOAuthClient::$accessTokenURL); + parse_str($response); + $token = new OAuthToken($oauth_token, $oauth_token_secret); + return $token; + } + + function verify_credentials() + { + $url = 'https://twitter.com/account/verify_credentials.json'; + $response = $this->oAuthGet($url); + $twitter_user = json_decode($response); + return $twitter_user; + } + + function statuses_update($status, $in_reply_to_status_id = null) + { + $url = 'https://twitter.com/statuses/update.json'; + $params = array('status' => $status, + 'in_reply_to_status_id' => $in_reply_to_status_id); + $response = $this->oAuthPost($url, $params); + $status = json_decode($response); + return $status; + } + + function oAuthGet($url) + { + $request = OAuthRequest::from_consumer_and_token($this->consumer, + $this->token, 'GET', $url, null); + $request->sign_request($this->sha1_method, + $this->consumer, $this->token); + + return $this->httpRequest($request->to_url()); + } + + function oAuthPost($url, $params = null) + { + $request = OAuthRequest::from_consumer_and_token($this->consumer, + $this->token, 'POST', $url, $params); + $request->sign_request($this->sha1_method, + $this->consumer, $this->token); + + return $this->httpRequest($request->get_normalized_http_url(), + $request->to_postdata()); + } + + function httpRequest($url, $params = null) + { + $options = array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FAILONERROR => true, + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => 'Laconica', + CURLOPT_CONNECTTIMEOUT => 120, + CURLOPT_TIMEOUT => 120, + CURLOPT_HTTPAUTH => CURLAUTH_ANY, + CURLOPT_SSL_VERIFYPEER => false, + + // Twitter is strict about accepting invalid "Expect" headers + + CURLOPT_HTTPHEADER => array('Expect:') + ); + + if (isset($params)) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = $params; + } + + $ch = curl_init($url); + curl_setopt_array($ch, $options); + $response = curl_exec($ch); + + if ($response === false) { + $msg = curl_error($ch); + $code = curl_errno($ch); + throw new OAuthClientCurlException($msg, $code); + } + + curl_close($ch); + + return $response; + } + +} |