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