diff --git a/actions/subscribe.php b/actions/subscribe.php index a90d7facdf..3745311b66 100644 --- a/actions/subscribe.php +++ b/actions/subscribe.php @@ -1,7 +1,9 @@ . + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Evan Prodromou + * @copyright 2008-2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ */ -if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Subscription action + * + * Subscribing to a profile. Does not work for OMB 0.1 remote subscriptions, + * but may work for other remote subscription protocols, like OStatus. + * + * Takes parameters: + * + * - subscribeto: a profile ID + * - token: session token to prevent CSRF attacks + * - ajax: boolean; whether to return Ajax or full-browser results + * + * Only works if the current user is logged in. + * + * @category Action + * @package StatusNet + * @author Evan Prodromou + * @copyright 2008-2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ class SubscribeAction extends Action { + var $user; + var $other; - function handle($args) + /** + * Check pre-requisites and instantiate attributes + * + * @param Array $args array of arguments (URL, GET, POST) + * + * @return boolean success flag + */ + + function prepare($args) { - parent::handle($args); + parent::prepare($args); - if (!common_logged_in()) { - $this->clientError(_('Not logged in.')); - return; - } - - $user = common_current_user(); + // Only allow POST requests if ($_SERVER['REQUEST_METHOD'] != 'POST') { - common_redirect(common_local_url('subscriptions', array('nickname' => $user->nickname))); - return; + $this->clientError(_('This action only accepts POST requests.')); + return false; } - # CSRF protection + // CSRF protection $token = $this->trimmed('token'); if (!$token || $token != common_session_token()) { - $this->clientError(_('There was a problem with your session token. Try again, please.')); - return; + $this->clientError(_('There was a problem with your session token.'. + ' Try again, please.')); + return false; } + // Only for logged-in users + + $this->user = common_current_user(); + + if (empty($this->user)) { + $this->clientError(_('Not logged in.')); + return false; + } + + // Profile to subscribe to + $other_id = $this->arg('subscribeto'); - $other = User::staticGet('id', $other_id); + $this->other = Profile::staticGet('id', $other_id); - if (!$other) { - $this->clientError(_('Not a local user.')); - return; + if (empty($this->other)) { + $this->clientError(_('No such profile.')); + return false; } - $result = subs_subscribe_to($user, $other); + // OMB 0.1 doesn't have a mechanism for local-server- + // originated subscription. - if (is_string($result)) { - $this->clientError($result); - return; + $omb01 = Remote_profile::staticGet('id', $other_id); + + if (!empty($omb01)) { + $this->clientError(_('You cannot subscribe to an OMB 0.1'. + ' remote profile with this action.')); + return false; } + return true; + } + + /** + * Handle request + * + * Does the subscription and returns results. + * + * @param Array $args unused. + * + * @return void + */ + + function handle($args) + { + // Throws exception on error + + Subscription::start($this->user->getProfile(), + $this->other); + if ($this->boolean('ajax')) { $this->startHTML('text/xml;charset=utf-8'); $this->elementStart('head'); $this->element('title', null, _('Subscribed')); $this->elementEnd('head'); $this->elementStart('body'); - $unsubscribe = new UnsubscribeForm($this, $other->getProfile()); + $unsubscribe = new UnsubscribeForm($this, $this->other->getProfile()); $unsubscribe->show(); $this->elementEnd('body'); $this->elementEnd('html'); } else { - common_redirect(common_local_url('subscriptions', array('nickname' => - $user->nickname)), - 303); + $url = common_local_url('subscriptions', + array('nickname' => $this->user->nickname)); + common_redirect($url, 303); } } } diff --git a/classes/Memcached_DataObject.php b/classes/Memcached_DataObject.php index 40576dc717..bc4c3a000c 100644 --- a/classes/Memcached_DataObject.php +++ b/classes/Memcached_DataObject.php @@ -501,7 +501,11 @@ class Memcached_DataObject extends Safe_DataObject function raiseError($message, $type = null, $behaviour = null) { - throw new ServerException("DB_DataObject error [$type]: $message"); + $id = get_class($this); + if ($this->id) { + $id .= ':' . $this->id; + } + throw new ServerException("[$id] DB_DataObject error [$type]: $message"); } static function cacheGet($keyPart) diff --git a/lib/activity.php b/lib/activity.php index 3689dac385..853741a9a1 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -104,6 +104,7 @@ class PoCo function __construct($profile) { $this->preferredUsername = $profile->nickname; + $this->displayName = $profile->getBestName(); $this->note = $profile->bio; $this->address = new PoCoAddress($profile->location); @@ -129,6 +130,12 @@ class PoCo $this->preferredUsername ); + $xs->element( + 'poco:displayName', + null, + $this->displayName + ); + if (!empty($this->note)) { $xs->element('poco:note', null, $this->note); } @@ -823,7 +830,9 @@ class Activity if ($namespace) { $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom', 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', - 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0'); + 'xmlns:georss' => 'http://www.georss.org/georss', + 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0', + 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0'); } else { $attrs = array(); } diff --git a/lib/noticelist.php b/lib/noticelist.php index 837cb90faa..dcf17be08c 100644 --- a/lib/noticelist.php +++ b/lib/noticelist.php @@ -438,14 +438,15 @@ class NoticeListItem extends Widget $this->out->text(_('at')); $this->out->text(' '); if (empty($url)) { - $this->out->element('span', array('class' => 'geo', + $this->out->element('abbr', array('class' => 'geo', 'title' => $latlon), $name); } else { - $this->out->element('a', array('class' => 'geo', - 'title' => $latlon, - 'href' => $url), + $this->out->elementStart('a', array('href' => $url)); + $this->out->element('abbr', array('class' => 'geo', + 'title' => $latlon), $name); + $this->out->elementEnd('a'); } $this->out->elementEnd('span'); } diff --git a/plugins/OStatus/OStatusPlugin.php b/plugins/OStatus/OStatusPlugin.php index 472008419a..35f9529355 100644 --- a/plugins/OStatus/OStatusPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -224,7 +224,7 @@ class OStatusPlugin extends Plugin * */ - function onEndFindMentions($sender, $text, &$mentions) + function onStartFindMentions($sender, $text, &$mentions) { preg_match_all('/(?:^|\s+)@((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)/', $text, @@ -251,58 +251,6 @@ class OStatusPlugin extends Plugin return true; } - /** - * Notify remote server and garbage collect unused feeds on unsubscribe. - * @fixme send these operations to background queues - * - * @param User $user - * @param Profile $other - * @return hook return value - */ - function onEndUnsubscribe($profile, $other) - { - $user = User::staticGet('id', $profile->id); - - if (empty($user)) { - return true; - } - - $oprofile = Ostatus_profile::staticGet('profile_id', $other->id); - - if (empty($oprofile)) { - return true; - } - - // Drop the PuSH subscription if there are no other subscribers. - - if ($other->subscriberCount() == 0) { - common_log(LOG_INFO, "Unsubscribing from now-unused feed $oprofile->feeduri"); - $oprofile->unsubscribe(); - } - - $act = new Activity(); - - $act->verb = ActivityVerb::UNFOLLOW; - - $act->id = TagURI::mint('unfollow:%d:%d:%s', - $profile->id, - $other->id, - common_date_iso8601(time())); - - $act->time = time(); - $act->title = _("Unfollow"); - $act->content = sprintf(_("%s stopped following %s."), - $profile->getBestName(), - $other->getBestName()); - - $act->actor = ActivityObject::fromProfile($profile); - $act->object = ActivityObject::fromProfile($other); - - $oprofile->notifyActivity($act); - - return true; - } - /** * Make sure necessary tables are filled out. */ @@ -312,6 +260,7 @@ class OStatusPlugin extends Plugin $schema->ensureTable('ostatus_source', Ostatus_source::schemaDef()); $schema->ensureTable('feedsub', FeedSub::schemaDef()); $schema->ensureTable('hubsub', HubSub::schemaDef()); + $schema->ensureTable('magicsig', Magicsig::schemaDef()); return true; } @@ -338,13 +287,19 @@ class OStatusPlugin extends Plugin function onStartNoticeSourceLink($notice, &$name, &$url, &$title) { if ($notice->source == 'ostatus') { - $bits = parse_url($notice->uri); - $domain = $bits['host']; + if ($notice->url) { + $bits = parse_url($notice->url); + $domain = $bits['host']; + if (substr($domain, 0, 4) == 'www.') { + $name = substr($domain, 4); + } else { + $name = $domain; + } - $name = $domain; - $url = $notice->uri; - $title = sprintf(_m("Sent from %s via OStatus"), $domain); - return false; + $url = $notice->url; + $title = sprintf(_m("Sent from %s via OStatus"), $domain); + return false; + } } } @@ -359,12 +314,56 @@ class OStatusPlugin extends Plugin { $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri); if ($oprofile) { - $oprofile->processFeed($feed); + $oprofile->processFeed($feed, 'push'); } else { common_log(LOG_DEBUG, "No ostatus profile for incoming feed $feedsub->uri"); } } + /** + * When about to subscribe to a remote user, start a server-to-server + * PuSH subscription if needed. If we can't establish that, abort. + * + * @fixme If something else aborts later, we could end up with a stray + * PuSH subscription. This is relatively harmless, though. + * + * @param Profile $subscriber + * @param Profile $other + * + * @return hook return code + * + * @throws Exception + */ + function onStartSubscribe($subscriber, $other) + { + $user = User::staticGet('id', $subscriber->id); + + if (empty($user)) { + return true; + } + + $oprofile = Ostatus_profile::staticGet('profile_id', $other->id); + + if (empty($oprofile)) { + return true; + } + + if (!$oprofile->subscribe()) { + throw new Exception(_m('Could not set up remote subscription.')); + } + } + + /** + * Having established a remote subscription, send a notification to the + * remote OStatus profile's endpoint. + * + * @param Profile $subscriber + * @param Profile $other + * + * @return hook return code + * + * @throws Exception + */ function onEndSubscribe($subscriber, $other) { $user = User::staticGet('id', $subscriber->id); @@ -402,6 +401,54 @@ class OStatusPlugin extends Plugin return true; } + /** + * Notify remote server and garbage collect unused feeds on unsubscribe. + * @fixme send these operations to background queues + * + * @param User $user + * @param Profile $other + * @return hook return value + */ + function onEndUnsubscribe($profile, $other) + { + $user = User::staticGet('id', $profile->id); + + if (empty($user)) { + return true; + } + + $oprofile = Ostatus_profile::staticGet('profile_id', $other->id); + + if (empty($oprofile)) { + return true; + } + + // Drop the PuSH subscription if there are no other subscribers. + $oprofile->garbageCollect(); + + $act = new Activity(); + + $act->verb = ActivityVerb::UNFOLLOW; + + $act->id = TagURI::mint('unfollow:%d:%d:%s', + $profile->id, + $other->id, + common_date_iso8601(time())); + + $act->time = time(); + $act->title = _("Unfollow"); + $act->content = sprintf(_("%s stopped following %s."), + $profile->getBestName(), + $other->getBestName()); + + $act->actor = ActivityObject::fromProfile($profile); + $act->object = ActivityObject::fromProfile($other); + + $oprofile->notifyActivity($act); + + return true; + } + /** * When one of our local users tries to join a remote group, * notify the remote server. If the notification is rejected, @@ -417,6 +464,10 @@ class OStatusPlugin extends Plugin { $oprofile = Ostatus_profile::staticGet('group_id', $group->id); if ($oprofile) { + if (!$oprofile->subscribe()) { + throw new Exception(_m('Could not set up remote group membership.')); + } + $member = Profile::staticGet($user->id); $act = new Activity(); @@ -438,7 +489,8 @@ class OStatusPlugin extends Plugin if ($oprofile->notifyActivity($act)) { return true; } else { - throw new ServerException(_m("Failed joining remote group.")); + $oprofile->garbageCollect(); + throw new Exception(_m("Failed joining remote group.")); } } } @@ -463,12 +515,7 @@ class OStatusPlugin extends Plugin $oprofile = Ostatus_profile::staticGet('group_id', $group->id); if ($oprofile) { // Drop the PuSH subscription if there are no other subscribers. - - $members = $group->getMembers(0, 1); - if ($members->N == 0) { - common_log(LOG_INFO, "Unsubscribing from now-unused group feed $oprofile->feeduri"); - $oprofile->unsubscribe(); - } + $oprofile->garbageCollect(); $member = Profile::staticGet($user->id); diff --git a/plugins/OStatus/actions/ostatussub.php b/plugins/OStatus/actions/ostatussub.php index ffa88cb088..12832cdcfb 100644 --- a/plugins/OStatus/actions/ostatussub.php +++ b/plugins/OStatus/actions/ostatussub.php @@ -87,53 +87,168 @@ class OStatusSubAction extends Action */ function showPreviewForm() { - $this->elementStart('form', array('method' => 'post', - 'id' => 'form_ostatus_sub', - 'class' => 'form_settings', - 'action' => - common_local_url('ostatussub'))); - - $this->hidden('token', common_session_token()); - $this->hidden('profile', $this->profile_uri); - - $this->elementStart('fieldset', array('id' => 'settings_feeds')); - if ($this->oprofile->isGroup()) { - $this->previewGroup(); - $this->submit('subscribe', _m('Join')); + $ok = $this->previewGroup(); } else { - $this->previewUser(); - $this->submit('subscribe', _m('Subscribe')); + $ok = $this->previewUser(); + } + if (!$ok) { + // @fixme maybe provide a cancel button or link back? + return; } - + $this->elementStart('div', 'entity_actions'); + $this->elementStart('ul'); + $this->elementStart('li', 'entity_subscribe'); + $this->elementStart('form', array('method' => 'post', + 'id' => 'form_ostatus_sub', + 'class' => 'form_remote_authorize', + 'action' => + common_local_url('ostatussub'))); + $this->elementStart('fieldset'); + $this->hidden('token', common_session_token()); + $this->hidden('profile', $this->profile_uri); + if ($this->oprofile->isGroup()) { + $this->submit('submit', _m('Join'), 'submit', null, + _m('Join this group')); + } else { + $this->submit('submit', _m('Subscribe'), 'submit', null, + _m('Subscribe to this user')); + } $this->elementEnd('fieldset'); - $this->elementEnd('form'); + $this->elementEnd('li'); + $this->elementEnd('ul'); + $this->elementEnd('div'); } /** * Show a preview for a remote user's profile + * @return boolean true if we're ok to try subscribing */ function previewUser() { $oprofile = $this->oprofile; $profile = $oprofile->localProfile(); - $this->text(sprintf(_m("Remote user %s"), $profile->nickname)); - // ... + $cur = common_current_user(); + if ($cur->isSubscribed($profile)) { + $this->element('div', array('class' => 'error'), + _m("You are already subscribed to this user.")); + $ok = false; + } else { + $ok = true; + } + + $avatar = $profile->getAvatar(AVATAR_PROFILE_SIZE); + $avatarUrl = $avatar ? $avatar->displayUrl() : false; + + $this->showEntity($profile, + $profile->profileurl, + $avatarUrl, + $profile->bio); + return $ok; } /** * Show a preview for a remote group's profile + * @return boolean true if we're ok to try joining */ function previewGroup() { $oprofile = $this->oprofile; $group = $oprofile->localGroup(); - $this->text(sprintf(_m("Remote group %s"), $group->nickname)); - // .. + $cur = common_current_user(); + if ($cur->isMember($group)) { + $this->element('div', array('class' => 'error'), + _m("You are already a member of this group.")); + $ok = false; + } else { + $ok = true; + } + + $this->showEntity($group, + $group->getProfileUrl(), + $group->homepage_logo, + $group->description); + return $ok; + } + + + function showEntity($entity, $profile, $avatar, $note) + { + $nickname = $entity->nickname; + $fullname = $entity->fullname; + $homepage = $entity->homepage; + $location = $entity->location; + + if (!$avatar) { + $avatar = Avatar::defaultImage(AVATAR_PROFILE_SIZE); + } + + $this->elementStart('div', 'entity_profile vcard'); + $this->elementStart('dl', 'entity_depiction'); + $this->element('dt', null, _('Photo')); + $this->elementStart('dd'); + $this->element('img', array('src' => $avatar, + 'class' => 'photo avatar', + 'width' => AVATAR_PROFILE_SIZE, + 'height' => AVATAR_PROFILE_SIZE, + 'alt' => $nickname)); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + + $this->elementStart('dl', 'entity_nickname'); + $this->element('dt', null, _('Nickname')); + $this->elementStart('dd'); + $hasFN = ($fullname !== '') ? 'nickname' : 'fn nickname'; + $this->elementStart('a', array('href' => $profile, + 'class' => 'url '.$hasFN)); + $this->raw($nickname); + $this->elementEnd('a'); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + + if (!is_null($fullname)) { + $this->elementStart('dl', 'entity_fn'); + $this->elementStart('dd'); + $this->elementStart('span', 'fn'); + $this->raw($fullname); + $this->elementEnd('span'); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + } + if (!is_null($location)) { + $this->elementStart('dl', 'entity_location'); + $this->element('dt', null, _('Location')); + $this->elementStart('dd', 'label'); + $this->raw($location); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + } + + if (!is_null($homepage)) { + $this->elementStart('dl', 'entity_url'); + $this->element('dt', null, _('URL')); + $this->elementStart('dd'); + $this->elementStart('a', array('href' => $homepage, + 'class' => 'url')); + $this->raw($homepage); + $this->elementEnd('a'); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + } + + if (!is_null($note)) { + $this->elementStart('dl', 'entity_note'); + $this->element('dt', null, _('Note')); + $this->elementStart('dd', 'note'); + $this->raw($note); + $this->elementEnd('dd'); + $this->elementEnd('dl'); + } + $this->elementEnd('div'); } /** @@ -173,10 +288,15 @@ class OStatusSubAction extends Action } $this->profile_uri = $profile_uri; - // @fixme validate, normalize bla bla try { - $oprofile = Ostatus_profile::ensureProfile($this->profile_uri); - $this->oprofile = $oprofile; + if (Validate::email($this->profile_uri)) { + $this->oprofile = Ostatus_profile::ensureWebfinger($this->profile_uri); + } else if (Validate::uri($this->profile_uri)) { + $this->oprofile = Ostatus_profile::ensureProfile($this->profile_uri); + } else { + $this->error = _m("Invalid address format."); + return false; + } return true; } catch (FeedSubBadURLException $e) { $this->error = _m('Invalid URL or could not reach server.'); @@ -209,11 +329,6 @@ class OStatusSubAction extends Action // And subscribe the current user to the local profile $user = common_current_user(); - if (!$this->oprofile->subscribe()) { - $this->showForm(_m("Failed to set up server-to-server subscription.")); - return; - } - if ($this->oprofile->isGroup()) { $group = $this->oprofile->localGroup(); if ($user->isMember($group)) { @@ -287,7 +402,7 @@ class OStatusSubAction extends Action } if ($this->validateFeed()) { - if ($this->arg('subscribe')) { + if ($this->arg('submit')) { $this->saveFeed(); return; } @@ -343,7 +458,7 @@ class OStatusSubAction extends Action function showPageNotice() { - if ($this->error) { + if (!empty($this->error)) { $this->element('p', 'error', $this->error); } } diff --git a/plugins/OStatus/actions/webfinger.php b/plugins/OStatus/actions/webfinger.php index cf60b8069d..34336a9039 100644 --- a/plugins/OStatus/actions/webfinger.php +++ b/plugins/OStatus/actions/webfinger.php @@ -65,12 +65,38 @@ class WebfingerAction extends Action 'format' => 'atom')), 'type' => 'application/atom+xml'); + // hCard + $xrd->links[] = array('rel' => 'http://microformats.org/profile/hcard', + 'type' => 'text/html', + 'href' => common_profile_url($nick)); + + // XFN + $xrd->links[] = array('rel' => 'http://gmpg.org/xfn/11', + 'type' => 'text/html', + 'href' => common_profile_url($nick)); + // FOAF + $xrd->links[] = array('rel' => 'describedby', + 'type' => 'application/rdf+xml', + 'href' => common_local_url('foaf', + array('nickname' => $nick))); + $salmon_url = common_local_url('salmon', array('id' => $this->user->id)); $xrd->links[] = array('rel' => 'salmon', 'href' => $salmon_url); + // Get this user's keypair + $magickey = Magicsig::staticGet('user_id', $this->user->id); + if (!$magickey) { + // No keypair yet, let's generate one. + $magickey = new Magicsig(); + $magickey->generate(); + } + + $xrd->links[] = array('rel' => Magicsig::PUBLICKEYREL, + 'href' => 'data:application/magic-public-key;'. $magickey->keypair); + // TODO - finalize where the redirect should go on the publisher $url = common_local_url('ostatussub') . '?profile={uri}'; $xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe', diff --git a/plugins/OStatus/lib/magicsig.php b/plugins/OStatus/classes/Magicsig.php similarity index 60% rename from plugins/OStatus/lib/magicsig.php rename to plugins/OStatus/classes/Magicsig.php index 50eb301ab3..681aec184d 100644 --- a/plugins/OStatus/lib/magicsig.php +++ b/plugins/OStatus/classes/Magicsig.php @@ -29,45 +29,86 @@ require_once 'Crypt/RSA.php'; -interface Magicsig +class Magicsig extends Memcached_DataObject { - public function sign($bytes); - - public function verify($signed, $signature_b64); -} - -class MagicsigRsaSha256 -{ - - public $keypair; + const PUBLICKEYREL = 'magic-public-key'; - public function __construct($init = null) + public $__table = 'magicsig'; + + public $user_id; + public $keypair; + public $alg; + + private $_rsa; + + public function __construct($alg = 'RSA-SHA256') { - if (is_null($init)) { - $this->generate(); - } else { - $this->fromString($init); - } + $this->alg = $alg; + } + + public /*static*/ function staticGet($k, $v=null) + { + return parent::staticGet(__CLASS__, $k, $v); } + function table() + { + return array( + 'user_id' => DB_DATAOBJECT_INT, + 'keypair' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'alg' => DB_DATAOBJECT_STR + ); + } + + static function schemaDef() + { + return array(new ColumnDef('user_id', 'integer', + null, true, 'PRI'), + new ColumnDef('keypair', 'varchar', + 255, false), + new ColumnDef('alg', 'varchar', + 64, false)); + } + + + function keys() + { + return array_keys($this->keyTypes()); + } + + function keyTypes() + { + return array('user_id' => 'K'); + } + + function insert() + { + $this->keypair = $this->toString(); + + return parent::insert(); + } + public function generate($key_length = 512) { + PEAR::pushErrorHandling(PEAR_ERROR_RETURN); + $keypair = new Crypt_RSA_KeyPair($key_length); $params['public_key'] = $keypair->getPublicKey(); $params['private_key'] = $keypair->getPrivateKey(); - PEAR::pushErrorHandling(PEAR_ERROR_RETURN); - $this->keypair = new Crypt_RSA($params); + $this->_rsa = new Crypt_RSA($params); PEAR::popErrorHandling(); + + $this->insert(); } public function toString($full_pair = true) { - $public_key = $this->keypair->_public_key; - $private_key = $this->keypair->_private_key; + $public_key = $this->_rsa->_public_key; + $private_key = $this->_rsa->_private_key; $mod = base64_url_encode($public_key->getModulus()); $exp = base64_url_encode($public_key->getExponent()); @@ -79,10 +120,12 @@ class MagicsigRsaSha256 return 'RSA.' . $mod . '.' . $exp . $private_exp; } - public function fromString($text) + public static function fromString($text) { PEAR::pushErrorHandling(PEAR_ERROR_RETURN); + $magic_sig = new Magicsig(); + // remove whitespace $text = preg_replace('/\s+/', '', $text); @@ -100,33 +143,46 @@ class MagicsigRsaSha256 $params['public_key'] = new Crypt_RSA_KEY($mod, $exp, 'public'); if ($params['public_key']->isError()) { $error = $params['public_key']->getLastError(); - print $error->getMessage(); - exit; + common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); + return false; } if ($private_exp) { $params['private_key'] = new Crypt_RSA_KEY($mod, $private_exp, 'private'); if ($params['private_key']->isError()) { $error = $params['private_key']->getLastError(); - print $error->getMessage(); - exit; + common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); + return false; } } - $this->keypair = new Crypt_RSA($params); + $magic_sig->_rsa = new Crypt_RSA($params); PEAR::popErrorHandling(); + + return $magic_sig; } public function getName() { - return 'RSA-SHA256'; + return $this->alg; } + public function getHash() + { + switch ($this->alg) { + + case 'RSA-SHA256': + return 'sha256'; + } + + } + public function sign($bytes) { - $sig = $this->keypair->createSign($bytes, null, 'sha256'); - if ($this->keypair->isError()) { - $error = $this->keypair->getLastError(); + $sig = $this->_rsa->createSign($bytes, null, 'sha256'); + if ($this->_rsa->isError()) { + $error = $this->_rsa->getLastError(); common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); + return false; } return $sig; @@ -134,11 +190,11 @@ class MagicsigRsaSha256 public function verify($signed_bytes, $signature) { - $result = $this->keypair->validateSign($signed_bytes, $signature, null, 'sha256'); - if ($this->keypair->isError()) { + $result = $this->_rsa->validateSign($signed_bytes, $signature, null, 'sha256'); + if ($this->_rsa->isError()) { $error = $this->keypair->getLastError(); - //common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); - print $error->getMessage(); + common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); + return false; } return $result; } diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index e8cc13c6c1..6beaf0f5d0 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -346,6 +346,29 @@ class Ostatus_profile extends Memcached_DataObject } } + /** + * Check if this remote profile has any active local subscriptions, and + * if not drop the PuSH subscription feed. + * + * @return boolean + */ + public function garbageCollect() + { + if ($this->isGroup()) { + $members = $this->localGroup()->getMembers(0, 1); + $count = $members->N; + } else { + $count = $this->localProfile()->subscriberCount(); + } + if ($count == 0) { + common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $oprofile->feeduri"); + $this->unsubscribe(); + return true; + } else { + return false; + } + } + /** * Send an Activity Streams notification to the remote Salmon endpoint, * if so configured. @@ -379,7 +402,8 @@ class Ostatus_profile extends Memcached_DataObject 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0', 'xmlns:georss' => 'http://www.georss.org/georss', - 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0'); + 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0', + 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0'); $entry = new XMLStringer(); $entry->elementStart('entry', $attributes); @@ -464,7 +488,7 @@ class Ostatus_profile extends Memcached_DataObject * * @param DOMDocument $feed */ - public function processFeed($feed) + public function processFeed($feed, $source) { $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); if ($entries->length == 0) { @@ -474,7 +498,7 @@ class Ostatus_profile extends Memcached_DataObject for ($i = 0; $i < $entries->length; $i++) { $entry = $entries->item($i); - $this->processEntry($entry, $feed); + $this->processEntry($entry, $feed, $source); } } @@ -484,15 +508,12 @@ class Ostatus_profile extends Memcached_DataObject * @param DOMElement $entry * @param DOMElement $feed for context */ - protected function processEntry($entry, $feed) + public function processEntry($entry, $feed, $source) { $activity = new Activity($entry, $feed); - $debug = var_export($activity, true); - common_log(LOG_DEBUG, $debug); - if ($activity->verb == ActivityVerb::POST) { - $this->processPost($activity); + $this->processPost($activity, $source); } else { common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb"); } @@ -501,130 +522,176 @@ class Ostatus_profile extends Memcached_DataObject /** * Process an incoming post activity from this remote feed. * @param Activity $activity + * @param string $method 'push' or 'salmon' + * @return mixed saved Notice or false * @fixme break up this function, it's getting nasty long */ - protected function processPost($activity) + public function processPost($activity, $method) { if ($this->isGroup()) { + // A group feed will contain posts from multiple authors. // @fixme validate these profiles in some way! $oprofile = self::ensureActorProfile($activity); + if ($oprofile->isGroup()) { + // Groups can't post notices in StatusNet. + common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri"); + return false; + } } else { + // Individual user feeds may contain only posts from themselves. + // Authorship is validated against the profile URI on upper layers, + // through PuSH setup or Salmon signature checks. $actorUri = self::getActorProfileURI($activity); if ($actorUri == $this->uri) { // @fixme check if profile info has changed and update it } else { - // @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely - common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->uri"); - //return; + common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri"); + return false; } $oprofile = $this; } + + // The id URI will be used as a unique identifier for for the notice, + // protecting against duplicate saves. It isn't required to be a URL; + // tag: URIs for instance are found in Google Buzz feeds. $sourceUri = $activity->object->id; - $dupe = Notice::staticGet('uri', $sourceUri); - if ($dupe) { common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri"); - return; + return false; } + // We'll also want to save a web link to the original notice, if provided. $sourceUrl = null; - if ($activity->object->link) { $sourceUrl = $activity->object->link; + } else if ($activity->link) { + $sourceUrl = $activity->link; } else if (preg_match('!^https?://!', $activity->object->id)) { $sourceUrl = $activity->object->id; } - // @fixme sanitize and save HTML content if available + // Get (safe!) HTML and text versions of the content + $rendered = $this->purify($activity->object->content); + $content = html_entity_decode(strip_tags($rendered)); - $content = $activity->object->title; - - $params = array('is_local' => Notice::REMOTE_OMB, + $options = array('is_local' => Notice::REMOTE_OMB, 'url' => $sourceUrl, - 'uri' => $sourceUri); + 'uri' => $sourceUri, + 'rendered' => $rendered, + 'replies' => array(), + 'groups' => array()); - $location = $activity->context->location; + // Check for optional attributes... - if ($location) { - $params['lat'] = $location->lat; - $params['lon'] = $location->lon; - if ($location->location_id) { - $params['location_ns'] = $location->location_ns; - $params['location_id'] = $location->location_id; - } + if (!empty($activity->time)) { + $options['created'] = common_sql_date($activity->time); } - $profile = $oprofile->localProfile(); - $params['groups'] = array(); - $params['replies'] = array(); if ($activity->context) { - foreach ($activity->context->attention as $recipient) { - $roprofile = Ostatus_profile::staticGet('uri', $recipient); - if ($roprofile) { - if ($roprofile->isGroup()) { - // Deliver to local recipients of this remote group. - // @fixme sender verification? - $params['groups'][] = $roprofile->group_id; - continue; - } else { - // Delivery to remote users is the source service's job. - continue; - } + // Any individual or group attn: targets? + $replies = $activity->context->attention; + $options['groups'] = $this->filterReplies($oprofile, $replies); + $options['replies'] = $replies; + + // Maintain direct reply associations + // @fixme what about conversation ID? + if (!empty($activity->context->replyToID)) { + $orig = Notice::staticGet('uri', + $activity->context->replyToID); + if (!empty($orig)) { + $options['reply_to'] = $orig->id; } - - $user = User::staticGet('uri', $recipient); - if ($user) { - // An @-reply directed to a local user. - // @fixme sender verification, spam etc? - $params['replies'][] = $recipient; - continue; - } - - // @fixme we need a uri on user_group - // $group = User_group::staticGet('uri', $recipient); - $template = common_local_url('groupbyid', array('id' => '31337')); - $template = preg_quote($template, '/'); - $template = str_replace('31337', '(\d+)', $template); - common_log(LOG_DEBUG, $template); - if (preg_match("/$template/", $recipient, $matches)) { - $id = $matches[1]; - $group = User_group::staticGet('id', $id); - if ($group) { - // Deliver to all members of this local group. - // @fixme sender verification? - if ($profile->isMember($group)) { - common_log(LOG_DEBUG, "delivering to group $id $group->nickname"); - $params['groups'][] = $group->id; - } else { - common_log(LOG_DEBUG, "not delivering to group $id $group->nickname because sender $profile->nickname is not a member"); - } - continue; - } else { - common_log(LOG_DEBUG, "not delivering to missing group $id"); - } - } else { - common_log(LOG_DEBUG, "not delivering to groups for $recipient"); + } + + $location = $activity->context->location; + if ($location) { + $options['lat'] = $location->lat; + $options['lon'] = $location->lon; + if ($location->location_id) { + $options['location_ns'] = $location->location_ns; + $options['location_id'] = $location->location_id; } } } try { - $saved = Notice::saveNew($profile->id, + $saved = Notice::saveNew($oprofile->profile_id, $content, 'ostatus', - $params); + $options); + if ($saved) { + Ostatus_source::saveNew($saved, $this, $method); + } } catch (Exception $e) { - common_log(LOG_ERR, "Failed saving notice entry for $sourceUri: " . $e->getMessage()); - return; + common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage()); + throw $e; } + common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id"); + return $saved; + } - // Record which feed this came through... - try { - Ostatus_source::saveNew($saved, $this, 'push'); - } catch (Exception $e) { - common_log(LOG_ERR, "Failed saving ostatus_source entry for $saved->notice_id: " . $e->getMessage()); + /** + * Clean up HTML + */ + protected function purify($html) + { + // @fixme disable caching or set a sane temp dir + require_once(INSTALLDIR.'/extlib/HTMLPurifier/HTMLPurifier.auto.php'); + $purifier = new HTMLPurifier(); + return $purifier->purify($html); + } + + /** + * Filters a list of recipient ID URIs to just those for local delivery. + * @param Ostatus_profile local profile of sender + * @param array in/out &$attention_uris set of URIs, will be pruned on output + * @return array of group IDs + */ + protected function filterReplies($sender, &$attention_uris) + { + $groups = array(); + $replies = array(); + foreach ($attention_uris as $recipient) { + // Is the recipient a local user? + $user = User::staticGet('uri', $recipient); + if ($user) { + // @fixme sender verification, spam etc? + $replies[] = $recipient; + continue; + } + + // Is the recipient a remote group? + $oprofile = Ostatus_profile::staticGet('uri', $recipient); + if ($oprofile) { + if ($oprofile->isGroup()) { + // Deliver to local members of this remote group. + // @fixme sender verification? + $groups[] = $oprofile->group_id; + } + continue; + } + + // Is the recipient a local group? + // @fixme we need a uri on user_group + // $group = User_group::staticGet('uri', $recipient); + $template = common_local_url('groupbyid', array('id' => '31337')); + $template = preg_quote($template, '/'); + $template = str_replace('31337', '(\d+)', $template); + if (preg_match("/$template/", $recipient, $matches)) { + $id = $matches[1]; + $group = User_group::staticGet('id', $id); + if ($group) { + // Deliver to all members of this local group if allowed. + if ($sender->localProfile()->isMember($group)) { + $groups[] = $group->id; + } + continue; + } + } } + $attention_uris = $replies; + return $groups; } /** diff --git a/plugins/OStatus/lib/magicenvelope.php b/plugins/OStatus/lib/magicenvelope.php index 1ae80d70c1..81f4609c5c 100644 --- a/plugins/OStatus/lib/magicenvelope.php +++ b/plugins/OStatus/lib/magicenvelope.php @@ -27,8 +27,6 @@ * @link http://status.net/ */ -require_once 'magicsig.php'; - class MagicEnvelope { const ENCODING = 'base64url'; @@ -64,7 +62,7 @@ class MagicEnvelope return false; } - $signature_alg = new MagicsigRsaSha256($this->getKeyPair($signer_uri)); + $signature_alg = Magicsig::fromString($this->getKeyPair($signer_uri)); $armored_text = base64_encode($text); return array( @@ -139,7 +137,7 @@ class MagicEnvelope $text = base64_decode($env['data']); $signer_uri = $this->getAuthor($text); - $verifier = new MagicsigRsaSha256($this->getKeyPair($signer_uri)); + $verifier = Magicsig::fromString($this->getKeyPair($signer_uri)); return $verifier->verify($env['data'], $env['sig']); } diff --git a/plugins/OStatus/lib/salmonaction.php b/plugins/OStatus/lib/salmonaction.php index 83cf0b8f8a..9aac2ed52f 100644 --- a/plugins/OStatus/lib/salmonaction.php +++ b/plugins/OStatus/lib/salmonaction.php @@ -185,54 +185,6 @@ class SalmonAction extends Action function saveNotice() { $oprofile = $this->ensureProfile(); - - // Get (safe!) HTML and text versions of the content - - require_once(INSTALLDIR.'/extlib/HTMLPurifier/HTMLPurifier.auto.php'); - - $html = $this->act->object->content; - - $purifier = new HTMLPurifier(); - - $rendered = $purifier->purify($html); - - $content = html_entity_decode(strip_tags($rendered)); - - $options = array('is_local' => Notice::REMOTE_OMB, - 'uri' => $this->act->object->id, - 'url' => $this->act->object->link, - 'rendered' => $rendered, - 'replies' => $this->act->context->attention); - - if (!empty($this->act->context->location)) { - $options['lat'] = $location->lat; - $options['lon'] = $location->lon; - if ($location->location_id) { - $options['location_ns'] = $location->location_ns; - $options['location_id'] = $location->location_id; - } - } - - if (!empty($this->act->context->replyToID)) { - $orig = Notice::staticGet('uri', - $this->act->context->replyToID); - if (!empty($orig)) { - $options['reply_to'] = $orig->id; - } - } - - if (!empty($this->act->time)) { - $options['created'] = common_sql_date($this->act->time); - } - - $saved = Notice::saveNew($oprofile->profile_id, - $content, - 'ostatus+salmon', - $options); - - // Record that this was saved through a validated Salmon source - // @fixme actually do the signature validation! - Ostatus_source::saveNew($saved, $oprofile, 'salmon'); - return $saved; + return $oprofile->processPost($this->act, 'salmon'); } } diff --git a/plugins/OStatus/lib/webfinger.php b/plugins/OStatus/lib/webfinger.php index 0386881d14..8a50376294 100644 --- a/plugins/OStatus/lib/webfinger.php +++ b/plugins/OStatus/lib/webfinger.php @@ -108,6 +108,10 @@ class Webfinger $content = $this->fetchURL($url); + if (!$content) { + return false; + } + return XRD::parse($content); } diff --git a/theme/default/css/display.css b/theme/default/css/display.css index 71470d55d2..deb985909e 100644 --- a/theme/default/css/display.css +++ b/theme/default/css/display.css @@ -184,6 +184,7 @@ button.close, .form_user_unsubscribe input.submit, .form_group_join input.submit, .form_user_subscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a, .entity_moderation p, .entity_sandbox input.submit, @@ -291,6 +292,7 @@ background-position:0 1px; .form_group_leave input.submit, .form_user_subscribe input.submit, .form_user_unsubscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a { background-color:#AAAAAA; color:#FFFFFF; @@ -301,6 +303,7 @@ background-position:5px -1246px; } .form_group_join input.submit, .form_user_subscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a { background-position:5px -1181px; } diff --git a/theme/identica/css/display.css b/theme/identica/css/display.css index 14a82a8def..0e54d82e2f 100644 --- a/theme/identica/css/display.css +++ b/theme/identica/css/display.css @@ -184,6 +184,7 @@ button.close, .form_user_unsubscribe input.submit, .form_group_join input.submit, .form_user_subscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a, .entity_moderation p, .entity_sandbox input.submit, @@ -290,6 +291,7 @@ background-position:0 1px; .form_group_leave input.submit, .form_user_subscribe input.submit, .form_user_unsubscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a { background-color:#AAAAAA; color:#FFFFFF; @@ -300,6 +302,7 @@ background-position:5px -1246px; } .form_group_join input.submit, .form_user_subscribe input.submit, +.form_remote_authorize input.submit, .entity_subscribe a { background-position:5px -1181px; }