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