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_date();
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 $this->verify_token = null;
501 $this->secret = null;
502 $this->sub_state = null;
503 $this->sub_start = null;
504 $this->sub_end = null;
505 $this->lastupdate = common_sql_date();
507 return $this->update($original);
511 * Send an Activity Streams notification to the remote Salmon endpoint,
514 * @param Profile $actor
515 * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
516 * @param $object object of the action; if null, the remote entity itself is assumed
518 public function notify(Profile $actor, $verb, $object=null)
520 if ($object == null) {
523 if ($this->salmonuri) {
524 $text = 'update'; // @fixme
525 $id = 'tag:' . common_config('site', 'server') .
528 ':' . time(); // @fixme
530 $entry = new Atom10Entry();
531 $entry->elementStart('entry');
532 $entry->element('id', null, $id);
533 $entry->element('title', null, $text);
534 $entry->element('summary', null, $text);
535 $entry->element('published', null, common_date_w3dtf());
537 $entry->element('activity:verb', null, $verb);
538 $entry->raw($profile->asAtomAuthor());
539 $entry->raw($profile->asActivityActor());
540 $entry->raw($object->asActivityNoun('object'));
541 $entry->elmentEnd('entry');
543 $feed = $this->atomFeed($actor);
545 $feed->addEntry($entry);
546 $feed->renderEntries();
549 $xml = $feed->getString();
550 common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
552 $salmon = new Salmon(); // ?
553 $salmon->post($this->salmonuri, $xml);
557 function getBestName()
559 if ($this->isGroup()) {
560 return $this->localGroup()->getBestName();
562 return $this->localProfile()->getBestName();
566 function atomFeed($actor)
568 $feed = new Atom10Feed();
569 // @fixme should these be set up somewhere else?
570 $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
571 $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0');
572 $feed->addNamespace('georss', 'http://www.georss.org/georss');
573 $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
575 $taguribase = common_config('integration', 'taguri');
576 $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
578 $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
579 $feed->setUpdated(time());
580 $feed->setPublished(time());
582 $feed->addLink(common_url('ApiTimelineUser',
583 array('id' => $actor->id,
585 array('rel' => 'self',
586 'type' => 'application/atom+xml'));
588 $feed->addLink(common_url('userbyid',
589 array('id' => $actor->id)),
590 array('rel' => 'alternate',
591 'type' => 'text/html'));
597 * Read and post notices for updates from the feed.
598 * Currently assumes that all items in the feed are new,
599 * coming from a PuSH hub.
601 * @param string $post source of Atom or RSS feed
602 * @param string $hmac X-Hub-Signature header, if present
604 public function postUpdates($post, $hmac)
606 common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $post");
608 if ($this->sub_state != 'active') {
609 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
614 common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
618 if (!$this->validatePushSig($post, $hmac)) {
619 // Per spec we silently drop input with a bad sig,
620 // while reporting receipt to the server.
624 $feed = new DOMDocument();
625 if (!$feed->loadXML($post)) {
626 // @fixme might help to include the err message
627 common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
631 $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
632 if ($entries->length == 0) {
633 common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
637 for ($i = 0; $i < $entries->length; $i++) {
638 $entry = $entries->item($i);
639 $this->processEntry($entry, $feed);
644 * Validate the given Atom chunk and HMAC signature against our
645 * shared secret that was set up at subscription time.
647 * If we don't have a shared secret, there should be no signature.
648 * If we we do, our the calculated HMAC should match theirs.
650 * @param string $post raw XML source as POSTed to us
651 * @param string $hmac X-Hub-Signature HTTP header value, or empty
652 * @return boolean true for a match
654 protected function validatePushSig($post, $hmac)
657 if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
658 $their_hmac = strtolower($matches[1]);
659 $our_hmac = hash_hmac('sha1', $post, $this->secret);
660 if ($their_hmac === $our_hmac) {
663 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
665 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
671 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
678 * Process a posted entry from this feed source.
680 * @param DOMElement $entry
681 * @param DOMElement $feed for context
683 protected function processEntry($entry, $feed)
685 $activity = new Activity($entry, $feed);
687 $debug = var_export($activity, true);
688 common_log(LOG_DEBUG, $debug);
690 if ($activity->verb == ActivityVerb::POST) {
691 $this->processPost($activity);
693 common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
698 * Process an incoming post activity from this remote feed.
699 * @param Activity $activity
701 protected function processPost($activity)
703 if ($this->isGroup()) {
704 // @fixme validate these profiles in some way!
705 $oprofile = $this->ensureActorProfile($activity);
707 $actorUri = $this->getActorProfileURI($activity);
708 if ($actorUri == $this->homeuri) {
709 // @fixme check if profile info has changed and update it
711 // @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely
712 common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->homeuri");
718 if ($activity->object->link) {
719 $sourceUri = $activity->object->link;
720 } else if (preg_match('!^https?://!', $activity->object->id)) {
721 $sourceUri = $activity->object->id;
723 common_log(LOG_INFO, "OStatus: ignoring post with no source link: id $activity->object->id");
727 $dupe = Notice::staticGet('uri', $sourceUri);
729 common_log(LOG_INFO, "OStatus: ignoring duplicate post: $noticeLink");
733 // @fixme sanitize and save HTML content if available
734 $content = $activity->object->title;
736 $params = array('is_local' => Notice::REMOTE_OMB,
737 'uri' => $sourceUri);
739 $location = $this->getEntryLocation($activity->entry);
741 $params['lat'] = $location->lat;
742 $params['lon'] = $location->lon;
743 if ($location->location_id) {
744 $params['location_ns'] = $location->location_ns;
745 $params['location_id'] = $location->location_id;
749 // @fixme save detailed ostatus source info
750 // @fixme ensure that groups get handled correctly
752 $saved = Notice::saveNew($oprofile->localProfile()->id,
759 * Parse location given as a GeoRSS-simple point, if provided.
760 * http://www.georss.org/simple
762 * @param feed item $entry
763 * @return mixed Location or false
765 function getLocation($dom)
767 $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point');
769 for ($i = 0; $i < $points->length; $i++) {
770 $point = $points->item(0)->textContent;
771 $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
772 $point = preg_replace('/\s+/', ' ', $point);
773 $point = trim($point);
774 $coords = explode(' ', $point);
775 if (count($coords) == 2) {
776 list($lat, $lon) = $coords;
777 if (is_numeric($lat) && is_numeric($lon)) {
778 common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
779 return Location::fromLatLon($lat, $lon);
782 common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
789 * Get an appropriate avatar image source URL, if available.
791 * @param ActivityObject $actor
792 * @param DOMElement $feed
795 function getAvatar($actor, $feed)
799 if ($actor->avatar) {
800 $url = trim($actor->avatar);
803 // Check <atom:logo> and <atom:icon> on the feed
804 $els = $feed->childNodes();
805 if ($els && $els->length) {
806 for ($i = 0; $i < $els->length; $i++) {
807 $el = $els->item($i);
808 if ($el->namespaceURI == Activity::ATOM) {
809 if (empty($url) && $el->localName == 'logo') {
810 $url = trim($el->textContent);
813 if (empty($icon) && $el->localName == 'icon') {
815 $icon = trim($el->textContent);
820 if ($icon && !$url) {
825 $opts = array('allowed_schemes' => array('http', 'https'));
826 if (Validate::uri($url, $opts)) {
830 return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
834 * @fixme move off of ostatus_profile or static?
836 function ensureActorProfile($activity)
838 $profile = $this->getActorProfile($activity);
840 $profile = $this->createActorProfile($activity);
846 * @param Activity $activity
847 * @return mixed matching Ostatus_profile or false if none known
849 function getActorProfile($activity)
851 $homeuri = $this->getActorProfileURI($activity);
852 return Ostatus_profile::staticGet('homeuri', $homeuri);
856 * @param Activity $activity
858 * @throws ServerException
860 function getActorProfileURI($activity)
862 $opts = array('allowed_schemes' => array('http', 'https'));
863 $actor = $activity->actor;
864 if ($actor->id && Validate::uri($actor->id, $opts)) {
867 if ($actor->link && Validate::uri($actor->link, $opts)) {
870 throw new ServerException("No author ID URI found");
876 function createActorProfile($activity)
878 $actor = $activity->actor();
879 $homeuri = $this->getActivityProfileURI($activity);
880 $nickname = $this->getAuthorNick($activity);
881 $avatar = $this->getAvatar($actor, $feed);
883 $profile = new Profile();
884 $profile->nickname = $nickname;
885 $profile->fullname = $actor->displayName;
886 $profile->homepage = $actor->link; // @fixme
887 $profile->profileurl = $homeuri;
889 // @fixme tags/categories
891 // @todo tags from categories
892 // @todo lat/lon/location?
894 $ok = $profile->insert();
896 $this->updateAvatar($profile, $avatar);
898 throw new ServerException("Can't save local profile");
901 // @fixme either need to do feed discovery here
902 // or need to split out some of the feed stuff
903 // so we can leave it empty until later.
904 $oprofile = new Ostatus_profile();
905 $oprofile->homeuri = $homeuri;
906 $oprofile->profile_id = $profile->id;
908 $ok = $oprofile->insert();
912 throw new ServerException("Can't save OStatus profile");
917 * @fixme move this into Activity?
918 * @param Activity $activity
921 function getAuthorNick($activity)
923 // @fixme not technically part of the actor?
924 foreach (array($activity->entry, $activity->feed) as $source) {
925 $author = ActivityUtil::child($source, 'author', Activity::ATOM);
927 $name = ActivityUtil::child($author, 'name', Activity::ATOM);
929 return trim($name->textContent);