From dc09453a77f33c4dfdff306321ce93cf5fbd2d57 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 8 Feb 2010 11:06:03 -0800 Subject: First steps on converting FeedSub into the pub/sub basis for OStatus communications: * renamed FeedSub plugin to OStatus * now setting avatar on subscriptions * general fixes for subscription * integrated PuSH hub to handle only user timelines on canonical ID url; sends updates directly * set $config['feedsub']['nohub'] = true to test w/ foreign feeds that don't have hubs (won't actually receive updates though) * a few bits of code documentation * HMAC support for verified distributions (safest if sub setup is on HTTPS) And a couple core changes: * minimizing HTML output for exceptions in API requests to aid in debugging * fix for rel=self link in apitimelineuser when id given This does not not yet include any of the individual subscription management (Salmon notifications for sub/unsub, etc) nor a nice UI for user subscriptions. Needs some further cleanup to treat posts as status updates instead of link references. --- plugins/OStatus/classes/Feedinfo.php | 345 +++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 plugins/OStatus/classes/Feedinfo.php (limited to 'plugins/OStatus/classes/Feedinfo.php') diff --git a/plugins/OStatus/classes/Feedinfo.php b/plugins/OStatus/classes/Feedinfo.php new file mode 100644 index 000000000..f29d08cb0 --- /dev/null +++ b/plugins/OStatus/classes/Feedinfo.php @@ -0,0 +1,345 @@ +. + */ + +/** + * @package FeedSubPlugin + * @maintainer Brion Vibber + */ + +/* +PuSH subscription flow: + + $feedinfo->subscribe() + generate random verification token + save to verify_token + sends a sub request to the hub... + + feedsub/callback + hub sends confirmation back to us via GET + We verify the request, then echo back the challenge. + On our end, we save the time we subscribed and the lease expiration + + feedsub/callback + hub sends us updates via POST + +*/ + +class FeedDBException extends FeedSubException +{ + public $obj; + + function __construct($obj) + { + parent::__construct('Database insert failure'); + $this->obj = $obj; + } +} + +class Feedinfo extends Memcached_DataObject +{ + public $__table = 'feedinfo'; + + public $id; + public $profile_id; + + public $feeduri; + public $homeuri; + public $huburi; + + // PuSH subscription data + public $secret; + public $verify_token; + public $sub_start; + public $sub_end; + + public $created; + public $lastupdate; + + + public /*static*/ function staticGet($k, $v=null) + { + return parent::staticGet(__CLASS__, $k, $v); + } + + /** + * 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('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, + 'profile_id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, + '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, + 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL, + 'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); + } + + static function schemaDef() + { + return array(new ColumnDef('id', 'integer', + /*size*/ null, + /*nullable*/ false, + /*key*/ 'PRI', + /*default*/ '0', + /*extra*/ null, + /*auto_increment*/ true), + new ColumnDef('profile_id', 'integer', + null, false), + new ColumnDef('feeduri', 'varchar', + 255, false, 'UNI'), + new ColumnDef('homeuri', 'varchar', + 255, false), + new ColumnDef('huburi', 'varchar', + 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', + null, true), + new ColumnDef('created', 'datetime', + null, false), + new ColumnDef('lastupdate', 'datetime', + null, 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 keys() + { + return array_keys($this->keyTypes()); + } + + /** + * return key definitions for Memcached_DataObject + * + * Our caching system uses the same key definitions, but uses a different + * method to get them. + * + * @return array key definitions + */ + + function keyTypes() + { + return array('id' => 'K'); // @fixme we'll need a profile_id key at least + } + + function sequenceKey() + { + return array('id', true, false); + } + + /** + * Fetch the StatusNet-side profile for this feed + * @return Profile + */ + public function getProfile() + { + return Profile::staticGet('id', $this->profile_id); + } + + /** + * @param FeedMunger $munger + * @return Feedinfo + */ + public static function ensureProfile($munger) + { + $feedinfo = $munger->feedinfo(); + + $current = self::staticGet('feeduri', $feedinfo->feeduri); + if ($current) { + // @fixme we should probably update info as necessary + return $current; + } + + $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(); + if (empty($result)) { + 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)) { + throw new FeedDBException($feedinfo); + } + + $feedinfo->query('COMMIT'); + } catch (FeedDBException $e) { + common_log_db_error($e->obj, 'INSERT', __FILE__); + $feedinfo->query('ROLLBACK'); + return false; + } + return $feedinfo; + } + + /** + * Send a subscription request to the hub for this feed. + * The hub will later send us a confirmation POST to /feedsub/callback. + * + * @return bool true on success, false on failure + */ + 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' => $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); + $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_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody()); + return false; + } + } catch (Exception $e) { + // wtf! + common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri"); + return false; + } + } + + /** + * Read and post notices for updates from the feed. + * Currently assumes that all items in the feed are new, + * 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, $hmac) + { + 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); + + $hits = 0; + foreach ($feed as $index => $entry) { + // @fixme this might sort in wrong order if we get multiple updates + + $notice = $munger->notice($index); + $notice->profile_id = $this->profile_id; + + // Double-check for oldies + // @fixme this could explode horribly for multiple feeds on a blog. sigh + $dupe = new Notice(); + $dupe->uri = $notice->uri; + if ($dupe->find(true)) { + common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}"); + continue; + } + + if (Event::handle('StartNoticeSave', array(&$notice))) { + $id = $notice->insert(); + Event::handle('EndNoticeSave', array($notice)); + } + $notice->addToInboxes(); + + common_log(LOG_INFO, __METHOD__ . ": saved notice {$notice->id} for entry $index of update to \"{$this->feeduri}\""); + $hits++; + } + if ($hits == 0) { + common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml"); + } + } +} -- cgit v1.2.3-54-g00ecf From 384387c9b05aefb438f5dbe7e272b1f234ede172 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 8 Feb 2010 14:06:36 -0800 Subject: OStatus cleanup... * Treat linkless feed posts as status updates; drop the "New post:" prefix and quotes on them. * Use stable user IDs for atom/rss2 feed links instead of unstable nicknames * Pull Atom feed preferentially when subscribing -- can now put the remote user's profile page straight into the feed subscription form and get to the right place. * Clean up naming for push endpoints --- actions/showstream.php | 4 +- classes/Notice.php | 4 + lib/util.php | 3 + plugins/OStatus/OStatusPlugin.php | 8 +- plugins/OStatus/actions/feedsubcallback.php | 105 ----------------- plugins/OStatus/actions/hub.php | 176 ---------------------------- plugins/OStatus/actions/pushcallback.php | 105 +++++++++++++++++ plugins/OStatus/actions/pushhub.php | 176 ++++++++++++++++++++++++++++ plugins/OStatus/classes/Feedinfo.php | 2 +- plugins/OStatus/lib/feeddiscovery.php | 22 +++- plugins/OStatus/lib/feedmunger.php | 47 +++++--- 11 files changed, 340 insertions(+), 312 deletions(-) delete mode 100644 plugins/OStatus/actions/feedsubcallback.php delete mode 100644 plugins/OStatus/actions/hub.php create mode 100644 plugins/OStatus/actions/pushcallback.php create mode 100644 plugins/OStatus/actions/pushhub.php (limited to 'plugins/OStatus/classes/Feedinfo.php') diff --git a/actions/showstream.php b/actions/showstream.php index 07cc68b76..f9407e35a 100644 --- a/actions/showstream.php +++ b/actions/showstream.php @@ -131,14 +131,14 @@ class ShowstreamAction extends ProfileAction new Feed(Feed::RSS2, common_local_url('ApiTimelineUser', array( - 'id' => $this->user->nickname, + 'id' => $this->user->id, 'format' => 'rss')), sprintf(_('Notice feed for %s (RSS 2.0)'), $this->user->nickname)), new Feed(Feed::ATOM, common_local_url('ApiTimelineUser', array( - 'id' => $this->user->nickname, + 'id' => $this->user->id, 'format' => 'atom')), sprintf(_('Notice feed for %s (Atom)'), $this->user->nickname)), diff --git a/classes/Notice.php b/classes/Notice.php index f9f386357..fca1c599c 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -1176,6 +1176,10 @@ class Notice extends Memcached_DataObject // Figure out who that is. $sender = Profile::staticGet('id', $profile_id); + if (empty($sender)) { + return null; + } + $recipient = common_relative_profile($sender, $nickname, common_sql_now()); if (empty($recipient)) { diff --git a/lib/util.php b/lib/util.php index f0f262dc5..00c21aeb2 100644 --- a/lib/util.php +++ b/lib/util.php @@ -665,6 +665,9 @@ function common_valid_profile_tag($str) function common_at_link($sender_id, $nickname) { $sender = Profile::staticGet($sender_id); + if (!$sender) { + return $nickname; + } $recipient = common_relative_profile($sender, common_canonical_nickname($nickname)); if ($recipient) { $user = User::staticGet('id', $recipient->id); diff --git a/plugins/OStatus/OStatusPlugin.php b/plugins/OStatus/OStatusPlugin.php index 941912112..4e8b892c6 100644 --- a/plugins/OStatus/OStatusPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -53,10 +53,10 @@ class OStatusPlugin extends Plugin */ function onRouterInitialized($m) { - $m->connect('push/hub', array('action' => 'hub')); + $m->connect('main/push/hub', array('action' => 'pushhub')); - $m->connect('feedsub/callback/:feed', - array('action' => 'feedsubcallback'), + $m->connect('main/push/callback/:feed', + array('action' => 'pushcallback'), array('feed' => '[0-9]+')); $m->connect('settings/feedsub', array('action' => 'feedsubsettings')); @@ -97,7 +97,7 @@ class OStatusPlugin extends Plugin // 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'))); + 'href' => common_local_url('pushhub'))); } } return true; diff --git a/plugins/OStatus/actions/feedsubcallback.php b/plugins/OStatus/actions/feedsubcallback.php deleted file mode 100644 index c57ea5b10..000000000 --- a/plugins/OStatus/actions/feedsubcallback.php +++ /dev/null @@ -1,105 +0,0 @@ -. - */ - -/** - * @package FeedSubPlugin - * @maintainer Brion Vibber - */ - -if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } - - -class FeedSubCallbackAction extends Action -{ - function handle() - { - parent::handle(); - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - $this->handlePost(); - } else { - $this->handleGet(); - } - } - - /** - * Handler for POST content updates from the hub - */ - function handlePost() - { - $feedid = $this->arg('feed'); - common_log(LOG_INFO, "POST for feed id $feedid"); - if (!$feedid) { - throw new ServerException('Empty or invalid feed id', 400); - } - - $feedinfo = Feedinfo::staticGet('id', $feedid); - 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, $hmac); - } - - /** - * Handler for GET verification requests from the hub - */ - function handleGet() - { - $mode = $this->arg('hub_mode'); - $topic = $this->arg('hub_topic'); - $challenge = $this->arg('hub_challenge'); - $lease_seconds = $this->arg('hub_lease_seconds'); - $verify_token = $this->arg('hub_verify_token'); - - if ($mode != 'subscribe' && $mode != 'unsubscribe') { - common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with mode \"$mode\""); - throw new ServerException("Bogus hub callback: bad mode", 404); - } - - $feedinfo = Feedinfo::staticGet('feeduri', $topic); - if (!$feedinfo) { - common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback for unknown feed $topic"); - throw new ServerException("Bogus hub callback: unknown feed", 404); - } - - # Can't currently set the token in our sub api - #if ($feedinfo->verify_token !== $verify_token) { - # common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with bad token \"$verify_token\" for feed $topic"); - # throw new ServerError("Bogus hub callback: bad token", 404); - #} - - // OK! - common_log(LOG_INFO, __METHOD__ . ': sub confirmed'); - $feedinfo->sub_start = common_sql_date(time()); - if ($lease_seconds > 0) { - $feedinfo->sub_end = common_sql_date(time() + $lease_seconds); - } else { - $feedinfo->sub_end = null; - } - $feedinfo->update(); - - print $challenge; - } -} diff --git a/plugins/OStatus/actions/hub.php b/plugins/OStatus/actions/hub.php deleted file mode 100644 index 5caf4b48e..000000000 --- a/plugins/OStatus/actions/hub.php +++ /dev/null @@ -1,176 +0,0 @@ -. - */ - -/** - * 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/OStatus/actions/pushcallback.php b/plugins/OStatus/actions/pushcallback.php new file mode 100644 index 000000000..a5e02e08f --- /dev/null +++ b/plugins/OStatus/actions/pushcallback.php @@ -0,0 +1,105 @@ +. + */ + +/** + * @package FeedSubPlugin + * @maintainer Brion Vibber + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } + + +class PushCallbackAction extends Action +{ + function handle() + { + parent::handle(); + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $this->handlePost(); + } else { + $this->handleGet(); + } + } + + /** + * Handler for POST content updates from the hub + */ + function handlePost() + { + $feedid = $this->arg('feed'); + common_log(LOG_INFO, "POST for feed id $feedid"); + if (!$feedid) { + throw new ServerException('Empty or invalid feed id', 400); + } + + $feedinfo = Feedinfo::staticGet('id', $feedid); + 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, $hmac); + } + + /** + * Handler for GET verification requests from the hub + */ + function handleGet() + { + $mode = $this->arg('hub_mode'); + $topic = $this->arg('hub_topic'); + $challenge = $this->arg('hub_challenge'); + $lease_seconds = $this->arg('hub_lease_seconds'); + $verify_token = $this->arg('hub_verify_token'); + + if ($mode != 'subscribe' && $mode != 'unsubscribe') { + common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with mode \"$mode\""); + throw new ServerException("Bogus hub callback: bad mode", 404); + } + + $feedinfo = Feedinfo::staticGet('feeduri', $topic); + if (!$feedinfo) { + common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback for unknown feed $topic"); + throw new ServerException("Bogus hub callback: unknown feed", 404); + } + + # Can't currently set the token in our sub api + #if ($feedinfo->verify_token !== $verify_token) { + # common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with bad token \"$verify_token\" for feed $topic"); + # throw new ServerError("Bogus hub callback: bad token", 404); + #} + + // OK! + common_log(LOG_INFO, __METHOD__ . ': sub confirmed'); + $feedinfo->sub_start = common_sql_date(time()); + if ($lease_seconds > 0) { + $feedinfo->sub_end = common_sql_date(time() + $lease_seconds); + } else { + $feedinfo->sub_end = null; + } + $feedinfo->update(); + + print $challenge; + } +} diff --git a/plugins/OStatus/actions/pushhub.php b/plugins/OStatus/actions/pushhub.php new file mode 100644 index 000000000..901c18f70 --- /dev/null +++ b/plugins/OStatus/actions/pushhub.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 PushHubAction 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/OStatus/classes/Feedinfo.php b/plugins/OStatus/classes/Feedinfo.php index f29d08cb0..107faf012 100644 --- a/plugins/OStatus/classes/Feedinfo.php +++ b/plugins/OStatus/classes/Feedinfo.php @@ -248,7 +248,7 @@ class Feedinfo extends Memcached_DataObject #$this->verify_token = $token; #$this->update(); // @fixme try { - $callback = common_local_url('feedsubcallback', array('feed' => $this->id)); + $callback = common_local_url('pushcallback', array('feed' => $this->id)); $headers = array('Content-Type: application/x-www-form-urlencoded'); $post = array('hub.mode' => 'subscribe', 'hub.callback' => $callback, diff --git a/plugins/OStatus/lib/feeddiscovery.php b/plugins/OStatus/lib/feeddiscovery.php index 9bc7892fb..39985fc90 100644 --- a/plugins/OStatus/lib/feeddiscovery.php +++ b/plugins/OStatus/lib/feeddiscovery.php @@ -168,7 +168,13 @@ class FeedDiscovery } // Ok... now on to the links! + // Types listed in order of priority -- we'll prefer Atom if available. // @fixme merge with the munger link checks + $feeds = array( + 'application/atom+xml' => false, + 'application/rss+xml' => false, + ); + $nodes = $dom->getElementsByTagName('link'); for ($i = 0; $i < $nodes->length; $i++) { $node = $nodes->item($i); @@ -181,17 +187,21 @@ class FeedDiscovery $type = trim($type->value); $href = trim($href->value); - $feedTypes = array( - 'application/rss+xml', - 'application/atom+xml', - ); - if (trim($rel) == 'alternate' && in_array($type, $feedTypes)) { - return $this->resolveURI($href, $base); + if (trim($rel) == 'alternate' && array_key_exists($type, $feeds) && empty($feeds[$type])) { + // Save the first feed found of each type... + $feeds[$type] = $this->resolveURI($href, $base); } } } } + // Return the highest-priority feed found + foreach ($feeds as $type => $url) { + if ($url) { + return $url; + } + } + return false; } diff --git a/plugins/OStatus/lib/feedmunger.php b/plugins/OStatus/lib/feedmunger.php index eeb8d2df3..948017702 100644 --- a/plugins/OStatus/lib/feedmunger.php +++ b/plugins/OStatus/lib/feedmunger.php @@ -235,34 +235,45 @@ class FeedMunger */ function noticeFromEntry($entry) { + $max = Notice::maxContent(); + $ellipsis = "\xe2\x80\xa6"; // U+2026 HORIZONTAL ELLIPSIS $title = $entry->title; $link = $entry->link; - + // @todo We can get 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. - - // @todo Should we force a language here? - $format = _m('New post: "%1$s" %2$s'); + $title = $entry->title; $link = $this->getAltLink($entry); - $out = sprintf($format, $title, $link); - - // Trim link if needed... - $max = Notice::maxContent(); - if (mb_strlen($out) > $max) { - $link = common_shorten_url($link); + 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 title if needed... - if (mb_strlen($out) > $max) { - $ellipsis = "\xe2\x80\xa6"; // U+2026 HORIZONTAL ELLIPSIS - $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); + // 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; -- cgit v1.2.3-54-g00ecf