diff --git a/actions/apitimelineuser.php b/actions/apitimelineuser.php index 830b16941d..ed9104905d 100644 --- a/actions/apitimelineuser.php +++ b/actions/apitimelineuser.php @@ -145,10 +145,11 @@ class ApiTimelineUserAction extends ApiBareAuthAction ); break; case 'atom': - if (isset($apidata['api_arg'])) { + $id = $this->arg('id'); + if ($id) { $selfuri = common_root_url() . 'api/statuses/user_timeline/' . - $apidata['api_arg'] . '.atom'; + rawurlencode($id) . '.atom'; } else { $selfuri = common_root_url() . 'api/statuses/user_timeline.atom'; diff --git a/lib/api.php b/lib/api.php index f819752167..fd07bbbbe0 100644 --- a/lib/api.php +++ b/lib/api.php @@ -77,6 +77,7 @@ class ApiAction extends Action function prepare($args) { + StatusNet::setApi(true); // reduce exception reports to aid in debugging parent::prepare($args); $this->format = $this->arg('format'); diff --git a/lib/error.php b/lib/error.php index 87a4d913b4..a6a29119f7 100644 --- a/lib/error.php +++ b/lib/error.php @@ -56,6 +56,7 @@ class ErrorAction extends Action $this->code = $code; $this->message = $message; + $this->minimal = StatusNet::isApi(); // XXX: hack alert: usually we aren't going to // call this page directly, but because it's @@ -102,7 +103,14 @@ class ErrorAction extends Action function showPage() { - parent::showPage(); + if ($this->minimal) { + // Even more minimal -- we're in a machine API + // and don't want to flood the output. + $this->extraHeaders(); + $this->showContent(); + } else { + parent::showPage(); + } // We don't want to have any more output after this exit(); diff --git a/lib/httpclient.php b/lib/httpclient.php index 3f82620761..4c3af8d7dd 100644 --- a/lib/httpclient.php +++ b/lib/httpclient.php @@ -81,12 +81,13 @@ class HTTPResponse extends HTTP_Request2_Response } /** - * Check if the response is OK, generally a 200 status code. + * Check if the response is OK, generally a 200 or other 2xx status code. * @return bool */ function isOk() { - return ($this->getStatus() == 200); + $status = $this->getStatus(); + return ($status >= 200 && $status < 300); } } diff --git a/lib/mysqlschema.php b/lib/mysqlschema.php index 1f7c3d0926..485096ac42 100644 --- a/lib/mysqlschema.php +++ b/lib/mysqlschema.php @@ -213,6 +213,7 @@ class MysqlSchema extends Schema $sql .= "); "; + common_log(LOG_INFO, $sql); $res = $this->conn->query($sql); if (PEAR::isError($res)) { diff --git a/lib/statusnet.php b/lib/statusnet.php index 29e9030267..4f82fdaa6c 100644 --- a/lib/statusnet.php +++ b/lib/statusnet.php @@ -30,6 +30,7 @@ global $config, $_server, $_path; class StatusNet { protected static $have_config; + protected static $is_api; /** * Configure and instantiate a plugin into the current configuration. @@ -63,7 +64,7 @@ class StatusNet } } if (!class_exists($pluginclass)) { - throw new ServerException(500, "Plugin $name not found."); + throw new ServerException("Plugin $name not found.", 500); } } @@ -147,6 +148,16 @@ class StatusNet return self::$have_config; } + public function isApi() + { + return self::$is_api; + } + + public function setApi($mode) + { + self::$is_api = $mode; + } + /** * Build default configuration array * @return array diff --git a/plugins/FeedSub/feedinfo.sql b/plugins/FeedSub/feedinfo.sql deleted file mode 100644 index e9b53d26eb..0000000000 --- a/plugins/FeedSub/feedinfo.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE `feedinfo` ( - `id` int(11) NOT NULL auto_increment, - `profile_id` int(11) NOT NULL, - `feeduri` varchar(255) NOT NULL, - `homeuri` varchar(255) NOT NULL, - `huburi` varchar(255) NOT NULL, - `verify_token` varchar(32) default NULL, - `sub_start` datetime default NULL, - `sub_end` datetime default NULL, - `created` datetime NOT NULL, - `lastupdate` datetime NOT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `feedinfo_feeduri_idx` (`feeduri`) -) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; diff --git a/plugins/FeedSub/FeedSubPlugin.php b/plugins/OStatus/OStatusPlugin.php similarity index 69% rename from plugins/FeedSub/FeedSubPlugin.php rename to plugins/OStatus/OStatusPlugin.php index e49e2a648a..9419121121 100644 --- a/plugins/FeedSub/FeedSubPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -43,7 +43,7 @@ class FeedSubException extends Exception { } -class FeedSubPlugin extends Plugin +class OStatusPlugin extends Plugin { /** * Hook for RouterInitialized event. @@ -53,6 +53,8 @@ class FeedSubPlugin extends Plugin */ function onRouterInitialized($m) { + $m->connect('push/hub', array('action' => 'hub')); + $m->connect('feedsub/callback/:feed', array('action' => 'feedsubcallback'), array('feed' => '[0-9]+')); @@ -61,6 +63,46 @@ class FeedSubPlugin extends Plugin return true; } + /** + * Set up queue handlers for outgoing hub pushes + * @param QueueManager $qm + * @return boolean hook return + */ + function onEndInitializeQueueManager(QueueManager $qm) + { + $qm->connect('hubverify', 'HubVerifyQueueHandler'); + $qm->connect('hubdistrib', 'HubDistribQueueHandler'); + $qm->connect('hubout', 'HubOutQueueHandler'); + return true; + } + + /** + * Put saved notices into the queue for pubsub distribution. + */ + function onStartEnqueueNotice($notice, &$transports) + { + $transports[] = 'hubdistrib'; + return true; + } + + /** + * Set up a PuSH hub link to our internal link for canonical timeline + * Atom feeds for users. + */ + function onStartApiAtom(Action $action) + { + if ($action instanceof ApiTimelineUserAction) { + $id = $action->arg('id'); + if (strval(intval($id)) === strval($id)) { + // Canonical form of id in URL? + // Updates will be handled for our internal PuSH hub. + $action->element('link', array('rel' => 'hub', + 'href' => common_local_url('hub'))); + } + } + return true; + } + /** * Add the feed settings page to the Connect Settings menu * @@ -92,7 +134,8 @@ class FeedSubPlugin extends Plugin { $base = dirname(__FILE__); $lower = strtolower($cls); - $files = array("$base/$lower.php"); + $files = array("$base/classes/$cls.php", + "$base/lib/$lower.php"); if (substr($lower, -6) == 'action') { $files[] = "$base/actions/" . substr($lower, 0, -6) . ".php"; } @@ -110,6 +153,7 @@ class FeedSubPlugin extends Plugin // alter table feedinfo change column id id int(11) not null auto_increment; $schema = Schema::get(); $schema->ensureTable('feedinfo', Feedinfo::schemaDef()); + $schema->ensureTable('hubsub', HubSub::schemaDef()); return true; } } diff --git a/plugins/FeedSub/README b/plugins/OStatus/README similarity index 100% rename from plugins/FeedSub/README rename to plugins/OStatus/README diff --git a/plugins/FeedSub/actions/feedsubcallback.php b/plugins/OStatus/actions/feedsubcallback.php similarity index 94% rename from plugins/FeedSub/actions/feedsubcallback.php rename to plugins/OStatus/actions/feedsubcallback.php index 0c4280c1fa..c57ea5b101 100644 --- a/plugins/FeedSub/actions/feedsubcallback.php +++ b/plugins/OStatus/actions/feedsubcallback.php @@ -52,9 +52,14 @@ class FeedSubCallbackAction extends Action if (!$feedinfo) { throw new ServerException('Unknown feed id ' . $feedid, 400); } - + + $hmac = ''; + if (isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) { + $hmac = $_SERVER['HTTP_X_HUB_SIGNATURE']; + } + $post = file_get_contents('php://input'); - $feedinfo->postUpdates($post); + $feedinfo->postUpdates($post, $hmac); } /** diff --git a/plugins/FeedSub/actions/feedsubsettings.php b/plugins/OStatus/actions/feedsubsettings.php similarity index 97% rename from plugins/FeedSub/actions/feedsubsettings.php rename to plugins/OStatus/actions/feedsubsettings.php index 0fba20a393..4d5b7b60f4 100644 --- a/plugins/FeedSub/actions/feedsubsettings.php +++ b/plugins/OStatus/actions/feedsubsettings.php @@ -184,7 +184,7 @@ class FeedSubSettingsAction extends ConnectSettingsAction $this->munger = $discover->feedMunger(); $this->feedinfo = $this->munger->feedInfo(); - if ($this->feedinfo->huburi == '') { + if ($this->feedinfo->huburi == '' && !common_config('feedsub', 'nohub')) { $this->showForm(_m('Feed is not PuSH-enabled; cannot subscribe.')); return false; } @@ -213,7 +213,10 @@ class FeedSubSettingsAction extends ConnectSettingsAction // And subscribe the current user to the local profile $user = common_current_user(); $profile = $this->feedinfo->getProfile(); - + if (!$profile) { + throw new ServerException("Feed profile was not saved properly."); + } + if ($user->isSubscribed($profile)) { $this->showForm(_m('Already subscribed!')); } elseif ($user->subscribeTo($profile)) { diff --git a/plugins/OStatus/actions/hub.php b/plugins/OStatus/actions/hub.php new file mode 100644 index 0000000000..5caf4b48eb --- /dev/null +++ b/plugins/OStatus/actions/hub.php @@ -0,0 +1,176 @@ +. + */ + +/** + * Integrated PuSH hub; lets us only ping them what need it. + * @package Hub + * @maintainer Brion Vibber + */ + +/** + + +Things to consider... +* should we purge incomplete subscriptions that never get a verification pingback? +* when can we send subscription renewal checks? + - at next send time probably ok +* when can we handle trimming of subscriptions? + - at next send time probably ok +* should we keep a fail count? + +*/ + + +class HubAction extends Action +{ + function arg($arg, $def=null) + { + // PHP converts '.'s in incoming var names to '_'s. + // It also merges multiple values, which'll break hub.verify and hub.topic for publishing + // @fixme handle multiple args + $arg = str_replace('.', '_', $arg); + return parent::arg($arg, $def); + } + + function prepare($args) + { + StatusNet::setApi(true); // reduce exception reports to aid in debugging + return parent::prepare($args); + } + + function handle() + { + $mode = $this->trimmed('hub.mode'); + switch ($mode) { + case "subscribe": + $this->subscribe(); + break; + case "unsubscribe": + $this->unsubscribe(); + break; + case "publish": + throw new ServerException("Publishing outside feeds not supported.", 400); + default: + throw new ServerException("Unrecognized mode '$mode'.", 400); + } + } + + /** + * Process a PuSH feed subscription request. + * + * HTTP return codes: + * 202 Accepted - request saved and awaiting verification + * 204 No Content - already subscribed + * 403 Forbidden - rejecting this (not specifically spec'd) + */ + function subscribe() + { + $feed = $this->argUrl('hub.topic'); + $callback = $this->argUrl('hub.callback'); + + common_log(LOG_DEBUG, __METHOD__ . ": checking sub'd to $feed $callback"); + if ($this->getSub($feed, $callback)) { + // Already subscribed; return 204 per spec. + header('HTTP/1.1 204 No Content'); + common_log(LOG_DEBUG, __METHOD__ . ': already subscribed'); + return; + } + + common_log(LOG_DEBUG, __METHOD__ . ': setting up'); + $sub = new HubSub(); + $sub->topic = $feed; + $sub->callback = $callback; + $sub->secret = $this->arg('hub.secret', null); + $sub->setLease(intval($this->arg('hub.lease_seconds'))); + + // @fixme check for feeds we don't manage + // @fixme check the verification mode, might want a return immediately? + + common_log(LOG_DEBUG, __METHOD__ . ': inserting'); + $ok = $sub->insert(); + + if (!$ok) { + throw new ServerException("Failed to save subscription record", 500); + } + + // @fixme check errors ;) + + $data = array('sub' => $sub, 'mode' => 'subscribe'); + $qm = QueueManager::get(); + $qm->enqueue($data, 'hubverify'); + + header('HTTP/1.1 202 Accepted'); + common_log(LOG_DEBUG, __METHOD__ . ': done'); + } + + /** + * Process a PuSH feed unsubscription request. + * + * HTTP return codes: + * 202 Accepted - request saved and awaiting verification + * 204 No Content - already subscribed + * 400 Bad Request - invalid params or rejected feed + */ + function unsubscribe() + { + $feed = $this->argUrl('hub.topic'); + $callback = $this->argUrl('hub.callback'); + $sub = $this->getSub($feed, $callback); + + if ($sub) { + if ($sub->verify('unsubscribe')) { + $sub->delete(); + common_log(LOG_INFO, "PuSH unsubscribed $feed for $callback"); + } else { + throw new ServerException("Failed PuSH unsubscription: verification failed! $feed for $callback"); + } + } else { + throw new ServerException("Failed PuSH unsubscription: not subscribed! $feed for $callback"); + } + } + + /** + * Grab and validate a URL from POST parameters. + * @throws ServerException for malformed or non-http/https URLs + */ + protected function argUrl($arg) + { + $url = $this->arg($arg); + $params = array('domain_check' => false, // otherwise breaks my local tests :P + 'allowed_schemes' => array('http', 'https')); + if (Validate::uri($url, $params)) { + return $url; + } else { + throw new ServerException("Invalid URL passed for $arg: '$url'", 400); + } + } + + /** + * Get HubSub subscription record for a given feed & subscriber. + * + * @param string $feed + * @param string $callback + * @return mixed HubSub or false + */ + protected function getSub($feed, $callback) + { + return HubSub::staticGet($feed, $callback); + } +} + diff --git a/plugins/FeedSub/feedinfo.php b/plugins/OStatus/classes/Feedinfo.php similarity index 67% rename from plugins/FeedSub/feedinfo.php rename to plugins/OStatus/classes/Feedinfo.php index b166bd6e12..f29d08cb03 100644 --- a/plugins/FeedSub/feedinfo.php +++ b/plugins/OStatus/classes/Feedinfo.php @@ -1,8 +1,29 @@ . + */ + +/** + * @package FeedSubPlugin + * @maintainer Brion Vibber + */ /* - -Subscription flow: +PuSH subscription flow: $feedinfo->subscribe() generate random verification token @@ -16,7 +37,6 @@ Subscription flow: feedsub/callback hub sends us updates via POST - ? */ @@ -43,6 +63,7 @@ class Feedinfo extends Memcached_DataObject public $huburi; // PuSH subscription data + public $secret; public $verify_token; public $sub_start; public $sub_end; @@ -72,6 +93,7 @@ class Feedinfo extends Memcached_DataObject 'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, 'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, 'huburi' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'secret' => DB_DATAOBJECT_STR, 'verify_token' => DB_DATAOBJECT_STR, 'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, 'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, @@ -98,6 +120,8 @@ class Feedinfo extends Memcached_DataObject 255, false), new ColumnDef('verify_token', 'varchar', 32, true), + new ColumnDef('secret', 'varchar', + 64, true), new ColumnDef('sub_start', 'datetime', null, true), new ColumnDef('sub_end', 'datetime', @@ -119,7 +143,7 @@ class Feedinfo extends Memcached_DataObject function keys() { - return array('id' => 'P'); //? + return array_keys($this->keyTypes()); } /** @@ -133,7 +157,12 @@ class Feedinfo extends Memcached_DataObject function keyTypes() { - return $this->keys(); + return array('id' => 'K'); // @fixme we'll need a profile_id key at least + } + + function sequenceKey() + { + return array('id', true, false); } /** @@ -161,6 +190,10 @@ class Feedinfo extends Memcached_DataObject $feedinfo->query('BEGIN'); + // Awful hack! Awful hack! + $feedinfo->verify = common_good_rand(16); + $feedinfo->secret = common_good_rand(32); + try { $profile = $munger->profile(); $result = $profile->insert(); @@ -168,6 +201,21 @@ class Feedinfo extends Memcached_DataObject throw new FeedDBException($profile); } + $avatar = $munger->getAvatar(); + if ($avatar) { + // @fixme this should be better encapsulated + // ripped from oauthstore.php (for old OMB client) + $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar'); + copy($avatar, $temp_filename); + $imagefile = new ImageFile($profile->id, $temp_filename); + $filename = Avatar::filename($profile->id, + image_type_to_extension($imagefile->type), + null, + common_timestamp()); + rename($temp_filename, Avatar::path($filename)); + $profile->setOriginal($filename); + } + $feedinfo->profile_id = $profile->id; $result = $feedinfo->insert(); if (empty($result)) { @@ -191,27 +239,38 @@ class Feedinfo extends Memcached_DataObject */ public function subscribe() { + if (common_config('feedsub', 'nohub')) { + // Fake it! We're just testing remote feeds w/o hubs. + return true; + } // @fixme use the verification token #$token = md5(mt_rand() . ':' . $this->feeduri); #$this->verify_token = $token; #$this->update(); // @fixme - try { $callback = common_local_url('feedsubcallback', array('feed' => $this->id)); $headers = array('Content-Type: application/x-www-form-urlencoded'); $post = array('hub.mode' => 'subscribe', 'hub.callback' => $callback, 'hub.verify' => 'async', - //'hub.verify_token' => $token, + 'hub.verify_token' => $this->verify_token, + 'hub.secret' => $this->secret, //'hub.lease_seconds' => 0, 'hub.topic' => $this->feeduri); $client = new HTTPClient(); $response = $client->post($this->huburi, $headers, $post); - if ($response->getStatus() >= 200 && $response->getStatus() < 300) { - common_log(LOG_INFO, __METHOD__ . ': sub req ok'); + $status = $response->getStatus(); + if ($status == 202) { + common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback'); return true; + } else if ($status == 204) { + common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified'); + return true; + } else if ($status >= 200 && $status < 300) { + common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody()); + return false; } else { - common_log(LOG_INFO, __METHOD__ . ': sub req failed'); + common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody()); return false; } } catch (Exception $e) { @@ -227,10 +286,29 @@ class Feedinfo extends Memcached_DataObject * coming from a PuSH hub. * * @param string $xml source of Atom or RSS feed + * @param string $hmac X-Hub-Signature header, if present */ - public function postUpdates($xml) + public function postUpdates($xml, $hmac) { - common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $xml"); + common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml"); + + if ($this->secret) { + if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { + $their_hmac = strtolower($matches[1]); + $our_hmac = sha1($xml . $this->secret); + if ($their_hmac !== $our_hmac) { + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac"); + return; + } + } else { + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'"); + return; + } + } else if ($hmac) { + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'"); + return; + } + require_once "XML/Feed/Parser.php"; $feed = new XML_Feed_Parser($xml, false, false, true); $munger = new FeedMunger($feed); @@ -246,8 +324,7 @@ class Feedinfo extends Memcached_DataObject // @fixme this could explode horribly for multiple feeds on a blog. sigh $dupe = new Notice(); $dupe->uri = $notice->uri; - $dupe->find(); - if ($dupe->fetch()) { + if ($dupe->find(true)) { common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}"); continue; } diff --git a/plugins/OStatus/classes/HubSub.php b/plugins/OStatus/classes/HubSub.php new file mode 100644 index 0000000000..1769f6c941 --- /dev/null +++ b/plugins/OStatus/classes/HubSub.php @@ -0,0 +1,272 @@ +. + */ + +/** + * PuSH feed subscription record + * @package Hub + * @author Brion Vibber + */ +class HubSub extends Memcached_DataObject +{ + public $__table = 'hubsub'; + + public $hashkey; // sha1(topic . '|' . $callback); (topic, callback) key is too long for myisam in utf8 + public $topic; + public $callback; + public $secret; + public $verify_token; + public $challenge; + public $lease; + public $sub_start; + public $sub_end; + public $created; + + public /*static*/ function staticGet($topic, $callback) + { + return parent::staticGet(__CLASS__, 'hashkey', self::hashkey($topic, $callback)); + } + + protected static function hashkey($topic, $callback) + { + return sha1($topic . '|' . $callback); + } + + /** + * return table definition for DB_DataObject + * + * DB_DataObject needs to know something about the table to manipulate + * instances. This method provides all the DB_DataObject needs to know. + * + * @return array array of column definitions + */ + + function table() + { + return array('hashkey' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'topic' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'callback' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'secret' => DB_DATAOBJECT_STR, + 'verify_token' => DB_DATAOBJECT_STR, + 'challenge' => DB_DATAOBJECT_STR, + 'lease' => DB_DATAOBJECT_INT, + 'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, + 'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, + 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); + } + + static function schemaDef() + { + return array(new ColumnDef('hashkey', 'char', + /*size*/40, + /*nullable*/false, + /*key*/'PRI'), + new ColumnDef('topic', 'varchar', + /*size*/255, + /*nullable*/false, + /*key*/'KEY'), + new ColumnDef('callback', 'varchar', + 255, false), + new ColumnDef('secret', 'text', + null, true), + new ColumnDef('verify_token', 'text', + null, true), + new ColumnDef('challenge', 'varchar', + 32, true), + new ColumnDef('lease', 'int', + null, true), + new ColumnDef('sub_start', 'datetime', + null, true), + new ColumnDef('sub_end', 'datetime', + null, true), + new ColumnDef('created', 'datetime', + null, false)); + } + + function keys() + { + return array_keys($this->keyTypes()); + } + + function sequenceKeys() + { + return array(false, false, false); + } + + /** + * return key definitions for DB_DataObject + * + * DB_DataObject needs to know about keys that the table has; this function + * defines them. + * + * @return array key definitions + */ + + function keyTypes() + { + return array('hashkey' => 'K'); + } + + /** + * Validates a requested lease length, sets length plus + * subscription start & end dates. + * + * Does not save to database -- use before insert() or update(). + * + * @param int $length in seconds + */ + function setLease($length) + { + assert(is_int($length)); + + $min = 86400; + $max = 86400 * 30; + + if ($length == 0) { + // We want to garbage collect dead subscriptions! + $length = $max; + } elseif( $length < $min) { + $length = $min; + } else if ($length > $max) { + $length = $max; + } + + $this->lease = $length; + $this->start_sub = common_sql_now(); + $this->end_sub = common_sql_date(time() + $length); + } + + /** + * Send a verification ping to subscriber + * @param string $mode 'subscribe' or 'unsubscribe' + */ + function verify($mode) + { + assert($mode == 'subscribe' || $mode == 'unsubscribe'); + + // Is this needed? data object fun... + $clone = clone($this); + $clone->challenge = common_good_rand(16); + $clone->update($this); + $this->challenge = $clone->challenge; + unset($clone); + + $params = array('hub.mode' => $mode, + 'hub.topic' => $this->topic, + 'hub.challenge' => $this->challenge); + if ($mode == 'subscribe') { + $params['hub.lease_seconds'] = $this->lease; + } + if ($this->verify_token) { + $params['hub.verify_token'] = $this->verify_token; + } + $url = $this->callback . '?' . http_build_query($params, '', '&'); // @fixme ugly urls + + try { + $request = new HTTPClient(); + $response = $request->get($url); + $status = $response->getStatus(); + + if ($status >= 200 && $status < 300) { + $fail = false; + } else { + // @fixme how can we schedule a second attempt? + // Or should we? + $fail = "Returned HTTP $status"; + } + } catch (Exception $e) { + $fail = $e->getMessage(); + } + if ($fail) { + // @fixme how can we schedule a second attempt? + // or save a fail count? + // Or should we? + common_log(LOG_ERR, "Failed to verify $mode for $this->topic at $this->callback: $fail"); + return false; + } else { + if ($mode == 'subscribe') { + // Establish or renew the subscription! + // This seems unnecessary... dataobject fun! + $clone = clone($this); + $clone->challenge = null; + $clone->setLease($this->lease); + $clone->update($this); + unset($clone); + + $this->challenge = null; + $this->setLease($this->lease); + common_log(LOG_ERR, "Verified $mode of $this->callback:$this->topic for $this->lease seconds"); + } else if ($mode == 'unsubscribe') { + common_log(LOG_ERR, "Verified $mode of $this->callback:$this->topic"); + $this->delete(); + } + return true; + } + } + + /** + * Insert wrapper; transparently set the hash key from topic and callback columns. + * @return boolean success + */ + function insert() + { + $this->hashkey = self::hashkey($this->topic, $this->callback); + return parent::insert(); + } + + /** + * Send a 'fat ping' to the subscriber's callback endpoint + * containing the given Atom feed chunk. + * + * Determination of which items to send should be done at + * a higher level; don't just shove in a complete feed! + * + * @param string $atom well-formed Atom feed + */ + function push($atom) + { + $headers = array('Content-Type: application/atom+xml'); + if ($this->secret) { + $hmac = sha1($atom . $this->secret); + $headers[] = "X-Hub-Signature: sha1=$hmac"; + } else { + $hmac = '(none)'; + } + common_log(LOG_INFO, "About to push feed to $this->callback for $this->topic, HMAC $hmac"); + try { + $request = new HTTPClient(); + $request->setBody($atom); + $response = $request->post($this->callback, $headers); + + if ($response->isOk()) { + return true; + } + common_log(LOG_ERR, "Error sending PuSH content " . + "to $this->callback for $this->topic: " . + $response->getStatus()); + return false; + + } catch (Exception $e) { + common_log(LOG_ERR, "Error sending PuSH content " . + "to $this->callback for $this->topic: " . + $e->getMessage()); + return false; + } + } +} + diff --git a/plugins/FeedSub/extlib/README b/plugins/OStatus/extlib/README similarity index 100% rename from plugins/FeedSub/extlib/README rename to plugins/OStatus/extlib/README diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser.php b/plugins/OStatus/extlib/XML/Feed/Parser.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser.php rename to plugins/OStatus/extlib/XML/Feed/Parser.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/Atom.php b/plugins/OStatus/extlib/XML/Feed/Parser/Atom.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/Atom.php rename to plugins/OStatus/extlib/XML/Feed/Parser/Atom.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/AtomElement.php b/plugins/OStatus/extlib/XML/Feed/Parser/AtomElement.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/AtomElement.php rename to plugins/OStatus/extlib/XML/Feed/Parser/AtomElement.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/Exception.php b/plugins/OStatus/extlib/XML/Feed/Parser/Exception.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/Exception.php rename to plugins/OStatus/extlib/XML/Feed/Parser/Exception.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS09.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS09.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS09.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS09.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS09Element.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS09Element.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS09Element.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS09Element.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS1.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS1.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS1.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS1.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS11.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS11.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS11.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS11.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS11Element.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS11Element.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS11Element.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS11Element.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS1Element.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS1Element.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS1Element.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS1Element.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS2.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS2.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS2.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS2.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/RSS2Element.php b/plugins/OStatus/extlib/XML/Feed/Parser/RSS2Element.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/RSS2Element.php rename to plugins/OStatus/extlib/XML/Feed/Parser/RSS2Element.php diff --git a/plugins/FeedSub/extlib/XML/Feed/Parser/Type.php b/plugins/OStatus/extlib/XML/Feed/Parser/Type.php similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/Parser/Type.php rename to plugins/OStatus/extlib/XML/Feed/Parser/Type.php diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/atom10-entryonly.xml b/plugins/OStatus/extlib/XML/Feed/samples/atom10-entryonly.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/atom10-entryonly.xml rename to plugins/OStatus/extlib/XML/Feed/samples/atom10-entryonly.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/atom10-example1.xml b/plugins/OStatus/extlib/XML/Feed/samples/atom10-example1.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/atom10-example1.xml rename to plugins/OStatus/extlib/XML/Feed/samples/atom10-example1.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/atom10-example2.xml b/plugins/OStatus/extlib/XML/Feed/samples/atom10-example2.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/atom10-example2.xml rename to plugins/OStatus/extlib/XML/Feed/samples/atom10-example2.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/delicious.feed b/plugins/OStatus/extlib/XML/Feed/samples/delicious.feed similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/delicious.feed rename to plugins/OStatus/extlib/XML/Feed/samples/delicious.feed diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/flickr.feed b/plugins/OStatus/extlib/XML/Feed/samples/flickr.feed similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/flickr.feed rename to plugins/OStatus/extlib/XML/Feed/samples/flickr.feed diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/grwifi-atom.xml b/plugins/OStatus/extlib/XML/Feed/samples/grwifi-atom.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/grwifi-atom.xml rename to plugins/OStatus/extlib/XML/Feed/samples/grwifi-atom.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/hoder.xml b/plugins/OStatus/extlib/XML/Feed/samples/hoder.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/hoder.xml rename to plugins/OStatus/extlib/XML/Feed/samples/hoder.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/illformed_atom10.xml b/plugins/OStatus/extlib/XML/Feed/samples/illformed_atom10.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/illformed_atom10.xml rename to plugins/OStatus/extlib/XML/Feed/samples/illformed_atom10.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss091-complete.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss091-complete.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss091-complete.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss091-complete.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss091-international.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss091-international.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss091-international.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss091-international.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss091-simple.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss091-simple.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss091-simple.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss091-simple.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss092-sample.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss092-sample.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss092-sample.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss092-sample.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss10-example1.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss10-example1.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss10-example1.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss10-example1.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss10-example2.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss10-example2.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss10-example2.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss10-example2.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/rss2sample.xml b/plugins/OStatus/extlib/XML/Feed/samples/rss2sample.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/rss2sample.xml rename to plugins/OStatus/extlib/XML/Feed/samples/rss2sample.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/sixapart-jp.xml b/plugins/OStatus/extlib/XML/Feed/samples/sixapart-jp.xml similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/sixapart-jp.xml rename to plugins/OStatus/extlib/XML/Feed/samples/sixapart-jp.xml diff --git a/plugins/FeedSub/extlib/XML/Feed/samples/technorati.feed b/plugins/OStatus/extlib/XML/Feed/samples/technorati.feed similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/samples/technorati.feed rename to plugins/OStatus/extlib/XML/Feed/samples/technorati.feed diff --git a/plugins/FeedSub/extlib/XML/Feed/schemas/atom.rnc b/plugins/OStatus/extlib/XML/Feed/schemas/atom.rnc similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/schemas/atom.rnc rename to plugins/OStatus/extlib/XML/Feed/schemas/atom.rnc diff --git a/plugins/FeedSub/extlib/XML/Feed/schemas/rss10.rnc b/plugins/OStatus/extlib/XML/Feed/schemas/rss10.rnc similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/schemas/rss10.rnc rename to plugins/OStatus/extlib/XML/Feed/schemas/rss10.rnc diff --git a/plugins/FeedSub/extlib/XML/Feed/schemas/rss11.rnc b/plugins/OStatus/extlib/XML/Feed/schemas/rss11.rnc similarity index 100% rename from plugins/FeedSub/extlib/XML/Feed/schemas/rss11.rnc rename to plugins/OStatus/extlib/XML/Feed/schemas/rss11.rnc diff --git a/plugins/FeedSub/extlib/xml-feed-parser-bug-16416.patch b/plugins/OStatus/extlib/xml-feed-parser-bug-16416.patch similarity index 100% rename from plugins/FeedSub/extlib/xml-feed-parser-bug-16416.patch rename to plugins/OStatus/extlib/xml-feed-parser-bug-16416.patch diff --git a/plugins/FeedSub/images/24px-Feed-icon.svg.png b/plugins/OStatus/images/24px-Feed-icon.svg.png similarity index 100% rename from plugins/FeedSub/images/24px-Feed-icon.svg.png rename to plugins/OStatus/images/24px-Feed-icon.svg.png diff --git a/plugins/FeedSub/images/48px-Feed-icon.svg.png b/plugins/OStatus/images/48px-Feed-icon.svg.png similarity index 100% rename from plugins/FeedSub/images/48px-Feed-icon.svg.png rename to plugins/OStatus/images/48px-Feed-icon.svg.png diff --git a/plugins/FeedSub/images/96px-Feed-icon.svg.png b/plugins/OStatus/images/96px-Feed-icon.svg.png similarity index 100% rename from plugins/FeedSub/images/96px-Feed-icon.svg.png rename to plugins/OStatus/images/96px-Feed-icon.svg.png diff --git a/plugins/FeedSub/images/README b/plugins/OStatus/images/README similarity index 100% rename from plugins/FeedSub/images/README rename to plugins/OStatus/images/README diff --git a/plugins/FeedSub/feeddiscovery.php b/plugins/OStatus/lib/feeddiscovery.php similarity index 94% rename from plugins/FeedSub/feeddiscovery.php rename to plugins/OStatus/lib/feeddiscovery.php index 35edaca33a..9bc7892fb2 100644 --- a/plugins/FeedSub/feeddiscovery.php +++ b/plugins/OStatus/lib/feeddiscovery.php @@ -48,6 +48,18 @@ class FeedSubNoFeedException extends FeedSubException { } +/** + * Given a web page or feed URL, discover the final location of the feed + * and return its current contents. + * + * @example + * $feed = new FeedDiscovery(); + * if ($feed->discoverFromURL($url)) { + * print $feed->uri; + * print $feed->type; + * processFeed($feed->body); + * } + */ class FeedDiscovery { public $uri; @@ -64,7 +76,7 @@ class FeedDiscovery /** * @param string $url - * @param bool $htmlOk + * @param bool $htmlOk pass false here if you don't want to follow web pages. * @return string with validated URL * @throws FeedSubBadURLException * @throws FeedSubBadHtmlException diff --git a/plugins/FeedSub/feedmunger.php b/plugins/OStatus/lib/feedmunger.php similarity index 87% rename from plugins/FeedSub/feedmunger.php rename to plugins/OStatus/lib/feedmunger.php index f3618b8eb0..eeb8d2df39 100644 --- a/plugins/FeedSub/feedmunger.php +++ b/plugins/OStatus/lib/feedmunger.php @@ -30,8 +30,8 @@ class FeedSubPreviewNotice extends Notice function __construct($profile) { - //parent::__construct(); // uhhh? $this->profile = $profile; + $this->profile_id = 0; } function getProfile() @@ -56,14 +56,19 @@ class FeedSubPreviewProfile extends Profile { function getAvatar($width, $height=null) { - return new FeedSubPreviewAvatar($width, $height); + return new FeedSubPreviewAvatar($width, $height, $this->avatar); } } class FeedSubPreviewAvatar extends Avatar { + function __construct($width, $height, $remote) + { + $this->remoteImage = $remote; + } + function displayUrl() { - return common_path('plugins/FeedSub/images/48px-Feed-icon.svg.png'); + return $this->remoteImage; } } @@ -150,6 +155,23 @@ class FeedMunger return $this->getAtomLink($this->feed, array('rel' => 'hub')); } + /** + * 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) { @@ -164,6 +186,10 @@ class FeedMunger $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? @@ -186,6 +212,12 @@ class FeedMunger } $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); diff --git a/plugins/OStatus/lib/hubdistribqueuehandler.php b/plugins/OStatus/lib/hubdistribqueuehandler.php new file mode 100644 index 0000000000..126f1355f9 --- /dev/null +++ b/plugins/OStatus/lib/hubdistribqueuehandler.php @@ -0,0 +1,87 @@ +. + */ + +/** + * Send a PuSH subscription verification from our internal hub. + * Queue up final distribution for + * @package Hub + * @author Brion Vibber + */ +class HubDistribQueueHandler extends QueueHandler +{ + function transport() + { + return 'hubdistrib'; + } + + function handle($notice) + { + assert($notice instanceof Notice); + + // 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')); + $sub = new HubSub(); + $sub->topic = $feed; + if ($sub->find()) { + common_log(LOG_INFO, "Preparing $sub->N PuSH distribution(s) for $feed"); + $qm = QueueManager::get(); + $atom = $this->userFeedForNotice($notice); + while ($sub->fetch()) { + common_log(LOG_INFO, "Prepping PuSH distribution to $sub->callback for $feed"); + $data = array('sub' => clone($sub), + 'atom' => $atom); + $qm->enqueue($data, 'hubout'); + } + } else { + common_log(LOG_INFO, "No PuSH subscribers for $feed"); + } + } + + /** + * Build a single-item version of the sending user's Atom feed. + * @param Notice $notice + * @return string + */ + function userFeedForNotice($notice) + { + // @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); + return $feed; + } +} + diff --git a/plugins/OStatus/lib/huboutqueuehandler.php b/plugins/OStatus/lib/huboutqueuehandler.php new file mode 100644 index 0000000000..cb44ad2c4e --- /dev/null +++ b/plugins/OStatus/lib/huboutqueuehandler.php @@ -0,0 +1,52 @@ +. + */ + +/** + * Send a raw PuSH atom update from our internal hub. + * @package Hub + * @author Brion Vibber + */ +class HubOutQueueHandler extends QueueHandler +{ + function transport() + { + return 'hubout'; + } + + function handle($data) + { + $sub = $data['sub']; + $atom = $data['atom']; + + assert($sub instanceof HubSub); + assert(is_string($atom)); + + 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? + // Currently we have no way to do this other than 'send NOW' + } + + return true; + } +} + diff --git a/plugins/OStatus/lib/hubverifyqueuehandler.php b/plugins/OStatus/lib/hubverifyqueuehandler.php new file mode 100644 index 0000000000..125d13a777 --- /dev/null +++ b/plugins/OStatus/lib/hubverifyqueuehandler.php @@ -0,0 +1,53 @@ +. + */ + +/** + * Send a PuSH subscription verification from our internal hub. + * @package Hub + * @author Brion Vibber + */ +class HubVerifyQueueHandler extends QueueHandler +{ + function transport() + { + return 'hubverify'; + } + + function handle($data) + { + $sub = $data['sub']; + $mode = $data['mode']; + + assert($sub instanceof HubSub); + assert($mode === 'subscribe' || $mode === 'unsubscribe'); + + common_log(LOG_INFO, __METHOD__ . ": $mode $sub->callback $sub->topic"); + try { + $sub->verify($mode); + } catch (Exception $e) { + common_log(LOG_ERR, "Failed PuSH $mode verify to $sub->callback for $sub->topic: " . + $e->getMessage()); + // @fixme schedule retry? + // @fixme just kill it? + } + + return true; + } +} + diff --git a/plugins/FeedSub/locale/FeedSub.po b/plugins/OStatus/locale/OStatus.po similarity index 100% rename from plugins/FeedSub/locale/FeedSub.po rename to plugins/OStatus/locale/OStatus.po diff --git a/plugins/FeedSub/locale/fr/LC_MESSAGES/FeedSub.po b/plugins/OStatus/locale/fr/LC_MESSAGES/OStatus.po similarity index 100% rename from plugins/FeedSub/locale/fr/LC_MESSAGES/FeedSub.po rename to plugins/OStatus/locale/fr/LC_MESSAGES/OStatus.po diff --git a/plugins/FeedSub/tests/FeedDiscoveryTest.php b/plugins/OStatus/tests/FeedDiscoveryTest.php similarity index 100% rename from plugins/FeedSub/tests/FeedDiscoveryTest.php rename to plugins/OStatus/tests/FeedDiscoveryTest.php diff --git a/plugins/FeedSub/tests/FeedMungerTest.php b/plugins/OStatus/tests/FeedMungerTest.php similarity index 100% rename from plugins/FeedSub/tests/FeedMungerTest.php rename to plugins/OStatus/tests/FeedMungerTest.php diff --git a/plugins/FeedSub/tests/gettext-speedtest.php b/plugins/OStatus/tests/gettext-speedtest.php similarity index 100% rename from plugins/FeedSub/tests/gettext-speedtest.php rename to plugins/OStatus/tests/gettext-speedtest.php