]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' of gitorious.org:statusnet/mainline into testing
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
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.
10  *
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.
15  *
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/>.
18  */
19
20 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
192         } else {
193             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
194         }
195     }
196
197     /**
198      * Subscribe a local user to this remote user.
199      * PuSH subscription will be started if necessary, and we'll
200      * send a Salmon notification to the remote server if available
201      * notifying them of the sub.
202      *
203      * @param User $user
204      * @return boolean success
205      * @throws FeedException
206      */
207     public function subscribeLocalToRemote(User $user)
208     {
209         if ($this->isGroup()) {
210             throw new ServerException("Can't subscribe to a remote group");
211         }
212
213         if ($this->subscribe()) {
214             if ($user->subscribeTo($this->localProfile())) {
215                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
216                 return true;
217             }
218         }
219         return false;
220     }
221
222     /**
223      * Mark this remote profile as subscribing to the given local user,
224      * and send appropriate notifications to the user.
225      *
226      * This will generally be in response to a subscription notification
227      * from a foreign site to our local Salmon response channel.
228      *
229      * @param User $user
230      * @return boolean success
231      */
232     public function subscribeRemoteToLocal(User $user)
233     {
234         if ($this->isGroup()) {
235             throw new ServerException("Remote groups can't subscribe to local users");
236         }
237
238         Subscription::start($this->localProfile(), $user->getProfile());
239
240         return true;
241     }
242
243     /**
244      * Send a subscription request to the hub for this feed.
245      * The hub will later send us a confirmation POST to /main/push/callback.
246      *
247      * @return bool true on success, false on failure
248      * @throws ServerException if feed state is not valid
249      */
250     public function subscribe()
251     {
252         $feedsub = FeedSub::ensureFeed($this->feeduri);
253         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
254             return true;
255         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
256             return $feedsub->subscribe();
257         } else if ('unsubscribe') {
258             throw new FeedSubException("Unsub is pending, can't subscribe...");
259         }
260     }
261
262     /**
263      * Send a PuSH unsubscription request to the hub for this feed.
264      * The hub will later send us a confirmation POST to /main/push/callback.
265      *
266      * @return bool true on success, false on failure
267      * @throws ServerException if feed state is not valid
268      */
269     public function unsubscribe() {
270         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
271         if (!$feedsub) {
272             return true;
273         }
274         if ($feedsub->sub_state == 'active') {
275             return $feedsub->unsubscribe();
276         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
277             return true;
278         } else if ($feedsub->sub_state == 'subscribe') {
279             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
280         }
281     }
282
283     /**
284      * Check if this remote profile has any active local subscriptions, and
285      * if not drop the PuSH subscription feed.
286      *
287      * @return boolean
288      */
289     public function garbageCollect()
290     {
291         if ($this->isGroup()) {
292             $members = $this->localGroup()->getMembers(0, 1);
293             $count = $members->N;
294         } else {
295             $count = $this->localProfile()->subscriberCount();
296         }
297         if ($count == 0) {
298             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
299             $this->unsubscribe();
300             return true;
301         } else {
302             return false;
303         }
304     }
305
306     /**
307      * Send an Activity Streams notification to the remote Salmon endpoint,
308      * if so configured.
309      *
310      * @param Profile $actor  Actor who did the activity
311      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
312      * @param Object  $object object of the action; must define asActivityNoun($tag)
313      */
314     public function notify($actor, $verb, $object=null)
315     {
316         if (!($actor instanceof Profile)) {
317             $type = gettype($actor);
318             if ($type == 'object') {
319                 $type = get_class($actor);
320             }
321             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
322         }
323         if ($object == null) {
324             $object = $this;
325         }
326         if ($this->salmonuri) {
327
328             $text = 'update';
329             $id = TagURI::mint('%s:%s:%s',
330                                $verb,
331                                $actor->getURI(),
332                                common_date_iso8601(time()));
333
334             // @fixme consolidate all these NS settings somewhere
335             $attributes = array('xmlns' => Activity::ATOM,
336                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
337                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
338                                 'xmlns:georss' => 'http://www.georss.org/georss',
339                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
340                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
341                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
342
343             $entry = new XMLStringer();
344             $entry->elementStart('entry', $attributes);
345             $entry->element('id', null, $id);
346             $entry->element('title', null, $text);
347             $entry->element('summary', null, $text);
348             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
349
350             $entry->element('activity:verb', null, $verb);
351             $entry->raw($actor->asAtomAuthor());
352             $entry->raw($actor->asActivityActor());
353             $entry->raw($object->asActivityNoun('object'));
354             $entry->elementEnd('entry');
355
356             $xml = $entry->getString();
357             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
358
359             $salmon = new Salmon(); // ?
360             return $salmon->post($this->salmonuri, $xml);
361         }
362         return false;
363     }
364
365     /**
366      * Send a Salmon notification ping immediately, and confirm that we got
367      * an acceptable response from the remote site.
368      *
369      * @param mixed $entry XML string, Notice, or Activity
370      * @return boolean success
371      */
372     public function notifyActivity($entry)
373     {
374         if ($this->salmonuri) {
375             $salmon = new Salmon();
376             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry));
377         }
378
379         return false;
380     }
381
382     /**
383      * Queue a Salmon notification for later. If queues are disabled we'll
384      * send immediately but won't get the return value.
385      *
386      * @param mixed $entry XML string, Notice, or Activity
387      * @return boolean success
388      */
389     public function notifyDeferred($entry)
390     {
391         if ($this->salmonuri) {
392             $data = array('salmonuri' => $this->salmonuri,
393                           'entry' => $this->notifyPrepXml($entry));
394
395             $qm = QueueManager::get();
396             return $qm->enqueue($data, 'salmon');
397         }
398
399         return false;
400     }
401
402     protected function notifyPrepXml($entry)
403     {
404         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
405         if (is_string($entry)) {
406             return $entry;
407         } else if ($entry instanceof Activity) {
408             return $preamble . $entry->asString(true);
409         } else if ($entry instanceof Notice) {
410             return $preamble . $entry->asAtomEntry(true, true);
411         } else {
412             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
413         }
414     }
415
416     function getBestName()
417     {
418         if ($this->isGroup()) {
419             return $this->localGroup()->getBestName();
420         } else {
421             return $this->localProfile()->getBestName();
422         }
423     }
424
425     /**
426      * Read and post notices for updates from the feed.
427      * Currently assumes that all items in the feed are new,
428      * coming from a PuSH hub.
429      *
430      * @param DOMDocument $feed
431      */
432     public function processFeed($feed, $source)
433     {
434         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
435         if ($entries->length == 0) {
436             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
437             return;
438         }
439
440         for ($i = 0; $i < $entries->length; $i++) {
441             $entry = $entries->item($i);
442             $this->processEntry($entry, $feed, $source);
443         }
444     }
445
446     /**
447      * Process a posted entry from this feed source.
448      *
449      * @param DOMElement $entry
450      * @param DOMElement $feed for context
451      */
452     public function processEntry($entry, $feed, $source)
453     {
454         $activity = new Activity($entry, $feed);
455
456         if ($activity->verb == ActivityVerb::POST) {
457             $this->processPost($activity, $source);
458         } else {
459             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
460         }
461     }
462
463     /**
464      * Process an incoming post activity from this remote feed.
465      * @param Activity $activity
466      * @param string $method 'push' or 'salmon'
467      * @return mixed saved Notice or false
468      * @fixme break up this function, it's getting nasty long
469      */
470     public function processPost($activity, $method)
471     {
472         if ($this->isGroup()) {
473             // A group feed will contain posts from multiple authors.
474             // @fixme validate these profiles in some way!
475             $oprofile = self::ensureActorProfile($activity);
476             if ($oprofile->isGroup()) {
477                 // Groups can't post notices in StatusNet.
478                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
479                 return false;
480             }
481         } else {
482             // Individual user feeds may contain only posts from themselves.
483             // Authorship is validated against the profile URI on upper layers,
484             // through PuSH setup or Salmon signature checks.
485             $actorUri = self::getActorProfileURI($activity);
486             if ($actorUri == $this->uri) {
487                 // Check if profile info has changed and update it
488                 $this->updateFromActivityObject($activity->actor);
489             } else {
490                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
491                 return false;
492             }
493             $oprofile = $this;
494         }
495
496         // The id URI will be used as a unique identifier for for the notice,
497         // protecting against duplicate saves. It isn't required to be a URL;
498         // tag: URIs for instance are found in Google Buzz feeds.
499         $sourceUri = $activity->object->id;
500         $dupe = Notice::staticGet('uri', $sourceUri);
501         if ($dupe) {
502             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
503             return false;
504         }
505
506         // We'll also want to save a web link to the original notice, if provided.
507         $sourceUrl = null;
508         if ($activity->object->link) {
509             $sourceUrl = $activity->object->link;
510         } else if ($activity->link) {
511             $sourceUrl = $activity->link;
512         } else if (preg_match('!^https?://!', $activity->object->id)) {
513             $sourceUrl = $activity->object->id;
514         }
515
516         // Get (safe!) HTML and text versions of the content
517         $rendered = $this->purify($activity->object->content);
518         $content = html_entity_decode(strip_tags($rendered));
519
520         $shortened = common_shorten_links($content);
521
522         // If it's too long, try using the summary, and make the
523         // HTML an attachment.
524
525         $attachment = null;
526
527         if (Notice::contentTooLong($shortened)) {
528             $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
529             $summary = $activity->object->summary;
530             if (empty($summary)) {
531                 $summary = $content;
532             }
533             $shortSummary = common_shorten_links($summary);
534             if (Notice::contentTooLong($shortSummary)) {
535                 $url = common_shorten_url(common_local_url('attachment',
536                                                            array('attachment' => $attachment->id)));
537                 $shortSummary = substr($shortSummary,
538                                        0,
539                                        Notice::maxContent() - (mb_strlen($url) + 2));
540                 $shortSummary .= '… ' . $url;
541                 $content = $shortSummary;
542                 $rendered = common_render_text($content);
543             }
544         }
545
546         $options = array('is_local' => Notice::REMOTE_OMB,
547                         'url' => $sourceUrl,
548                         'uri' => $sourceUri,
549                         'rendered' => $rendered,
550                         'replies' => array(),
551                         'groups' => array(),
552                         'tags' => array());
553
554         // Check for optional attributes...
555
556         if (!empty($activity->time)) {
557             $options['created'] = common_sql_date($activity->time);
558         }
559
560         if ($activity->context) {
561             // Any individual or group attn: targets?
562             $replies = $activity->context->attention;
563             $options['groups'] = $this->filterReplies($oprofile, $replies);
564             $options['replies'] = $replies;
565
566             // Maintain direct reply associations
567             // @fixme what about conversation ID?
568             if (!empty($activity->context->replyToID)) {
569                 $orig = Notice::staticGet('uri',
570                                           $activity->context->replyToID);
571                 if (!empty($orig)) {
572                     $options['reply_to'] = $orig->id;
573                 }
574             }
575
576             $location = $activity->context->location;
577             if ($location) {
578                 $options['lat'] = $location->lat;
579                 $options['lon'] = $location->lon;
580                 if ($location->location_id) {
581                     $options['location_ns'] = $location->location_ns;
582                     $options['location_id'] = $location->location_id;
583                 }
584             }
585         }
586
587         // Atom categories <-> hashtags
588         foreach ($activity->categories as $cat) {
589             if ($cat->term) {
590                 $term = common_canonical_tag($cat->term);
591                 if ($term) {
592                     $options['tags'][] = $term;
593                 }
594             }
595         }
596
597         try {
598             $saved = Notice::saveNew($oprofile->profile_id,
599                                      $content,
600                                      'ostatus',
601                                      $options);
602             if ($saved) {
603                 Ostatus_source::saveNew($saved, $this, $method);
604                 if (!empty($attachment)) {
605                     File_to_post::processNew($attachment->id, $saved->id);
606                 }
607             }
608         } catch (Exception $e) {
609             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
610             throw $e;
611         }
612         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
613         return $saved;
614     }
615
616     /**
617      * Clean up HTML
618      */
619     protected function purify($html)
620     {
621         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
622         $config = array('safe' => 1);
623         return htmLawed($html, $config);
624     }
625
626     /**
627      * Filters a list of recipient ID URIs to just those for local delivery.
628      * @param Ostatus_profile local profile of sender
629      * @param array in/out &$attention_uris set of URIs, will be pruned on output
630      * @return array of group IDs
631      */
632     protected function filterReplies($sender, &$attention_uris)
633     {
634         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
635         $groups = array();
636         $replies = array();
637         foreach ($attention_uris as $recipient) {
638             // Is the recipient a local user?
639             $user = User::staticGet('uri', $recipient);
640             if ($user) {
641                 // @fixme sender verification, spam etc?
642                 $replies[] = $recipient;
643                 continue;
644             }
645
646             // Is the recipient a remote group?
647             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
648             if ($oprofile) {
649                 if ($oprofile->isGroup()) {
650                     // Deliver to local members of this remote group.
651                     // @fixme sender verification?
652                     $groups[] = $oprofile->group_id;
653                 } else {
654                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
655                 }
656                 continue;
657             }
658
659             // Is the recipient a local group?
660             // @fixme we need a uri on user_group
661             // $group = User_group::staticGet('uri', $recipient);
662             $template = common_local_url('groupbyid', array('id' => '31337'));
663             $template = preg_quote($template, '/');
664             $template = str_replace('31337', '(\d+)', $template);
665             if (preg_match("/$template/", $recipient, $matches)) {
666                 $id = $matches[1];
667                 $group = User_group::staticGet('id', $id);
668                 if ($group) {
669                     // Deliver to all members of this local group if allowed.
670                     $profile = $sender->localProfile();
671                     if ($profile->isMember($group)) {
672                         $groups[] = $group->id;
673                     } else {
674                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
675                     }
676                     continue;
677                 } else {
678                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
679                 }
680             }
681
682             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
683
684         }
685         $attention_uris = $replies;
686         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
687         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
688         return $groups;
689     }
690
691     /**
692      * @param string $profile_url
693      * @return Ostatus_profile
694      * @throws FeedSubException
695      */
696     public static function ensureProfile($profile_uri, $hints=array())
697     {
698         // Get the canonical feed URI and check it
699         $discover = new FeedDiscovery();
700         if ($hints['feedurl']) {
701             $feeduri = $hints['feedurl'];
702             $feeduri = $discover->discoverFromFeedURL($feeduri);
703         } else {
704             $feeduri = $discover->discoverFromURL($profile_uri);
705             $hints['feedurl'] = $feeduri;
706         }
707
708         $huburi = $discover->getAtomLink('hub');
709         $hints['hub'] = $huburi;
710         $salmonuri = $discover->getAtomLink('salmon');
711         $hints['salmon'] = $salmonuri;
712
713         if (!$huburi) {
714             // We can only deal with folks with a PuSH hub
715             throw new FeedSubNoHubException();
716         }
717
718         // Try to get a profile from the feed activity:subject
719
720         $feedEl = $discover->feed->documentElement;
721
722         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
723
724         if (!empty($subject)) {
725             $subjObject = new ActivityObject($subject);
726             return self::ensureActivityObjectProfile($subjObject, $hints);
727         }
728
729         // Otherwise, try the feed author
730
731         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
732
733         if (!empty($author)) {
734             $authorObject = new ActivityObject($author);
735             return self::ensureActivityObjectProfile($authorObject, $hints);
736         }
737
738         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
739         // entries.
740
741         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
742
743         if (!empty($entries) && $entries->length > 0) {
744
745             $entry = $entries->item(0);
746
747             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
748
749             if (!empty($actor)) {
750                 $actorObject = new ActivityObject($actor);
751                 return self::ensureActivityObjectProfile($actorObject, $hints);
752
753             }
754
755             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
756
757             if (!empty($author)) {
758                 $authorObject = new ActivityObject($author);
759                 return self::ensureActivityObjectProfile($authorObject, $hints);
760             }
761         }
762
763         // XXX: make some educated guesses here
764
765         throw new FeedSubException("Can't find enough profile information to make a feed.");
766     }
767
768     /**
769      *
770      * Download and update given avatar image
771      * @param string $url
772      * @throws Exception in various failure cases
773      */
774     protected function updateAvatar($url)
775     {
776         if ($url == $this->avatar) {
777             // We've already got this one.
778             return;
779         }
780
781         if ($this->isGroup()) {
782             $self = $this->localGroup();
783         } else {
784             $self = $this->localProfile();
785         }
786         if (!$self) {
787             throw new ServerException(sprintf(
788                 _m("Tried to update avatar for unsaved remote profile %s"),
789                 $this->uri));
790         }
791
792         // @fixme this should be better encapsulated
793         // ripped from oauthstore.php (for old OMB client)
794         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
795         if (!copy($url, $temp_filename)) {
796             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
797         }
798
799         if ($this->isGroup()) {
800             $id = $this->group_id;
801         } else {
802             $id = $this->profile_id;
803         }
804         // @fixme should we be using different ids?
805         $imagefile = new ImageFile($id, $temp_filename);
806         $filename = Avatar::filename($id,
807                                      image_type_to_extension($imagefile->type),
808                                      null,
809                                      common_timestamp());
810         rename($temp_filename, Avatar::path($filename));
811         $self->setOriginal($filename);
812
813         $orig = clone($this);
814         $this->avatar = $url;
815         $this->update($orig);
816     }
817
818     /**
819      * Pull avatar URL from ActivityObject or profile hints
820      *
821      * @param ActivityObject $object
822      * @param array $hints
823      * @return mixed URL string or false
824      */
825
826     protected static function getActivityObjectAvatar($object, $hints=array())
827     {
828         if ($object->avatarLinks) {
829             $best = false;
830             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
831             foreach ($object->avatarLinks as $avatar) {
832                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
833                     // Exact match!
834                     $best = $avatar;
835                     break;
836                 }
837                 if (!$best || $avatar->width > $best->width) {
838                     $best = $avatar;
839                 }
840             }
841             return $best->url;
842         } else if (array_key_exists('avatar', $hints)) {
843             return $hints['avatar'];
844         }
845         return false;
846     }
847
848     /**
849      * Get an appropriate avatar image source URL, if available.
850      *
851      * @param ActivityObject $actor
852      * @param DOMElement $feed
853      * @return string
854      */
855
856     protected static function getAvatar($actor, $feed)
857     {
858         $url = '';
859         $icon = '';
860         if ($actor->avatar) {
861             $url = trim($actor->avatar);
862         }
863         if (!$url) {
864             // Check <atom:logo> and <atom:icon> on the feed
865             $els = $feed->childNodes();
866             if ($els && $els->length) {
867                 for ($i = 0; $i < $els->length; $i++) {
868                     $el = $els->item($i);
869                     if ($el->namespaceURI == Activity::ATOM) {
870                         if (empty($url) && $el->localName == 'logo') {
871                             $url = trim($el->textContent);
872                             break;
873                         }
874                         if (empty($icon) && $el->localName == 'icon') {
875                             // Use as a fallback
876                             $icon = trim($el->textContent);
877                         }
878                     }
879                 }
880             }
881             if ($icon && !$url) {
882                 $url = $icon;
883             }
884         }
885         if ($url) {
886             $opts = array('allowed_schemes' => array('http', 'https'));
887             if (Validate::uri($url, $opts)) {
888                 return $url;
889             }
890         }
891         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
892     }
893
894     /**
895      * Fetch, or build if necessary, an Ostatus_profile for the actor
896      * in a given Activity Streams activity.
897      *
898      * @param Activity $activity
899      * @param string $feeduri if we already know the canonical feed URI!
900      * @param string $salmonuri if we already know the salmon return channel URI
901      * @return Ostatus_profile
902      */
903
904     public static function ensureActorProfile($activity, $hints=array())
905     {
906         return self::ensureActivityObjectProfile($activity->actor, $hints);
907     }
908
909     public static function ensureActivityObjectProfile($object, $hints=array())
910     {
911         $profile = self::getActivityObjectProfile($object);
912         if ($profile) {
913             $profile->updateFromActivityObject($object, $hints);
914         } else {
915             $profile = self::createActivityObjectProfile($object, $hints);
916         }
917         return $profile;
918     }
919
920     /**
921      * @param Activity $activity
922      * @return mixed matching Ostatus_profile or false if none known
923      */
924     public static function getActorProfile($activity)
925     {
926         return self::getActivityObjectProfile($activity->actor);
927     }
928
929     protected static function getActivityObjectProfile($object)
930     {
931         $uri = self::getActivityObjectProfileURI($object);
932         return Ostatus_profile::staticGet('uri', $uri);
933     }
934
935     protected static function getActorProfileURI($activity)
936     {
937         return self::getActivityObjectProfileURI($activity->actor);
938     }
939
940     /**
941      * @param Activity $activity
942      * @return string
943      * @throws ServerException
944      */
945     protected static function getActivityObjectProfileURI($object)
946     {
947         $opts = array('allowed_schemes' => array('http', 'https'));
948         if ($object->id && Validate::uri($object->id, $opts)) {
949             return $object->id;
950         }
951         if ($object->link && Validate::uri($object->link, $opts)) {
952             return $object->link;
953         }
954         throw new ServerException("No author ID URI found");
955     }
956
957     /**
958      * @fixme validate stuff somewhere
959      */
960
961     /**
962      * Create local ostatus_profile and profile/user_group entries for
963      * the provided remote user or group.
964      *
965      * @param ActivityObject $object
966      * @param array $hints
967      *
968      * @return Ostatus_profile
969      */
970     protected static function createActivityObjectProfile($object, $hints=array())
971     {
972         $homeuri = $object->id;
973         $discover = false;
974
975         if (!$homeuri) {
976             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
977             throw new ServerException("No profile URI");
978         }
979
980         if (array_key_exists('feedurl', $hints)) {
981             $feeduri = $hints['feedurl'];
982         } else {
983             $discover = new FeedDiscovery();
984             $feeduri = $discover->discoverFromURL($homeuri);
985         }
986
987         if (array_key_exists('salmon', $hints)) {
988             $salmonuri = $hints['salmon'];
989         } else {
990             if (!$discover) {
991                 $discover = new FeedDiscovery();
992                 $discover->discoverFromFeedURL($hints['feedurl']);
993             }
994             $salmonuri = $discover->getAtomLink('salmon');
995         }
996
997         if (array_key_exists('hub', $hints)) {
998             $huburi = $hints['hub'];
999         } else {
1000             if (!$discover) {
1001                 $discover = new FeedDiscovery();
1002                 $discover->discoverFromFeedURL($hints['feedurl']);
1003             }
1004             $huburi = $discover->getAtomLink('hub');
1005         }
1006
1007         if (!$huburi) {
1008             // We can only deal with folks with a PuSH hub
1009             throw new FeedSubNoHubException();
1010         }
1011
1012         $oprofile = new Ostatus_profile();
1013
1014         $oprofile->uri        = $homeuri;
1015         $oprofile->feeduri    = $feeduri;
1016         $oprofile->salmonuri  = $salmonuri;
1017
1018         $oprofile->created    = common_sql_now();
1019         $oprofile->modified   = common_sql_now();
1020
1021         if ($object->type == ActivityObject::PERSON) {
1022             $profile = new Profile();
1023             $profile->created = common_sql_now();
1024             self::updateProfile($profile, $object, $hints);
1025
1026             $oprofile->profile_id = $profile->insert();
1027             if (!$oprofile->profile_id) {
1028                 throw new ServerException("Can't save local profile");
1029             }
1030         } else {
1031             $group = new User_group();
1032             $group->uri = $homeuri;
1033             $group->created = common_sql_now();
1034             self::updateGroup($group, $object, $hints);
1035
1036             $oprofile->group_id = $group->insert();
1037             if (!$oprofile->group_id) {
1038                 throw new ServerException("Can't save local profile");
1039             }
1040         }
1041
1042         $ok = $oprofile->insert();
1043
1044         if ($ok) {
1045             $avatar = self::getActivityObjectAvatar($object, $hints);
1046             if ($avatar) {
1047                 $oprofile->updateAvatar($avatar);
1048             }
1049             return $oprofile;
1050         } else {
1051             throw new ServerException("Can't save OStatus profile");
1052         }
1053     }
1054
1055     /**
1056      * Save any updated profile information to our local copy.
1057      * @param ActivityObject $object
1058      * @param array $hints
1059      */
1060     public function updateFromActivityObject($object, $hints=array())
1061     {
1062         if ($this->isGroup()) {
1063             $group = $this->localGroup();
1064             self::updateGroup($group, $object, $hints);
1065         } else {
1066             $profile = $this->localProfile();
1067             self::updateProfile($profile, $object, $hints);
1068         }
1069         $avatar = self::getActivityObjectAvatar($object, $hints);
1070         if ($avatar) {
1071             $this->updateAvatar($avatar);
1072         }
1073     }
1074
1075     protected static function updateProfile($profile, $object, $hints=array())
1076     {
1077         $orig = clone($profile);
1078
1079         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1080
1081         if (!empty($object->title)) {
1082             $profile->fullname = $object->title;
1083         } else if (array_key_exists('fullname', $hints)) {
1084             $profile->fullname = $hints['fullname'];
1085         }
1086
1087         if (!empty($object->link)) {
1088             $profile->profileurl = $object->link;
1089         } else if (array_key_exists('profileurl', $hints)) {
1090             $profile->profileurl = $hints['profileurl'];
1091         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1092             $profile->profileurl = $object->id;
1093         }
1094
1095         $profile->bio      = self::getActivityObjectBio($object, $hints);
1096         $profile->location = self::getActivityObjectLocation($object, $hints);
1097         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1098
1099         if (!empty($object->geopoint)) {
1100             $location = ActivityContext::locationFromPoint($object->geopoint);
1101             if (!empty($location)) {
1102                 $profile->lat = $location->lat;
1103                 $profile->lon = $location->lon;
1104             }
1105         }
1106
1107         // @fixme tags/categories
1108         // @todo tags from categories
1109
1110         if ($profile->id) {
1111             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1112             $profile->update($orig);
1113         }
1114     }
1115
1116     protected static function updateGroup($group, $object, $hints=array())
1117     {
1118         $orig = clone($group);
1119
1120         $group->nickname = self::getActivityObjectNickname($object, $hints);
1121         $group->fullname = $object->title;
1122
1123         if (!empty($object->link)) {
1124             $group->mainpage = $object->link;
1125         } else if (array_key_exists('profileurl', $hints)) {
1126             $group->mainpage = $hints['profileurl'];
1127         }
1128
1129         // @todo tags from categories
1130         $group->description = self::getActivityObjectBio($object, $hints);
1131         $group->location = self::getActivityObjectLocation($object, $hints);
1132         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1133
1134         if ($group->id) {
1135             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1136             $group->update($orig);
1137         }
1138     }
1139
1140     protected static function getActivityObjectHomepage($object, $hints=array())
1141     {
1142         $homepage = null;
1143         $poco     = $object->poco;
1144
1145         if (!empty($poco)) {
1146             $url = $poco->getPrimaryURL();
1147             if ($url->type == 'homepage') {
1148                 $homepage = $url->value;
1149             }
1150         }
1151
1152         // @todo Try for a another PoCo URL?
1153
1154         return $homepage;
1155     }
1156
1157     protected static function getActivityObjectLocation($object, $hints=array())
1158     {
1159         $location = null;
1160
1161         if (!empty($object->poco) &&
1162             isset($object->poco->address->formatted)) {
1163             $location = $object->poco->address->formatted;
1164         } else if (array_key_exists('location', $hints)) {
1165             $location = $hints['location'];
1166         }
1167
1168         if (!empty($location)) {
1169             if (mb_strlen($location) > 255) {
1170                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1171             }
1172         }
1173
1174         // @todo Try to find location some othe way? Via goerss point?
1175
1176         return $location;
1177     }
1178
1179     protected static function getActivityObjectBio($object, $hints=array())
1180     {
1181         $bio  = null;
1182
1183         if (!empty($object->poco)) {
1184             $note = $object->poco->note;
1185         } else if (array_key_exists('bio', $hints)) {
1186             $note = $hints['bio'];
1187         }
1188
1189         if (!empty($note)) {
1190             if (Profile::bioTooLong($note)) {
1191                 // XXX: truncate ok?
1192                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1193             } else {
1194                 $bio = $note;
1195             }
1196         }
1197
1198         // @todo Try to get bio info some other way?
1199
1200         return $bio;
1201     }
1202
1203     protected static function getActivityObjectNickname($object, $hints=array())
1204     {
1205         if ($object->poco) {
1206             if (!empty($object->poco->preferredUsername)) {
1207                 return common_nicknamize($object->poco->preferredUsername);
1208             }
1209         }
1210
1211         if (!empty($object->nickname)) {
1212             return common_nicknamize($object->nickname);
1213         }
1214
1215         if (array_key_exists('nickname', $hints)) {
1216             return $hints['nickname'];
1217         }
1218
1219         // Try the definitive ID
1220
1221         $nickname = self::nicknameFromURI($object->id);
1222
1223         // Try a Webfinger if one was passed (way) down
1224
1225         if (empty($nickname)) {
1226             if (array_key_exists('webfinger', $hints)) {
1227                 $nickname = self::nicknameFromURI($hints['webfinger']);
1228             }
1229         }
1230
1231         // Try the name
1232
1233         if (empty($nickname)) {
1234             $nickname = common_nicknamize($object->title);
1235         }
1236
1237         return $nickname;
1238     }
1239
1240     protected static function nicknameFromURI($uri)
1241     {
1242         preg_match('/(\w+):/', $uri, $matches);
1243
1244         $protocol = $matches[1];
1245
1246         switch ($protocol) {
1247         case 'acct':
1248         case 'mailto':
1249             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1250                 return common_canonical_nickname($matches[1]);
1251             }
1252             return null;
1253         case 'http':
1254             return common_url_to_nickname($uri);
1255             break;
1256         default:
1257             return null;
1258         }
1259     }
1260
1261     public static function ensureWebfinger($addr)
1262     {
1263         // First, try the cache
1264
1265         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1266
1267         if ($uri !== false) {
1268             if (is_null($uri)) {
1269                 return null;
1270             }
1271             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1272             if (!empty($oprofile)) {
1273                 return $oprofile;
1274             }
1275         }
1276
1277         // First, look it up
1278
1279         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1280
1281         if (!empty($oprofile)) {
1282             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1283             return $oprofile;
1284         }
1285
1286         // Now, try some discovery
1287
1288         $wf = new Webfinger();
1289
1290         $result = $wf->lookup($addr);
1291
1292         if (!$result) {
1293             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1294             return null;
1295         }
1296
1297         foreach ($result->links as $link) {
1298             switch ($link['rel']) {
1299             case Webfinger::PROFILEPAGE:
1300                 $profileUrl = $link['href'];
1301                 break;
1302             case 'salmon':
1303                 $salmonEndpoint = $link['href'];
1304                 break;
1305             case Webfinger::UPDATESFROM:
1306                 $feedUrl = $link['href'];
1307                 break;
1308             case Webfinger::HCARD:
1309                 $hcardUrl = $link['href'];
1310                 break;
1311             default:
1312                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1313                 break;
1314             }
1315         }
1316
1317         $hints = array('webfinger' => $addr,
1318                        'profileurl' => $profileUrl,
1319                        'feedurl' => $feedUrl,
1320                        'salmon' => $salmonEndpoint);
1321
1322         if (isset($hcardUrl)) {
1323             $hcardHints = self::slurpHcard($hcardUrl);
1324             // Note: Webfinger > hcard
1325             $hints = array_merge($hcardHints, $hints);
1326         }
1327
1328         // If we got a feed URL, try that
1329
1330         if (isset($feedUrl)) {
1331             try {
1332                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL $feedUrl");
1333                 $oprofile = self::ensureProfile($feedUrl, $hints);
1334                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1335                 return $oprofile;
1336             } catch (Exception $e) {
1337                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1338                 // keep looking
1339             }
1340         }
1341
1342         // If we got a profile page, try that!
1343
1344         if (isset($profileUrl)) {
1345             try {
1346                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1347                 $oprofile = self::ensureProfile($profileUrl, $hints);
1348                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1349                 return $oprofile;
1350             } catch (Exception $e) {
1351                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1352                 // keep looking
1353             }
1354         }
1355
1356         // XXX: try hcard
1357         // XXX: try FOAF
1358
1359         if (isset($salmonEndpoint)) {
1360
1361             // An account URL, a salmon endpoint, and a dream? Not much to go
1362             // on, but let's give it a try
1363
1364             $uri = 'acct:'.$addr;
1365
1366             $profile = new Profile();
1367
1368             $profile->nickname = self::nicknameFromUri($uri);
1369             $profile->created  = common_sql_now();
1370
1371             if (isset($profileUrl)) {
1372                 $profile->profileurl = $profileUrl;
1373             }
1374
1375             $profile_id = $profile->insert();
1376
1377             if (!$profile_id) {
1378                 common_log_db_error($profile, 'INSERT', __FILE__);
1379                 throw new Exception("Couldn't save profile for '$addr'");
1380             }
1381
1382             $oprofile = new Ostatus_profile();
1383
1384             $oprofile->uri        = $uri;
1385             $oprofile->salmonuri  = $salmonEndpoint;
1386             $oprofile->profile_id = $profile_id;
1387             $oprofile->created    = common_sql_now();
1388
1389             if (isset($feedUrl)) {
1390                 $profile->feeduri = $feedUrl;
1391             }
1392
1393             $result = $oprofile->insert();
1394
1395             if (!$result) {
1396                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1397                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1398             }
1399
1400             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1401             return $oprofile;
1402         }
1403
1404         return null;
1405     }
1406
1407     function saveHTMLFile($title, $rendered)
1408     {
1409         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1410                          '<body><div>%s</div></body></html>',
1411                          htmlspecialchars($title),
1412                          $rendered);
1413
1414         $filename = File::filename($this->localProfile(),
1415                                    'ostatus', // ignored?
1416                                    'text/html');
1417
1418         $filepath = File::path($filename);
1419
1420         file_put_contents($filepath, $final);
1421
1422         $file = new File;
1423
1424         $file->filename = $filename;
1425         $file->url      = File::url($filename);
1426         $file->size     = filesize($filepath);
1427         $file->date     = time();
1428         $file->mimetype = 'text/html';
1429
1430         $file_id = $file->insert();
1431
1432         if ($file_id === false) {
1433             common_log_db_error($file, "INSERT", __FILE__);
1434             throw new ServerException(_('Could not store HTML content of long post as file.'));
1435         }
1436
1437         return $file;
1438     }
1439
1440     protected static function slurpHcard($url)
1441     {
1442         set_include_path(get_include_path() . PATH_SEPARATOR . INSTALLDIR . '/plugins/OStatus/extlib/hkit/');
1443         require_once('hkit.class.php');
1444
1445         $h      = new hKit;
1446
1447         // Google Buzz hcards need to be tidied. Probably others too.
1448
1449         $h->tidy_mode = 'proxy'; // 'proxy', 'exec', 'php' or 'none'
1450
1451         // Get by URL
1452         $hcards = $h->getByURL('hcard', $url);
1453
1454         if (empty($hcards)) {
1455             return array();
1456         }
1457
1458         // @fixme more intelligent guess on multi-hcard pages
1459         $hcard = $hcards[0];
1460
1461         $hints = array();
1462
1463         $hints['profileurl'] = $url;
1464
1465         if (array_key_exists('nickname', $hcard)) {
1466             $hints['nickname'] = $hcard['nickname'];
1467         }
1468
1469         if (array_key_exists('fn', $hcard)) {
1470             $hints['fullname'] = $hcard['fn'];
1471         } else if (array_key_exists('n', $hcard)) {
1472             $hints['fullname'] = implode(' ', $hcard['n']);
1473         }
1474
1475         if (array_key_exists('photo', $hcard)) {
1476             $hints['avatar'] = $hcard['photo'];
1477         }
1478
1479         if (array_key_exists('note', $hcard)) {
1480             $hints['bio'] = $hcard['note'];
1481         }
1482
1483         if (array_key_exists('adr', $hcard)) {
1484             if (is_string($hcard['adr'])) {
1485                 $hints['location'] = $hcard['adr'];
1486             } else if (is_array($hcard['adr'])) {
1487                 $hints['location'] = implode(' ', $hcard['adr']);
1488             }
1489         }
1490
1491         if (array_key_exists('url', $hcard)) {
1492             if (is_string($hcard['url'])) {
1493                 $hints['homepage'] = $hcard['url'];
1494             } else if (is_array($hcard['adr'])) {
1495                 // HACK get the last one; that's how our hcards look
1496                 $hints['homepage'] = $hcard['url'][count($hcard['url'])-1];
1497             }
1498         }
1499
1500         return $hints;
1501     }
1502 }