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/js/util.js b/js/util.js index 3623337b9f..4b6c39a1dc 100644 --- a/js/util.js +++ b/js/util.js @@ -54,7 +54,8 @@ var SN = { // StatusNet NoticeGeoName: 'notice_data-geo_name', NoticeDataGeo: 'notice_data-geo', NoticeDataGeoCookie: 'notice_data-geo_cookie', - NoticeDataGeoSelected: 'notice_data-geo_selected' + NoticeDataGeoSelected: 'notice_data-geo_selected', + StatusNetInstance:'StatusNetInstance' } }, @@ -670,6 +671,35 @@ var SN = { // StatusNet date.setFullYear(year, month, day); return date; + }, + + StatusNetInstance: { + Set: function(value) { + var SNI = SN.U.StatusNetInstance.Get(); + if (SNI !== null) { + value = $.extend(SNI, value); + } + + $.cookie( + SN.C.S.StatusNetInstance, + JSON.stringify(value), + { + path: '/', + expires: SN.U.GetFullYear(2029, 0, 1) + }); + }, + + Get: function() { + var cookieValue = $.cookie(SN.C.S.StatusNetInstance); + if (cookieValue !== null) { + return JSON.parse(cookieValue); + } + return null; + }, + + Delete: function() { + $.cookie(SN.C.S.StatusNetInstance, null); + } } }, @@ -707,6 +737,20 @@ var SN = { // StatusNet SN.U.NewDirectMessage(); } + }, + + Login: function() { + if (SN.U.StatusNetInstance.Get() !== null) { + var nickname = SN.U.StatusNetInstance.Get().Nickname; + if (nickname !== null) { + $('#form_login #nickname').val(nickname); + } + } + + $('#form_login').bind('submit', function() { + SN.U.StatusNetInstance.Set({Nickname: $('#form_login #nickname').val()}); + return true; + }); } } }; @@ -721,5 +765,8 @@ $(document).ready(function(){ if ($('#content .entity_actions').length > 0) { SN.Init.EntityActions(); } + if ($('#form_login').length > 0) { + SN.Init.Login(); + } }); diff --git a/lib/activity.php b/lib/activity.php index 04c57c561c..fa4ae02748 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -34,6 +34,7 @@ if (!defined('STATUSNET')) { class PoCoURL { + const URLS = 'urls'; const TYPE = 'type'; const VALUE = 'value'; const PRIMARY = 'primary'; @@ -55,7 +56,7 @@ class PoCoURL $xs->elementStart('poco:urls'); $xs->element('poco:type', null, $this->type); $xs->element('poco:value', null, $this->value); - if ($this->primary) { + if (!empty($this->primary)) { $xs->element('poco:primary', null, 'true'); } $xs->elementEnd('poco:urls'); @@ -70,21 +71,19 @@ class PoCoAddress public $formatted; - function __construct($formatted) - { - if (empty($formatted)) { - return null; - } - $this->formatted = $formatted; - } + // @todo Other address fields function asString() { - $xs = new XMLStringer(true); - $xs->elementStart('poco:address'); - $xs->element('poco:formatted', null, $this->formatted); - $xs->elementEnd('poco:address'); - return $xs->getString(); + if (!empty($this->formatted)) { + $xs = new XMLStringer(true); + $xs->elementStart('poco:address'); + $xs->element('poco:formatted', null, $this->formatted); + $xs->elementEnd('poco:address'); + return $xs->getString(); + } + + return null; } } @@ -92,25 +91,117 @@ class PoCo { const NS = 'http://portablecontacts.net/spec/1.0'; - const USERNAME = 'preferredUsername'; - const NOTE = 'note'; - const URLS = 'urls'; + const USERNAME = 'preferredUsername'; + const DISPLAYNAME = 'displayName'; + const NOTE = 'note'; public $preferredUsername; + public $displayName; public $note; public $address; public $urls = array(); - function __construct($profile) + function __construct($element = null) { - $this->preferredUsername = $profile->nickname; + if (empty($element)) { + return; + } - $this->note = $profile->bio; - $this->address = new PoCoAddress($profile->location); + $this->preferredUsername = ActivityUtils::childContent( + $element, + self::USERNAME, + self::NS + ); + + $this->displayName = ActivityUtils::childContent( + $element, + self::DISPLAYNAME, + self::NS + ); + + $this->note = ActivityUtils::childContent( + $element, + self::NOTE, + self::NS + ); + + $this->address = $this->_getAddress($element); + $this->urls = $this->_getURLs($element); + } + + private function _getURLs($element) + { + $urlEls = $element->getElementsByTagnameNS(self::NS, PoCoURL::URLS); + $urls = array(); + + foreach ($urlEls as $urlEl) { + + $type = ActivityUtils::childContent( + $urlEl, + PoCoURL::TYPE, + PoCo::NS + ); + + $value = ActivityUtils::childContent( + $urlEl, + PoCoURL::VALUE, + PoCo::NS + ); + + $primary = ActivityUtils::childContent( + $urlEl, + PoCoURL::PRIMARY, + PoCo::NS + ); + + array_push($urls, new PoCoURL($type, $value, $primary)); + } + return $urls; + } + + private function _getAddress($element) + { + $addressEl = ActivityUtils::child( + $element, + PoCoAddress::ADDRESS, + PoCo::NS + ); + + $formatted = ActivityUtils::childContent( + $addressEl, + PoCoAddress::FORMATTED, + self::NS + ); + + if (!empty($formatted)) { + $address = new PoCoAddress(); + $address->formatted = $formatted; + return $address; + } + + return null; + } + + function fromProfile($profile) + { + if (empty($profile)) { + return null; + } + + $poco = new PoCo(); + + $poco->preferredUsername = $profile->nickname; + $poco->displayName = $profile->getBestName(); + + $poco->note = $profile->bio; + + $paddy = new PoCoAddress(); + $paddy->formatted = $profile->location; + $poco->address = $paddy; if (!empty($profile->homepage)) { array_push( - $this->urls, + $poco->urls, new PoCoURL( 'homepage', $profile->homepage, @@ -118,6 +209,8 @@ class PoCo ) ); } + + return $poco; } function asString() @@ -129,6 +222,12 @@ class PoCo $this->preferredUsername ); + $xs->element( + 'poco:displayName', + null, + $this->displayName + ); + if (!empty($this->note)) { $xs->element('poco:note', null, $this->note); } @@ -374,6 +473,8 @@ class ActivityObject public $source; public $avatar; public $geopoint; + public $poco; + public $displayName; /** * Constructor @@ -426,7 +527,6 @@ class ActivityObject $this->link = ActivityUtils::getPermalink($element); - // XXX: grab PoCo stuff } // Some per-type attributes... @@ -434,8 +534,9 @@ class ActivityObject $this->displayName = $this->title; // @fixme we may have multiple avatars with different resolutions specified - $this->avatar = ActivityUtils::getLink($element, 'avatar'); - $this->nickname = ActivityUtils::childContent($element, PoCo::USERNAME, PoCo::NS); + $this->avatar = ActivityUtils::getLink($element, 'avatar'); + + $this->poco = new PoCo($element); } } @@ -490,7 +591,7 @@ class ActivityObject $object->geopoint = (float)$profile->lat . ' ' . (float)$profile->lon; } - $object->poco = new PoCo($profile); + $object->poco = PoCo::fromProfile($profile); return $object; } @@ -519,11 +620,19 @@ class ActivityObject } if (!empty($this->link)) { - $xs->element('link', array('rel' => 'alternate', 'type' => 'text/html'), - $this->link); + $xs->element( + 'link', + array( + 'rel' => 'alternate', + 'type' => 'text/html', + 'href' => $this->link + ), + null + ); } - if ($this->type == ActivityObject::PERSON) { + if ($this->type == ActivityObject::PERSON + || $this->type == ActivityObject::GROUP) { $xs->element( 'link', array( 'type' => empty($this->avatar) ? 'image/png' : $this->avatar->mediatype, @@ -532,7 +641,7 @@ class ActivityObject ? Avatar::defaultImage(AVATAR_PROFILE_SIZE) : $this->avatar->displayUrl() ), - '' + null ); } @@ -848,11 +957,24 @@ class Activity } // XXX: add context - // XXX: add target + $xs->elementStart('author'); + $xs->element('uri', array(), $this->actor->id); + if ($this->actor->title) { + $xs->element('name', array(), $this->actor->title); + } + $xs->elementEnd('author'); $xs->raw($this->actor->asString('activity:actor')); + $xs->element('activity:verb', null, $this->verb); - $xs->raw($this->object->asString()); + + if ($this->object) { + $xs->raw($this->object->asString()); + } + + if ($this->target) { + $xs->raw($this->target->asString('activity:target')); + } $xs->elementEnd('entry'); 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 934c858ac1..8c8d909a84 100644 --- a/plugins/OStatus/OStatusPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -78,11 +78,16 @@ class OStatusPlugin extends Plugin */ function onEndInitializeQueueManager(QueueManager $qm) { + // Prepare outgoing distributions after notice save. + $qm->connect('ostatus', 'OStatusQueueHandler'); + // Outgoing from our internal PuSH hub $qm->connect('hubverify', 'HubVerifyQueueHandler'); - $qm->connect('hubdistrib', 'HubDistribQueueHandler'); $qm->connect('hubout', 'HubOutQueueHandler'); + // Outgoing Salmon replies (when we don't need a return value) + $qm->connect('salmonout', 'SalmonOutQueueHandler'); + // Incoming from a foreign PuSH hub $qm->connect('pushinput', 'PushInputQueueHandler'); return true; @@ -93,7 +98,7 @@ class OStatusPlugin extends Plugin */ function onStartEnqueueNotice($notice, &$transports) { - $transports[] = 'hubdistrib'; + $transports[] = 'ostatus'; return true; } @@ -199,25 +204,6 @@ class OStatusPlugin extends Plugin function onEndNoticeSave($notice) { - $mentioned = $notice->getReplies(); - - foreach ($mentioned as $profile_id) { - - $oprofile = Ostatus_profile::staticGet('profile_id', $profile_id); - - if (!empty($oprofile) && !empty($oprofile->salmonuri)) { - - common_log(LOG_INFO, "Sending notice '{$notice->uri}' to remote profile '{$oprofile->uri}'."); - - // FIXME: this needs to go out in a queue handler - - $xml = ''; - $xml .= $notice->asAtomEntry(true, true); - - $salmon = new Salmon(); - $salmon->post($oprofile->salmonuri, $xml); - } - } } /** @@ -295,13 +281,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; + } } } @@ -316,7 +308,7 @@ 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"); } @@ -517,12 +509,8 @@ class OStatusPlugin extends Plugin $oprofile = Ostatus_profile::staticGet('group_id', $group->id); if ($oprofile) { // 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); diff --git a/plugins/OStatus/actions/groupsalmon.php b/plugins/OStatus/actions/groupsalmon.php index 2e4fe94436..29377b5fa0 100644 --- a/plugins/OStatus/actions/groupsalmon.php +++ b/plugins/OStatus/actions/groupsalmon.php @@ -46,6 +46,11 @@ class GroupsalmonAction extends SalmonAction $this->clientError(_('No such group.')); } + $oprofile = Ostatus_profile::staticGet('group_id', $id); + if ($oprofile) { + $this->clientError(_m("Can't accept remote posts for a remote group.")); + } + return true; } @@ -74,13 +79,13 @@ class GroupsalmonAction extends SalmonAction throw new ClientException("Not to the attention of anyone."); } else { $uri = common_local_url('groupbyid', array('id' => $this->group->id)); - if (!in_array($context->attention, $uri)) { + if (!in_array($uri, $context->attention)) { throw new ClientException("Not to the attention of this group."); } } $profile = $this->ensureProfile(); - // @fixme save the post + $this->saveNotice(); } /** diff --git a/plugins/OStatus/actions/ostatussub.php b/plugins/OStatus/actions/ostatussub.php index b3569e6951..12832cdcfb 100644 --- a/plugins/OStatus/actions/ostatussub.php +++ b/plugins/OStatus/actions/ostatussub.php @@ -288,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.'); diff --git a/plugins/OStatus/actions/webfinger.php b/plugins/OStatus/actions/webfinger.php index fbbd8d0397..34336a9039 100644 --- a/plugins/OStatus/actions/webfinger.php +++ b/plugins/OStatus/actions/webfinger.php @@ -65,6 +65,21 @@ 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)); diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index 91b957fa23..9f9efb96ee 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -33,6 +33,7 @@ class Ostatus_profile extends Memcached_DataObject public $feeduri; public $salmonuri; + public $avatar; // remote URL of the last avatar we saved public $created; public $modified; @@ -58,6 +59,7 @@ class Ostatus_profile extends Memcached_DataObject 'group_id' => DB_DATAOBJECT_INT, 'feeduri' => DB_DATAOBJECT_STR, 'salmonuri' => DB_DATAOBJECT_STR, + 'avatar' => DB_DATAOBJECT_STR, 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL, 'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); } @@ -74,6 +76,8 @@ class Ostatus_profile extends Memcached_DataObject 255, true, 'UNI'), new ColumnDef('salmonuri', 'text', null, true), + new ColumnDef('avatar', 'text', + null, true), new ColumnDef('created', 'datetime', null, false), new ColumnDef('modified', 'datetime', @@ -488,7 +492,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) { @@ -498,7 +502,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); } } @@ -508,15 +512,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"); } @@ -525,130 +526,190 @@ 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 + // Check if profile info has changed and update it + $this->updateFromActivityObject($activity->actor); } 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; + } + + /** + * 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) + { + common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $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; + } else { + common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient"); + } + 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. + $profile = $sender->localProfile(); + if ($profile->isMember($group)) { + $groups[] = $group->id; + } else { + common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member"); + } + continue; + } else { + common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient"); + } + } + + common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient"); - // 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()); } + $attention_uris = $replies; + common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies)); + common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups)); + return $groups; } /** @@ -729,6 +790,11 @@ class Ostatus_profile extends Memcached_DataObject */ protected function updateAvatar($url) { + if ($url == $this->avatar) { + // We've already got this one. + return; + } + if ($this->isGroup()) { $self = $this->localGroup(); } else { @@ -760,12 +826,28 @@ class Ostatus_profile extends Memcached_DataObject common_timestamp()); rename($temp_filename, Avatar::path($filename)); $self->setOriginal($filename); + + $orig = clone($this); + $this->avatar = $url; + $this->update($orig); } - protected static function getActivityObjectAvatar($object) + /** + * Pull avatar URL from ActivityObject or profile hints + * + * @param ActivityObject $object + * @param array $hints + * @return mixed URL string or false + */ + + protected static function getActivityObjectAvatar($object, $hints=array()) { - // XXX: go poke around in the feed - return $object->avatar; + if ($object->avatar) { + return $object->avatar; + } else if (array_key_exists('avatar', $hints)) { + return $hints['avatar']; + } + return false; } /** @@ -832,7 +914,9 @@ class Ostatus_profile extends Memcached_DataObject public static function ensureActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array()) { $profile = self::getActivityObjectProfile($object); - if (!$profile) { + if ($profile) { + $profile->updateFromActivityObject($object, $hints); + } else { $profile = self::createActivityObjectProfile($object, $feeduri, $salmonuri, $hints); } return $profile; @@ -901,8 +985,6 @@ class Ostatus_profile extends Memcached_DataObject protected static function createActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array()) { $homeuri = $object->id; - $nickname = self::getActivityObjectNickname($object, $hints); - $avatar = self::getActivityObjectAvatar($object); if (!$homeuri) { common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true)); @@ -946,43 +1028,19 @@ class Ostatus_profile extends Memcached_DataObject if ($object->type == ActivityObject::PERSON) { $profile = new Profile(); - $profile->nickname = $nickname; - $profile->fullname = $object->title; - if (!empty($object->link)) { - $profile->profileurl = $object->link; - } else if (array_key_exists('profileurl', $hints)) { - $profile->profileurl = $hints['profileurl']; - } - $profile->created = common_sql_now(); - - // @fixme bio - // @fixme tags/categories - // @fixme location? - // @todo tags from categories - // @todo lat/lon/location? - + self::updateProfile($profile, $object, $hints); + $profile->created = common_sql_now(); + $oprofile->profile_id = $profile->insert(); - if (!$oprofile->profile_id) { throw new ServerException("Can't save local profile"); } } else { $group = new User_group(); - $group->nickname = $nickname; - $group->fullname = $object->title; - // @fixme no canonical profileurl; using homepage instead for now - $group->homepage = $homeuri; $group->created = common_sql_now(); - - // @fixme homepage - // @fixme bio - // @fixme tags/categories - // @fixme location? - // @todo tags from categories - // @todo lat/lon/location? + self::updateGroup($group, $object, $hints); $oprofile->group_id = $group->insert(); - if (!$oprofile->group_id) { throw new ServerException("Can't save local profile"); } @@ -991,6 +1049,7 @@ class Ostatus_profile extends Memcached_DataObject $ok = $oprofile->insert(); if ($ok) { + $avatar = self::getActivityObjectAvatar($object, $hints); if ($avatar) { $oprofile->updateAvatar($avatar); } @@ -1000,8 +1059,82 @@ class Ostatus_profile extends Memcached_DataObject } } + /** + * Save any updated profile information to our local copy. + * @param ActivityObject $object + * @param array $hints + */ + protected function updateFromActivityObject($object, $hints=array()) + { + if ($this->isGroup()) { + $group = $this->localGroup(); + self::updateGroup($group, $object, $hints); + } else { + $profile = $this->localProfile(); + self::updateProfile($profile, $object, $hints); + } + $avatar = self::getActivityObjectAvatar($object, $hints); + if ($avatar) { + $this->updateAvatar($avatar); + } + } + + protected static function updateProfile($profile, $object, $hints=array()) + { + $orig = clone($profile); + + $profile->nickname = self::getActivityObjectNickname($object, $hints); + $profile->fullname = $object->title; + if (!empty($object->link)) { + $profile->profileurl = $object->link; + } else if (array_key_exists('profileurl', $hints)) { + $profile->profileurl = $hints['profileurl']; + } + + // @fixme bio + // @fixme tags/categories + // @fixme location? + // @todo tags from categories + // @todo lat/lon/location? + + if ($profile->id) { + common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true)); + $profile->update($orig); + } + } + + protected static function updateGroup($group, $object, $hints=array()) + { + $orig = clone($group); + + // @fixme need to make nick unique etc *hack hack* + $group->nickname = self::getActivityObjectNickname($object, $hints); + $group->fullname = $object->title; + + // @fixme no canonical profileurl; using homepage instead for now + $group->homepage = $object->id; + + // @fixme homepage + // @fixme bio + // @fixme tags/categories + // @fixme location? + // @todo tags from categories + // @todo lat/lon/location? + + if ($group->id) { + common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true)); + $group->update($orig); + } + } + + protected static function getActivityObjectNickname($object, $hints=array()) { + if ($object->poco) { + if (!empty($object->poco->preferredUsername)) { + return common_nicknamize($object->poco->preferredUsername); + } + } if (!empty($object->nickname)) { return common_nicknamize($object->nickname); } diff --git a/plugins/OStatus/js/ostatus.js b/plugins/OStatus/js/ostatus.js index 1fc44b21b6..bd29b5c0cf 100644 --- a/plugins/OStatus/js/ostatus.js +++ b/plugins/OStatus/js/ostatus.js @@ -24,35 +24,9 @@ * @note Everything in here should eventually migrate over to /js/util.js's SN. */ -SN.C.S.StatusNetInstance = 'StatusNetInstance'; - -SN.U.StatusNetInstance = { - Set: function(value) { - $.cookie( - SN.C.S.StatusNetInstance, - JSON.stringify(value), - { - path: '/', - expires: SN.U.GetFullYear(2029, 0, 1) - }); - }, - - Get: function() { - var cookieValue = $.cookie(SN.C.S.StatusNetInstance); - if (cookieValue !== null) { - return JSON.parse(cookieValue); - } - return null; - }, - - Delete: function() { - $.cookie(SN.C.S.StatusNetInstance, null); - } -}; - SN.Init.OStatusCookie = function() { if (SN.U.StatusNetInstance.Get() === null) { - SN.U.StatusNetInstance.Set({profile: null}); + SN.U.StatusNetInstance.Set({RemoteProfile: null}); } }; @@ -101,10 +75,10 @@ SN.U.DialogBox = { if (form.attr('id') == 'form_ostatus_connect') { SN.Init.OStatusCookie(); - form.find('#profile').val(SN.U.StatusNetInstance.Get().profile); + form.find('#profile').val(SN.U.StatusNetInstance.Get().RemoteProfile); form.find("[type=submit]").bind('click', function() { - SN.U.StatusNetInstance.Set({profile: form.find('#profile').val()}); + SN.U.StatusNetInstance.Set({RemoteProfile: form.find('#profile').val()}); return true; }); } @@ -123,4 +97,6 @@ SN.Init.Subscribe = function() { $(document).ready(function() { SN.Init.Subscribe(); + + $('.form_remote_authorize').bind('submit', function() { $(this).addClass(SN.C.S.Processing); return true; }); }); diff --git a/plugins/OStatus/lib/hubdistribqueuehandler.php b/plugins/OStatus/lib/ostatusqueuehandler.php similarity index 65% rename from plugins/OStatus/lib/hubdistribqueuehandler.php rename to plugins/OStatus/lib/ostatusqueuehandler.php index c2bd630f9c..c1e50bffa1 100644 --- a/plugins/OStatus/lib/hubdistribqueuehandler.php +++ b/plugins/OStatus/lib/ostatusqueuehandler.php @@ -18,46 +18,89 @@ */ /** - * Send a PuSH subscription verification from our internal hub. - * Queue up final distribution for - * @package Hub + * Prepare PuSH and Salmon distributions for an outgoing message. + * + * @package OStatusPlugin * @author Brion Vibber */ -class HubDistribQueueHandler extends QueueHandler +class OStatusQueueHandler extends QueueHandler { function transport() { - return 'hubdistrib'; + return 'ostatus'; } function handle($notice) { assert($notice instanceof Notice); - $this->pushUser($notice); + $this->notice = $notice; + $this->user = User::staticGet($notice->profile_id); + + $this->pushUser(); + foreach ($notice->getGroups() as $group) { - $this->pushGroup($notice, $group->id); + $oprofile = Ostatus_profile::staticGet('group_id', $group->id); + if ($oprofile) { + $this->pingReply($oprofile); + } else { + $this->pushGroup($group->id); + } } + + foreach ($notice->getReplies() as $profile_id) { + $oprofile = Ostatus_profile::staticGet('profile_id', $profile_id); + if ($oprofile) { + $this->pingReply($oprofile); + } + } + return true; } - - function pushUser($notice) + + function pushUser() { - // See if there's any PuSH subscriptions, including OStatus clients. - // @fixme handle group subscriptions as well - // http://identi.ca/api/statuses/user_timeline/1.atom - $feed = common_local_url('ApiTimelineUser', - array('id' => $notice->profile_id, - 'format' => 'atom')); - $this->pushFeed($feed, array($this, 'userFeedForNotice'), $notice); + if ($this->user) { + // For local posts, ping the PuSH hub to update their feed. + // http://identi.ca/api/statuses/user_timeline/1.atom + $feed = common_local_url('ApiTimelineUser', + array('id' => $this->user->id, + 'format' => 'atom')); + $this->pushFeed($feed, array($this, 'userFeedForNotice')); + } } - function pushGroup($notice, $group_id) + function pushGroup($group_id) { + // For a local group, ping the PuSH hub to update its feed. + // Updates may come from either a local or a remote user. $feed = common_local_url('ApiTimelineGroup', array('id' => $group_id, 'format' => 'atom')); - $this->pushFeed($feed, array($this, 'groupFeedForNotice'), $group_id, $notice); + $this->pushFeed($feed, array($this, 'groupFeedForNotice'), $group_id); + } + + function pingReply($oprofile) + { + if ($this->user) { + if (!empty($oprofile->salmonuri)) { + // For local posts, send a Salmon ping to the mentioned + // remote user or group. + // @fixme as an optimization we can skip this if the + // remote profile is subscribed to the author. + + common_log(LOG_INFO, "Prepping to send notice '{$this->notice->uri}' to remote profile '{$oprofile->uri}'."); + + $xml = ''; + $xml .= $this->notice->asAtomEntry(true, true); + + $data = array('salmonuri' => $oprofile->salmonuri, + 'entry' => $xml); + + $qm = QueueManager::get(); + $qm->enqueue($data, 'salmonout'); + } + } } /** @@ -122,7 +165,6 @@ class HubDistribQueueHandler extends QueueHandler function pushFeedInternal($atom, $sub) { common_log(LOG_INFO, "Preparing $sub->N PuSH distribution(s) for $sub->topic"); - $qm = QueueManager::get(); while ($sub->fetch()) { $sub->distribute($atom); } @@ -130,20 +172,19 @@ class HubDistribQueueHandler extends QueueHandler /** * Build a single-item version of the sending user's Atom feed. - * @param Notice $notice * @return string */ - function userFeedForNotice($notice) + function userFeedForNotice() { // @fixme this feels VERY hacky... // should probably be a cleaner way to do it ob_start(); $api = new ApiTimelineUserAction(); - $api->prepare(array('id' => $notice->profile_id, + $api->prepare(array('id' => $this->notice->profile_id, 'format' => 'atom', - 'max_id' => $notice->id, - 'since_id' => $notice->id - 1)); + 'max_id' => $this->notice->id, + 'since_id' => $this->notice->id - 1)); $api->showTimeline(); $feed = ob_get_clean(); @@ -155,7 +196,7 @@ class HubDistribQueueHandler extends QueueHandler return $feed; } - function groupFeedForNotice($group_id, $notice) + function groupFeedForNotice($group_id) { // @fixme this feels VERY hacky... // should probably be a cleaner way to do it @@ -164,8 +205,8 @@ class HubDistribQueueHandler extends QueueHandler $api = new ApiTimelineGroupAction(); $args = array('id' => $group_id, 'format' => 'atom', - 'max_id' => $notice->id, - 'since_id' => $notice->id - 1); + 'max_id' => $this->notice->id, + 'since_id' => $this->notice->id - 1); $api->prepare($args); $api->handle($args); $feed = ob_get_clean(); 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/salmonoutqueuehandler.php b/plugins/OStatus/lib/salmonoutqueuehandler.php new file mode 100644 index 0000000000..536ff94af6 --- /dev/null +++ b/plugins/OStatus/lib/salmonoutqueuehandler.php @@ -0,0 +1,44 @@ +. + */ + +/** + * Send a Salmon notification in the background. + * @package OStatusPlugin + * @author Brion Vibber + */ +class SalmonOutQueueHandler extends QueueHandler +{ + function transport() + { + return 'salmonout'; + } + + function handle($data) + { + assert(is_array($data)); + assert(is_string($data['salmonuri'])); + assert(is_string($data['entry'])); + + $salmon = new Salmon(); + $salmon->post($data['salmonuri'], $data['entry']); + + // @fixme detect failure and attempt to resend + return true; + } +} diff --git a/plugins/OStatus/tests/ActivityParseTests.php b/tests/ActivityParseTests.php similarity index 57% rename from plugins/OStatus/tests/ActivityParseTests.php rename to tests/ActivityParseTests.php index d7305dedea..5de97d2e2e 100644 --- a/plugins/OStatus/tests/ActivityParseTests.php +++ b/tests/ActivityParseTests.php @@ -7,11 +7,10 @@ if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) { // XXX: we should probably have some common source for this stuff -define('INSTALLDIR', realpath(dirname(__FILE__) . '/../../..')); +define('INSTALLDIR', realpath(dirname(__FILE__) . '/..')); define('STATUSNET', true); require_once INSTALLDIR . '/lib/common.php'; -require_once INSTALLDIR . '/plugins/OStatus/lib/activity.php'; class ActivityParseTests extends PHPUnit_Framework_TestCase { @@ -97,6 +96,45 @@ class ActivityParseTests extends PHPUnit_Framework_TestCase $this->assertFalse(empty($act->actor)); } + + public function testExample5() + { + global $_example5; + $dom = DOMDocument::loadXML($_example5); + + $feed = $dom->documentElement; + + // @todo Test feed elements + + $entries = $feed->getElementsByTagName('entry'); + $entry = $entries->item(0); + + $act = new Activity($entry, $feed); + + // Post + $this->assertEquals($act->verb, ActivityVerb::POST); + $this->assertFalse(empty($act->context)); + + // Actor w/Portable Contacts stuff + $this->assertFalse(empty($act->actor)); + $this->assertEquals($act->actor->type, ActivityObject::PERSON); + $this->assertEquals($act->actor->title, 'Test User'); + $this->assertEquals($act->actor->id, 'http://example.net/mysite/user/3'); + $this->assertEquals($act->actor->link, 'http://example.net/mysite/testuser'); + $this->assertEquals( + $act->actor->avatar, + 'http://example.net/mysite/avatar/3-96-20100224004207.jpeg' + ); + $this->assertEquals($act->actor->displayName, 'Test User'); + + $poco = $act->actor->poco; + $this->assertEquals($poco->preferredUsername, 'testuser'); + $this->assertEquals($poco->address->formatted, 'San Francisco, CA'); + $this->assertEquals($poco->urls[0]->type, 'homepage'); + $this->assertEquals($poco->urls[0]->value, 'http://example.com/blog.html'); + $this->assertEquals($poco->urls[0]->primary, 'true'); + } + } $_example1 = << - Example Feed - A subtitle. - - - urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6 - 2003-12-13T18:30:02Z - - John Doe - johndoe@example.com - + Example Feed + A subtitle. + + + urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6 + 2003-12-13T18:30:02Z + + John Doe + johndoe@example.com + - - Atom-Powered Robots Run Amok - - - - urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a - 2003-12-13T18:30:02Z - Some text. - + + Atom-Powered Robots Run Amok + + + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2003-12-13T18:30:02Z + Some text. + EXAMPLE3; @@ -207,3 +245,82 @@ $_example4 = << EXAMPLE4; + +$_example5 = << + + 3 + testuser timeline + Updates from testuser on Zach Dev! + http://example.net/mysite/avatar/3-96-20100224004207.jpeg + 2010-02-24T06:38:49+00:00 + + testuser + http://example.net/mysite/user/3 + + + + + + + + + http://activitystrea.ms/schema/1.0/person + http://example.net/mysite/user/3 + Test User + + + 37.7749295 -122.4194155 + +testuser +Test User +Just another test user. + + San Francisco, CA + + + homepage + http://example.com/blog.html + true + + + + + Hey man, is that Freedom Code?! #freedom #hippy + Hey man, is that Freedom Code?! #freedom #hippy + + testuser + http://example.net/mysite/user/3 + + + http://activitystrea.ms/schema/1.0/person + http://example.net/mysite/user/3 + Test User + + + 37.7749295 -122.4194155 + +testuser +Test User +Just another test user. + + San Francisco, CA + + + homepage + http://example.com/blog.html + true + + + + + http://example.net/mysite/notice/7 + 2010-02-24T00:53:06+00:00 + 2010-02-24T00:53:06+00:00 + + Hey man, is that Freedom Code?! #<span class="tag"><a href="http://example.net/mysite/tag/freedom" rel="tag">freedom</a></span> #<span class="tag"><a href="http://example.net/mysite/tag/hippy" rel="tag">hippy</a></span> + 37.8313160 -122.2852473 + + + +EXAMPLE5; diff --git a/theme/base/css/display.css b/theme/base/css/display.css index 380975e324..52f97f6b12 100644 --- a/theme/base/css/display.css +++ b/theme/base/css/display.css @@ -1490,6 +1490,7 @@ text-align:center; } .aside .tag-cloud { font-size:0.8em; +word-wrap:break-word; } .tag-cloud li { display:inline;