3 * StatusNet - the distributed open-source microblogging tool
4 * Copyright (C) 2009-2010, StatusNet, Inc.
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
16 * You should have received a copy of the GNU Affero General Public License
17 * along with this program. If not, see <http://www.gnu.org/licenses/>.
21 * @package FeedSubPlugin
22 * @maintainer Brion Vibber <brion@status.net>
26 PuSH subscription flow:
29 generate random verification token
31 sends a sub request to the hub...
34 hub sends confirmation back to us via GET
35 We verify the request, then echo back the challenge.
36 On our end, we save the time we subscribed and the lease expiration
39 hub sends us updates via POST
43 class FeedDBException extends FeedSubException
47 function __construct($obj)
49 parent::__construct('Database insert failure');
54 class Ostatus_profile extends Memcached_DataObject
56 public $__table = 'ostatus_profile';
65 // PuSH subscription data
69 public $sub_state; // subscribe, active, unsubscribe
78 public /*static*/ function staticGet($k, $v=null)
80 return parent::staticGet(__CLASS__, $k, $v);
84 * return table definition for DB_DataObject
86 * DB_DataObject needs to know something about the table to manipulate
87 * instances. This method provides all the DB_DataObject needs to know.
89 * @return array array of column definitions
94 return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
95 'profile_id' => DB_DATAOBJECT_INT,
96 'group_id' => DB_DATAOBJECT_INT,
97 'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
98 'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
99 'huburi' => DB_DATAOBJECT_STR,
100 'secret' => DB_DATAOBJECT_STR,
101 'verify_token' => DB_DATAOBJECT_STR,
102 'sub_state' => DB_DATAOBJECT_STR,
103 'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
104 'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
105 'salmonuri' => DB_DATAOBJECT_STR,
106 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
107 'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
110 static function schemaDef()
112 return array(new ColumnDef('id', 'integer',
118 /*auto_increment*/ true),
119 new ColumnDef('profile_id', 'integer',
121 new ColumnDef('group_id', 'integer',
123 new ColumnDef('feeduri', 'varchar',
125 new ColumnDef('homeuri', 'varchar',
127 new ColumnDef('huburi', 'text',
129 new ColumnDef('verify_token', 'varchar',
131 new ColumnDef('secret', 'varchar',
133 new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe')",
135 new ColumnDef('sub_start', 'datetime',
137 new ColumnDef('sub_end', 'datetime',
139 new ColumnDef('salmonuri', 'text',
141 new ColumnDef('created', 'datetime',
143 new ColumnDef('lastupdate', 'datetime',
148 * return key definitions for DB_DataObject
150 * DB_DataObject needs to know about keys that the table has; this function
153 * @return array key definitions
158 return array_keys($this->keyTypes());
162 * return key definitions for Memcached_DataObject
164 * Our caching system uses the same key definitions, but uses a different
165 * method to get them.
167 * @return array key definitions
172 return array('id' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
175 function sequenceKey()
177 return array('id', true, false);
181 * Fetch the StatusNet-side profile for this feed
184 public function localProfile()
186 if ($this->profile_id) {
187 return Profile::staticGet('id', $this->profile_id);
193 * Fetch the StatusNet-side profile for this feed
196 public function localGroup()
198 if ($this->group_id) {
199 return User_group::staticGet('id', $this->group_id);
205 * @param FeedMunger $munger
206 * @param boolean $isGroup is this a group record?
207 * @return Ostatus_profile
209 public static function ensureProfile($munger)
211 $profile = $munger->ostatusProfile();
213 $current = self::staticGet('feeduri', $profile->feeduri);
215 // @fixme we should probably update info as necessary
219 $profile->query('BEGIN');
222 $local = $munger->profile();
224 if ($profile->isGroup()) {
225 $group = new User_group();
226 $group->nickname = $local->nickname . '@remote'; // @fixme
227 $group->fullname = $local->fullname;
228 $group->homepage = $local->homepage;
229 $group->location = $local->location;
230 $group->created = $local->created;
232 if (empty($result)) {
233 throw new FeedDBException($group);
235 $profile->group_id = $group->id;
237 $result = $local->insert();
238 if (empty($result)) {
239 throw new FeedDBException($local);
241 $profile->profile_id = $local->id;
244 $profile->created = common_sql_now();
245 $profile->lastupdate = common_sql_now();
246 $result = $profile->insert();
247 if (empty($result)) {
248 throw new FeedDBException($profile);
251 $profile->query('COMMIT');
252 } catch (FeedDBException $e) {
253 common_log_db_error($e->obj, 'INSERT', __FILE__);
254 $profile->query('ROLLBACK');
258 $avatar = $munger->getAvatar();
261 $profile->updateAvatar($avatar);
262 } catch (Exception $e) {
263 common_log(LOG_ERR, "Exception setting OStatus avatar: " .
272 * Download and update given avatar image
274 * @throws Exception in various failure cases
276 public function updateAvatar($url)
278 // @fixme this should be better encapsulated
279 // ripped from oauthstore.php (for old OMB client)
280 $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
281 copy($url, $temp_filename);
283 // @fixme should we be using different ids?
284 $imagefile = new ImageFile($this->id, $temp_filename);
285 $filename = Avatar::filename($this->id,
286 image_type_to_extension($imagefile->type),
289 rename($temp_filename, Avatar::path($filename));
290 if ($this->isGroup()) {
291 $group = $this->localGroup();
292 $group->setOriginal($filename);
294 $profile = $this->localProfile();
295 $profile->setOriginal($filename);
300 * Returns an XML string fragment with profile information as an
301 * Activity Streams noun object with the given element type.
303 * Assumes that 'activity' namespace has been previously defined.
305 * @param string $element one of 'actor', 'subject', 'object', 'target'
308 function asActivityNoun($element)
310 $xs = new XMLStringer(true);
312 $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
313 $avatarType = 'image/png';
314 if ($this->isGroup()) {
315 $type = 'http://activitystrea.ms/schema/1.0/group';
316 $self = $this->localGroup();
318 // @fixme put a standard getAvatar() interface on groups too
319 if ($self->homepage_logo) {
320 $avatarHref = $self->homepage_logo;
321 $map = array('png' => 'image/png',
322 'jpg' => 'image/jpeg',
323 'jpeg' => 'image/jpeg',
324 'gif' => 'image/gif');
325 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
326 if (isset($map[$extension])) {
327 $avatarType = $map[$extension];
331 $type = 'http://activitystrea.ms/schema/1.0/person';
332 $self = $this->localProfile();
333 $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
335 $avatarHref = $avatar->
336 $avatarType = $avatar->mediatype;
339 $xs->elementStart('activity:' . $element);
341 'activity:object-type',
348 $this->homeuri); // ?
349 $xs->element('title', null, $self->getBestName());
353 'type' => $avatarType,
354 'href' => $avatarHref
359 $xs->elementEnd('activity:' . $element);
361 return $xs->getString();
369 return (strpos($this->feeduri, '/groups/') !== false);
373 * Send a subscription request to the hub for this feed.
374 * The hub will later send us a confirmation POST to /main/push/callback.
376 * @return bool true on success, false on failure
377 * @throws ServerException if feed state is not valid
379 public function subscribe($mode='subscribe')
381 if ($this->sub_state != '') {
382 throw new ServerException("Attempting to start PuSH subscription to feed in state $this->sub_state");
384 if (empty($this->huburi)) {
385 if (common_config('feedsub', 'nohub')) {
386 // Fake it! We're just testing remote feeds w/o hubs.
389 throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
393 return $this->doSubscribe('subscribe');
397 * Send a PuSH unsubscription request to the hub for this feed.
398 * The hub will later send us a confirmation POST to /main/push/callback.
400 * @return bool true on success, false on failure
401 * @throws ServerException if feed state is not valid
403 public function unsubscribe() {
404 if ($this->sub_state != 'active') {
405 throw new ServerException("Attempting to end PuSH subscription to feed in state $this->sub_state");
407 if (empty($this->huburi)) {
408 if (common_config('feedsub', 'nohub')) {
409 // Fake it! We're just testing remote feeds w/o hubs.
412 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
416 return $this->doSubscribe('unsubscribe');
419 protected function doSubscribe($mode)
421 $orig = clone($this);
422 $this->verify_token = common_good_rand(16);
423 if ($mode == 'subscribe') {
424 $this->secret = common_good_rand(32);
426 $this->sub_state = $mode;
427 $this->update($orig);
431 $callback = common_local_url('pushcallback', array('feed' => $this->id));
432 $headers = array('Content-Type: application/x-www-form-urlencoded');
433 $post = array('hub.mode' => $mode,
434 'hub.callback' => $callback,
435 'hub.verify' => 'async',
436 'hub.verify_token' => $this->verify_token,
437 'hub.secret' => $this->secret,
438 //'hub.lease_seconds' => 0,
439 'hub.topic' => $this->feeduri);
440 $client = new HTTPClient();
441 $response = $client->post($this->huburi, $headers, $post);
442 $status = $response->getStatus();
443 if ($status == 202) {
444 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
446 } else if ($status == 204) {
447 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
449 } else if ($status >= 200 && $status < 300) {
450 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
453 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
456 } catch (Exception $e) {
458 common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
460 $orig = clone($this);
461 $this->verify_token = null;
462 $this->sub_state = null;
463 $this->update($orig);
471 * Save PuSH subscription confirmation.
472 * Sets approximate lease start and end times and finalizes state.
474 * @param int $lease_seconds provided hub.lease_seconds parameter, if given
476 public function confirmSubscribe($lease_seconds=0)
478 $original = clone($this);
480 $this->sub_state = 'active';
481 $this->sub_start = common_sql_date(time());
482 if ($lease_seconds > 0) {
483 $this->sub_end = common_sql_date(time() + $lease_seconds);
485 $this->sub_end = null;
487 $this->lastupdate = common_sql_now();
489 return $this->update($original);
493 * Save PuSH unsubscription confirmation.
494 * Wipes active PuSH sub info and resets state.
496 public function confirmUnsubscribe()
498 $original = clone($this);
500 // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
501 $this->verify_token = '';
503 $this->sub_state = '';
504 $this->sub_start = '';
506 $this->lastupdate = common_sql_now();
508 return $this->update($original);
512 * Send an Activity Streams notification to the remote Salmon endpoint,
515 * @param Profile $actor
516 * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
517 * @param $object object of the action; if null, the remote entity itself is assumed
519 public function notify(Profile $actor, $verb, $object=null)
521 if ($object == null) {
524 if ($this->salmonuri) {
525 $text = 'update'; // @fixme
526 $id = 'tag:' . common_config('site', 'server') .
529 ':' . time(); // @fixme
531 //$entry = new Atom10Entry();
532 $entry = new XMLStringer();
533 $entry->elementStart('entry');
534 $entry->element('id', null, $id);
535 $entry->element('title', null, $text);
536 $entry->element('summary', null, $text);
537 $entry->element('published', null, common_date_w3dtf(time()));
539 $entry->element('activity:verb', null, $verb);
540 $entry->raw($actor->asAtomAuthor());
541 $entry->raw($actor->asActivityActor());
542 $entry->raw($object->asActivityNoun('object'));
543 $entry->elementEnd('entry');
545 $feed = $this->atomFeed($actor);
547 $feed->addEntry($entry);
548 #$feed->renderEntries();
551 $xml = $feed->getString();
552 common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
554 $salmon = new Salmon(); // ?
555 $salmon->post($this->salmonuri, $xml);
559 function getBestName()
561 if ($this->isGroup()) {
562 return $this->localGroup()->getBestName();
564 return $this->localProfile()->getBestName();
568 function atomFeed($actor)
570 $feed = new Atom10Feed();
571 // @fixme should these be set up somewhere else?
572 $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
573 $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
574 $feed->addNamespace('georss', 'http://www.georss.org/georss');
575 $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
577 $taguribase = common_config('integration', 'taguri');
578 $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
580 $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
581 $feed->setUpdated(time());
582 $feed->setPublished(time());
584 $feed->addLink(common_local_url('ApiTimelineUser',
585 array('id' => $actor->id,
587 array('rel' => 'self',
588 'type' => 'application/atom+xml'));
590 $feed->addLink(common_local_url('userbyid',
591 array('id' => $actor->id)),
592 array('rel' => 'alternate',
593 'type' => 'text/html'));
599 * Read and post notices for updates from the feed.
600 * Currently assumes that all items in the feed are new,
601 * coming from a PuSH hub.
603 * @param string $post source of Atom or RSS feed
604 * @param string $hmac X-Hub-Signature header, if present
606 public function postUpdates($post, $hmac)
608 common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $post");
610 if ($this->sub_state != 'active') {
611 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
616 common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
620 if (!$this->validatePushSig($post, $hmac)) {
621 // Per spec we silently drop input with a bad sig,
622 // while reporting receipt to the server.
626 $feed = new DOMDocument();
627 if (!$feed->loadXML($post)) {
628 // @fixme might help to include the err message
629 common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
633 $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
634 if ($entries->length == 0) {
635 common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
639 for ($i = 0; $i < $entries->length; $i++) {
640 $entry = $entries->item($i);
641 $this->processEntry($entry, $feed);
646 * Validate the given Atom chunk and HMAC signature against our
647 * shared secret that was set up at subscription time.
649 * If we don't have a shared secret, there should be no signature.
650 * If we we do, our the calculated HMAC should match theirs.
652 * @param string $post raw XML source as POSTed to us
653 * @param string $hmac X-Hub-Signature HTTP header value, or empty
654 * @return boolean true for a match
656 protected function validatePushSig($post, $hmac)
659 if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
660 $their_hmac = strtolower($matches[1]);
661 $our_hmac = hash_hmac('sha1', $post, $this->secret);
662 if ($their_hmac === $our_hmac) {
665 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
667 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
673 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
680 * Process a posted entry from this feed source.
682 * @param DOMElement $entry
683 * @param DOMElement $feed for context
685 protected function processEntry($entry, $feed)
687 $activity = new Activity($entry, $feed);
689 $debug = var_export($activity, true);
690 common_log(LOG_DEBUG, $debug);
692 if ($activity->verb == ActivityVerb::POST) {
693 $this->processPost($activity);
695 common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
700 * Process an incoming post activity from this remote feed.
701 * @param Activity $activity
703 protected function processPost($activity)
705 if ($this->isGroup()) {
706 // @fixme validate these profiles in some way!
707 $oprofile = $this->ensureActorProfile($activity);
709 $actorUri = $this->getActorProfileURI($activity);
710 if ($actorUri == $this->homeuri) {
711 // @fixme check if profile info has changed and update it
713 // @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely
714 common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->homeuri");
720 if ($activity->object->link) {
721 $sourceUri = $activity->object->link;
722 } else if (preg_match('!^https?://!', $activity->object->id)) {
723 $sourceUri = $activity->object->id;
725 common_log(LOG_INFO, "OStatus: ignoring post with no source link: id $activity->object->id");
729 $dupe = Notice::staticGet('uri', $sourceUri);
731 common_log(LOG_INFO, "OStatus: ignoring duplicate post: $noticeLink");
735 // @fixme sanitize and save HTML content if available
736 $content = $activity->object->title;
738 $params = array('is_local' => Notice::REMOTE_OMB,
739 'uri' => $sourceUri);
741 $location = $this->getEntryLocation($activity->entry);
743 $params['lat'] = $location->lat;
744 $params['lon'] = $location->lon;
745 if ($location->location_id) {
746 $params['location_ns'] = $location->location_ns;
747 $params['location_id'] = $location->location_id;
751 // @fixme save detailed ostatus source info
752 // @fixme ensure that groups get handled correctly
754 $saved = Notice::saveNew($oprofile->localProfile()->id,
761 * Parse location given as a GeoRSS-simple point, if provided.
762 * http://www.georss.org/simple
764 * @param feed item $entry
765 * @return mixed Location or false
767 function getLocation($dom)
769 $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point');
771 for ($i = 0; $i < $points->length; $i++) {
772 $point = $points->item(0)->textContent;
773 $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
774 $point = preg_replace('/\s+/', ' ', $point);
775 $point = trim($point);
776 $coords = explode(' ', $point);
777 if (count($coords) == 2) {
778 list($lat, $lon) = $coords;
779 if (is_numeric($lat) && is_numeric($lon)) {
780 common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
781 return Location::fromLatLon($lat, $lon);
784 common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
791 * Get an appropriate avatar image source URL, if available.
793 * @param ActivityObject $actor
794 * @param DOMElement $feed
797 function getAvatar($actor, $feed)
801 if ($actor->avatar) {
802 $url = trim($actor->avatar);
805 // Check <atom:logo> and <atom:icon> on the feed
806 $els = $feed->childNodes();
807 if ($els && $els->length) {
808 for ($i = 0; $i < $els->length; $i++) {
809 $el = $els->item($i);
810 if ($el->namespaceURI == Activity::ATOM) {
811 if (empty($url) && $el->localName == 'logo') {
812 $url = trim($el->textContent);
815 if (empty($icon) && $el->localName == 'icon') {
817 $icon = trim($el->textContent);
822 if ($icon && !$url) {
827 $opts = array('allowed_schemes' => array('http', 'https'));
828 if (Validate::uri($url, $opts)) {
832 return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
836 * @fixme move off of ostatus_profile or static?
838 function ensureActorProfile($activity)
840 $profile = $this->getActorProfile($activity);
842 $profile = $this->createActorProfile($activity);
848 * @param Activity $activity
849 * @return mixed matching Ostatus_profile or false if none known
851 function getActorProfile($activity)
853 $homeuri = $this->getActorProfileURI($activity);
854 return Ostatus_profile::staticGet('homeuri', $homeuri);
858 * @param Activity $activity
860 * @throws ServerException
862 function getActorProfileURI($activity)
864 $opts = array('allowed_schemes' => array('http', 'https'));
865 $actor = $activity->actor;
866 if ($actor->id && Validate::uri($actor->id, $opts)) {
869 if ($actor->link && Validate::uri($actor->link, $opts)) {
872 throw new ServerException("No author ID URI found");
878 function createActorProfile($activity)
880 $actor = $activity->actor();
881 $homeuri = $this->getActivityProfileURI($activity);
882 $nickname = $this->getAuthorNick($activity);
883 $avatar = $this->getAvatar($actor, $feed);
885 $profile = new Profile();
886 $profile->nickname = $nickname;
887 $profile->fullname = $actor->displayName;
888 $profile->homepage = $actor->link; // @fixme
889 $profile->profileurl = $homeuri;
891 // @fixme tags/categories
893 // @todo tags from categories
894 // @todo lat/lon/location?
896 $ok = $profile->insert();
898 $this->updateAvatar($profile, $avatar);
900 throw new ServerException("Can't save local profile");
903 // @fixme either need to do feed discovery here
904 // or need to split out some of the feed stuff
905 // so we can leave it empty until later.
906 $oprofile = new Ostatus_profile();
907 $oprofile->homeuri = $homeuri;
908 $oprofile->profile_id = $profile->id;
910 $ok = $oprofile->insert();
914 throw new ServerException("Can't save OStatus profile");
919 * @fixme move this into Activity?
920 * @param Activity $activity
923 function getAuthorNick($activity)
925 // @fixme not technically part of the actor?
926 foreach (array($activity->entry, $activity->feed) as $source) {
927 $author = ActivityUtil::child($source, 'author', Activity::ATOM);
929 $name = ActivityUtil::child($author, 'name', Activity::ATOM);
931 return trim($name->textContent);