]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' of git@gitorious.org:statusnet/mainline into 0.9.x
[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, $actor);
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, $actor)
373     {
374         if ($this->salmonuri) {
375             $salmon = new Salmon();
376             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
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, $actor)
390     {
391         if ($this->salmonuri) {
392             $data = array('salmonuri' => $this->salmonuri,
393                           'entry' => $this->notifyPrepXml($entry),
394                           'actor' => $actor->id);
395
396             $qm = QueueManager::get();
397             return $qm->enqueue($data, 'salmon');
398         }
399
400         return false;
401     }
402
403     protected function notifyPrepXml($entry)
404     {
405         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
406         if (is_string($entry)) {
407             return $entry;
408         } else if ($entry instanceof Activity) {
409             return $preamble . $entry->asString(true);
410         } else if ($entry instanceof Notice) {
411             return $preamble . $entry->asAtomEntry(true, true);
412         } else {
413             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
414         }
415     }
416
417     function getBestName()
418     {
419         if ($this->isGroup()) {
420             return $this->localGroup()->getBestName();
421         } else {
422             return $this->localProfile()->getBestName();
423         }
424     }
425
426     /**
427      * Read and post notices for updates from the feed.
428      * Currently assumes that all items in the feed are new,
429      * coming from a PuSH hub.
430      *
431      * @param DOMDocument $feed
432      */
433     public function processFeed($feed, $source)
434     {
435         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
436         if ($entries->length == 0) {
437             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
438             return;
439         }
440
441         for ($i = 0; $i < $entries->length; $i++) {
442             $entry = $entries->item($i);
443             $this->processEntry($entry, $feed, $source);
444         }
445     }
446
447     /**
448      * Process a posted entry from this feed source.
449      *
450      * @param DOMElement $entry
451      * @param DOMElement $feed for context
452      */
453     public function processEntry($entry, $feed, $source)
454     {
455         $activity = new Activity($entry, $feed);
456
457         if ($activity->verb == ActivityVerb::POST) {
458             $this->processPost($activity, $source);
459         } else {
460             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
461         }
462     }
463
464     /**
465      * Process an incoming post activity from this remote feed.
466      * @param Activity $activity
467      * @param string $method 'push' or 'salmon'
468      * @return mixed saved Notice or false
469      * @fixme break up this function, it's getting nasty long
470      */
471     public function processPost($activity, $method)
472     {
473         if ($this->isGroup()) {
474             // A group feed will contain posts from multiple authors.
475             // @fixme validate these profiles in some way!
476             $oprofile = self::ensureActorProfile($activity);
477             if ($oprofile->isGroup()) {
478                 // Groups can't post notices in StatusNet.
479                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
480                 return false;
481             }
482         } else {
483             // Individual user feeds may contain only posts from themselves.
484             // Authorship is validated against the profile URI on upper layers,
485             // through PuSH setup or Salmon signature checks.
486             $actorUri = self::getActorProfileURI($activity);
487             if ($actorUri == $this->uri) {
488                 // Check if profile info has changed and update it
489                 $this->updateFromActivityObject($activity->actor);
490             } else {
491                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
492                 return false;
493             }
494             $oprofile = $this;
495         }
496
497         // The id URI will be used as a unique identifier for for the notice,
498         // protecting against duplicate saves. It isn't required to be a URL;
499         // tag: URIs for instance are found in Google Buzz feeds.
500         $sourceUri = $activity->object->id;
501         $dupe = Notice::staticGet('uri', $sourceUri);
502         if ($dupe) {
503             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
504             return false;
505         }
506
507         // We'll also want to save a web link to the original notice, if provided.
508         $sourceUrl = null;
509         if ($activity->object->link) {
510             $sourceUrl = $activity->object->link;
511         } else if ($activity->link) {
512             $sourceUrl = $activity->link;
513         } else if (preg_match('!^https?://!', $activity->object->id)) {
514             $sourceUrl = $activity->object->id;
515         }
516
517         // Get (safe!) HTML and text versions of the content
518         $rendered = $this->purify($activity->object->content);
519         $content = html_entity_decode(strip_tags($rendered));
520
521         $shortened = common_shorten_links($content);
522
523         // If it's too long, try using the summary, and make the
524         // HTML an attachment.
525
526         $attachment = null;
527
528         if (Notice::contentTooLong($shortened)) {
529             $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
530             $summary = $activity->object->summary;
531             if (empty($summary)) {
532                 $summary = $content;
533             }
534             $shortSummary = common_shorten_links($summary);
535             if (Notice::contentTooLong($shortSummary)) {
536                 $url = common_shorten_url(common_local_url('attachment',
537                                                            array('attachment' => $attachment->id)));
538                 $shortSummary = substr($shortSummary,
539                                        0,
540                                        Notice::maxContent() - (mb_strlen($url) + 2));
541                 $shortSummary .= '… ' . $url;
542                 $content = $shortSummary;
543                 $rendered = common_render_text($content);
544             }
545         }
546
547         $options = array('is_local' => Notice::REMOTE_OMB,
548                         'url' => $sourceUrl,
549                         'uri' => $sourceUri,
550                         'rendered' => $rendered,
551                         'replies' => array(),
552                         'groups' => array(),
553                         'tags' => array());
554
555         // Check for optional attributes...
556
557         if (!empty($activity->time)) {
558             $options['created'] = common_sql_date($activity->time);
559         }
560
561         if ($activity->context) {
562             // Any individual or group attn: targets?
563             $replies = $activity->context->attention;
564             $options['groups'] = $this->filterReplies($oprofile, $replies);
565             $options['replies'] = $replies;
566
567             // Maintain direct reply associations
568             // @fixme what about conversation ID?
569             if (!empty($activity->context->replyToID)) {
570                 $orig = Notice::staticGet('uri',
571                                           $activity->context->replyToID);
572                 if (!empty($orig)) {
573                     $options['reply_to'] = $orig->id;
574                 }
575             }
576
577             $location = $activity->context->location;
578             if ($location) {
579                 $options['lat'] = $location->lat;
580                 $options['lon'] = $location->lon;
581                 if ($location->location_id) {
582                     $options['location_ns'] = $location->location_ns;
583                     $options['location_id'] = $location->location_id;
584                 }
585             }
586         }
587
588         // Atom categories <-> hashtags
589         foreach ($activity->categories as $cat) {
590             if ($cat->term) {
591                 $term = common_canonical_tag($cat->term);
592                 if ($term) {
593                     $options['tags'][] = $term;
594                 }
595             }
596         }
597
598         try {
599             $saved = Notice::saveNew($oprofile->profile_id,
600                                      $content,
601                                      'ostatus',
602                                      $options);
603             if ($saved) {
604                 Ostatus_source::saveNew($saved, $this, $method);
605                 if (!empty($attachment)) {
606                     File_to_post::processNew($attachment->id, $saved->id);
607                 }
608             }
609         } catch (Exception $e) {
610             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
611             throw $e;
612         }
613         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
614         return $saved;
615     }
616
617     /**
618      * Clean up HTML
619      */
620     protected function purify($html)
621     {
622         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
623         $config = array('safe' => 1);
624         return htmLawed($html, $config);
625     }
626
627     /**
628      * Filters a list of recipient ID URIs to just those for local delivery.
629      * @param Ostatus_profile local profile of sender
630      * @param array in/out &$attention_uris set of URIs, will be pruned on output
631      * @return array of group IDs
632      */
633     protected function filterReplies($sender, &$attention_uris)
634     {
635         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
636         $groups = array();
637         $replies = array();
638         foreach ($attention_uris as $recipient) {
639             // Is the recipient a local user?
640             $user = User::staticGet('uri', $recipient);
641             if ($user) {
642                 // @fixme sender verification, spam etc?
643                 $replies[] = $recipient;
644                 continue;
645             }
646
647             // Is the recipient a remote group?
648             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
649             if ($oprofile) {
650                 if ($oprofile->isGroup()) {
651                     // Deliver to local members of this remote group.
652                     // @fixme sender verification?
653                     $groups[] = $oprofile->group_id;
654                 } else {
655                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
656                 }
657                 continue;
658             }
659
660             // Is the recipient a local group?
661             // @fixme we need a uri on user_group
662             // $group = User_group::staticGet('uri', $recipient);
663             $template = common_local_url('groupbyid', array('id' => '31337'));
664             $template = preg_quote($template, '/');
665             $template = str_replace('31337', '(\d+)', $template);
666             if (preg_match("/$template/", $recipient, $matches)) {
667                 $id = $matches[1];
668                 $group = User_group::staticGet('id', $id);
669                 if ($group) {
670                     // Deliver to all members of this local group if allowed.
671                     $profile = $sender->localProfile();
672                     if ($profile->isMember($group)) {
673                         $groups[] = $group->id;
674                     } else {
675                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
676                     }
677                     continue;
678                 } else {
679                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
680                 }
681             }
682
683             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
684
685         }
686         $attention_uris = $replies;
687         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
688         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
689         return $groups;
690     }
691
692     /**
693      * @param string $profile_url
694      * @return Ostatus_profile
695      * @throws FeedSubException
696      */
697     public static function ensureProfile($profile_uri, $hints=array())
698     {
699         // Get the canonical feed URI and check it
700         $discover = new FeedDiscovery();
701         if (isset($hints['feedurl'])) {
702             $feeduri = $hints['feedurl'];
703             $feeduri = $discover->discoverFromFeedURL($feeduri);
704         } else {
705             $feeduri = $discover->discoverFromURL($profile_uri);
706             $hints['feedurl'] = $feeduri;
707         }
708
709         $huburi = $discover->getAtomLink('hub');
710         $hints['hub'] = $huburi;
711         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
712         $hints['salmon'] = $salmonuri;
713
714         if (!$huburi) {
715             // We can only deal with folks with a PuSH hub
716             throw new FeedSubNoHubException();
717         }
718
719         // Try to get a profile from the feed activity:subject
720
721         $feedEl = $discover->feed->documentElement;
722
723         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
724
725         if (!empty($subject)) {
726             $subjObject = new ActivityObject($subject);
727             return self::ensureActivityObjectProfile($subjObject, $hints);
728         }
729
730         // Otherwise, try the feed author
731
732         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
733
734         if (!empty($author)) {
735             $authorObject = new ActivityObject($author);
736             return self::ensureActivityObjectProfile($authorObject, $hints);
737         }
738
739         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
740         // entries.
741
742         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
743
744         if (!empty($entries) && $entries->length > 0) {
745
746             $entry = $entries->item(0);
747
748             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
749
750             if (!empty($actor)) {
751                 $actorObject = new ActivityObject($actor);
752                 return self::ensureActivityObjectProfile($actorObject, $hints);
753
754             }
755
756             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
757
758             if (!empty($author)) {
759                 $authorObject = new ActivityObject($author);
760                 return self::ensureActivityObjectProfile($authorObject, $hints);
761             }
762         }
763
764         // XXX: make some educated guesses here
765
766         throw new FeedSubException("Can't find enough profile information to make a feed.");
767     }
768
769     /**
770      *
771      * Download and update given avatar image
772      * @param string $url
773      * @throws Exception in various failure cases
774      */
775     protected function updateAvatar($url)
776     {
777         if ($url == $this->avatar) {
778             // We've already got this one.
779             return;
780         }
781
782         if ($this->isGroup()) {
783             $self = $this->localGroup();
784         } else {
785             $self = $this->localProfile();
786         }
787         if (!$self) {
788             throw new ServerException(sprintf(
789                 _m("Tried to update avatar for unsaved remote profile %s"),
790                 $this->uri));
791         }
792
793         // @fixme this should be better encapsulated
794         // ripped from oauthstore.php (for old OMB client)
795         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
796         if (!copy($url, $temp_filename)) {
797             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
798         }
799
800         if ($this->isGroup()) {
801             $id = $this->group_id;
802         } else {
803             $id = $this->profile_id;
804         }
805         // @fixme should we be using different ids?
806         $imagefile = new ImageFile($id, $temp_filename);
807         $filename = Avatar::filename($id,
808                                      image_type_to_extension($imagefile->type),
809                                      null,
810                                      common_timestamp());
811         rename($temp_filename, Avatar::path($filename));
812         $self->setOriginal($filename);
813
814         $orig = clone($this);
815         $this->avatar = $url;
816         $this->update($orig);
817     }
818
819     /**
820      * Pull avatar URL from ActivityObject or profile hints
821      *
822      * @param ActivityObject $object
823      * @param array $hints
824      * @return mixed URL string or false
825      */
826
827     protected static function getActivityObjectAvatar($object, $hints=array())
828     {
829         if ($object->avatarLinks) {
830             $best = false;
831             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
832             foreach ($object->avatarLinks as $avatar) {
833                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
834                     // Exact match!
835                     $best = $avatar;
836                     break;
837                 }
838                 if (!$best || $avatar->width > $best->width) {
839                     $best = $avatar;
840                 }
841             }
842             return $best->url;
843         } else if (array_key_exists('avatar', $hints)) {
844             return $hints['avatar'];
845         }
846         return false;
847     }
848
849     /**
850      * Get an appropriate avatar image source URL, if available.
851      *
852      * @param ActivityObject $actor
853      * @param DOMElement $feed
854      * @return string
855      */
856
857     protected static function getAvatar($actor, $feed)
858     {
859         $url = '';
860         $icon = '';
861         if ($actor->avatar) {
862             $url = trim($actor->avatar);
863         }
864         if (!$url) {
865             // Check <atom:logo> and <atom:icon> on the feed
866             $els = $feed->childNodes();
867             if ($els && $els->length) {
868                 for ($i = 0; $i < $els->length; $i++) {
869                     $el = $els->item($i);
870                     if ($el->namespaceURI == Activity::ATOM) {
871                         if (empty($url) && $el->localName == 'logo') {
872                             $url = trim($el->textContent);
873                             break;
874                         }
875                         if (empty($icon) && $el->localName == 'icon') {
876                             // Use as a fallback
877                             $icon = trim($el->textContent);
878                         }
879                     }
880                 }
881             }
882             if ($icon && !$url) {
883                 $url = $icon;
884             }
885         }
886         if ($url) {
887             $opts = array('allowed_schemes' => array('http', 'https'));
888             if (Validate::uri($url, $opts)) {
889                 return $url;
890             }
891         }
892         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
893     }
894
895     /**
896      * Fetch, or build if necessary, an Ostatus_profile for the actor
897      * in a given Activity Streams activity.
898      *
899      * @param Activity $activity
900      * @param string $feeduri if we already know the canonical feed URI!
901      * @param string $salmonuri if we already know the salmon return channel URI
902      * @return Ostatus_profile
903      */
904
905     public static function ensureActorProfile($activity, $hints=array())
906     {
907         return self::ensureActivityObjectProfile($activity->actor, $hints);
908     }
909
910     public static function ensureActivityObjectProfile($object, $hints=array())
911     {
912         $profile = self::getActivityObjectProfile($object);
913         if ($profile) {
914             $profile->updateFromActivityObject($object, $hints);
915         } else {
916             $profile = self::createActivityObjectProfile($object, $hints);
917         }
918         return $profile;
919     }
920
921     /**
922      * @param Activity $activity
923      * @return mixed matching Ostatus_profile or false if none known
924      */
925     public static function getActorProfile($activity)
926     {
927         return self::getActivityObjectProfile($activity->actor);
928     }
929
930     protected static function getActivityObjectProfile($object)
931     {
932         $uri = self::getActivityObjectProfileURI($object);
933         return Ostatus_profile::staticGet('uri', $uri);
934     }
935
936     protected static function getActorProfileURI($activity)
937     {
938         return self::getActivityObjectProfileURI($activity->actor);
939     }
940
941     /**
942      * @param Activity $activity
943      * @return string
944      * @throws ServerException
945      */
946     protected static function getActivityObjectProfileURI($object)
947     {
948         $opts = array('allowed_schemes' => array('http', 'https'));
949         if ($object->id && Validate::uri($object->id, $opts)) {
950             return $object->id;
951         }
952         if ($object->link && Validate::uri($object->link, $opts)) {
953             return $object->link;
954         }
955         throw new ServerException("No author ID URI found");
956     }
957
958     /**
959      * @fixme validate stuff somewhere
960      */
961
962     /**
963      * Create local ostatus_profile and profile/user_group entries for
964      * the provided remote user or group.
965      *
966      * @param ActivityObject $object
967      * @param array $hints
968      *
969      * @return Ostatus_profile
970      */
971     protected static function createActivityObjectProfile($object, $hints=array())
972     {
973         $homeuri = $object->id;
974         $discover = false;
975
976         if (!$homeuri) {
977             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
978             throw new ServerException("No profile URI");
979         }
980
981         if (array_key_exists('feedurl', $hints)) {
982             $feeduri = $hints['feedurl'];
983         } else {
984             $discover = new FeedDiscovery();
985             $feeduri = $discover->discoverFromURL($homeuri);
986         }
987
988         if (array_key_exists('salmon', $hints)) {
989             $salmonuri = $hints['salmon'];
990         } else {
991             if (!$discover) {
992                 $discover = new FeedDiscovery();
993                 $discover->discoverFromFeedURL($hints['feedurl']);
994             }
995             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
996         }
997
998         if (array_key_exists('hub', $hints)) {
999             $huburi = $hints['hub'];
1000         } else {
1001             if (!$discover) {
1002                 $discover = new FeedDiscovery();
1003                 $discover->discoverFromFeedURL($hints['feedurl']);
1004             }
1005             $huburi = $discover->getAtomLink('hub');
1006         }
1007
1008         if (!$huburi) {
1009             // We can only deal with folks with a PuSH hub
1010             throw new FeedSubNoHubException();
1011         }
1012
1013         $oprofile = new Ostatus_profile();
1014
1015         $oprofile->uri        = $homeuri;
1016         $oprofile->feeduri    = $feeduri;
1017         $oprofile->salmonuri  = $salmonuri;
1018
1019         $oprofile->created    = common_sql_now();
1020         $oprofile->modified   = common_sql_now();
1021
1022         if ($object->type == ActivityObject::PERSON) {
1023             $profile = new Profile();
1024             $profile->created = common_sql_now();
1025             self::updateProfile($profile, $object, $hints);
1026
1027             $oprofile->profile_id = $profile->insert();
1028             if (!$oprofile->profile_id) {
1029                 throw new ServerException("Can't save local profile");
1030             }
1031         } else {
1032             $group = new User_group();
1033             $group->uri = $homeuri;
1034             $group->created = common_sql_now();
1035             self::updateGroup($group, $object, $hints);
1036
1037             $oprofile->group_id = $group->insert();
1038             if (!$oprofile->group_id) {
1039                 throw new ServerException("Can't save local profile");
1040             }
1041         }
1042
1043         $ok = $oprofile->insert();
1044
1045         if ($ok) {
1046             $avatar = self::getActivityObjectAvatar($object, $hints);
1047             if ($avatar) {
1048                 $oprofile->updateAvatar($avatar);
1049             }
1050             return $oprofile;
1051         } else {
1052             throw new ServerException("Can't save OStatus profile");
1053         }
1054     }
1055
1056     /**
1057      * Save any updated profile information to our local copy.
1058      * @param ActivityObject $object
1059      * @param array $hints
1060      */
1061     public function updateFromActivityObject($object, $hints=array())
1062     {
1063         if ($this->isGroup()) {
1064             $group = $this->localGroup();
1065             self::updateGroup($group, $object, $hints);
1066         } else {
1067             $profile = $this->localProfile();
1068             self::updateProfile($profile, $object, $hints);
1069         }
1070         $avatar = self::getActivityObjectAvatar($object, $hints);
1071         if ($avatar) {
1072             $this->updateAvatar($avatar);
1073         }
1074     }
1075
1076     protected static function updateProfile($profile, $object, $hints=array())
1077     {
1078         $orig = clone($profile);
1079
1080         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1081
1082         if (!empty($object->title)) {
1083             $profile->fullname = $object->title;
1084         } else if (array_key_exists('fullname', $hints)) {
1085             $profile->fullname = $hints['fullname'];
1086         }
1087
1088         if (!empty($object->link)) {
1089             $profile->profileurl = $object->link;
1090         } else if (array_key_exists('profileurl', $hints)) {
1091             $profile->profileurl = $hints['profileurl'];
1092         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1093             $profile->profileurl = $object->id;
1094         }
1095
1096         $profile->bio      = self::getActivityObjectBio($object, $hints);
1097         $profile->location = self::getActivityObjectLocation($object, $hints);
1098         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1099
1100         if (!empty($object->geopoint)) {
1101             $location = ActivityContext::locationFromPoint($object->geopoint);
1102             if (!empty($location)) {
1103                 $profile->lat = $location->lat;
1104                 $profile->lon = $location->lon;
1105             }
1106         }
1107
1108         // @fixme tags/categories
1109         // @todo tags from categories
1110
1111         if ($profile->id) {
1112             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1113             $profile->update($orig);
1114         }
1115     }
1116
1117     protected static function updateGroup($group, $object, $hints=array())
1118     {
1119         $orig = clone($group);
1120
1121         $group->nickname = self::getActivityObjectNickname($object, $hints);
1122         $group->fullname = $object->title;
1123
1124         if (!empty($object->link)) {
1125             $group->mainpage = $object->link;
1126         } else if (array_key_exists('profileurl', $hints)) {
1127             $group->mainpage = $hints['profileurl'];
1128         }
1129
1130         // @todo tags from categories
1131         $group->description = self::getActivityObjectBio($object, $hints);
1132         $group->location = self::getActivityObjectLocation($object, $hints);
1133         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1134
1135         if ($group->id) {
1136             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1137             $group->update($orig);
1138         }
1139     }
1140
1141     protected static function getActivityObjectHomepage($object, $hints=array())
1142     {
1143         $homepage = null;
1144         $poco     = $object->poco;
1145
1146         if (!empty($poco)) {
1147             $url = $poco->getPrimaryURL();
1148             if ($url && $url->type == 'homepage') {
1149                 $homepage = $url->value;
1150             }
1151         }
1152
1153         // @todo Try for a another PoCo URL?
1154
1155         return $homepage;
1156     }
1157
1158     protected static function getActivityObjectLocation($object, $hints=array())
1159     {
1160         $location = null;
1161
1162         if (!empty($object->poco) &&
1163             isset($object->poco->address->formatted)) {
1164             $location = $object->poco->address->formatted;
1165         } else if (array_key_exists('location', $hints)) {
1166             $location = $hints['location'];
1167         }
1168
1169         if (!empty($location)) {
1170             if (mb_strlen($location) > 255) {
1171                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1172             }
1173         }
1174
1175         // @todo Try to find location some othe way? Via goerss point?
1176
1177         return $location;
1178     }
1179
1180     protected static function getActivityObjectBio($object, $hints=array())
1181     {
1182         $bio  = null;
1183
1184         if (!empty($object->poco)) {
1185             $note = $object->poco->note;
1186         } else if (array_key_exists('bio', $hints)) {
1187             $note = $hints['bio'];
1188         }
1189
1190         if (!empty($note)) {
1191             if (Profile::bioTooLong($note)) {
1192                 // XXX: truncate ok?
1193                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1194             } else {
1195                 $bio = $note;
1196             }
1197         }
1198
1199         // @todo Try to get bio info some other way?
1200
1201         return $bio;
1202     }
1203
1204     protected static function getActivityObjectNickname($object, $hints=array())
1205     {
1206         if ($object->poco) {
1207             if (!empty($object->poco->preferredUsername)) {
1208                 return common_nicknamize($object->poco->preferredUsername);
1209             }
1210         }
1211
1212         if (!empty($object->nickname)) {
1213             return common_nicknamize($object->nickname);
1214         }
1215
1216         if (array_key_exists('nickname', $hints)) {
1217             return $hints['nickname'];
1218         }
1219
1220         // Try the definitive ID
1221
1222         $nickname = self::nicknameFromURI($object->id);
1223
1224         // Try a Webfinger if one was passed (way) down
1225
1226         if (empty($nickname)) {
1227             if (array_key_exists('webfinger', $hints)) {
1228                 $nickname = self::nicknameFromURI($hints['webfinger']);
1229             }
1230         }
1231
1232         // Try the name
1233
1234         if (empty($nickname)) {
1235             $nickname = common_nicknamize($object->title);
1236         }
1237
1238         return $nickname;
1239     }
1240
1241     protected static function nicknameFromURI($uri)
1242     {
1243         preg_match('/(\w+):/', $uri, $matches);
1244
1245         $protocol = $matches[1];
1246
1247         switch ($protocol) {
1248         case 'acct':
1249         case 'mailto':
1250             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1251                 return common_canonical_nickname($matches[1]);
1252             }
1253             return null;
1254         case 'http':
1255             return common_url_to_nickname($uri);
1256             break;
1257         default:
1258             return null;
1259         }
1260     }
1261
1262     public static function ensureWebfinger($addr)
1263     {
1264         // First, try the cache
1265
1266         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1267
1268         if ($uri !== false) {
1269             if (is_null($uri)) {
1270                 return null;
1271             }
1272             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1273             if (!empty($oprofile)) {
1274                 return $oprofile;
1275             }
1276         }
1277
1278         // First, look it up
1279
1280         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1281
1282         if (!empty($oprofile)) {
1283             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1284             return $oprofile;
1285         }
1286
1287         // Now, try some discovery
1288
1289         $disco = new Discovery();
1290
1291         try {
1292             $result = $disco->lookup($addr);
1293         } catch (Exception $e) {
1294             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1295             return null;
1296         }
1297
1298         foreach ($result->links as $link) {
1299             switch ($link['rel']) {
1300             case Discovery::PROFILEPAGE:
1301                 $profileUrl = $link['href'];
1302                 break;
1303             case Salmon::NS_REPLIES:
1304                 $salmonEndpoint = $link['href'];
1305                 break;
1306             case Discovery::UPDATESFROM:
1307                 $feedUrl = $link['href'];
1308                 break;
1309             case Discovery::HCARD:
1310                 $hcardUrl = $link['href'];
1311                 break;
1312             default:
1313                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1314                 break;
1315             }
1316         }
1317
1318         $hints = array('webfinger' => $addr,
1319                        'profileurl' => $profileUrl,
1320                        'feedurl' => $feedUrl,
1321                        'salmon' => $salmonEndpoint);
1322
1323         if (isset($hcardUrl)) {
1324             $hcardHints = self::slurpHcard($hcardUrl);
1325             // Note: Webfinger > hcard
1326             $hints = array_merge($hcardHints, $hints);
1327         }
1328
1329         // If we got a feed URL, try that
1330
1331         if (isset($feedUrl)) {
1332             try {
1333                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL $feedUrl");
1334                 $oprofile = self::ensureProfile($feedUrl, $hints);
1335                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1336                 return $oprofile;
1337             } catch (Exception $e) {
1338                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1339                 // keep looking
1340             }
1341         }
1342
1343         // If we got a profile page, try that!
1344
1345         if (isset($profileUrl)) {
1346             try {
1347                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1348                 $oprofile = self::ensureProfile($profileUrl, $hints);
1349                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1350                 return $oprofile;
1351             } catch (Exception $e) {
1352                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1353                 // keep looking
1354             }
1355         }
1356
1357         // XXX: try hcard
1358         // XXX: try FOAF
1359
1360         if (isset($salmonEndpoint)) {
1361
1362             // An account URL, a salmon endpoint, and a dream? Not much to go
1363             // on, but let's give it a try
1364
1365             $uri = 'acct:'.$addr;
1366
1367             $profile = new Profile();
1368
1369             $profile->nickname = self::nicknameFromUri($uri);
1370             $profile->created  = common_sql_now();
1371
1372             if (isset($profileUrl)) {
1373                 $profile->profileurl = $profileUrl;
1374             }
1375
1376             $profile_id = $profile->insert();
1377
1378             if (!$profile_id) {
1379                 common_log_db_error($profile, 'INSERT', __FILE__);
1380                 throw new Exception("Couldn't save profile for '$addr'");
1381             }
1382
1383             $oprofile = new Ostatus_profile();
1384
1385             $oprofile->uri        = $uri;
1386             $oprofile->salmonuri  = $salmonEndpoint;
1387             $oprofile->profile_id = $profile_id;
1388             $oprofile->created    = common_sql_now();
1389
1390             if (isset($feedUrl)) {
1391                 $profile->feeduri = $feedUrl;
1392             }
1393
1394             $result = $oprofile->insert();
1395
1396             if (!$result) {
1397                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1398                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1399             }
1400
1401             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1402             return $oprofile;
1403         }
1404
1405         return null;
1406     }
1407
1408     function saveHTMLFile($title, $rendered)
1409     {
1410         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1411                          '<body><div>%s</div></body></html>',
1412                          htmlspecialchars($title),
1413                          $rendered);
1414
1415         $filename = File::filename($this->localProfile(),
1416                                    'ostatus', // ignored?
1417                                    'text/html');
1418
1419         $filepath = File::path($filename);
1420
1421         file_put_contents($filepath, $final);
1422
1423         $file = new File;
1424
1425         $file->filename = $filename;
1426         $file->url      = File::url($filename);
1427         $file->size     = filesize($filepath);
1428         $file->date     = time();
1429         $file->mimetype = 'text/html';
1430
1431         $file_id = $file->insert();
1432
1433         if ($file_id === false) {
1434             common_log_db_error($file, "INSERT", __FILE__);
1435             throw new ServerException(_('Could not store HTML content of long post as file.'));
1436         }
1437
1438         return $file;
1439     }
1440
1441     protected static function slurpHcard($url)
1442     {
1443         set_include_path(get_include_path() . PATH_SEPARATOR . INSTALLDIR . '/plugins/OStatus/extlib/hkit/');
1444         require_once('hkit.class.php');
1445
1446         $h      = new hKit;
1447
1448         // Google Buzz hcards need to be tidied. Probably others too.
1449
1450         $h->tidy_mode = 'proxy'; // 'proxy', 'exec', 'php' or 'none'
1451
1452         // Get by URL
1453         $hcards = $h->getByURL('hcard', $url);
1454
1455         if (empty($hcards)) {
1456             return array();
1457         }
1458
1459         // @fixme more intelligent guess on multi-hcard pages
1460         $hcard = $hcards[0];
1461
1462         $hints = array();
1463
1464         $hints['profileurl'] = $url;
1465
1466         if (array_key_exists('nickname', $hcard)) {
1467             $hints['nickname'] = $hcard['nickname'];
1468         }
1469
1470         if (array_key_exists('fn', $hcard)) {
1471             $hints['fullname'] = $hcard['fn'];
1472         } else if (array_key_exists('n', $hcard)) {
1473             $hints['fullname'] = implode(' ', $hcard['n']);
1474         }
1475
1476         if (array_key_exists('photo', $hcard)) {
1477             $hints['avatar'] = $hcard['photo'];
1478         }
1479
1480         if (array_key_exists('note', $hcard)) {
1481             $hints['bio'] = $hcard['note'];
1482         }
1483
1484         if (array_key_exists('adr', $hcard)) {
1485             if (is_string($hcard['adr'])) {
1486                 $hints['location'] = $hcard['adr'];
1487             } else if (is_array($hcard['adr'])) {
1488                 $hints['location'] = implode(' ', $hcard['adr']);
1489             }
1490         }
1491
1492         if (array_key_exists('url', $hcard)) {
1493             if (is_string($hcard['url'])) {
1494                 $hints['homepage'] = $hcard['url'];
1495             } else if (is_array($hcard['adr'])) {
1496                 // HACK get the last one; that's how our hcards look
1497                 $hints['homepage'] = $hcard['url'][count($hcard['url'])-1];
1498             }
1499         }
1500
1501         return $hints;
1502     }
1503 }