Merge branch 'testing' of git@gitorious.org:statusnet/mainline into testing

This commit is contained in:
Zach Copley 2010-02-24 01:40:14 +00:00
commit 8ad7629422
14 changed files with 666 additions and 305 deletions

View File

@ -1,7 +1,9 @@
<?php <?php
/* /**
* StatusNet - the distributed open-source microblogging tool * StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2008, 2009, StatusNet, Inc. * Copyright (C) 2008-2010, StatusNet, Inc.
*
* Subscription action.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published by
@ -15,68 +17,142 @@
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* PHP version 5
*
* @category Action
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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 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()) { // Only allow POST requests
$this->clientError(_('Not logged in.'));
return;
}
$user = common_current_user();
if ($_SERVER['REQUEST_METHOD'] != 'POST') { if ($_SERVER['REQUEST_METHOD'] != 'POST') {
common_redirect(common_local_url('subscriptions', array('nickname' => $user->nickname))); $this->clientError(_('This action only accepts POST requests.'));
return; return false;
} }
# CSRF protection // CSRF protection
$token = $this->trimmed('token'); $token = $this->trimmed('token');
if (!$token || $token != common_session_token()) { if (!$token || $token != common_session_token()) {
$this->clientError(_('There was a problem with your session token. Try again, please.')); $this->clientError(_('There was a problem with your session token.'.
return; ' 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_id = $this->arg('subscribeto');
$other = User::staticGet('id', $other_id); $this->other = Profile::staticGet('id', $other_id);
if (!$other) { if (empty($this->other)) {
$this->clientError(_('Not a local user.')); $this->clientError(_('No such profile.'));
return; 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)) { $omb01 = Remote_profile::staticGet('id', $other_id);
$this->clientError($result);
return; 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')) { if ($this->boolean('ajax')) {
$this->startHTML('text/xml;charset=utf-8'); $this->startHTML('text/xml;charset=utf-8');
$this->elementStart('head'); $this->elementStart('head');
$this->element('title', null, _('Subscribed')); $this->element('title', null, _('Subscribed'));
$this->elementEnd('head'); $this->elementEnd('head');
$this->elementStart('body'); $this->elementStart('body');
$unsubscribe = new UnsubscribeForm($this, $other->getProfile()); $unsubscribe = new UnsubscribeForm($this, $this->other->getProfile());
$unsubscribe->show(); $unsubscribe->show();
$this->elementEnd('body'); $this->elementEnd('body');
$this->elementEnd('html'); $this->elementEnd('html');
} else { } else {
common_redirect(common_local_url('subscriptions', array('nickname' => $url = common_local_url('subscriptions',
$user->nickname)), array('nickname' => $this->user->nickname));
303); common_redirect($url, 303);
} }
} }
} }

View File

@ -501,7 +501,11 @@ class Memcached_DataObject extends Safe_DataObject
function raiseError($message, $type = null, $behaviour = null) 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) static function cacheGet($keyPart)

View File

@ -104,6 +104,7 @@ class PoCo
function __construct($profile) function __construct($profile)
{ {
$this->preferredUsername = $profile->nickname; $this->preferredUsername = $profile->nickname;
$this->displayName = $profile->getBestName();
$this->note = $profile->bio; $this->note = $profile->bio;
$this->address = new PoCoAddress($profile->location); $this->address = new PoCoAddress($profile->location);
@ -129,6 +130,12 @@ class PoCo
$this->preferredUsername $this->preferredUsername
); );
$xs->element(
'poco:displayName',
null,
$this->displayName
);
if (!empty($this->note)) { if (!empty($this->note)) {
$xs->element('poco:note', null, $this->note); $xs->element('poco:note', null, $this->note);
} }
@ -823,7 +830,9 @@ class Activity
if ($namespace) { if ($namespace) {
$attrs = array('xmlns' => 'http://www.w3.org/2005/Atom', $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', '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 { } else {
$attrs = array(); $attrs = array();
} }

View File

@ -438,14 +438,15 @@ class NoticeListItem extends Widget
$this->out->text(_('at')); $this->out->text(_('at'));
$this->out->text(' '); $this->out->text(' ');
if (empty($url)) { if (empty($url)) {
$this->out->element('span', array('class' => 'geo', $this->out->element('abbr', array('class' => 'geo',
'title' => $latlon), 'title' => $latlon),
$name); $name);
} else { } else {
$this->out->element('a', array('class' => 'geo', $this->out->elementStart('a', array('href' => $url));
'title' => $latlon, $this->out->element('abbr', array('class' => 'geo',
'href' => $url), 'title' => $latlon),
$name); $name);
$this->out->elementEnd('a');
} }
$this->out->elementEnd('span'); $this->out->elementEnd('span');
} }

View File

@ -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+)/', preg_match_all('/(?:^|\s+)@((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)/',
$text, $text,
@ -251,58 +251,6 @@ class OStatusPlugin extends Plugin
return true; 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. * Make sure necessary tables are filled out.
*/ */
@ -312,6 +260,7 @@ class OStatusPlugin extends Plugin
$schema->ensureTable('ostatus_source', Ostatus_source::schemaDef()); $schema->ensureTable('ostatus_source', Ostatus_source::schemaDef());
$schema->ensureTable('feedsub', FeedSub::schemaDef()); $schema->ensureTable('feedsub', FeedSub::schemaDef());
$schema->ensureTable('hubsub', HubSub::schemaDef()); $schema->ensureTable('hubsub', HubSub::schemaDef());
$schema->ensureTable('magicsig', Magicsig::schemaDef());
return true; return true;
} }
@ -338,13 +287,19 @@ class OStatusPlugin extends Plugin
function onStartNoticeSourceLink($notice, &$name, &$url, &$title) function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
{ {
if ($notice->source == 'ostatus') { if ($notice->source == 'ostatus') {
$bits = parse_url($notice->uri); if ($notice->url) {
$domain = $bits['host']; $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->url;
$url = $notice->uri; $title = sprintf(_m("Sent from %s via OStatus"), $domain);
$title = sprintf(_m("Sent from %s via OStatus"), $domain); return false;
return false; }
} }
} }
@ -359,12 +314,56 @@ class OStatusPlugin extends Plugin
{ {
$oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri); $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri);
if ($oprofile) { if ($oprofile) {
$oprofile->processFeed($feed); $oprofile->processFeed($feed, 'push');
} else { } else {
common_log(LOG_DEBUG, "No ostatus profile for incoming feed $feedsub->uri"); 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) function onEndSubscribe($subscriber, $other)
{ {
$user = User::staticGet('id', $subscriber->id); $user = User::staticGet('id', $subscriber->id);
@ -402,6 +401,54 @@ class OStatusPlugin extends Plugin
return true; 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, * When one of our local users tries to join a remote group,
* notify the remote server. If the notification is rejected, * 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); $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
if ($oprofile) { if ($oprofile) {
if (!$oprofile->subscribe()) {
throw new Exception(_m('Could not set up remote group membership.'));
}
$member = Profile::staticGet($user->id); $member = Profile::staticGet($user->id);
$act = new Activity(); $act = new Activity();
@ -438,7 +489,8 @@ class OStatusPlugin extends Plugin
if ($oprofile->notifyActivity($act)) { if ($oprofile->notifyActivity($act)) {
return true; return true;
} else { } 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); $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
if ($oprofile) { if ($oprofile) {
// Drop the PuSH subscription if there are no other subscribers. // Drop the PuSH subscription if there are no other subscribers.
$oprofile->garbageCollect();
$members = $group->getMembers(0, 1);
if ($members->N == 0) {
common_log(LOG_INFO, "Unsubscribing from now-unused group feed $oprofile->feeduri");
$oprofile->unsubscribe();
}
$member = Profile::staticGet($user->id); $member = Profile::staticGet($user->id);

View File

@ -87,53 +87,168 @@ class OStatusSubAction extends Action
*/ */
function showPreviewForm() 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()) { if ($this->oprofile->isGroup()) {
$this->previewGroup(); $ok = $this->previewGroup();
$this->submit('subscribe', _m('Join'));
} else { } else {
$this->previewUser(); $ok = $this->previewUser();
$this->submit('subscribe', _m('Subscribe')); }
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('fieldset');
$this->elementEnd('form'); $this->elementEnd('form');
$this->elementEnd('li');
$this->elementEnd('ul');
$this->elementEnd('div');
} }
/** /**
* Show a preview for a remote user's profile * Show a preview for a remote user's profile
* @return boolean true if we're ok to try subscribing
*/ */
function previewUser() function previewUser()
{ {
$oprofile = $this->oprofile; $oprofile = $this->oprofile;
$profile = $oprofile->localProfile(); $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 * Show a preview for a remote group's profile
* @return boolean true if we're ok to try joining
*/ */
function previewGroup() function previewGroup()
{ {
$oprofile = $this->oprofile; $oprofile = $this->oprofile;
$group = $oprofile->localGroup(); $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; $this->profile_uri = $profile_uri;
// @fixme validate, normalize bla bla
try { try {
$oprofile = Ostatus_profile::ensureProfile($this->profile_uri); if (Validate::email($this->profile_uri)) {
$this->oprofile = $oprofile; $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; return true;
} catch (FeedSubBadURLException $e) { } catch (FeedSubBadURLException $e) {
$this->error = _m('Invalid URL or could not reach server.'); $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 // And subscribe the current user to the local profile
$user = common_current_user(); $user = common_current_user();
if (!$this->oprofile->subscribe()) {
$this->showForm(_m("Failed to set up server-to-server subscription."));
return;
}
if ($this->oprofile->isGroup()) { if ($this->oprofile->isGroup()) {
$group = $this->oprofile->localGroup(); $group = $this->oprofile->localGroup();
if ($user->isMember($group)) { if ($user->isMember($group)) {
@ -287,7 +402,7 @@ class OStatusSubAction extends Action
} }
if ($this->validateFeed()) { if ($this->validateFeed()) {
if ($this->arg('subscribe')) { if ($this->arg('submit')) {
$this->saveFeed(); $this->saveFeed();
return; return;
} }
@ -343,7 +458,7 @@ class OStatusSubAction extends Action
function showPageNotice() function showPageNotice()
{ {
if ($this->error) { if (!empty($this->error)) {
$this->element('p', 'error', $this->error); $this->element('p', 'error', $this->error);
} }
} }

View File

@ -65,12 +65,38 @@ class WebfingerAction extends Action
'format' => 'atom')), 'format' => 'atom')),
'type' => 'application/atom+xml'); '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', $salmon_url = common_local_url('salmon',
array('id' => $this->user->id)); array('id' => $this->user->id));
$xrd->links[] = array('rel' => 'salmon', $xrd->links[] = array('rel' => 'salmon',
'href' => $salmon_url); '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 // TODO - finalize where the redirect should go on the publisher
$url = common_local_url('ostatussub') . '?profile={uri}'; $url = common_local_url('ostatussub') . '?profile={uri}';
$xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe', $xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe',

View File

@ -29,45 +29,86 @@
require_once 'Crypt/RSA.php'; require_once 'Crypt/RSA.php';
interface Magicsig class Magicsig extends Memcached_DataObject
{ {
public function sign($bytes); const PUBLICKEYREL = 'magic-public-key';
public function verify($signed, $signature_b64);
}
class MagicsigRsaSha256
{
public $keypair;
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->alg = $alg;
$this->generate(); }
} else {
$this->fromString($init); 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) public function generate($key_length = 512)
{ {
PEAR::pushErrorHandling(PEAR_ERROR_RETURN);
$keypair = new Crypt_RSA_KeyPair($key_length); $keypair = new Crypt_RSA_KeyPair($key_length);
$params['public_key'] = $keypair->getPublicKey(); $params['public_key'] = $keypair->getPublicKey();
$params['private_key'] = $keypair->getPrivateKey(); $params['private_key'] = $keypair->getPrivateKey();
PEAR::pushErrorHandling(PEAR_ERROR_RETURN); $this->_rsa = new Crypt_RSA($params);
$this->keypair = new Crypt_RSA($params);
PEAR::popErrorHandling(); PEAR::popErrorHandling();
$this->insert();
} }
public function toString($full_pair = true) public function toString($full_pair = true)
{ {
$public_key = $this->keypair->_public_key; $public_key = $this->_rsa->_public_key;
$private_key = $this->keypair->_private_key; $private_key = $this->_rsa->_private_key;
$mod = base64_url_encode($public_key->getModulus()); $mod = base64_url_encode($public_key->getModulus());
$exp = base64_url_encode($public_key->getExponent()); $exp = base64_url_encode($public_key->getExponent());
@ -79,10 +120,12 @@ class MagicsigRsaSha256
return 'RSA.' . $mod . '.' . $exp . $private_exp; return 'RSA.' . $mod . '.' . $exp . $private_exp;
} }
public function fromString($text) public static function fromString($text)
{ {
PEAR::pushErrorHandling(PEAR_ERROR_RETURN); PEAR::pushErrorHandling(PEAR_ERROR_RETURN);
$magic_sig = new Magicsig();
// remove whitespace // remove whitespace
$text = preg_replace('/\s+/', '', $text); $text = preg_replace('/\s+/', '', $text);
@ -100,33 +143,46 @@ class MagicsigRsaSha256
$params['public_key'] = new Crypt_RSA_KEY($mod, $exp, 'public'); $params['public_key'] = new Crypt_RSA_KEY($mod, $exp, 'public');
if ($params['public_key']->isError()) { if ($params['public_key']->isError()) {
$error = $params['public_key']->getLastError(); $error = $params['public_key']->getLastError();
print $error->getMessage(); common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage());
exit; return false;
} }
if ($private_exp) { if ($private_exp) {
$params['private_key'] = new Crypt_RSA_KEY($mod, $private_exp, 'private'); $params['private_key'] = new Crypt_RSA_KEY($mod, $private_exp, 'private');
if ($params['private_key']->isError()) { if ($params['private_key']->isError()) {
$error = $params['private_key']->getLastError(); $error = $params['private_key']->getLastError();
print $error->getMessage(); common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage());
exit; return false;
} }
} }
$this->keypair = new Crypt_RSA($params); $magic_sig->_rsa = new Crypt_RSA($params);
PEAR::popErrorHandling(); PEAR::popErrorHandling();
return $magic_sig;
} }
public function getName() public function getName()
{ {
return 'RSA-SHA256'; return $this->alg;
} }
public function getHash()
{
switch ($this->alg) {
case 'RSA-SHA256':
return 'sha256';
}
}
public function sign($bytes) public function sign($bytes)
{ {
$sig = $this->keypair->createSign($bytes, null, 'sha256'); $sig = $this->_rsa->createSign($bytes, null, 'sha256');
if ($this->keypair->isError()) { if ($this->_rsa->isError()) {
$error = $this->keypair->getLastError(); $error = $this->_rsa->getLastError();
common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage());
return false;
} }
return $sig; return $sig;
@ -134,11 +190,11 @@ class MagicsigRsaSha256
public function verify($signed_bytes, $signature) public function verify($signed_bytes, $signature)
{ {
$result = $this->keypair->validateSign($signed_bytes, $signature, null, 'sha256'); $result = $this->_rsa->validateSign($signed_bytes, $signature, null, 'sha256');
if ($this->keypair->isError()) { if ($this->_rsa->isError()) {
$error = $this->keypair->getLastError(); $error = $this->keypair->getLastError();
//common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage()); common_log(LOG_DEBUG, 'RSA Error: '. $error->getMessage());
print $error->getMessage(); return false;
} }
return $result; return $result;
} }

View File

@ -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, * Send an Activity Streams notification to the remote Salmon endpoint,
* if so configured. * if so configured.
@ -379,7 +402,8 @@ class Ostatus_profile extends Memcached_DataObject
'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
'xmlns:thr' => 'http://purl.org/syndication/thread/1.0', 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
'xmlns:georss' => 'http://www.georss.org/georss', '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 = new XMLStringer();
$entry->elementStart('entry', $attributes); $entry->elementStart('entry', $attributes);
@ -464,7 +488,7 @@ class Ostatus_profile extends Memcached_DataObject
* *
* @param DOMDocument $feed * @param DOMDocument $feed
*/ */
public function processFeed($feed) public function processFeed($feed, $source)
{ {
$entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
if ($entries->length == 0) { if ($entries->length == 0) {
@ -474,7 +498,7 @@ class Ostatus_profile extends Memcached_DataObject
for ($i = 0; $i < $entries->length; $i++) { for ($i = 0; $i < $entries->length; $i++) {
$entry = $entries->item($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 $entry
* @param DOMElement $feed for context * @param DOMElement $feed for context
*/ */
protected function processEntry($entry, $feed) public function processEntry($entry, $feed, $source)
{ {
$activity = new Activity($entry, $feed); $activity = new Activity($entry, $feed);
$debug = var_export($activity, true);
common_log(LOG_DEBUG, $debug);
if ($activity->verb == ActivityVerb::POST) { if ($activity->verb == ActivityVerb::POST) {
$this->processPost($activity); $this->processPost($activity, $source);
} else { } else {
common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb"); 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. * Process an incoming post activity from this remote feed.
* @param Activity $activity * @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 * @fixme break up this function, it's getting nasty long
*/ */
protected function processPost($activity) public function processPost($activity, $method)
{ {
if ($this->isGroup()) { if ($this->isGroup()) {
// A group feed will contain posts from multiple authors.
// @fixme validate these profiles in some way! // @fixme validate these profiles in some way!
$oprofile = self::ensureActorProfile($activity); $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 { } 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); $actorUri = self::getActorProfileURI($activity);
if ($actorUri == $this->uri) { if ($actorUri == $this->uri) {
// @fixme check if profile info has changed and update it // @fixme check if profile info has changed and update it
} else { } else {
// @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->uri"); return false;
//return;
} }
$oprofile = $this; $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; $sourceUri = $activity->object->id;
$dupe = Notice::staticGet('uri', $sourceUri); $dupe = Notice::staticGet('uri', $sourceUri);
if ($dupe) { if ($dupe) {
common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri"); 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; $sourceUrl = null;
if ($activity->object->link) { if ($activity->object->link) {
$sourceUrl = $activity->object->link; $sourceUrl = $activity->object->link;
} else if ($activity->link) {
$sourceUrl = $activity->link;
} else if (preg_match('!^https?://!', $activity->object->id)) { } else if (preg_match('!^https?://!', $activity->object->id)) {
$sourceUrl = $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; $options = array('is_local' => Notice::REMOTE_OMB,
$params = array('is_local' => Notice::REMOTE_OMB,
'url' => $sourceUrl, 'url' => $sourceUrl,
'uri' => $sourceUri); 'uri' => $sourceUri,
'rendered' => $rendered,
'replies' => array(),
'groups' => array());
$location = $activity->context->location; // Check for optional attributes...
if ($location) { if (!empty($activity->time)) {
$params['lat'] = $location->lat; $options['created'] = common_sql_date($activity->time);
$params['lon'] = $location->lon;
if ($location->location_id) {
$params['location_ns'] = $location->location_ns;
$params['location_id'] = $location->location_id;
}
} }
$profile = $oprofile->localProfile();
$params['groups'] = array();
$params['replies'] = array();
if ($activity->context) { if ($activity->context) {
foreach ($activity->context->attention as $recipient) { // Any individual or group attn: targets?
$roprofile = Ostatus_profile::staticGet('uri', $recipient); $replies = $activity->context->attention;
if ($roprofile) { $options['groups'] = $this->filterReplies($oprofile, $replies);
if ($roprofile->isGroup()) { $options['replies'] = $replies;
// Deliver to local recipients of this remote group.
// @fixme sender verification? // Maintain direct reply associations
$params['groups'][] = $roprofile->group_id; // @fixme what about conversation ID?
continue; if (!empty($activity->context->replyToID)) {
} else { $orig = Notice::staticGet('uri',
// Delivery to remote users is the source service's job. $activity->context->replyToID);
continue; if (!empty($orig)) {
} $options['reply_to'] = $orig->id;
} }
}
$user = User::staticGet('uri', $recipient);
if ($user) { $location = $activity->context->location;
// An @-reply directed to a local user. if ($location) {
// @fixme sender verification, spam etc? $options['lat'] = $location->lat;
$params['replies'][] = $recipient; $options['lon'] = $location->lon;
continue; if ($location->location_id) {
} $options['location_ns'] = $location->location_ns;
$options['location_id'] = $location->location_id;
// @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");
} }
} }
} }
try { try {
$saved = Notice::saveNew($profile->id, $saved = Notice::saveNew($oprofile->profile_id,
$content, $content,
'ostatus', 'ostatus',
$params); $options);
if ($saved) {
Ostatus_source::saveNew($saved, $this, $method);
}
} catch (Exception $e) { } catch (Exception $e) {
common_log(LOG_ERR, "Failed saving notice entry for $sourceUri: " . $e->getMessage()); common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
return; 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 { * Clean up HTML
Ostatus_source::saveNew($saved, $this, 'push'); */
} catch (Exception $e) { protected function purify($html)
common_log(LOG_ERR, "Failed saving ostatus_source entry for $saved->notice_id: " . $e->getMessage()); {
// @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;
} }
/** /**

View File

@ -27,8 +27,6 @@
* @link http://status.net/ * @link http://status.net/
*/ */
require_once 'magicsig.php';
class MagicEnvelope class MagicEnvelope
{ {
const ENCODING = 'base64url'; const ENCODING = 'base64url';
@ -64,7 +62,7 @@ class MagicEnvelope
return false; return false;
} }
$signature_alg = new MagicsigRsaSha256($this->getKeyPair($signer_uri)); $signature_alg = Magicsig::fromString($this->getKeyPair($signer_uri));
$armored_text = base64_encode($text); $armored_text = base64_encode($text);
return array( return array(
@ -139,7 +137,7 @@ class MagicEnvelope
$text = base64_decode($env['data']); $text = base64_decode($env['data']);
$signer_uri = $this->getAuthor($text); $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']); return $verifier->verify($env['data'], $env['sig']);
} }

View File

@ -185,54 +185,6 @@ class SalmonAction extends Action
function saveNotice() function saveNotice()
{ {
$oprofile = $this->ensureProfile(); $oprofile = $this->ensureProfile();
return $oprofile->processPost($this->act, 'salmon');
// 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;
} }
} }

View File

@ -108,6 +108,10 @@ class Webfinger
$content = $this->fetchURL($url); $content = $this->fetchURL($url);
if (!$content) {
return false;
}
return XRD::parse($content); return XRD::parse($content);
} }

View File

@ -184,6 +184,7 @@ button.close,
.form_user_unsubscribe input.submit, .form_user_unsubscribe input.submit,
.form_group_join input.submit, .form_group_join input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a, .entity_subscribe a,
.entity_moderation p, .entity_moderation p,
.entity_sandbox input.submit, .entity_sandbox input.submit,
@ -291,6 +292,7 @@ background-position:0 1px;
.form_group_leave input.submit, .form_group_leave input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_user_unsubscribe input.submit, .form_user_unsubscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a { .entity_subscribe a {
background-color:#AAAAAA; background-color:#AAAAAA;
color:#FFFFFF; color:#FFFFFF;
@ -301,6 +303,7 @@ background-position:5px -1246px;
} }
.form_group_join input.submit, .form_group_join input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a { .entity_subscribe a {
background-position:5px -1181px; background-position:5px -1181px;
} }

View File

@ -184,6 +184,7 @@ button.close,
.form_user_unsubscribe input.submit, .form_user_unsubscribe input.submit,
.form_group_join input.submit, .form_group_join input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a, .entity_subscribe a,
.entity_moderation p, .entity_moderation p,
.entity_sandbox input.submit, .entity_sandbox input.submit,
@ -290,6 +291,7 @@ background-position:0 1px;
.form_group_leave input.submit, .form_group_leave input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_user_unsubscribe input.submit, .form_user_unsubscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a { .entity_subscribe a {
background-color:#AAAAAA; background-color:#AAAAAA;
color:#FFFFFF; color:#FFFFFF;
@ -300,6 +302,7 @@ background-position:5px -1246px;
} }
.form_group_join input.submit, .form_group_join input.submit,
.form_user_subscribe input.submit, .form_user_subscribe input.submit,
.form_remote_authorize input.submit,
.entity_subscribe a { .entity_subscribe a {
background-position:5px -1181px; background-position:5px -1181px;
} }