diff options
Diffstat (limited to 'plugins/OStatus/lib')
-rw-r--r-- | plugins/OStatus/lib/activity.php | 448 | ||||
-rw-r--r-- | plugins/OStatus/lib/discovery.php | 310 | ||||
-rw-r--r-- | plugins/OStatus/lib/feeddiscovery.php | 50 | ||||
-rw-r--r-- | plugins/OStatus/lib/feedmunger.php | 350 | ||||
-rw-r--r-- | plugins/OStatus/lib/hubconfqueuehandler.php (renamed from plugins/OStatus/lib/hubverifyqueuehandler.php) | 7 | ||||
-rw-r--r-- | plugins/OStatus/lib/huboutqueuehandler.php | 18 | ||||
-rw-r--r-- | plugins/OStatus/lib/magicenvelope.php | 215 | ||||
-rw-r--r-- | plugins/OStatus/lib/ostatusqueuehandler.php (renamed from plugins/OStatus/lib/hubdistribqueuehandler.php) | 123 | ||||
-rw-r--r-- | plugins/OStatus/lib/pushinqueuehandler.php | 53 | ||||
-rw-r--r-- | plugins/OStatus/lib/salmon.php | 60 | ||||
-rw-r--r-- | plugins/OStatus/lib/salmonaction.php | 196 | ||||
-rw-r--r-- | plugins/OStatus/lib/salmonqueuehandler.php | 46 | ||||
-rw-r--r-- | plugins/OStatus/lib/webfinger.php | 143 | ||||
-rw-r--r-- | plugins/OStatus/lib/xrd.php | 32 | ||||
-rw-r--r-- | plugins/OStatus/lib/xrdaction.php | 105 |
15 files changed, 1117 insertions, 1039 deletions
diff --git a/plugins/OStatus/lib/activity.php b/plugins/OStatus/lib/activity.php deleted file mode 100644 index f137946ab..000000000 --- a/plugins/OStatus/lib/activity.php +++ /dev/null @@ -1,448 +0,0 @@ -<?php -/** - * StatusNet, the distributed open-source microblogging tool - * - * An activity - * - * PHP version 5 - * - * LICENCE: This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -if (!defined('STATUSNET')) { - exit(1); -} - -/** - * Utilities for turning DOMish things into Activityish things - * - * Some common functions that I didn't have the bandwidth to try to factor - * into some kind of reasonable superclass, so just dumped here. Might - * be useful to have an ActivityObject parent class or something. - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityUtils -{ - const ATOM = 'http://www.w3.org/2005/Atom'; - - const LINK = 'link'; - const REL = 'rel'; - const TYPE = 'type'; - const HREF = 'href'; - - /** - * Get the permalink for an Activity object - * - * @param DOMElement $element A DOM element - * - * @return string related link, if any - */ - - static function getPermalink($element) - { - return self::getLink($element, 'alternate', 'text/html'); - } - - /** - * Get the permalink for an Activity object - * - * @param DOMElement $element A DOM element - * - * @return string related link, if any - */ - - static function getLink($element, $rel, $type=null) - { - $links = $element->getElementsByTagnameNS(self::ATOM, self::LINK); - - foreach ($links as $link) { - - $linkRel = $link->getAttribute(self::REL); - $linkType = $link->getAttribute(self::TYPE); - - if ($linkRel == $rel && - (is_null($type) || $linkType == $type)) { - return $link->getAttribute(self::HREF); - } - } - - return null; - } - - /** - * Gets the first child element with the given tag - * - * @param DOMElement $element element to pick at - * @param string $tag tag to look for - * @param string $namespace Namespace to look under - * - * @return DOMElement found element or null - */ - - static function child($element, $tag, $namespace=self::ATOM) - { - $els = $element->childNodes; - if (empty($els) || $els->length == 0) { - return null; - } else { - for ($i = 0; $i < $els->length; $i++) { - $el = $els->item($i); - if ($el->localName == $tag && $el->namespaceURI == $namespace) { - return $el; - } - } - } - } - - /** - * Grab the text content of a DOM element child of the current element - * - * @param DOMElement $element Element whose children we examine - * @param string $tag Tag to look up - * @param string $namespace Namespace to use, defaults to Atom - * - * @return string content of the child - */ - - static function childContent($element, $tag, $namespace=self::ATOM) - { - $el = self::child($element, $tag, $namespace); - - if (empty($el)) { - return null; - } else { - return $el->textContent; - } - } -} - -/** - * A noun-ish thing in the activity universe - * - * The activity streams spec talks about activity objects, while also having - * a tag activity:object, which is in fact an activity object. Aaaaaah! - * - * This is just a thing in the activity universe. Can be the subject, object, - * or indirect object (target!) of an activity verb. Rotten name, and I'm - * propagating it. *sigh* - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityObject -{ - const ARTICLE = 'http://activitystrea.ms/schema/1.0/article'; - const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry'; - const NOTE = 'http://activitystrea.ms/schema/1.0/note'; - const STATUS = 'http://activitystrea.ms/schema/1.0/status'; - const FILE = 'http://activitystrea.ms/schema/1.0/file'; - const PHOTO = 'http://activitystrea.ms/schema/1.0/photo'; - const ALBUM = 'http://activitystrea.ms/schema/1.0/photo-album'; - const PLAYLIST = 'http://activitystrea.ms/schema/1.0/playlist'; - const VIDEO = 'http://activitystrea.ms/schema/1.0/video'; - const AUDIO = 'http://activitystrea.ms/schema/1.0/audio'; - const BOOKMARK = 'http://activitystrea.ms/schema/1.0/bookmark'; - const PERSON = 'http://activitystrea.ms/schema/1.0/person'; - const GROUP = 'http://activitystrea.ms/schema/1.0/group'; - const PLACE = 'http://activitystrea.ms/schema/1.0/place'; - const COMMENT = 'http://activitystrea.ms/schema/1.0/comment'; - // ^^^^^^^^^^ tea! - - // Atom elements we snarf - - const TITLE = 'title'; - const SUMMARY = 'summary'; - const CONTENT = 'content'; - const ID = 'id'; - const SOURCE = 'source'; - - const NAME = 'name'; - const URI = 'uri'; - const EMAIL = 'email'; - - public $element; - public $type; - public $id; - public $title; - public $summary; - public $content; - public $link; - public $source; - - /** - * Constructor - * - * This probably needs to be refactored - * to generate a local class (ActivityPerson, ActivityFile, ...) - * based on the object type. - * - * @param DOMElement $element DOM thing to turn into an Activity thing - */ - - function __construct($element) - { - $this->element = $element; - - if ($element->tagName == 'author') { - - $this->type = self::PERSON; // XXX: is this fair? - $this->title = $this->_childContent($element, self::NAME); - $this->id = $this->_childContent($element, self::URI); - - if (empty($this->id)) { - $email = $this->_childContent($element, self::EMAIL); - if (!empty($email)) { - // XXX: acct: ? - $this->id = 'mailto:'.$email; - } - } - - } else { - - $this->type = $this->_childContent($element, Activity::OBJECTTYPE, - Activity::SPEC); - - if (empty($this->type)) { - $this->type = ActivityObject::NOTE; - } - - $this->id = $this->_childContent($element, self::ID); - $this->title = $this->_childContent($element, self::TITLE); - $this->summary = $this->_childContent($element, self::SUMMARY); - $this->content = $this->_childContent($element, self::CONTENT); - - $this->source = $this->_getSource($element); - - $this->link = ActivityUtils::getPermalink($element); - - // XXX: grab PoCo stuff - } - - // Some per-type attributes... - if ($this->type == self::PERSON || $this->type == self::GROUP) { - $this->displayName = $this->title; - - // @fixme we may have multiple avatars with different resolutions specified - $this->avatar = ActivityUtils::getLink($element, 'avatar'); - } - } - - private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM) - { - return ActivityUtils::childContent($element, $tag, $namespace); - } - - // Try to get a unique id for the source feed - - private function _getSource($element) - { - $sourceEl = ActivityUtils::child($element, 'source'); - - if (empty($sourceEl)) { - return null; - } else { - $href = ActivityUtils::getLink($sourceEl, 'self'); - if (!empty($href)) { - return $href; - } else { - return ActivityUtils::childContent($sourceEl, 'id'); - } - } - } -} - -/** - * Utility class to hold a bunch of constant defining default verb types - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityVerb -{ - const POST = 'http://activitystrea.ms/schema/1.0/post'; - const SHARE = 'http://activitystrea.ms/schema/1.0/share'; - const SAVE = 'http://activitystrea.ms/schema/1.0/save'; - const FAVORITE = 'http://activitystrea.ms/schema/1.0/favorite'; - const PLAY = 'http://activitystrea.ms/schema/1.0/play'; - const FOLLOW = 'http://activitystrea.ms/schema/1.0/follow'; - const FRIEND = 'http://activitystrea.ms/schema/1.0/make-friend'; - const JOIN = 'http://activitystrea.ms/schema/1.0/join'; - const TAG = 'http://activitystrea.ms/schema/1.0/tag'; -} - -/** - * An activity in the ActivityStrea.ms world - * - * An activity is kind of like a sentence: someone did something - * to something else. - * - * 'someone' is the 'actor'; 'did something' is the verb; - * 'something else' is the object. - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class Activity -{ - const SPEC = 'http://activitystrea.ms/spec/1.0/'; - const SCHEMA = 'http://activitystrea.ms/schema/1.0/'; - - const VERB = 'verb'; - const OBJECT = 'object'; - const ACTOR = 'actor'; - const SUBJECT = 'subject'; - const OBJECTTYPE = 'object-type'; - const CONTEXT = 'context'; - const TARGET = 'target'; - - const ATOM = 'http://www.w3.org/2005/Atom'; - - const AUTHOR = 'author'; - const PUBLISHED = 'published'; - const UPDATED = 'updated'; - - public $actor; // an ActivityObject - public $verb; // a string (the URL) - public $object; // an ActivityObject - public $target; // an ActivityObject - public $context; // an ActivityObject - public $time; // Time of the activity - public $link; // an ActivityObject - public $entry; // the source entry - public $feed; // the source feed - - /** - * Turns a regular old Atom <entry> into a magical activity - * - * @param DOMElement $entry Atom entry to poke at - * @param DOMElement $feed Atom feed, for context - */ - - function __construct($entry, $feed = null) - { - $this->entry = $entry; - $this->feed = $feed; - - $pubEl = $this->_child($entry, self::PUBLISHED, self::ATOM); - - if (!empty($pubEl)) { - $this->time = strtotime($pubEl->textContent); - } else { - // XXX technically an error; being liberal. Good idea...? - $updateEl = $this->_child($entry, self::UPDATED, self::ATOM); - if (!empty($updateEl)) { - $this->time = strtotime($updateEl->textContent); - } else { - $this->time = null; - } - } - - $this->link = ActivityUtils::getPermalink($entry); - - $verbEl = $this->_child($entry, self::VERB); - - if (!empty($verbEl)) { - $this->verb = trim($verbEl->textContent); - } else { - $this->verb = ActivityVerb::POST; - // XXX: do other implied stuff here - } - - $objectEl = $this->_child($entry, self::OBJECT); - - if (!empty($objectEl)) { - $this->object = new ActivityObject($objectEl); - } else { - $this->object = new ActivityObject($entry); - } - - $actorEl = $this->_child($entry, self::ACTOR); - - if (!empty($actorEl)) { - - $this->actor = new ActivityObject($actorEl); - - } else if (!empty($feed) && - $subjectEl = $this->_child($feed, self::SUBJECT)) { - - $this->actor = new ActivityObject($subjectEl); - - } else if ($authorEl = $this->_child($entry, self::AUTHOR, self::ATOM)) { - - $this->actor = new ActivityObject($authorEl); - - } else if (!empty($feed) && $authorEl = $this->_child($feed, self::AUTHOR, - self::ATOM)) { - - $this->actor = new ActivityObject($authorEl); - } - - $contextEl = $this->_child($entry, self::CONTEXT); - - if (!empty($contextEl)) { - $this->context = new ActivityObject($contextEl); - } - - $targetEl = $this->_child($entry, self::TARGET); - - if (!empty($targetEl)) { - $this->target = new ActivityObject($targetEl); - } - } - - /** - * Returns an Atom <entry> based on this activity - * - * @return DOMElement Atom entry - */ - - function toAtomEntry() - { - return null; - } - - private function _child($element, $tag, $namespace=self::SPEC) - { - return ActivityUtils::child($element, $tag, $namespace); - } -}
\ No newline at end of file diff --git a/plugins/OStatus/lib/discovery.php b/plugins/OStatus/lib/discovery.php new file mode 100644 index 000000000..f8449b309 --- /dev/null +++ b/plugins/OStatus/lib/discovery.php @@ -0,0 +1,310 @@ +<?php +/** + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * A sample module to show best practices for StatusNet plugins + * + * PHP version 5 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @package StatusNet + * @author James Walker <james@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +/** + * This class implements LRDD-based service discovery based on the "Hammer Draft" + * (including webfinger) + * + * @see http://groups.google.com/group/webfinger/browse_thread/thread/9f3d93a479e91bbf + */ +class Discovery +{ + + const LRDD_REL = 'lrdd'; + const PROFILEPAGE = 'http://webfinger.net/rel/profile-page'; + const UPDATESFROM = 'http://schemas.google.com/g/2010#updates-from'; + const HCARD = 'http://microformats.org/profile/hcard'; + + public $methods = array(); + + public function __construct() + { + $this->registerMethod('Discovery_LRDD_Host_Meta'); + $this->registerMethod('Discovery_LRDD_Link_Header'); + $this->registerMethod('Discovery_LRDD_Link_HTML'); + } + + + public function registerMethod($class) + { + $this->methods[] = $class; + } + + /** + * Given a "user id" make sure it's normalized to either a webfinger + * acct: uri or a profile HTTP URL. + */ + public static function normalize($user_id) + { + if (substr($user_id, 0, 5) == 'http:' || + substr($user_id, 0, 6) == 'https:' || + substr($user_id, 0, 5) == 'acct:') { + return $user_id; + } + + if (strpos($user_id, '@') !== FALSE) { + return 'acct:' . $user_id; + } + + return 'http://' . $user_id; + } + + public static function isWebfinger($user_id) + { + $uri = Discovery::normalize($user_id); + + return (substr($uri, 0, 5) == 'acct:'); + } + + /** + * This implements the actual lookup procedure + */ + public function lookup($id) + { + // Normalize the incoming $id to make sure we have a uri + $uri = $this->normalize($id); + + foreach ($this->methods as $class) { + $links = call_user_func(array($class, 'discover'), $uri); + if ($link = Discovery::getService($links, Discovery::LRDD_REL)) { + // Load the LRDD XRD + if (!empty($link['template'])) { + $xrd_uri = Discovery::applyTemplate($link['template'], $uri); + } else { + $xrd_uri = $link['href']; + } + + $xrd = $this->fetchXrd($xrd_uri); + if ($xrd) { + return $xrd; + } + } + } + + throw new Exception('Unable to find services for '. $id); + } + + public static function getService($links, $service) { + if (!is_array($links)) { + return false; + } + + foreach ($links as $link) { + if ($link['rel'] == $service) { + return $link; + } + } + } + + + public static function applyTemplate($template, $id) + { + $template = str_replace('{uri}', urlencode($id), $template); + + return $template; + } + + + public static function fetchXrd($url) + { + try { + $client = new HTTPClient(); + $response = $client->get($url); + } catch (HTTP_Request2_Exception $e) { + return false; + } + + if ($response->getStatus() != 200) { + return false; + } + + return XRD::parse($response->getBody()); + } +} + +interface Discovery_LRDD +{ + public function discover($uri); +} + +class Discovery_LRDD_Host_Meta implements Discovery_LRDD +{ + public function discover($uri) + { + if (!Discovery::isWebfinger($uri)) { + return false; + } + + // We have a webfinger acct: - start with host-meta + list($name, $domain) = explode('@', $uri); + $url = 'http://'. $domain .'/.well-known/host-meta'; + + $xrd = Discovery::fetchXrd($url); + + if ($xrd) { + if ($xrd->host != $domain) { + return false; + } + + return $xrd->links; + } + } +} + +class Discovery_LRDD_Link_Header implements Discovery_LRDD +{ + public function discover($uri) + { + try { + $client = new HTTPClient(); + $response = $client->get($uri); + } catch (HTTP_Request2_Exception $e) { + return false; + } + + if ($response->getStatus() != 200) { + return false; + } + + $link_header = $response->getHeader('Link'); + if (!$link_header) { + // return false; + } + + return Discovery_LRDD_Link_Header::parseHeader($link_header); + } + + protected static function parseHeader($header) + { + preg_match('/^<[^>]+>/', $header, $uri_reference); + //if (empty($uri_reference)) return; + + $links = array(); + + $link_uri = trim($uri_reference[0], '<>'); + $link_rel = array(); + $link_type = null; + + // remove uri-reference from header + $header = substr($header, strlen($uri_reference[0])); + + // parse link-params + $params = explode(';', $header); + + foreach ($params as $param) { + if (empty($param)) continue; + list($param_name, $param_value) = explode('=', $param, 2); + $param_name = trim($param_name); + $param_value = preg_replace('(^"|"$)', '', trim($param_value)); + + // for now we only care about 'rel' and 'type' link params + // TODO do something with the other links-params + switch ($param_name) { + case 'rel': + $link_rel = trim($param_value); + break; + + case 'type': + $link_type = trim($param_value); + } + } + + $links[] = array( + 'href' => $link_uri, + 'rel' => $link_rel, + 'type' => $link_type); + + return $links; + } +} + +class Discovery_LRDD_Link_HTML implements Discovery_LRDD +{ + public function discover($uri) + { + try { + $client = new HTTPClient(); + $response = $client->get($uri); + } catch (HTTP_Request2_Exception $e) { + return false; + } + + if ($response->getStatus() != 200) { + return false; + } + + return Discovery_LRDD_Link_HTML::parse($response->getBody()); + } + + + public function parse($html) + { + $links = array(); + + preg_match('/<head(\s[^>]*)?>(.*?)<\/head>/is', $html, $head_matches); + $head_html = $head_matches[2]; + + preg_match_all('/<link\s[^>]*>/i', $head_html, $link_matches); + + foreach ($link_matches[0] as $link_html) { + $link_url = null; + $link_rel = null; + $link_type = null; + + preg_match('/\srel=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $rel_matches); + if ( isset($rel_matches[3]) ) { + $link_rel = $rel_matches[3]; + } else if ( isset($rel_matches[1]) ) { + $link_rel = $rel_matches[1]; + } + + preg_match('/\shref=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $href_matches); + if ( isset($href_matches[3]) ) { + $link_uri = $href_matches[3]; + } else if ( isset($href_matches[1]) ) { + $link_uri = $href_matches[1]; + } + + preg_match('/\stype=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $type_matches); + if ( isset($type_matches[3]) ) { + $link_type = $type_matches[3]; + } else if ( isset($type_matches[1]) ) { + $link_type = $type_matches[1]; + } + + $links[] = array( + 'href' => $link_url, + 'rel' => $link_rel, + 'type' => $link_type, + ); + } + + return $links; + } +} diff --git a/plugins/OStatus/lib/feeddiscovery.php b/plugins/OStatus/lib/feeddiscovery.php index 39985fc90..7afb71bdc 100644 --- a/plugins/OStatus/lib/feeddiscovery.php +++ b/plugins/OStatus/lib/feeddiscovery.php @@ -48,6 +48,14 @@ class FeedSubNoFeedException extends FeedSubException { } +class FeedSubBadXmlException extends FeedSubException +{ +} + +class FeedSubNoHubException extends FeedSubException +{ +} + /** * Given a web page or feed URL, discover the final location of the feed * and return its current contents. @@ -57,21 +65,25 @@ class FeedSubNoFeedException extends FeedSubException * if ($feed->discoverFromURL($url)) { * print $feed->uri; * print $feed->type; - * processFeed($feed->body); + * processFeed($feed->feed); // DOMDocument * } */ class FeedDiscovery { public $uri; public $type; - public $body; + public $feed; + /** Post-initialize query helper... */ + public function getLink($rel, $type=null) + { + // @fixme check for non-Atom links in RSS2 feeds as well + return self::getAtomLink($rel, $type); + } - public function feedMunger() + public function getAtomLink($rel, $type=null) { - require_once 'XML/Feed/Parser.php'; - $feed = new XML_Feed_Parser($this->body, false, false, true); // @fixme - return new FeedMunger($feed, $this->uri); + return ActivityUtils::getLink($this->feed->documentElement, $rel, $type); } /** @@ -90,6 +102,7 @@ class FeedDiscovery $client = new HTTPClient(); $response = $client->get($url); } catch (HTTP_Request2_Exception $e) { + common_log(LOG_ERR, __METHOD__ . " Failure for $url - " . $e->getMessage()); throw new FeedSubBadURLException($e); } @@ -107,7 +120,12 @@ class FeedDiscovery return $this->initFromResponse($response); } - + + function discoverFromFeedURL($url) + { + return $this->discoverFromURL($url, false); + } + function initFromResponse($response) { if (!$response->isOk()) { @@ -122,16 +140,26 @@ class FeedDiscovery $type = $response->getHeader('Content-Type'); if (preg_match('!^(text/xml|application/xml|application/(rss|atom)\+xml)!i', $type)) { - $this->uri = $sourceurl; - $this->type = $type; - $this->body = $body; - return true; + return $this->init($sourceurl, $type, $body); } else { common_log(LOG_WARNING, "Unrecognized feed type $type for $sourceurl"); throw new FeedSubUnrecognizedTypeException($type); } } + function init($sourceurl, $type, $body) + { + $feed = new DOMDocument(); + if ($feed->loadXML($body)) { + $this->uri = $sourceurl; + $this->type = $type; + $this->feed = $feed; + return $this->uri; + } else { + throw new FeedSubBadXmlException($url); + } + } + /** * @param string $url source URL, used to resolve relative links * @param string $body HTML body text diff --git a/plugins/OStatus/lib/feedmunger.php b/plugins/OStatus/lib/feedmunger.php deleted file mode 100644 index e8c46de90..000000000 --- a/plugins/OStatus/lib/feedmunger.php +++ /dev/null @@ -1,350 +0,0 @@ -<?php -/* - * StatusNet - the distributed open-source microblogging tool - * Copyright (C) 2009, StatusNet, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -/** - * @package FeedSubPlugin - * @maintainer Brion Vibber <brion@status.net> - */ - -if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } - -class FeedSubPreviewNotice extends Notice -{ - protected $fetched = true; - - function __construct($profile) - { - $this->profile = $profile; - $this->profile_id = 0; - } - - function getProfile() - { - return $this->profile; - } - - function find() - { - return true; - } - - function fetch() - { - $got = $this->fetched; - $this->fetched = false; - return $got; - } -} - -class FeedSubPreviewProfile extends Profile -{ - function getAvatar($width, $height=null) - { - return new FeedSubPreviewAvatar($width, $height, $this->avatar); - } -} - -class FeedSubPreviewAvatar extends Avatar -{ - function __construct($width, $height, $remote) - { - $this->remoteImage = $remote; - } - - function displayUrl() { - return $this->remoteImage; - } -} - -class FeedMunger -{ - /** - * @param XML_Feed_Parser $feed - */ - function __construct($feed, $url=null) - { - $this->feed = $feed; - $this->url = $url; - } - - function ostatusProfile() - { - $profile = new Ostatus_profile(); - $profile->feeduri = $this->url; - $profile->homeuri = $this->feed->link; - $profile->huburi = $this->getHubLink(); - $salmon = $this->getSalmonLink(); - if ($salmon) { - $profile->salmonuri = $salmon; - } - return $profile; - } - - function getAtomLink($item, $attribs=array()) - { - // XML_Feed_Parser gets confused by multiple <link> elements. - $dom = $item->model; - - // Note that RSS feeds would embed an <atom:link> so this should work for both. - /// http://code.google.com/p/pubsubhubbub/wiki/RssFeeds - // <link rel='hub' href='http://pubsubhubbub.appspot.com/'/> - $links = $dom->getElementsByTagNameNS('http://www.w3.org/2005/Atom', 'link'); - for ($i = 0; $i < $links->length; $i++) { - $node = $links->item($i); - if ($node->hasAttributes()) { - $href = $node->attributes->getNamedItem('href'); - if ($href) { - $matches = 0; - foreach ($attribs as $name => $val) { - $attrib = $node->attributes->getNamedItem($name); - if ($attrib && $attrib->value == $val) { - $matches++; - } - } - if ($matches == count($attribs)) { - return $href->value; - } - } - } - } - return false; - } - - function getRssLink($item) - { - // XML_Feed_Parser gets confused by multiple <link> elements. - $dom = $item->model; - - // Note that RSS feeds would embed an <atom:link> so this should work for both. - /// http://code.google.com/p/pubsubhubbub/wiki/RssFeeds - // <link rel='hub' href='http://pubsubhubbub.appspot.com/'/> - $links = $dom->getElementsByTagName('link'); - for ($i = 0; $i < $links->length; $i++) { - $node = $links->item($i); - if (!$node->hasAttributes()) { - return $node->textContent; - } - } - return false; - } - - function getAltLink($item) - { - // Check for an atom link... - $link = $this->getAtomLink($item, array('rel' => 'alternate', 'type' => 'text/html')); - if (!$link) { - $link = $this->getRssLink($item); - } - return $link; - } - - function getHubLink() - { - return $this->getAtomLink($this->feed, array('rel' => 'hub')); - } - - function getSalmonLink() - { - return $this->getAtomLink($this->feed, array('rel' => 'salmon')); - } - - function getSelfLink() - { - return $this->getAtomLink($this->feed, array('rel' => 'self')); - } - - /** - * Get an appropriate avatar image source URL, if available. - * @return mixed string or false - */ - function getAvatar() - { - $logo = $this->feed->logo; - if ($logo) { - return $logo; - } - $icon = $this->feed->icon; - if ($icon) { - return $icon; - } - return common_path('plugins/OStatus/images/48px-Feed-icon.svg.png'); - } - - function profile($preview=false) - { - if ($preview) { - $profile = new FeedSubPreviewProfile(); - } else { - $profile = new Profile(); - } - - // @todo validate/normalize nick? - $profile->nickname = $this->feed->title; - $profile->fullname = $this->feed->title; - $profile->homepage = $this->getAltLink($this->feed); - $profile->bio = $this->feed->description; - $profile->profileurl = $this->getAltLink($this->feed); - - if ($preview) { - $profile->avatar = $this->getAvatar(); - } - - // @todo tags from categories - // @todo lat/lon/location? - - return $profile; - } - - function notice($index=1, $preview=false) - { - $entry = $this->feed->getEntryByOffset($index); - if (!$entry) { - return null; - } - - if ($preview) { - $notice = new FeedSubPreviewNotice($this->profile(true)); - $notice->id = -1; - } else { - $notice = new Notice(); - $notice->profile_id = $this->profileIdForEntry($index); - } - - $link = $this->getAltLink($entry); - if (empty($link)) { - if (preg_match('!^https?://!', $entry->id)) { - $link = $entry->id; - common_log(LOG_DEBUG, "No link on entry, using URL from id: $link"); - } - } - $notice->uri = $link; - $notice->url = $link; - $notice->content = $this->noticeFromEntry($entry); - $notice->rendered = common_render_content($notice->content, $notice); // @fixme this is failing on group posts - $notice->created = common_sql_date($entry->updated); // @fixme - $notice->is_local = Notice::GATEWAY; - $notice->source = 'feed'; - - $location = $this->getLocation($entry); - if ($location) { - if ($location->location_id) { - $notice->location_ns = $location->location_ns; - $notice->location_id = $location->location_id; - } - $notice->lat = $location->lat; - $notice->lon = $location->lon; - } - - return $notice; - } - - function profileIdForEntry($index=1) - { - // hack hack hack - // should get profile for this entry's author... - $feeduri = $this->getSelfLink(); - $remote = Ostatus_profile::staticGet('feeduri', $feeduri); - if ($remote) { - return $remote->profile_id; - } else { - throw new Exception("Can't find feed profile for $feeduri"); - } - } - - /** - * Parse location given as a GeoRSS-simple point, if provided. - * http://www.georss.org/simple - * - * @param feed item $entry - * @return mixed Location or false - */ - function getLocation($entry) - { - $dom = $entry->model; - $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point'); - - for ($i = 0; $i < $points->length; $i++) { - $point = $points->item(0)->textContent; - $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace" - $point = preg_replace('/\s+/', ' ', $point); - $point = trim($point); - $coords = explode(' ', $point); - if (count($coords) == 2) { - list($lat, $lon) = $coords; - if (is_numeric($lat) && is_numeric($lon)) { - common_log(LOG_INFO, "Looking up location for $lat $lon from georss"); - return Location::fromLatLon($lat, $lon); - } - } - common_log(LOG_ERR, "Ignoring bogus georss:point value $point"); - } - - return false; - } - - /** - * @param XML_Feed_Type $entry - * @return string notice text, within post size limit - */ - function noticeFromEntry($entry) - { - $max = Notice::maxContent(); - $ellipsis = "\xe2\x80\xa6"; // U+2026 HORIZONTAL ELLIPSIS - $title = $entry->title; - $link = $entry->link; - - // @todo We can get <category> entries like this: - // $cats = $entry->getCategory('category', array(0, true)); - // but it feels like an awful hack. If it's accessible cleanly, - // try adding #hashtags from the categories/tags on a post. - - $title = $entry->title; - $link = $this->getAltLink($entry); - if ($link) { - // Blog post or such... - // @todo Should we force a language here? - $format = _m('New post: "%1$s" %2$s'); - $out = sprintf($format, $title, $link); - - // Trim link if needed... - if (mb_strlen($out) > $max) { - $link = common_shorten_url($link); - $out = sprintf($format, $title, $link); - } - - // Trim title if needed... - if (mb_strlen($out) > $max) { - $used = mb_strlen($out) - mb_strlen($title); - $available = $max - $used - mb_strlen($ellipsis); - $title = mb_substr($title, 0, $available) . $ellipsis; - $out = sprintf($format, $title, $link); - } - } else { - // No link? Consider a bare status update. - if (mb_strlen($title) > $max) { - $available = $max - mb_strlen($ellipsis); - $out = mb_substr($title, 0, $available) . $ellipsis; - } else { - $out = $title; - } - } - - return $out; - } -} diff --git a/plugins/OStatus/lib/hubverifyqueuehandler.php b/plugins/OStatus/lib/hubconfqueuehandler.php index 125d13a77..c8e0b72fe 100644 --- a/plugins/OStatus/lib/hubverifyqueuehandler.php +++ b/plugins/OStatus/lib/hubconfqueuehandler.php @@ -22,24 +22,25 @@ * @package Hub * @author Brion Vibber <brion@status.net> */ -class HubVerifyQueueHandler extends QueueHandler +class HubConfQueueHandler extends QueueHandler { function transport() { - return 'hubverify'; + return 'hubconf'; } function handle($data) { $sub = $data['sub']; $mode = $data['mode']; + $token = $data['token']; assert($sub instanceof HubSub); assert($mode === 'subscribe' || $mode === 'unsubscribe'); common_log(LOG_INFO, __METHOD__ . ": $mode $sub->callback $sub->topic"); try { - $sub->verify($mode); + $sub->verify($mode, $token); } catch (Exception $e) { common_log(LOG_ERR, "Failed PuSH $mode verify to $sub->callback for $sub->topic: " . $e->getMessage()); diff --git a/plugins/OStatus/lib/huboutqueuehandler.php b/plugins/OStatus/lib/huboutqueuehandler.php index 0791c7e5d..3ad94646e 100644 --- a/plugins/OStatus/lib/huboutqueuehandler.php +++ b/plugins/OStatus/lib/huboutqueuehandler.php @@ -33,6 +33,7 @@ class HubOutQueueHandler extends QueueHandler { $sub = $data['sub']; $atom = $data['atom']; + $retries = $data['retries']; assert($sub instanceof HubSub); assert(is_string($atom)); @@ -40,13 +41,20 @@ class HubOutQueueHandler extends QueueHandler try { $sub->push($atom); } catch (Exception $e) { - common_log(LOG_ERR, "Failed PuSH to $sub->callback for $sub->topic: " . - $e->getMessage()); - // @fixme Reschedule a later delivery? - return true; + $retries--; + $msg = "Failed PuSH to $sub->callback for $sub->topic: " . + $e->getMessage(); + if ($retries > 0) { + common_log(LOG_ERR, "$msg; scheduling for $retries more tries"); + + // @fixme when we have infrastructure to schedule a retry + // after a delay, use it. + $sub->distribute($atom, $retries); + } else { + common_log(LOG_ERR, "$msg; discarding"); + } } return true; } } - diff --git a/plugins/OStatus/lib/magicenvelope.php b/plugins/OStatus/lib/magicenvelope.php new file mode 100644 index 000000000..fb8c57c71 --- /dev/null +++ b/plugins/OStatus/lib/magicenvelope.php @@ -0,0 +1,215 @@ +<?php +/** + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * A sample module to show best practices for StatusNet plugins + * + * PHP version 5 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @package StatusNet + * @author James Walker <james@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class MagicEnvelope +{ + const ENCODING = 'base64url'; + + const NS = 'http://salmon-protocol.org/ns/magic-env'; + + private function normalizeUser($user_id) + { + if (substr($user_id, 0, 5) == 'http:' || + substr($user_id, 0, 6) == 'https:' || + substr($user_id, 0, 5) == 'acct:') { + return $user_id; + } + + if (strpos($user_id, '@') !== FALSE) { + return 'acct:' . $user_id; + } + + return 'http://' . $user_id; + } + + public function getKeyPair($signer_uri) + { + $disco = new Discovery(); + + try { + $xrd = $disco->lookup($signer_uri); + } catch (Exception $e) { + return false; + } + if ($xrd->links) { + if ($link = Discovery::getService($xrd->links, Magicsig::PUBLICKEYREL)) { + list($type, $keypair) = explode(';', $link['href']); + return $keypair; + } + } + throw new Exception('Unable to locate signer public key'); + } + + + public function signMessage($text, $mimetype, $keypair) + { + $signature_alg = Magicsig::fromString($keypair); + $armored_text = base64_encode($text); + + return array( + 'data' => $armored_text, + 'encoding' => MagicEnvelope::ENCODING, + 'data_type' => $mimetype, + 'sig' => $signature_alg->sign($armored_text), + 'alg' => $signature_alg->getName() + ); + + + } + + public function toXML($env) { + $dom = new DOMDocument(); + + $envelope = $dom->createElementNS(MagicEnvelope::NS, 'me:env'); + $envelope->setAttribute('xmlns:me', MagicEnvelope::NS); + $data = $dom->createElementNS(MagicEnvelope::NS, 'me:data', $env['data']); + $data->setAttribute('type', $env['data_type']); + $envelope->appendChild($data); + $enc = $dom->createElementNS(MagicEnvelope::NS, 'me:encoding', $env['encoding']); + $envelope->appendChild($enc); + $alg = $dom->createElementNS(MagicEnvelope::NS, 'me:alg', $env['alg']); + $envelope->appendChild($alg); + $sig = $dom->createElementNS(MagicEnvelope::NS, 'me:sig', $env['sig']); + $envelope->appendChild($sig); + + $dom->appendChild($envelope); + + + return $dom->saveXML(); + } + + + public function unfold($env) + { + $dom = new DOMDocument(); + $dom->loadXML(base64_decode($env['data'])); + + if ($dom->documentElement->tagName != 'entry') { + return false; + } + + $prov = $dom->createElementNS(MagicEnvelope::NS, 'me:provenance'); + $prov->setAttribute('xmlns:me', MagicEnvelope::NS); + $data = $dom->createElementNS(MagicEnvelope::NS, 'me:data', $env['data']); + $data->setAttribute('type', $env['data_type']); + $prov->appendChild($data); + $enc = $dom->createElementNS(MagicEnvelope::NS, 'me:encoding', $env['encoding']); + $prov->appendChild($enc); + $alg = $dom->createElementNS(MagicEnvelope::NS, 'me:alg', $env['alg']); + $prov->appendChild($alg); + $sig = $dom->createElementNS(MagicEnvelope::NS, 'me:sig', $env['sig']); + $prov->appendChild($sig); + + $dom->documentElement->appendChild($prov); + + return $dom->saveXML(); + } + + public function getAuthor($text) { + $doc = new DOMDocument(); + if (!$doc->loadXML($text)) { + return FALSE; + } + + if ($doc->documentElement->tagName == 'entry') { + $authors = $doc->documentElement->getElementsByTagName('author'); + foreach ($authors as $author) { + $uris = $author->getElementsByTagName('uri'); + foreach ($uris as $uri) { + return $this->normalizeUser($uri->nodeValue); + } + } + } + } + + public function checkAuthor($text, $signer_uri) + { + return ($this->getAuthor($text) == $signer_uri); + } + + public function verify($env) + { + if ($env['alg'] != 'RSA-SHA256') { + common_log(LOG_DEBUG, "Salmon error: bad algorithm"); + return false; + } + + if ($env['encoding'] != MagicEnvelope::ENCODING) { + common_log(LOG_DEBUG, "Salmon error: bad encoding"); + return false; + } + + $text = base64_decode($env['data']); + $signer_uri = $this->getAuthor($text); + + try { + $keypair = $this->getKeyPair($signer_uri); + } catch (Exception $e) { + common_log(LOG_DEBUG, "Salmon error: ".$e->getMessage()); + return false; + } + + $verifier = Magicsig::fromString($keypair); + + if (!$verifier) { + common_log(LOG_DEBUG, "Salmon error: unable to parse keypair"); + return false; + } + + return $verifier->verify($env['data'], $env['sig']); + } + + public function parse($text) + { + $dom = DOMDocument::loadXML($text); + return $this->fromDom($dom); + } + + public function fromDom($dom) + { + if ($dom->documentElement->tagName == 'entry') { + $env_element = $dom->getElementsByTagNameNS(MagicEnvelope::NS, 'provenance')->item(0); + } else if ($dom->documentElement->tagName == 'me:env') { + $env_element = $dom->documentElement; + } else { + return false; + } + + $data_element = $env_element->getElementsByTagNameNS(MagicEnvelope::NS, 'data')->item(0); + + return array( + 'data' => trim($data_element->nodeValue), + 'data_type' => $data_element->getAttribute('type'), + 'encoding' => $env_element->getElementsByTagNameNS(MagicEnvelope::NS, 'encoding')->item(0)->nodeValue, + 'alg' => $env_element->getElementsByTagNameNS(MagicEnvelope::NS, 'alg')->item(0)->nodeValue, + 'sig' => $env_element->getElementsByTagNameNS(MagicEnvelope::NS, 'sig')->item(0)->nodeValue, + ); + } + +} diff --git a/plugins/OStatus/lib/hubdistribqueuehandler.php b/plugins/OStatus/lib/ostatusqueuehandler.php index 245a57f72..d1e58f1d6 100644 --- a/plugins/OStatus/lib/hubdistribqueuehandler.php +++ b/plugins/OStatus/lib/ostatusqueuehandler.php @@ -18,46 +18,77 @@ */ /** - * 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 <brion@status.net> */ -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->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) { + // 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. + $oprofile->notifyDeferred($this->notice, $this->user); + } } /** @@ -122,62 +153,32 @@ 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()) { - common_log(LOG_INFO, "Prepping PuSH distribution to $sub->callback for $sub->topic"); - $data = array('sub' => clone($sub), - 'atom' => $atom); - $qm->enqueue($data, 'hubout'); + $sub->distribute($atom); } } /** * 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, - 'format' => 'atom', - 'max_id' => $notice->id, - 'since_id' => $notice->id - 1)); - $api->showTimeline(); - $feed = ob_get_clean(); - - // ...and override the content-type back to something normal... eww! - // hope there's no other headers that got set while we weren't looking. - header('Content-Type: text/html; charset=utf-8'); - - common_log(LOG_DEBUG, $feed); + $atom = new AtomUserNoticeFeed($this->user); + $atom->addEntryFromNotice($this->notice); + $feed = $atom->getString(); + 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 - - ob_start(); - $api = new ApiTimelineGroupAction(); - $args = array('id' => $group_id, - 'format' => 'atom', - 'max_id' => $notice->id, - 'since_id' => $notice->id - 1); - $api->prepare($args); - $api->handle($args); - $feed = ob_get_clean(); - - // ...and override the content-type back to something normal... eww! - // hope there's no other headers that got set while we weren't looking. - header('Content-Type: text/html; charset=utf-8'); - - common_log(LOG_DEBUG, $feed); + $group = User_group::staticGet('id', $group_id); + + $atom = new AtomGroupNoticeFeed($group); + $atom->addEntryFromNotice($this->notice); + $feed = $atom->getString(); + return $feed; } diff --git a/plugins/OStatus/lib/pushinqueuehandler.php b/plugins/OStatus/lib/pushinqueuehandler.php new file mode 100644 index 000000000..1fd29ae30 --- /dev/null +++ b/plugins/OStatus/lib/pushinqueuehandler.php @@ -0,0 +1,53 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * Process a feed distribution POST from a PuSH hub. + * @package FeedSub + * @author Brion Vibber <brion@status.net> + */ + +class PushInQueueHandler extends QueueHandler +{ + function transport() + { + return 'pushin'; + } + + function handle($data) + { + assert(is_array($data)); + + $feedsub_id = $data['feedsub_id']; + $post = $data['post']; + $hmac = $data['hmac']; + + $feedsub = FeedSub::staticGet('id', $feedsub_id); + if ($feedsub) { + try { + $feedsub->receive($post, $hmac); + } catch(Exception $e) { + common_log(LOG_ERR, "Exception during PuSH input processing for $feedsub->uri: " . $e->getMessage()); + } + } else { + common_log(LOG_ERR, "Discarding POST to unknown feed subscription id $feedsub_id"); + } + return true; + } +} diff --git a/plugins/OStatus/lib/salmon.php b/plugins/OStatus/lib/salmon.php index 8c77222a6..3d3341bc6 100644 --- a/plugins/OStatus/lib/salmon.php +++ b/plugins/OStatus/lib/salmon.php @@ -28,37 +28,83 @@ */ class Salmon { - public function post($endpoint_uri, $xml) + + const NS_REPLIES = "http://salmon-protocol.org/ns/salmon-replies"; + + const NS_MENTIONS = "http://salmon-protocol.org/ns/salmon-mention"; + + /** + * Sign and post the given Atom entry as a Salmon message. + * + * @fixme pass through the actor for signing? + * + * @param string $endpoint_uri + * @param string $xml + * @return boolean success + */ + public function post($endpoint_uri, $xml, $actor) { if (empty($endpoint_uri)) { - return FALSE; + return false; + } + + try { + $xml = $this->createMagicEnv($xml, $actor); + } catch (Exception $e) { + common_log(LOG_ERR, "Salmon unable to sign: " . $e->getMessage()); + return false; } - $headers = array('Content-type: application/atom+xml'); + $headers = array('Content-Type: application/magic-envelope+xml'); try { $client = new HTTPClient(); $client->setBody($xml); $response = $client->post($endpoint_uri, $headers); } catch (HTTP_Request2_Exception $e) { + common_log(LOG_ERR, "Salmon post to $endpoint_uri failed: " . $e->getMessage()); return false; } if ($response->getStatus() != 200) { + common_log(LOG_ERR, "Salmon at $endpoint_uri returned status " . + $response->getStatus() . ': ' . $response->getBody()); return false; } - + return true; } - public function createMagicEnv($text, $userid) + public function createMagicEnv($text, $actor) { + $magic_env = new MagicEnvelope(); + $user = User::staticGet('id', $actor->id); + if ($user->id) { + // Use local key + $magickey = Magicsig::staticGet('user_id', $user->id); + if (!$magickey) { + // No keypair yet, let's generate one. + $magickey = new Magicsig(); + $magickey->generate($user->id); + } + } else { + throw new Exception("Salmon invalid actor for signing"); + } + try { + $env = $magic_env->signMessage($text, 'application/atom+xml', $magickey->toString()); + } catch (Exception $e) { + return $text; + } + return $magic_env->toXML($env); } - public function verifyMagicEnv($env) + public function verifyMagicEnv($text) { + $magic_env = new MagicEnvelope(); + + $env = $magic_env->parse($text); - + return $magic_env->verify($env); } } diff --git a/plugins/OStatus/lib/salmonaction.php b/plugins/OStatus/lib/salmonaction.php new file mode 100644 index 000000000..fa9dc3b1d --- /dev/null +++ b/plugins/OStatus/lib/salmonaction.php @@ -0,0 +1,196 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * @package OStatusPlugin + * @author James Walker <james@status.net> + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class SalmonAction extends Action +{ + var $xml = null; + var $activity = null; + + function prepare($args) + { + StatusNet::setApi(true); // Send smaller error pages + + parent::prepare($args); + + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + $this->clientError(_m('This method requires a POST.')); + } + + if (empty($_SERVER['CONTENT_TYPE']) || $_SERVER['CONTENT_TYPE'] != 'application/magic-envelope+xml') { + $this->clientError(_m('Salmon requires application/magic-envelope+xml')); + } + + $xml = file_get_contents('php://input'); + + + // Check the signature + $salmon = new Salmon; + if (!$salmon->verifyMagicEnv($xml)) { + common_log(LOG_DEBUG, "Salmon signature verification failed."); + $this->clientError(_m('Salmon signature verification failed.')); + } else { + $magic_env = new MagicEnvelope(); + $env = $magic_env->parse($xml); + $xml = $magic_env->unfold($env); + } + + + $dom = DOMDocument::loadXML($xml); + if ($dom->documentElement->namespaceURI != Activity::ATOM || + $dom->documentElement->localName != 'entry') { + common_log(LOG_DEBUG, "Got invalid Salmon post: $xml"); + $this->clientError(_m('Salmon post must be an Atom entry.')); + } + + $this->act = new Activity($dom->documentElement); + return true; + } + + /** + * Check the posted activity type and break out to appropriate processing. + */ + + function handle($args) + { + StatusNet::setApi(true); // Send smaller error pages + + common_log(LOG_DEBUG, "Got a " . $this->act->verb); + if (Event::handle('StartHandleSalmon', array($this->activity))) { + switch ($this->act->verb) + { + case ActivityVerb::POST: + $this->handlePost(); + break; + case ActivityVerb::SHARE: + $this->handleShare(); + break; + case ActivityVerb::FAVORITE: + $this->handleFavorite(); + break; + case ActivityVerb::UNFAVORITE: + $this->handleUnfavorite(); + break; + case ActivityVerb::FOLLOW: + case ActivityVerb::FRIEND: + $this->handleFollow(); + break; + case ActivityVerb::UNFOLLOW: + $this->handleUnfollow(); + break; + case ActivityVerb::JOIN: + $this->handleJoin(); + break; + case ActivityVerb::LEAVE: + $this->handleLeave(); + break; + case ActivityVerb::UPDATE_PROFILE: + $this->handleUpdateProfile(); + break; + default: + throw new ClientException(_m("Unrecognized activity type.")); + } + Event::handle('EndHandleSalmon', array($this->activity)); + } + } + + function handlePost() + { + throw new ClientException(_m("This target doesn't understand posts.")); + } + + function handleFollow() + { + throw new ClientException(_m("This target doesn't understand follows.")); + } + + function handleUnfollow() + { + throw new ClientException(_m("This target doesn't understand unfollows.")); + } + + function handleFavorite() + { + throw new ClientException(_m("This target doesn't understand favorites.")); + } + + function handleUnfavorite() + { + throw new ClientException(_m("This target doesn't understand unfavorites.")); + } + + function handleShare() + { + throw new ClientException(_m("This target doesn't understand share events.")); + } + + function handleJoin() + { + throw new ClientException(_m("This target doesn't understand joins.")); + } + + function handleLeave() + { + throw new ClientException(_m("This target doesn't understand leave events.")); + } + + /** + * Remote user sent us an update to their profile. + * If we already know them, accept the updates. + */ + function handleUpdateProfile() + { + $oprofile = Ostatus_profile::getActorProfile($this->act); + if ($oprofile) { + common_log(LOG_INFO, "Got a profile-update ping from $oprofile->uri"); + $oprofile->updateFromActivityObject($this->act->actor); + } else { + common_log(LOG_INFO, "Ignoring profile-update ping from unknown " . $this->act->actor->id); + } + } + + /** + * @return Ostatus_profile + */ + function ensureProfile() + { + $actor = $this->act->actor; + if (empty($actor->id)) { + common_log(LOG_ERR, "broken actor: " . var_export($actor, true)); + common_log(LOG_ERR, "activity with no actor: " . var_export($this->act, true)); + throw new Exception("Received a salmon slap from unidentified actor."); + } + + return Ostatus_profile::ensureActivityObjectProfile($actor); + } + + function saveNotice() + { + $oprofile = $this->ensureProfile(); + return $oprofile->processPost($this->act, 'salmon'); + } +} diff --git a/plugins/OStatus/lib/salmonqueuehandler.php b/plugins/OStatus/lib/salmonqueuehandler.php new file mode 100644 index 000000000..7eeb5f8e9 --- /dev/null +++ b/plugins/OStatus/lib/salmonqueuehandler.php @@ -0,0 +1,46 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * Send a Salmon notification in the background. + * @package OStatusPlugin + * @author Brion Vibber <brion@status.net> + */ +class SalmonQueueHandler extends QueueHandler +{ + function transport() + { + return 'salmon'; + } + + function handle($data) + { + assert(is_array($data)); + assert(is_string($data['salmonuri'])); + assert(is_string($data['entry'])); + + $actor = Profile::staticGet($data['actor']); + + $salmon = new Salmon(); + $salmon->post($data['salmonuri'], $data['entry'], $actor); + + // @fixme detect failure and attempt to resend + return true; + } +} diff --git a/plugins/OStatus/lib/webfinger.php b/plugins/OStatus/lib/webfinger.php deleted file mode 100644 index 417d54904..000000000 --- a/plugins/OStatus/lib/webfinger.php +++ /dev/null @@ -1,143 +0,0 @@ -<?php -/** - * StatusNet - the distributed open-source microblogging tool - * Copyright (C) 2010, StatusNet, Inc. - * - * A sample module to show best practices for StatusNet plugins - * - * PHP version 5 - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - * @package StatusNet - * @author James Walker <james@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 - * @link http://status.net/ - */ - -define('WEBFINGER_SERVICE_REL_VALUE', 'lrdd'); - -/** - * Implement the webfinger protocol. - */ -class Webfinger -{ - /** - * Perform a webfinger lookup given an account. - */ - public function lookup($id) - { - $id = $this->normalize($id); - list($name, $domain) = explode('@', $id); - - $links = $this->getServiceLinks($domain); - if (!$links) { - return false; - } - - $services = array(); - foreach ($links as $link) { - if ($link['template']) { - return $this->getServiceDescription($link['template'], $id); - } - if ($link['href']) { - return $this->getServiceDescription($link['href'], $id); - } - } - } - - /** - * Normalize an account ID - */ - function normalize($id) - { - if (substr($id, 0, 7) == 'acct://') { - return substr($id, 7); - } else if (substr($id, 0, 5) == 'acct:') { - return substr($id, 5); - } - - return $id; - } - - function getServiceLinks($domain) - { - $url = 'http://'. $domain .'/.well-known/host-meta'; - $content = $this->fetchURL($url); - if (empty($content)) { - common_log(LOG_DEBUG, 'Error fetching host-meta'); - return false; - } - $result = XRD::parse($content); - - // Ensure that the host == domain (spec may include signing later) - if ($result->host != $domain) { - return false; - } - - $links = array(); - foreach ($result->links as $link) { - if ($link['rel'] == WEBFINGER_SERVICE_REL_VALUE) { - $links[] = $link; - } - - } - return $links; - } - - function getServiceDescription($template, $id) - { - $url = $this->applyTemplate($template, 'acct:' . $id); - - $content = $this->fetchURL($url); - - return XRD::parse($content); - } - - function fetchURL($url) - { - try { - $client = new HTTPClient(); - $response = $client->get($url); - } catch (HTTP_Request2_Exception $e) { - return false; - } - - if ($response->getStatus() != 200) { - return false; - } - - return $response->getBody(); - } - - function applyTemplate($template, $id) - { - $template = str_replace('{uri}', urlencode($id), $template); - - return $template; - } - - function getHostMeta($domain, $template) { - $xrd = new XRD(); - $xrd->host = $domain; - $xrd->links[] = array('rel' => 'lrdd', - 'template' => $template, - 'title' => array('Resource Descriptor')); - - return $xrd->toXML(); - } -} - - diff --git a/plugins/OStatus/lib/xrd.php b/plugins/OStatus/lib/xrd.php index 16d27f8eb..aa13ef024 100644 --- a/plugins/OStatus/lib/xrd.php +++ b/plugins/OStatus/lib/xrd.php @@ -53,17 +53,25 @@ class XRD $xrd = new XRD(); $dom = new DOMDocument(); - $dom->loadXML($xml); + if (!$dom->loadXML($xml)) { + throw new Exception("Invalid XML"); + } $xrd_element = $dom->getElementsByTagName('XRD')->item(0); + if (!$xrd_element) { + throw new Exception("Invalid XML, missing XRD root"); + } // Check for host-meta host - $host = $xrd_element->getElementsByTagName('Host')->item(0)->nodeValue; + $host = $xrd_element->getElementsByTagName('Host')->item(0); if ($host) { - $xrd->host = $host; + $xrd->host = $host->nodeValue; } // Loop through other elements foreach ($xrd_element->childNodes as $node) { + if (!($node instanceof DOMElement)) { + continue; + } switch ($node->tagName) { case 'Expires': $xrd->expires = $node->nodeValue; @@ -144,9 +152,11 @@ class XRD $link['href'] = $element->getAttribute('href'); $link['template'] = $element->getAttribute('template'); foreach ($element->childNodes as $node) { - switch($node->tagName) { - case 'Title': - $link['title'][] = $node->nodeValue; + if ($node instanceof DOMElement) { + switch($node->tagName) { + case 'Title': + $link['title'][] = $node->nodeValue; + } } } @@ -156,20 +166,20 @@ class XRD function saveLink($doc, $link) { $link_element = $doc->createElement('Link'); - if ($link['rel']) { + if (!empty($link['rel'])) { $link_element->setAttribute('rel', $link['rel']); } - if ($link['type']) { + if (!empty($link['type'])) { $link_element->setAttribute('type', $link['type']); } - if ($link['href']) { + if (!empty($link['href'])) { $link_element->setAttribute('href', $link['href']); } - if ($link['template']) { + if (!empty($link['template'])) { $link_element->setAttribute('template', $link['template']); } - if (is_array($link['title'])) { + if (!empty($link['title']) && is_array($link['title'])) { foreach($link['title'] as $title) { $title = $doc->createElement('Title', $title); $link_element->appendChild($title); diff --git a/plugins/OStatus/lib/xrdaction.php b/plugins/OStatus/lib/xrdaction.php new file mode 100644 index 000000000..6881292ad --- /dev/null +++ b/plugins/OStatus/lib/xrdaction.php @@ -0,0 +1,105 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * @package OStatusPlugin + * @maintainer James Walker <james@status.net> + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } + +class XrdAction extends Action +{ + + public $uri; + + public $user; + + public $xrd; + + function handle() + { + $nick = $this->user->nickname; + + if (empty($this->xrd)) { + $xrd = new XRD(); + } else { + $xrd = $this->xrd; + } + + if (empty($xrd->subject)) { + $xrd->subject = Discovery::normalize($this->uri); + } + $xrd->alias[] = common_profile_url($nick); + $xrd->links[] = array('rel' => Discovery::PROFILEPAGE, + 'type' => 'text/html', + 'href' => common_profile_url($nick)); + + $xrd->links[] = array('rel' => Discovery::UPDATESFROM, + 'href' => common_local_url('ApiTimelineUser', + array('id' => $this->user->id, + 'format' => 'atom')), + 'type' => 'application/atom+xml'); + + // hCard + $xrd->links[] = array('rel' => Discovery::HCARD, + 'type' => 'text/html', + 'href' => common_local_url('hcard', array('nickname' => $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 + $salmon_url = common_local_url('usersalmon', + array('id' => $this->user->id)); + + $xrd->links[] = array('rel' => Salmon::NS_REPLIES, + 'href' => $salmon_url); + + $xrd->links[] = array('rel' => Salmon::NS_MENTIONS, + '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($this->user->id); + } + + $xrd->links[] = array('rel' => Magicsig::PUBLICKEYREL, + 'href' => 'data:application/magic-public-key;'. $magickey->toString(false)); + + // 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', + 'template' => $url ); + + header('Content-type: text/xml'); + print $xrd->toXML(); + } + +} |