]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Updated 'more' anchor for attachments to do an XHR GET
[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      * Send a subscription request to the hub for this feed.
199      * The hub will later send us a confirmation POST to /main/push/callback.
200      *
201      * @return bool true on success, false on failure
202      * @throws ServerException if feed state is not valid
203      */
204     public function subscribe()
205     {
206         $feedsub = FeedSub::ensureFeed($this->feeduri);
207         if ($feedsub->sub_state == 'active') {
208             // Active subscription, we don't need to do anything.
209             return true;
210         } else {
211             // Inactive or we got left in an inconsistent state.
212             // Run a subscription request to make sure we're current!
213             return $feedsub->subscribe();
214         }
215     }
216
217     /**
218      * Send a PuSH unsubscription request to the hub for this feed.
219      * The hub will later send us a confirmation POST to /main/push/callback.
220      *
221      * @return bool true on success, false on failure
222      * @throws ServerException if feed state is not valid
223      */
224     public function unsubscribe() {
225         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
226         if (!$feedsub || $feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
227             // No active PuSH subscription, we can just leave it be.
228             return true;
229         } else {
230             // PuSH subscription is either active or in an indeterminate state.
231             // Send an unsubscribe.
232             return $feedsub->unsubscribe();
233         }
234     }
235
236     /**
237      * Check if this remote profile has any active local subscriptions, and
238      * if not drop the PuSH subscription feed.
239      *
240      * @return boolean
241      */
242     public function garbageCollect()
243     {
244         if ($this->isGroup()) {
245             $members = $this->localGroup()->getMembers(0, 1);
246             $count = $members->N;
247         } else {
248             $count = $this->localProfile()->subscriberCount();
249         }
250         if ($count == 0) {
251             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
252             $this->unsubscribe();
253             return true;
254         } else {
255             return false;
256         }
257     }
258
259     /**
260      * Send an Activity Streams notification to the remote Salmon endpoint,
261      * if so configured.
262      *
263      * @param Profile $actor  Actor who did the activity
264      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
265      * @param Object  $object object of the action; must define asActivityNoun($tag)
266      */
267     public function notify($actor, $verb, $object=null)
268     {
269         if (!($actor instanceof Profile)) {
270             $type = gettype($actor);
271             if ($type == 'object') {
272                 $type = get_class($actor);
273             }
274             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
275         }
276         if ($object == null) {
277             $object = $this;
278         }
279         if ($this->salmonuri) {
280
281             $text = 'update';
282             $id = TagURI::mint('%s:%s:%s',
283                                $verb,
284                                $actor->getURI(),
285                                common_date_iso8601(time()));
286
287             // @fixme consolidate all these NS settings somewhere
288             $attributes = array('xmlns' => Activity::ATOM,
289                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
290                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
291                                 'xmlns:georss' => 'http://www.georss.org/georss',
292                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
293                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
294                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
295
296             $entry = new XMLStringer();
297             $entry->elementStart('entry', $attributes);
298             $entry->element('id', null, $id);
299             $entry->element('title', null, $text);
300             $entry->element('summary', null, $text);
301             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
302
303             $entry->element('activity:verb', null, $verb);
304             $entry->raw($actor->asAtomAuthor());
305             $entry->raw($actor->asActivityActor());
306             $entry->raw($object->asActivityNoun('object'));
307             $entry->elementEnd('entry');
308
309             $xml = $entry->getString();
310             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
311
312             $salmon = new Salmon(); // ?
313             return $salmon->post($this->salmonuri, $xml, $actor);
314         }
315         return false;
316     }
317
318     /**
319      * Send a Salmon notification ping immediately, and confirm that we got
320      * an acceptable response from the remote site.
321      *
322      * @param mixed $entry XML string, Notice, or Activity
323      * @return boolean success
324      */
325     public function notifyActivity($entry, $actor)
326     {
327         if ($this->salmonuri) {
328             $salmon = new Salmon();
329             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
330         }
331
332         return false;
333     }
334
335     /**
336      * Queue a Salmon notification for later. If queues are disabled we'll
337      * send immediately but won't get the return value.
338      *
339      * @param mixed $entry XML string, Notice, or Activity
340      * @return boolean success
341      */
342     public function notifyDeferred($entry, $actor)
343     {
344         if ($this->salmonuri) {
345             $data = array('salmonuri' => $this->salmonuri,
346                           'entry' => $this->notifyPrepXml($entry),
347                           'actor' => $actor->id);
348
349             $qm = QueueManager::get();
350             return $qm->enqueue($data, 'salmon');
351         }
352
353         return false;
354     }
355
356     protected function notifyPrepXml($entry)
357     {
358         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
359         if (is_string($entry)) {
360             return $entry;
361         } else if ($entry instanceof Activity) {
362             return $preamble . $entry->asString(true);
363         } else if ($entry instanceof Notice) {
364             return $preamble . $entry->asAtomEntry(true, true);
365         } else {
366             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
367         }
368     }
369
370     function getBestName()
371     {
372         if ($this->isGroup()) {
373             return $this->localGroup()->getBestName();
374         } else {
375             return $this->localProfile()->getBestName();
376         }
377     }
378
379     /**
380      * Read and post notices for updates from the feed.
381      * Currently assumes that all items in the feed are new,
382      * coming from a PuSH hub.
383      *
384      * @param DOMDocument $doc
385      * @param string $source identifier ("push")
386      */
387     public function processFeed(DOMDocument $doc, $source)
388     {
389         $feed = $doc->documentElement;
390
391         if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
392             $this->processAtomFeed($feed, $source);
393         } else if ($feed->localName == 'rss') { // @fixme check namespace
394             $this->processRssFeed($feed, $source);
395         } else {
396             throw new Exception("Unknown feed format.");
397         }
398     }
399
400     public function processAtomFeed(DOMElement $feed, $source)
401     {
402         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
403         if ($entries->length == 0) {
404             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
405             return;
406         }
407
408         for ($i = 0; $i < $entries->length; $i++) {
409             $entry = $entries->item($i);
410             $this->processEntry($entry, $feed, $source);
411         }
412     }
413
414     public function processRssFeed(DOMElement $rss, $source)
415     {
416         $channels = $rss->getElementsByTagName('channel');
417
418         if ($channels->length == 0) {
419             throw new Exception("RSS feed without a channel.");
420         } else if ($channels->length > 1) {
421             common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
422         }
423
424         $channel = $channels->item(0);
425
426         $items = $channel->getElementsByTagName('item');
427
428         for ($i = 0; $i < $items->length; $i++) {
429             $item = $items->item($i);
430             $this->processEntry($item, $channel, $source);
431         }
432     }
433
434     /**
435      * Process a posted entry from this feed source.
436      *
437      * @param DOMElement $entry
438      * @param DOMElement $feed for context
439      * @param string $source identifier ("push" or "salmon")
440      */
441     public function processEntry($entry, $feed, $source)
442     {
443         $activity = new Activity($entry, $feed);
444
445         // @todo process all activity objects
446         switch ($activity->objects[0]->type) {
447         case ActivityObject::ARTICLE:
448         case ActivityObject::BLOGENTRY:
449         case ActivityObject::NOTE:
450         case ActivityObject::STATUS:
451         case ActivityObject::COMMENT:
452             break;
453         default:
454             throw new ClientException("Can't handle that kind of post.");
455         }
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             $actor = $activity->actor;
484
485             if (empty($actor)) {
486                 // OK here! assume the default
487             } else if ($actor->id == $this->uri || $actor->link == $this->uri) {
488                 $this->updateFromActivityObject($actor);
489             } else {
490                 throw new Exception("Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
491             }
492
493             $oprofile = $this;
494         }
495
496         // It's not always an ActivityObject::NOTE, but... let's just say it is.
497
498         $note = $activity->objects[0];
499
500         // The id URI will be used as a unique identifier for for the notice,
501         // protecting against duplicate saves. It isn't required to be a URL;
502         // tag: URIs for instance are found in Google Buzz feeds.
503         $sourceUri = $note->id;
504         $dupe = Notice::staticGet('uri', $sourceUri);
505         if ($dupe) {
506             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
507             return false;
508         }
509
510         // We'll also want to save a web link to the original notice, if provided.
511         $sourceUrl = null;
512         if ($note->link) {
513             $sourceUrl = $note->link;
514         } else if ($activity->link) {
515             $sourceUrl = $activity->link;
516         } else if (preg_match('!^https?://!', $note->id)) {
517             $sourceUrl = $note->id;
518         }
519
520         // Use summary as fallback for content
521
522         if (!empty($note->content)) {
523             $sourceContent = $note->content;
524         } else if (!empty($note->summary)) {
525             $sourceContent = $note->summary;
526         } else if (!empty($note->title)) {
527             $sourceContent = $note->title;
528         } else {
529             // @fixme fetch from $sourceUrl?
530             throw new ClientException("No content for notice {$sourceUri}");
531         }
532
533         // Get (safe!) HTML and text versions of the content
534
535         $rendered = $this->purify($sourceContent);
536         $content = html_entity_decode(strip_tags($rendered));
537
538         $shortened = common_shorten_links($content);
539
540         // If it's too long, try using the summary, and make the
541         // HTML an attachment.
542
543         $attachment = null;
544
545         if (Notice::contentTooLong($shortened)) {
546             $attachment = $this->saveHTMLFile($note->title, $rendered);
547             $summary = html_entity_decode(strip_tags($note->summary));
548             if (empty($summary)) {
549                 $summary = $content;
550             }
551             $shortSummary = common_shorten_links($summary);
552             if (Notice::contentTooLong($shortSummary)) {
553                 $url = common_shorten_url(common_local_url('attachment',
554                                                            array('attachment' => $attachment->id)));
555                 $shortSummary = substr($shortSummary,
556                                        0,
557                                        Notice::maxContent() - (mb_strlen($url) + 2));
558                 $shortSummary .= '…';
559                 $content = $shortSummary . ' ' . $url;
560
561                 // We mark up the attachment link specially for the HTML output
562                 // so we can fold-out the full version inline.
563                 $attachUrl = common_local_url('attachment',
564                                               array('attachment' => $attachment->id));
565                 $rendered = common_render_text($shortSummary) .
566                             ' ' .
567                             '<a href="' .
568                             htmlspecialchars($attachUrl) .
569                             '" class="attachment more">' .
570                             // TRANS: expansion link for too-long remote messages
571                             htmlspecialchars(_m('more')) .
572                             '</a>';
573             }
574         }
575
576         $options = array('is_local' => Notice::REMOTE_OMB,
577                         'url' => $sourceUrl,
578                         'uri' => $sourceUri,
579                         'rendered' => $rendered,
580                         'replies' => array(),
581                         'groups' => array(),
582                         'tags' => array(),
583                         'urls' => array());
584
585         // Check for optional attributes...
586
587         if (!empty($activity->time)) {
588             $options['created'] = common_sql_date($activity->time);
589         }
590
591         if ($activity->context) {
592             // Any individual or group attn: targets?
593             $replies = $activity->context->attention;
594             $options['groups'] = $this->filterReplies($oprofile, $replies);
595             $options['replies'] = $replies;
596
597             // Maintain direct reply associations
598             // @fixme what about conversation ID?
599             if (!empty($activity->context->replyToID)) {
600                 $orig = Notice::staticGet('uri',
601                                           $activity->context->replyToID);
602                 if (!empty($orig)) {
603                     $options['reply_to'] = $orig->id;
604                 }
605             }
606
607             $location = $activity->context->location;
608             if ($location) {
609                 $options['lat'] = $location->lat;
610                 $options['lon'] = $location->lon;
611                 if ($location->location_id) {
612                     $options['location_ns'] = $location->location_ns;
613                     $options['location_id'] = $location->location_id;
614                 }
615             }
616         }
617
618         // Atom categories <-> hashtags
619         foreach ($activity->categories as $cat) {
620             if ($cat->term) {
621                 $term = common_canonical_tag($cat->term);
622                 if ($term) {
623                     $options['tags'][] = $term;
624                 }
625             }
626         }
627
628         // Atom enclosures -> attachment URLs
629         foreach ($activity->enclosures as $href) {
630             // @fixme save these locally or....?
631             $options['urls'][] = $href;
632         }
633
634         try {
635             $saved = Notice::saveNew($oprofile->profile_id,
636                                      $content,
637                                      'ostatus',
638                                      $options);
639             if ($saved) {
640                 Ostatus_source::saveNew($saved, $this, $method);
641                 if (!empty($attachment)) {
642                     File_to_post::processNew($attachment->id, $saved->id);
643                 }
644             }
645         } catch (Exception $e) {
646             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
647             throw $e;
648         }
649         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
650         return $saved;
651     }
652
653     /**
654      * Clean up HTML
655      */
656     protected function purify($html)
657     {
658         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
659         $config = array('safe' => 1,
660                         'deny_attribute' => 'id,style,on*');
661         return htmLawed($html, $config);
662     }
663
664     /**
665      * Filters a list of recipient ID URIs to just those for local delivery.
666      * @param Ostatus_profile local profile of sender
667      * @param array in/out &$attention_uris set of URIs, will be pruned on output
668      * @return array of group IDs
669      */
670     protected function filterReplies($sender, &$attention_uris)
671     {
672         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
673         $groups = array();
674         $replies = array();
675         foreach ($attention_uris as $recipient) {
676             // Is the recipient a local user?
677             $user = User::staticGet('uri', $recipient);
678             if ($user) {
679                 // @fixme sender verification, spam etc?
680                 $replies[] = $recipient;
681                 continue;
682             }
683
684             // Is the recipient a remote group?
685             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
686             if ($oprofile) {
687                 if ($oprofile->isGroup()) {
688                     // Deliver to local members of this remote group.
689                     // @fixme sender verification?
690                     $groups[] = $oprofile->group_id;
691                 } else {
692                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
693                 }
694                 continue;
695             }
696
697             // Is the recipient a local group?
698             // @fixme uri on user_group isn't reliable yet
699             // $group = User_group::staticGet('uri', $recipient);
700             $id = OStatusPlugin::localGroupFromUrl($recipient);
701             if ($id) {
702                 $group = User_group::staticGet('id', $id);
703                 if ($group) {
704                     // Deliver to all members of this local group if allowed.
705                     $profile = $sender->localProfile();
706                     if ($profile->isMember($group)) {
707                         $groups[] = $group->id;
708                     } else {
709                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
710                     }
711                     continue;
712                 } else {
713                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
714                 }
715             }
716
717             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
718
719         }
720         $attention_uris = $replies;
721         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
722         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
723         return $groups;
724     }
725
726     /**
727      * Look up and if necessary create an Ostatus_profile for the remote entity
728      * with the given profile page URL. This should never return null -- you
729      * will either get an object or an exception will be thrown.
730      *
731      * @param string $profile_url
732      * @return Ostatus_profile
733      * @throws Exception
734      */
735
736     public static function ensureProfileURL($profile_url, $hints=array())
737     {
738         $oprofile = self::getFromProfileURL($profile_url);
739
740         if (!empty($oprofile)) {
741             return $oprofile;
742         }
743
744         $hints['profileurl'] = $profile_url;
745
746         // Fetch the URL
747         // XXX: HTTP caching
748
749         $client = new HTTPClient();
750         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
751         $response = $client->get($profile_url);
752
753         if (!$response->isOk()) {
754             throw new Exception("Could not reach profile page: " . $profile_url);
755         }
756
757         // Check if we have a non-canonical URL
758
759         $finalUrl = $response->getUrl();
760
761         if ($finalUrl != $profile_url) {
762
763             $hints['profileurl'] = $finalUrl;
764
765             $oprofile = self::getFromProfileURL($finalUrl);
766
767             if (!empty($oprofile)) {
768                 return $oprofile;
769             }
770         }
771
772         // Try to get some hCard data
773
774         $body = $response->getBody();
775
776         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
777
778         if (!empty($hcardHints)) {
779             $hints = array_merge($hints, $hcardHints);
780         }
781
782         // Check if they've got an LRDD header
783
784         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
785
786         if (!empty($lrdd)) {
787
788             $xrd = Discovery::fetchXrd($lrdd);
789             $xrdHints = DiscoveryHints::fromXRD($xrd);
790
791             $hints = array_merge($hints, $xrdHints);
792         }
793
794         // If discovery found a feedurl (probably from LRDD), use it.
795
796         if (array_key_exists('feedurl', $hints)) {
797             return self::ensureFeedURL($hints['feedurl'], $hints);
798         }
799
800         // Get the feed URL from HTML
801
802         $discover = new FeedDiscovery();
803
804         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
805
806         if (!empty($feedurl)) {
807             $hints['feedurl'] = $feedurl;
808             return self::ensureFeedURL($feedurl, $hints);
809         }
810
811         throw new Exception("Could not find a feed URL for profile page " . $finalUrl);
812     }
813
814     /**
815      * Look up the Ostatus_profile, if present, for a remote entity with the
816      * given profile page URL. Will return null for both unknown and invalid
817      * remote profiles.
818      *
819      * @return mixed Ostatus_profile or null
820      * @throws Exception for local profiles
821      */
822     static function getFromProfileURL($profile_url)
823     {
824         $profile = Profile::staticGet('profileurl', $profile_url);
825
826         if (empty($profile)) {
827             return null;
828         }
829
830         // Is it a known Ostatus profile?
831
832         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
833
834         if (!empty($oprofile)) {
835             return $oprofile;
836         }
837
838         // Is it a local user?
839
840         $user = User::staticGet('id', $profile->id);
841
842         if (!empty($user)) {
843             throw new Exception("'$profile_url' is the profile for local user '{$user->nickname}'.");
844         }
845
846         // Continue discovery; it's a remote profile
847         // for OMB or some other protocol, may also
848         // support OStatus
849
850         return null;
851     }
852
853     /**
854      * Look up and if necessary create an Ostatus_profile for remote entity
855      * with the given update feed. This should never return null -- you will
856      * either get an object or an exception will be thrown.
857      *
858      * @return Ostatus_profile
859      * @throws Exception
860      */
861     public static function ensureFeedURL($feed_url, $hints=array())
862     {
863         $discover = new FeedDiscovery();
864
865         $feeduri = $discover->discoverFromFeedURL($feed_url);
866         $hints['feedurl'] = $feeduri;
867
868         $huburi = $discover->getAtomLink('hub');
869         $hints['hub'] = $huburi;
870         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
871         $hints['salmon'] = $salmonuri;
872
873         if (!$huburi) {
874             // We can only deal with folks with a PuSH hub
875             throw new FeedSubNoHubException();
876         }
877
878         $feedEl = $discover->root;
879
880         if ($feedEl->tagName == 'feed') {
881             return self::ensureAtomFeed($feedEl, $hints);
882         } else if ($feedEl->tagName == 'channel') {
883             return self::ensureRssChannel($feedEl, $hints);
884         } else {
885             throw new FeedSubBadXmlException($feeduri);
886         }
887     }
888
889     /**
890      * Look up and, if necessary, create an Ostatus_profile for the remote
891      * profile with the given Atom feed - actually loaded from the feed.
892      * This should never return null -- you will either get an object or
893      * an exception will be thrown.
894      *
895      * @param DOMElement $feedEl root element of a loaded Atom feed
896      * @param array $hints additional discovery information passed from higher levels
897      * @fixme should this be marked public?
898      * @return Ostatus_profile
899      * @throws Exception
900      */
901     public static function ensureAtomFeed($feedEl, $hints)
902     {
903         // Try to get a profile from the feed activity:subject
904
905         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
906
907         if (!empty($subject)) {
908             $subjObject = new ActivityObject($subject);
909             return self::ensureActivityObjectProfile($subjObject, $hints);
910         }
911
912         // Otherwise, try the feed author
913
914         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
915
916         if (!empty($author)) {
917             $authorObject = new ActivityObject($author);
918             return self::ensureActivityObjectProfile($authorObject, $hints);
919         }
920
921         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
922         // entries.
923
924         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
925
926         if (!empty($entries) && $entries->length > 0) {
927
928             $entry = $entries->item(0);
929
930             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
931
932             if (!empty($actor)) {
933                 $actorObject = new ActivityObject($actor);
934                 return self::ensureActivityObjectProfile($actorObject, $hints);
935
936             }
937
938             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
939
940             if (!empty($author)) {
941                 $authorObject = new ActivityObject($author);
942                 return self::ensureActivityObjectProfile($authorObject, $hints);
943             }
944         }
945
946         // XXX: make some educated guesses here
947
948         throw new FeedSubException("Can't find enough profile information to make a feed.");
949     }
950
951     /**
952      * Look up and, if necessary, create an Ostatus_profile for the remote
953      * profile with the given RSS feed - actually loaded from the feed.
954      * This should never return null -- you will either get an object or
955      * an exception will be thrown.
956      *
957      * @param DOMElement $feedEl root element of a loaded RSS feed
958      * @param array $hints additional discovery information passed from higher levels
959      * @fixme should this be marked public?
960      * @return Ostatus_profile
961      * @throws Exception
962      */
963     public static function ensureRssChannel($feedEl, $hints)
964     {
965         // Special-case for Posterous. They have some nice metadata in their
966         // posterous:author elements. We should use them instead of the channel.
967
968         $items = $feedEl->getElementsByTagName('item');
969
970         if ($items->length > 0) {
971             $item = $items->item(0);
972             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
973             if (!empty($authorEl)) {
974                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
975                 // Posterous has multiple authors per feed, and multiple feeds
976                 // per author. We check if this is the "main" feed for this author.
977                 if (array_key_exists('profileurl', $hints) &&
978                     !empty($obj->poco) &&
979                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
980                     return self::ensureActivityObjectProfile($obj, $hints);
981                 }
982             }
983         }
984
985         // @fixme we should check whether this feed has elements
986         // with different <author> or <dc:creator> elements, and... I dunno.
987         // Do something about that.
988
989         $obj = ActivityObject::fromRssChannel($feedEl);
990
991         return self::ensureActivityObjectProfile($obj, $hints);
992     }
993
994     /**
995      * Download and update given avatar image
996      *
997      * @param string $url
998      * @throws Exception in various failure cases
999      */
1000     protected function updateAvatar($url)
1001     {
1002         if ($url == $this->avatar) {
1003             // We've already got this one.
1004             return;
1005         }
1006         if (!common_valid_http_url($url)) {
1007             throw new ServerException(_m("Invalid avatar URL %s"), $url);
1008         }
1009
1010         if ($this->isGroup()) {
1011             $self = $this->localGroup();
1012         } else {
1013             $self = $this->localProfile();
1014         }
1015         if (!$self) {
1016             throw new ServerException(sprintf(
1017                 _m("Tried to update avatar for unsaved remote profile %s"),
1018                 $this->uri));
1019         }
1020
1021         // @fixme this should be better encapsulated
1022         // ripped from oauthstore.php (for old OMB client)
1023         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1024         if (!copy($url, $temp_filename)) {
1025             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
1026         }
1027
1028         if ($this->isGroup()) {
1029             $id = $this->group_id;
1030         } else {
1031             $id = $this->profile_id;
1032         }
1033         // @fixme should we be using different ids?
1034         $imagefile = new ImageFile($id, $temp_filename);
1035         $filename = Avatar::filename($id,
1036                                      image_type_to_extension($imagefile->type),
1037                                      null,
1038                                      common_timestamp());
1039         rename($temp_filename, Avatar::path($filename));
1040         $self->setOriginal($filename);
1041
1042         $orig = clone($this);
1043         $this->avatar = $url;
1044         $this->update($orig);
1045     }
1046
1047     /**
1048      * Pull avatar URL from ActivityObject or profile hints
1049      *
1050      * @param ActivityObject $object
1051      * @param array $hints
1052      * @return mixed URL string or false
1053      */
1054
1055     protected static function getActivityObjectAvatar($object, $hints=array())
1056     {
1057         if ($object->avatarLinks) {
1058             $best = false;
1059             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1060             foreach ($object->avatarLinks as $avatar) {
1061                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1062                     // Exact match!
1063                     $best = $avatar;
1064                     break;
1065                 }
1066                 if (!$best || $avatar->width > $best->width) {
1067                     $best = $avatar;
1068                 }
1069             }
1070             return $best->url;
1071         } else if (array_key_exists('avatar', $hints)) {
1072             return $hints['avatar'];
1073         }
1074         return false;
1075     }
1076
1077     /**
1078      * Get an appropriate avatar image source URL, if available.
1079      *
1080      * @param ActivityObject $actor
1081      * @param DOMElement $feed
1082      * @return string
1083      */
1084
1085     protected static function getAvatar($actor, $feed)
1086     {
1087         $url = '';
1088         $icon = '';
1089         if ($actor->avatar) {
1090             $url = trim($actor->avatar);
1091         }
1092         if (!$url) {
1093             // Check <atom:logo> and <atom:icon> on the feed
1094             $els = $feed->childNodes();
1095             if ($els && $els->length) {
1096                 for ($i = 0; $i < $els->length; $i++) {
1097                     $el = $els->item($i);
1098                     if ($el->namespaceURI == Activity::ATOM) {
1099                         if (empty($url) && $el->localName == 'logo') {
1100                             $url = trim($el->textContent);
1101                             break;
1102                         }
1103                         if (empty($icon) && $el->localName == 'icon') {
1104                             // Use as a fallback
1105                             $icon = trim($el->textContent);
1106                         }
1107                     }
1108                 }
1109             }
1110             if ($icon && !$url) {
1111                 $url = $icon;
1112             }
1113         }
1114         if ($url) {
1115             $opts = array('allowed_schemes' => array('http', 'https'));
1116             if (Validate::uri($url, $opts)) {
1117                 return $url;
1118             }
1119         }
1120         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1121     }
1122
1123     /**
1124      * Fetch, or build if necessary, an Ostatus_profile for the actor
1125      * in a given Activity Streams activity.
1126      * This should never return null -- you will either get an object or
1127      * an exception will be thrown.
1128      *
1129      * @param Activity $activity
1130      * @param string $feeduri if we already know the canonical feed URI!
1131      * @param string $salmonuri if we already know the salmon return channel URI
1132      * @return Ostatus_profile
1133      * @throws Exception
1134      */
1135
1136     public static function ensureActorProfile($activity, $hints=array())
1137     {
1138         return self::ensureActivityObjectProfile($activity->actor, $hints);
1139     }
1140
1141     /**
1142      * Fetch, or build if necessary, an Ostatus_profile for the profile
1143      * in a given Activity Streams object (can be subject, actor, or object).
1144      * This should never return null -- you will either get an object or
1145      * an exception will be thrown.
1146      *
1147      * @param ActivityObject $object
1148      * @param array $hints additional discovery information passed from higher levels
1149      * @return Ostatus_profile
1150      * @throws Exception
1151      */
1152
1153     public static function ensureActivityObjectProfile($object, $hints=array())
1154     {
1155         $profile = self::getActivityObjectProfile($object);
1156         if ($profile) {
1157             $profile->updateFromActivityObject($object, $hints);
1158         } else {
1159             $profile = self::createActivityObjectProfile($object, $hints);
1160         }
1161         return $profile;
1162     }
1163
1164     /**
1165      * @param Activity $activity
1166      * @return mixed matching Ostatus_profile or false if none known
1167      * @throws ServerException if feed info invalid
1168      */
1169     public static function getActorProfile($activity)
1170     {
1171         return self::getActivityObjectProfile($activity->actor);
1172     }
1173
1174     /**
1175      * @param ActivityObject $activity
1176      * @return mixed matching Ostatus_profile or false if none known
1177      * @throws ServerException if feed info invalid
1178      */
1179     protected static function getActivityObjectProfile($object)
1180     {
1181         $uri = self::getActivityObjectProfileURI($object);
1182         return Ostatus_profile::staticGet('uri', $uri);
1183     }
1184
1185     /**
1186      * Get the identifier URI for the remote entity described
1187      * by this ActivityObject. This URI is *not* guaranteed to be
1188      * a resolvable HTTP/HTTPS URL.
1189      *
1190      * @param ActivityObject $object
1191      * @return string
1192      * @throws ServerException if feed info invalid
1193      */
1194     protected static function getActivityObjectProfileURI($object)
1195     {
1196         if ($object->id) {
1197             if (ActivityUtils::validateUri($object->id)) {
1198                 return $object->id;
1199             }
1200         }
1201
1202         // If the id is missing or invalid (we've seen feeds mistakenly listing
1203         // things like local usernames in that field) then we'll use the profile
1204         // page link, if valid.
1205         if ($object->link && common_valid_http_url($object->link)) {
1206             return $object->link;
1207         }
1208         throw new ServerException("No author ID URI found");
1209     }
1210
1211     /**
1212      * @fixme validate stuff somewhere
1213      */
1214
1215     /**
1216      * Create local ostatus_profile and profile/user_group entries for
1217      * the provided remote user or group.
1218      * This should never return null -- you will either get an object or
1219      * an exception will be thrown.
1220      *
1221      * @param ActivityObject $object
1222      * @param array $hints
1223      *
1224      * @return Ostatus_profile
1225      */
1226     protected static function createActivityObjectProfile($object, $hints=array())
1227     {
1228         $homeuri = $object->id;
1229         $discover = false;
1230
1231         if (!$homeuri) {
1232             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1233             throw new Exception("No profile URI");
1234         }
1235
1236         $user = User::staticGet('uri', $homeuri);
1237         if ($user) {
1238             throw new Exception("Local user can't be referenced as remote.");
1239         }
1240
1241         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1242             throw new Exception("Local group can't be referenced as remote.");
1243         }
1244
1245         if (array_key_exists('feedurl', $hints)) {
1246             $feeduri = $hints['feedurl'];
1247         } else {
1248             $discover = new FeedDiscovery();
1249             $feeduri = $discover->discoverFromURL($homeuri);
1250         }
1251
1252         if (array_key_exists('salmon', $hints)) {
1253             $salmonuri = $hints['salmon'];
1254         } else {
1255             if (!$discover) {
1256                 $discover = new FeedDiscovery();
1257                 $discover->discoverFromFeedURL($hints['feedurl']);
1258             }
1259             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1260         }
1261
1262         if (array_key_exists('hub', $hints)) {
1263             $huburi = $hints['hub'];
1264         } else {
1265             if (!$discover) {
1266                 $discover = new FeedDiscovery();
1267                 $discover->discoverFromFeedURL($hints['feedurl']);
1268             }
1269             $huburi = $discover->getAtomLink('hub');
1270         }
1271
1272         if (!$huburi) {
1273             // We can only deal with folks with a PuSH hub
1274             throw new FeedSubNoHubException();
1275         }
1276
1277         $oprofile = new Ostatus_profile();
1278
1279         $oprofile->uri        = $homeuri;
1280         $oprofile->feeduri    = $feeduri;
1281         $oprofile->salmonuri  = $salmonuri;
1282
1283         $oprofile->created    = common_sql_now();
1284         $oprofile->modified   = common_sql_now();
1285
1286         if ($object->type == ActivityObject::PERSON) {
1287             $profile = new Profile();
1288             $profile->created = common_sql_now();
1289             self::updateProfile($profile, $object, $hints);
1290
1291             $oprofile->profile_id = $profile->insert();
1292             if (!$oprofile->profile_id) {
1293                 throw new ServerException("Can't save local profile");
1294             }
1295         } else {
1296             $group = new User_group();
1297             $group->uri = $homeuri;
1298             $group->created = common_sql_now();
1299             self::updateGroup($group, $object, $hints);
1300
1301             $oprofile->group_id = $group->insert();
1302             if (!$oprofile->group_id) {
1303                 throw new ServerException("Can't save local profile");
1304             }
1305         }
1306
1307         $ok = $oprofile->insert();
1308
1309         if ($ok) {
1310             $avatar = self::getActivityObjectAvatar($object, $hints);
1311             if ($avatar) {
1312                 $oprofile->updateAvatar($avatar);
1313             }
1314             return $oprofile;
1315         } else {
1316             throw new ServerException("Can't save OStatus profile");
1317         }
1318     }
1319
1320     /**
1321      * Save any updated profile information to our local copy.
1322      * @param ActivityObject $object
1323      * @param array $hints
1324      */
1325     public function updateFromActivityObject($object, $hints=array())
1326     {
1327         if ($this->isGroup()) {
1328             $group = $this->localGroup();
1329             self::updateGroup($group, $object, $hints);
1330         } else {
1331             $profile = $this->localProfile();
1332             self::updateProfile($profile, $object, $hints);
1333         }
1334         $avatar = self::getActivityObjectAvatar($object, $hints);
1335         if ($avatar) {
1336             $this->updateAvatar($avatar);
1337         }
1338     }
1339
1340     protected static function updateProfile($profile, $object, $hints=array())
1341     {
1342         $orig = clone($profile);
1343
1344         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1345
1346         if (!empty($object->title)) {
1347             $profile->fullname = $object->title;
1348         } else if (array_key_exists('fullname', $hints)) {
1349             $profile->fullname = $hints['fullname'];
1350         }
1351
1352         if (!empty($object->link)) {
1353             $profile->profileurl = $object->link;
1354         } else if (array_key_exists('profileurl', $hints)) {
1355             $profile->profileurl = $hints['profileurl'];
1356         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1357             $profile->profileurl = $object->id;
1358         }
1359
1360         $profile->bio      = self::getActivityObjectBio($object, $hints);
1361         $profile->location = self::getActivityObjectLocation($object, $hints);
1362         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1363
1364         if (!empty($object->geopoint)) {
1365             $location = ActivityContext::locationFromPoint($object->geopoint);
1366             if (!empty($location)) {
1367                 $profile->lat = $location->lat;
1368                 $profile->lon = $location->lon;
1369             }
1370         }
1371
1372         // @fixme tags/categories
1373         // @todo tags from categories
1374
1375         if ($profile->id) {
1376             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1377             $profile->update($orig);
1378         }
1379     }
1380
1381     protected static function updateGroup($group, $object, $hints=array())
1382     {
1383         $orig = clone($group);
1384
1385         $group->nickname = self::getActivityObjectNickname($object, $hints);
1386         $group->fullname = $object->title;
1387
1388         if (!empty($object->link)) {
1389             $group->mainpage = $object->link;
1390         } else if (array_key_exists('profileurl', $hints)) {
1391             $group->mainpage = $hints['profileurl'];
1392         }
1393
1394         // @todo tags from categories
1395         $group->description = self::getActivityObjectBio($object, $hints);
1396         $group->location = self::getActivityObjectLocation($object, $hints);
1397         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1398
1399         if ($group->id) {
1400             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1401             $group->update($orig);
1402         }
1403     }
1404
1405     protected static function getActivityObjectHomepage($object, $hints=array())
1406     {
1407         $homepage = null;
1408         $poco     = $object->poco;
1409
1410         if (!empty($poco)) {
1411             $url = $poco->getPrimaryURL();
1412             if ($url && $url->type == 'homepage') {
1413                 $homepage = $url->value;
1414             }
1415         }
1416
1417         // @todo Try for a another PoCo URL?
1418
1419         return $homepage;
1420     }
1421
1422     protected static function getActivityObjectLocation($object, $hints=array())
1423     {
1424         $location = null;
1425
1426         if (!empty($object->poco) &&
1427             isset($object->poco->address->formatted)) {
1428             $location = $object->poco->address->formatted;
1429         } else if (array_key_exists('location', $hints)) {
1430             $location = $hints['location'];
1431         }
1432
1433         if (!empty($location)) {
1434             if (mb_strlen($location) > 255) {
1435                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1436             }
1437         }
1438
1439         // @todo Try to find location some othe way? Via goerss point?
1440
1441         return $location;
1442     }
1443
1444     protected static function getActivityObjectBio($object, $hints=array())
1445     {
1446         $bio  = null;
1447
1448         if (!empty($object->poco)) {
1449             $note = $object->poco->note;
1450         } else if (array_key_exists('bio', $hints)) {
1451             $note = $hints['bio'];
1452         }
1453
1454         if (!empty($note)) {
1455             if (Profile::bioTooLong($note)) {
1456                 // XXX: truncate ok?
1457                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1458             } else {
1459                 $bio = $note;
1460             }
1461         }
1462
1463         // @todo Try to get bio info some other way?
1464
1465         return $bio;
1466     }
1467
1468     protected static function getActivityObjectNickname($object, $hints=array())
1469     {
1470         if ($object->poco) {
1471             if (!empty($object->poco->preferredUsername)) {
1472                 return common_nicknamize($object->poco->preferredUsername);
1473             }
1474         }
1475
1476         if (!empty($object->nickname)) {
1477             return common_nicknamize($object->nickname);
1478         }
1479
1480         if (array_key_exists('nickname', $hints)) {
1481             return $hints['nickname'];
1482         }
1483
1484         // Try the profile url (like foo.example.com or example.com/user/foo)
1485
1486         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1487
1488         if (!empty($profileUrl)) {
1489             $nickname = self::nicknameFromURI($profileUrl);
1490         }
1491
1492         // Try the URI (may be a tag:, http:, acct:, ...
1493
1494         if (empty($nickname)) {
1495             $nickname = self::nicknameFromURI($object->id);
1496         }
1497
1498         // Try a Webfinger if one was passed (way) down
1499
1500         if (empty($nickname)) {
1501             if (array_key_exists('webfinger', $hints)) {
1502                 $nickname = self::nicknameFromURI($hints['webfinger']);
1503             }
1504         }
1505
1506         // Try the name
1507
1508         if (empty($nickname)) {
1509             $nickname = common_nicknamize($object->title);
1510         }
1511
1512         return $nickname;
1513     }
1514
1515     protected static function nicknameFromURI($uri)
1516     {
1517         preg_match('/(\w+):/', $uri, $matches);
1518
1519         $protocol = $matches[1];
1520
1521         switch ($protocol) {
1522         case 'acct':
1523         case 'mailto':
1524             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1525                 return common_canonical_nickname($matches[1]);
1526             }
1527             return null;
1528         case 'http':
1529             return common_url_to_nickname($uri);
1530             break;
1531         default:
1532             return null;
1533         }
1534     }
1535
1536     /**
1537      * Look up, and if necessary create, an Ostatus_profile for the remote
1538      * entity with the given webfinger address.
1539      * This should never return null -- you will either get an object or
1540      * an exception will be thrown.
1541      *
1542      * @param string $addr webfinger address
1543      * @return Ostatus_profile
1544      * @throws Exception on error conditions
1545      */
1546     public static function ensureWebfinger($addr)
1547     {
1548         // First, try the cache
1549
1550         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1551
1552         if ($uri !== false) {
1553             if (is_null($uri)) {
1554                 // Negative cache entry
1555                 throw new Exception('Not a valid webfinger address.');
1556             }
1557             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1558             if (!empty($oprofile)) {
1559                 return $oprofile;
1560             }
1561         }
1562
1563         // Try looking it up
1564
1565         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1566
1567         if (!empty($oprofile)) {
1568             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1569             return $oprofile;
1570         }
1571
1572         // Now, try some discovery
1573
1574         $disco = new Discovery();
1575
1576         try {
1577             $xrd = $disco->lookup($addr);
1578         } catch (Exception $e) {
1579             // Save negative cache entry so we don't waste time looking it up again.
1580             // @fixme distinguish temporary failures?
1581             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1582             throw new Exception('Not a valid webfinger address.');
1583         }
1584
1585         $hints = array('webfinger' => $addr);
1586
1587         $dhints = DiscoveryHints::fromXRD($xrd);
1588
1589         $hints = array_merge($hints, $dhints);
1590
1591         // If there's an Hcard, let's grab its info
1592
1593         if (array_key_exists('hcard', $hints)) {
1594             if (!array_key_exists('profileurl', $hints) ||
1595                 $hints['hcard'] != $hints['profileurl']) {
1596                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1597                 $hints = array_merge($hcardHints, $hints);
1598             }
1599         }
1600
1601         // If we got a feed URL, try that
1602
1603         if (array_key_exists('feedurl', $hints)) {
1604             try {
1605                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1606                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1607                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1608                 return $oprofile;
1609             } catch (Exception $e) {
1610                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1611                 // keep looking
1612             }
1613         }
1614
1615         // If we got a profile page, try that!
1616
1617         if (array_key_exists('profileurl', $hints)) {
1618             try {
1619                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1620                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1621                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1622                 return $oprofile;
1623             } catch (Exception $e) {
1624                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1625                 // keep looking
1626             }
1627         }
1628
1629         // XXX: try hcard
1630         // XXX: try FOAF
1631
1632         if (array_key_exists('salmon', $hints)) {
1633
1634             $salmonEndpoint = $hints['salmon'];
1635
1636             // An account URL, a salmon endpoint, and a dream? Not much to go
1637             // on, but let's give it a try
1638
1639             $uri = 'acct:'.$addr;
1640
1641             $profile = new Profile();
1642
1643             $profile->nickname = self::nicknameFromUri($uri);
1644             $profile->created  = common_sql_now();
1645
1646             if (isset($profileUrl)) {
1647                 $profile->profileurl = $profileUrl;
1648             }
1649
1650             $profile_id = $profile->insert();
1651
1652             if (!$profile_id) {
1653                 common_log_db_error($profile, 'INSERT', __FILE__);
1654                 throw new Exception("Couldn't save profile for '$addr'");
1655             }
1656
1657             $oprofile = new Ostatus_profile();
1658
1659             $oprofile->uri        = $uri;
1660             $oprofile->salmonuri  = $salmonEndpoint;
1661             $oprofile->profile_id = $profile_id;
1662             $oprofile->created    = common_sql_now();
1663
1664             if (isset($feedUrl)) {
1665                 $profile->feeduri = $feedUrl;
1666             }
1667
1668             $result = $oprofile->insert();
1669
1670             if (!$result) {
1671                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1672                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1673             }
1674
1675             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1676             return $oprofile;
1677         }
1678
1679         throw new Exception("Couldn't find a valid profile for '$addr'");
1680     }
1681
1682     function saveHTMLFile($title, $rendered)
1683     {
1684         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1685                          '<body><div>%s</div></body></html>',
1686                          htmlspecialchars($title),
1687                          $rendered);
1688
1689         $filename = File::filename($this->localProfile(),
1690                                    'ostatus', // ignored?
1691                                    'text/html');
1692
1693         $filepath = File::path($filename);
1694
1695         file_put_contents($filepath, $final);
1696
1697         $file = new File;
1698
1699         $file->filename = $filename;
1700         $file->url      = File::url($filename);
1701         $file->size     = filesize($filepath);
1702         $file->date     = time();
1703         $file->mimetype = 'text/html';
1704
1705         $file_id = $file->insert();
1706
1707         if ($file_id === false) {
1708             common_log_db_error($file, "INSERT", __FILE__);
1709             throw new ServerException(_('Could not store HTML content of long post as file.'));
1710         }
1711
1712         return $file;
1713     }
1714 }