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/HubSub.php | 272 +++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 plugins/OStatus/classes/HubSub.php (limited to 'plugins/OStatus/classes/HubSub.php') diff --git a/plugins/OStatus/classes/HubSub.php b/plugins/OStatus/classes/HubSub.php new file mode 100644 index 000000000..1769f6c94 --- /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; + } + } +} + -- cgit v1.2.3-54-g00ecf