]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch '0.9.x' of gitorious.org:statusnet/mainline into 0.9.x
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
192         } else {
193             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
194         }
195     }
196
197     /**
198      * 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($sourceUrl);
554                 $shortSummary = substr($shortSummary,
555                                        0,
556                                        Notice::maxContent() - (mb_strlen($url) + 2));
557                 $content = $shortSummary . ' ' . $url;
558
559                 // We mark up the attachment link specially for the HTML output
560                 // so we can fold-out the full version inline.
561                 $attachUrl = common_local_url('attachment',
562                                               array('attachment' => $attachment->id));
563                 $rendered = common_render_text($shortSummary) .
564                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
565                             ' class="attachment more"' .
566                             ' title="'. htmlspecialchars(_m('Show more')) . '">' .
567                             '&#8230;' .
568                             '</a>';
569             }
570         }
571
572         $options = array('is_local' => Notice::REMOTE_OMB,
573                         'url' => $sourceUrl,
574                         'uri' => $sourceUri,
575                         'rendered' => $rendered,
576                         'replies' => array(),
577                         'groups' => array(),
578                         'tags' => array(),
579                         'urls' => array());
580
581         // Check for optional attributes...
582
583         if (!empty($activity->time)) {
584             $options['created'] = common_sql_date($activity->time);
585         }
586
587         if ($activity->context) {
588             // Any individual or group attn: targets?
589             $replies = $activity->context->attention;
590             $options['groups'] = $this->filterReplies($oprofile, $replies);
591             $options['replies'] = $replies;
592
593             // Maintain direct reply associations
594             // @fixme what about conversation ID?
595             if (!empty($activity->context->replyToID)) {
596                 $orig = Notice::staticGet('uri',
597                                           $activity->context->replyToID);
598                 if (!empty($orig)) {
599                     $options['reply_to'] = $orig->id;
600                 }
601             }
602
603             $location = $activity->context->location;
604             if ($location) {
605                 $options['lat'] = $location->lat;
606                 $options['lon'] = $location->lon;
607                 if ($location->location_id) {
608                     $options['location_ns'] = $location->location_ns;
609                     $options['location_id'] = $location->location_id;
610                 }
611             }
612         }
613
614         // Atom categories <-> hashtags
615         foreach ($activity->categories as $cat) {
616             if ($cat->term) {
617                 $term = common_canonical_tag($cat->term);
618                 if ($term) {
619                     $options['tags'][] = $term;
620                 }
621             }
622         }
623
624         // Atom enclosures -> attachment URLs
625         foreach ($activity->enclosures as $href) {
626             // @fixme save these locally or....?
627             $options['urls'][] = $href;
628         }
629
630         try {
631             $saved = Notice::saveNew($oprofile->profile_id,
632                                      $content,
633                                      'ostatus',
634                                      $options);
635             if ($saved) {
636                 Ostatus_source::saveNew($saved, $this, $method);
637                 if (!empty($attachment)) {
638                     File_to_post::processNew($attachment->id, $saved->id);
639                 }
640             }
641         } catch (Exception $e) {
642             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
643             throw $e;
644         }
645         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
646         return $saved;
647     }
648
649     /**
650      * Clean up HTML
651      */
652     protected function purify($html)
653     {
654         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
655         $config = array('safe' => 1,
656                         'deny_attribute' => 'id,style,on*');
657         return htmLawed($html, $config);
658     }
659
660     /**
661      * Filters a list of recipient ID URIs to just those for local delivery.
662      * @param Ostatus_profile local profile of sender
663      * @param array in/out &$attention_uris set of URIs, will be pruned on output
664      * @return array of group IDs
665      */
666     protected function filterReplies($sender, &$attention_uris)
667     {
668         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
669         $groups = array();
670         $replies = array();
671         foreach ($attention_uris as $recipient) {
672             // Is the recipient a local user?
673             $user = User::staticGet('uri', $recipient);
674             if ($user) {
675                 // @fixme sender verification, spam etc?
676                 $replies[] = $recipient;
677                 continue;
678             }
679
680             // Is the recipient a remote group?
681             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
682             if ($oprofile) {
683                 if ($oprofile->isGroup()) {
684                     // Deliver to local members of this remote group.
685                     // @fixme sender verification?
686                     $groups[] = $oprofile->group_id;
687                 } else {
688                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
689                 }
690                 continue;
691             }
692
693             // Is the recipient a local group?
694             // @fixme uri on user_group isn't reliable yet
695             // $group = User_group::staticGet('uri', $recipient);
696             $id = OStatusPlugin::localGroupFromUrl($recipient);
697             if ($id) {
698                 $group = User_group::staticGet('id', $id);
699                 if ($group) {
700                     // Deliver to all members of this local group if allowed.
701                     $profile = $sender->localProfile();
702                     if ($profile->isMember($group)) {
703                         $groups[] = $group->id;
704                     } else {
705                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
706                     }
707                     continue;
708                 } else {
709                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
710                 }
711             }
712
713             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
714
715         }
716         $attention_uris = $replies;
717         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
718         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
719         return $groups;
720     }
721
722     /**
723      * Look up and if necessary create an Ostatus_profile for the remote entity
724      * with the given profile page URL. This should never return null -- you
725      * will either get an object or an exception will be thrown.
726      *
727      * @param string $profile_url
728      * @return Ostatus_profile
729      * @throws Exception on various error conditions
730      * @throws OStatusShadowException if this reference would obscure a local user/group
731      */
732
733     public static function ensureProfileURL($profile_url, $hints=array())
734     {
735         $oprofile = self::getFromProfileURL($profile_url);
736
737         if (!empty($oprofile)) {
738             return $oprofile;
739         }
740
741         $hints['profileurl'] = $profile_url;
742
743         // Fetch the URL
744         // XXX: HTTP caching
745
746         $client = new HTTPClient();
747         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
748         $response = $client->get($profile_url);
749
750         if (!$response->isOk()) {
751             throw new Exception("Could not reach profile page: " . $profile_url);
752         }
753
754         // Check if we have a non-canonical URL
755
756         $finalUrl = $response->getUrl();
757
758         if ($finalUrl != $profile_url) {
759
760             $hints['profileurl'] = $finalUrl;
761
762             $oprofile = self::getFromProfileURL($finalUrl);
763
764             if (!empty($oprofile)) {
765                 return $oprofile;
766             }
767         }
768
769         // Try to get some hCard data
770
771         $body = $response->getBody();
772
773         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
774
775         if (!empty($hcardHints)) {
776             $hints = array_merge($hints, $hcardHints);
777         }
778
779         // Check if they've got an LRDD header
780
781         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
782
783         if (!empty($lrdd)) {
784
785             $xrd = Discovery::fetchXrd($lrdd);
786             $xrdHints = DiscoveryHints::fromXRD($xrd);
787
788             $hints = array_merge($hints, $xrdHints);
789         }
790
791         // If discovery found a feedurl (probably from LRDD), use it.
792
793         if (array_key_exists('feedurl', $hints)) {
794             return self::ensureFeedURL($hints['feedurl'], $hints);
795         }
796
797         // Get the feed URL from HTML
798
799         $discover = new FeedDiscovery();
800
801         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
802
803         if (!empty($feedurl)) {
804             $hints['feedurl'] = $feedurl;
805             return self::ensureFeedURL($feedurl, $hints);
806         }
807
808         throw new Exception("Could not find a feed URL for profile page " . $finalUrl);
809     }
810
811     /**
812      * Look up the Ostatus_profile, if present, for a remote entity with the
813      * given profile page URL. Will return null for both unknown and invalid
814      * remote profiles.
815      *
816      * @return mixed Ostatus_profile or null
817      * @throws OStatusShadowException for local profiles
818      */
819     static function getFromProfileURL($profile_url)
820     {
821         $profile = Profile::staticGet('profileurl', $profile_url);
822
823         if (empty($profile)) {
824             return null;
825         }
826
827         // Is it a known Ostatus profile?
828
829         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
830
831         if (!empty($oprofile)) {
832             return $oprofile;
833         }
834
835         // Is it a local user?
836
837         $user = User::staticGet('id', $profile->id);
838
839         if (!empty($user)) {
840             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
841         }
842
843         // Continue discovery; it's a remote profile
844         // for OMB or some other protocol, may also
845         // support OStatus
846
847         return null;
848     }
849
850     /**
851      * Look up and if necessary create an Ostatus_profile for remote entity
852      * with the given update feed. This should never return null -- you will
853      * either get an object or an exception will be thrown.
854      *
855      * @return Ostatus_profile
856      * @throws Exception
857      */
858     public static function ensureFeedURL($feed_url, $hints=array())
859     {
860         $discover = new FeedDiscovery();
861
862         $feeduri = $discover->discoverFromFeedURL($feed_url);
863         $hints['feedurl'] = $feeduri;
864
865         $huburi = $discover->getAtomLink('hub');
866         $hints['hub'] = $huburi;
867         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
868         $hints['salmon'] = $salmonuri;
869
870         if (!$huburi) {
871             // We can only deal with folks with a PuSH hub
872             throw new FeedSubNoHubException();
873         }
874
875         $feedEl = $discover->root;
876
877         if ($feedEl->tagName == 'feed') {
878             return self::ensureAtomFeed($feedEl, $hints);
879         } else if ($feedEl->tagName == 'channel') {
880             return self::ensureRssChannel($feedEl, $hints);
881         } else {
882             throw new FeedSubBadXmlException($feeduri);
883         }
884     }
885
886     /**
887      * Look up and, if necessary, create an Ostatus_profile for the remote
888      * profile with the given Atom feed - actually loaded from the feed.
889      * This should never return null -- you will either get an object or
890      * an exception will be thrown.
891      *
892      * @param DOMElement $feedEl root element of a loaded Atom feed
893      * @param array $hints additional discovery information passed from higher levels
894      * @fixme should this be marked public?
895      * @return Ostatus_profile
896      * @throws Exception
897      */
898     public static function ensureAtomFeed($feedEl, $hints)
899     {
900         // Try to get a profile from the feed activity:subject
901
902         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
903
904         if (!empty($subject)) {
905             $subjObject = new ActivityObject($subject);
906             return self::ensureActivityObjectProfile($subjObject, $hints);
907         }
908
909         // Otherwise, try the feed author
910
911         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
912
913         if (!empty($author)) {
914             $authorObject = new ActivityObject($author);
915             return self::ensureActivityObjectProfile($authorObject, $hints);
916         }
917
918         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
919         // entries.
920
921         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
922
923         if (!empty($entries) && $entries->length > 0) {
924
925             $entry = $entries->item(0);
926
927             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
928
929             if (!empty($actor)) {
930                 $actorObject = new ActivityObject($actor);
931                 return self::ensureActivityObjectProfile($actorObject, $hints);
932
933             }
934
935             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
936
937             if (!empty($author)) {
938                 $authorObject = new ActivityObject($author);
939                 return self::ensureActivityObjectProfile($authorObject, $hints);
940             }
941         }
942
943         // XXX: make some educated guesses here
944
945         throw new FeedSubException("Can't find enough profile information to make a feed.");
946     }
947
948     /**
949      * Look up and, if necessary, create an Ostatus_profile for the remote
950      * profile with the given RSS feed - actually loaded from the feed.
951      * This should never return null -- you will either get an object or
952      * an exception will be thrown.
953      *
954      * @param DOMElement $feedEl root element of a loaded RSS feed
955      * @param array $hints additional discovery information passed from higher levels
956      * @fixme should this be marked public?
957      * @return Ostatus_profile
958      * @throws Exception
959      */
960     public static function ensureRssChannel($feedEl, $hints)
961     {
962         // Special-case for Posterous. They have some nice metadata in their
963         // posterous:author elements. We should use them instead of the channel.
964
965         $items = $feedEl->getElementsByTagName('item');
966
967         if ($items->length > 0) {
968             $item = $items->item(0);
969             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
970             if (!empty($authorEl)) {
971                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
972                 // Posterous has multiple authors per feed, and multiple feeds
973                 // per author. We check if this is the "main" feed for this author.
974                 if (array_key_exists('profileurl', $hints) &&
975                     !empty($obj->poco) &&
976                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
977                     return self::ensureActivityObjectProfile($obj, $hints);
978                 }
979             }
980         }
981
982         // @fixme we should check whether this feed has elements
983         // with different <author> or <dc:creator> elements, and... I dunno.
984         // Do something about that.
985
986         $obj = ActivityObject::fromRssChannel($feedEl);
987
988         return self::ensureActivityObjectProfile($obj, $hints);
989     }
990
991     /**
992      * Download and update given avatar image
993      *
994      * @param string $url
995      * @throws Exception in various failure cases
996      */
997     protected function updateAvatar($url)
998     {
999         if ($url == $this->avatar) {
1000             // We've already got this one.
1001             return;
1002         }
1003         if (!common_valid_http_url($url)) {
1004             throw new ServerException(sprintf(_m("Invalid avatar URL %s"), $url));
1005         }
1006
1007         if ($this->isGroup()) {
1008             $self = $this->localGroup();
1009         } else {
1010             $self = $this->localProfile();
1011         }
1012         if (!$self) {
1013             throw new ServerException(sprintf(
1014                 _m("Tried to update avatar for unsaved remote profile %s"),
1015                 $this->uri));
1016         }
1017
1018         // @fixme this should be better encapsulated
1019         // ripped from oauthstore.php (for old OMB client)
1020         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1021         if (!copy($url, $temp_filename)) {
1022             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
1023         }
1024
1025         if ($this->isGroup()) {
1026             $id = $this->group_id;
1027         } else {
1028             $id = $this->profile_id;
1029         }
1030         // @fixme should we be using different ids?
1031         $imagefile = new ImageFile($id, $temp_filename);
1032         $filename = Avatar::filename($id,
1033                                      image_type_to_extension($imagefile->type),
1034                                      null,
1035                                      common_timestamp());
1036         rename($temp_filename, Avatar::path($filename));
1037         $self->setOriginal($filename);
1038
1039         $orig = clone($this);
1040         $this->avatar = $url;
1041         $this->update($orig);
1042     }
1043
1044     /**
1045      * Pull avatar URL from ActivityObject or profile hints
1046      *
1047      * @param ActivityObject $object
1048      * @param array $hints
1049      * @return mixed URL string or false
1050      */
1051
1052     protected static function getActivityObjectAvatar($object, $hints=array())
1053     {
1054         if ($object->avatarLinks) {
1055             $best = false;
1056             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1057             foreach ($object->avatarLinks as $avatar) {
1058                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1059                     // Exact match!
1060                     $best = $avatar;
1061                     break;
1062                 }
1063                 if (!$best || $avatar->width > $best->width) {
1064                     $best = $avatar;
1065                 }
1066             }
1067             return $best->url;
1068         } else if (array_key_exists('avatar', $hints)) {
1069             return $hints['avatar'];
1070         }
1071         return false;
1072     }
1073
1074     /**
1075      * Get an appropriate avatar image source URL, if available.
1076      *
1077      * @param ActivityObject $actor
1078      * @param DOMElement $feed
1079      * @return string
1080      */
1081
1082     protected static function getAvatar($actor, $feed)
1083     {
1084         $url = '';
1085         $icon = '';
1086         if ($actor->avatar) {
1087             $url = trim($actor->avatar);
1088         }
1089         if (!$url) {
1090             // Check <atom:logo> and <atom:icon> on the feed
1091             $els = $feed->childNodes();
1092             if ($els && $els->length) {
1093                 for ($i = 0; $i < $els->length; $i++) {
1094                     $el = $els->item($i);
1095                     if ($el->namespaceURI == Activity::ATOM) {
1096                         if (empty($url) && $el->localName == 'logo') {
1097                             $url = trim($el->textContent);
1098                             break;
1099                         }
1100                         if (empty($icon) && $el->localName == 'icon') {
1101                             // Use as a fallback
1102                             $icon = trim($el->textContent);
1103                         }
1104                     }
1105                 }
1106             }
1107             if ($icon && !$url) {
1108                 $url = $icon;
1109             }
1110         }
1111         if ($url) {
1112             $opts = array('allowed_schemes' => array('http', 'https'));
1113             if (Validate::uri($url, $opts)) {
1114                 return $url;
1115             }
1116         }
1117         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1118     }
1119
1120     /**
1121      * Fetch, or build if necessary, an Ostatus_profile for the actor
1122      * in a given Activity Streams activity.
1123      * This should never return null -- you will either get an object or
1124      * an exception will be thrown.
1125      *
1126      * @param Activity $activity
1127      * @param string $feeduri if we already know the canonical feed URI!
1128      * @param string $salmonuri if we already know the salmon return channel URI
1129      * @return Ostatus_profile
1130      * @throws Exception
1131      */
1132
1133     public static function ensureActorProfile($activity, $hints=array())
1134     {
1135         return self::ensureActivityObjectProfile($activity->actor, $hints);
1136     }
1137
1138     /**
1139      * Fetch, or build if necessary, an Ostatus_profile for the profile
1140      * in a given Activity Streams object (can be subject, actor, or object).
1141      * This should never return null -- you will either get an object or
1142      * an exception will be thrown.
1143      *
1144      * @param ActivityObject $object
1145      * @param array $hints additional discovery information passed from higher levels
1146      * @return Ostatus_profile
1147      * @throws Exception
1148      */
1149
1150     public static function ensureActivityObjectProfile($object, $hints=array())
1151     {
1152         $profile = self::getActivityObjectProfile($object);
1153         if ($profile) {
1154             $profile->updateFromActivityObject($object, $hints);
1155         } else {
1156             $profile = self::createActivityObjectProfile($object, $hints);
1157         }
1158         return $profile;
1159     }
1160
1161     /**
1162      * @param Activity $activity
1163      * @return mixed matching Ostatus_profile or false if none known
1164      * @throws ServerException if feed info invalid
1165      */
1166     public static function getActorProfile($activity)
1167     {
1168         return self::getActivityObjectProfile($activity->actor);
1169     }
1170
1171     /**
1172      * @param ActivityObject $activity
1173      * @return mixed matching Ostatus_profile or false if none known
1174      * @throws ServerException if feed info invalid
1175      */
1176     protected static function getActivityObjectProfile($object)
1177     {
1178         $uri = self::getActivityObjectProfileURI($object);
1179         return Ostatus_profile::staticGet('uri', $uri);
1180     }
1181
1182     /**
1183      * Get the identifier URI for the remote entity described
1184      * by this ActivityObject. This URI is *not* guaranteed to be
1185      * a resolvable HTTP/HTTPS URL.
1186      *
1187      * @param ActivityObject $object
1188      * @return string
1189      * @throws ServerException if feed info invalid
1190      */
1191     protected static function getActivityObjectProfileURI($object)
1192     {
1193         if ($object->id) {
1194             if (ActivityUtils::validateUri($object->id)) {
1195                 return $object->id;
1196             }
1197         }
1198
1199         // If the id is missing or invalid (we've seen feeds mistakenly listing
1200         // things like local usernames in that field) then we'll use the profile
1201         // page link, if valid.
1202         if ($object->link && common_valid_http_url($object->link)) {
1203             return $object->link;
1204         }
1205         throw new ServerException("No author ID URI found");
1206     }
1207
1208     /**
1209      * @fixme validate stuff somewhere
1210      */
1211
1212     /**
1213      * Create local ostatus_profile and profile/user_group entries for
1214      * the provided remote user or group.
1215      * This should never return null -- you will either get an object or
1216      * an exception will be thrown.
1217      *
1218      * @param ActivityObject $object
1219      * @param array $hints
1220      *
1221      * @return Ostatus_profile
1222      */
1223     protected static function createActivityObjectProfile($object, $hints=array())
1224     {
1225         $homeuri = $object->id;
1226         $discover = false;
1227
1228         if (!$homeuri) {
1229             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1230             throw new Exception("No profile URI");
1231         }
1232
1233         $user = User::staticGet('uri', $homeuri);
1234         if ($user) {
1235             throw new Exception("Local user can't be referenced as remote.");
1236         }
1237
1238         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1239             throw new Exception("Local group can't be referenced as remote.");
1240         }
1241
1242         if (array_key_exists('feedurl', $hints)) {
1243             $feeduri = $hints['feedurl'];
1244         } else {
1245             $discover = new FeedDiscovery();
1246             $feeduri = $discover->discoverFromURL($homeuri);
1247         }
1248
1249         if (array_key_exists('salmon', $hints)) {
1250             $salmonuri = $hints['salmon'];
1251         } else {
1252             if (!$discover) {
1253                 $discover = new FeedDiscovery();
1254                 $discover->discoverFromFeedURL($hints['feedurl']);
1255             }
1256             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1257         }
1258
1259         if (array_key_exists('hub', $hints)) {
1260             $huburi = $hints['hub'];
1261         } else {
1262             if (!$discover) {
1263                 $discover = new FeedDiscovery();
1264                 $discover->discoverFromFeedURL($hints['feedurl']);
1265             }
1266             $huburi = $discover->getAtomLink('hub');
1267         }
1268
1269         if (!$huburi) {
1270             // We can only deal with folks with a PuSH hub
1271             throw new FeedSubNoHubException();
1272         }
1273
1274         $oprofile = new Ostatus_profile();
1275
1276         $oprofile->uri        = $homeuri;
1277         $oprofile->feeduri    = $feeduri;
1278         $oprofile->salmonuri  = $salmonuri;
1279
1280         $oprofile->created    = common_sql_now();
1281         $oprofile->modified   = common_sql_now();
1282
1283         if ($object->type == ActivityObject::PERSON) {
1284             $profile = new Profile();
1285             $profile->created = common_sql_now();
1286             self::updateProfile($profile, $object, $hints);
1287
1288             $oprofile->profile_id = $profile->insert();
1289             if (!$oprofile->profile_id) {
1290                 throw new ServerException("Can't save local profile");
1291             }
1292         } else {
1293             $group = new User_group();
1294             $group->uri = $homeuri;
1295             $group->created = common_sql_now();
1296             self::updateGroup($group, $object, $hints);
1297
1298             $oprofile->group_id = $group->insert();
1299             if (!$oprofile->group_id) {
1300                 throw new ServerException("Can't save local profile");
1301             }
1302         }
1303
1304         $ok = $oprofile->insert();
1305
1306         if (!$ok) {
1307             throw new ServerException("Can't save OStatus profile");
1308         }
1309
1310         $avatar = self::getActivityObjectAvatar($object, $hints);
1311
1312         if ($avatar) {
1313             try {
1314                 $oprofile->updateAvatar($avatar);
1315             } catch (Exception $ex) {
1316                 // Profile is saved, but Avatar is messed up. We're
1317                 // just going to continue.
1318                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1319             }
1320         }
1321
1322         return $oprofile;
1323     }
1324
1325     /**
1326      * Save any updated profile information to our local copy.
1327      * @param ActivityObject $object
1328      * @param array $hints
1329      */
1330     public function updateFromActivityObject($object, $hints=array())
1331     {
1332         if ($this->isGroup()) {
1333             $group = $this->localGroup();
1334             self::updateGroup($group, $object, $hints);
1335         } else {
1336             $profile = $this->localProfile();
1337             self::updateProfile($profile, $object, $hints);
1338         }
1339         $avatar = self::getActivityObjectAvatar($object, $hints);
1340         if ($avatar) {
1341             try {
1342                 $this->updateAvatar($avatar);
1343             } catch (Exception $ex) {
1344                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1345             }
1346         }
1347     }
1348
1349     protected static function updateProfile($profile, $object, $hints=array())
1350     {
1351         $orig = clone($profile);
1352
1353         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1354
1355         if (!empty($object->title)) {
1356             $profile->fullname = $object->title;
1357         } else if (array_key_exists('fullname', $hints)) {
1358             $profile->fullname = $hints['fullname'];
1359         }
1360
1361         if (!empty($object->link)) {
1362             $profile->profileurl = $object->link;
1363         } else if (array_key_exists('profileurl', $hints)) {
1364             $profile->profileurl = $hints['profileurl'];
1365         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1366             $profile->profileurl = $object->id;
1367         }
1368
1369         $profile->bio      = self::getActivityObjectBio($object, $hints);
1370         $profile->location = self::getActivityObjectLocation($object, $hints);
1371         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1372
1373         if (!empty($object->geopoint)) {
1374             $location = ActivityContext::locationFromPoint($object->geopoint);
1375             if (!empty($location)) {
1376                 $profile->lat = $location->lat;
1377                 $profile->lon = $location->lon;
1378             }
1379         }
1380
1381         // @fixme tags/categories
1382         // @todo tags from categories
1383
1384         if ($profile->id) {
1385             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1386             $profile->update($orig);
1387         }
1388     }
1389
1390     protected static function updateGroup($group, $object, $hints=array())
1391     {
1392         $orig = clone($group);
1393
1394         $group->nickname = self::getActivityObjectNickname($object, $hints);
1395         $group->fullname = $object->title;
1396
1397         if (!empty($object->link)) {
1398             $group->mainpage = $object->link;
1399         } else if (array_key_exists('profileurl', $hints)) {
1400             $group->mainpage = $hints['profileurl'];
1401         }
1402
1403         // @todo tags from categories
1404         $group->description = self::getActivityObjectBio($object, $hints);
1405         $group->location = self::getActivityObjectLocation($object, $hints);
1406         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1407
1408         if ($group->id) {
1409             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1410             $group->update($orig);
1411         }
1412     }
1413
1414     protected static function getActivityObjectHomepage($object, $hints=array())
1415     {
1416         $homepage = null;
1417         $poco     = $object->poco;
1418
1419         if (!empty($poco)) {
1420             $url = $poco->getPrimaryURL();
1421             if ($url && $url->type == 'homepage') {
1422                 $homepage = $url->value;
1423             }
1424         }
1425
1426         // @todo Try for a another PoCo URL?
1427
1428         return $homepage;
1429     }
1430
1431     protected static function getActivityObjectLocation($object, $hints=array())
1432     {
1433         $location = null;
1434
1435         if (!empty($object->poco) &&
1436             isset($object->poco->address->formatted)) {
1437             $location = $object->poco->address->formatted;
1438         } else if (array_key_exists('location', $hints)) {
1439             $location = $hints['location'];
1440         }
1441
1442         if (!empty($location)) {
1443             if (mb_strlen($location) > 255) {
1444                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1445             }
1446         }
1447
1448         // @todo Try to find location some othe way? Via goerss point?
1449
1450         return $location;
1451     }
1452
1453     protected static function getActivityObjectBio($object, $hints=array())
1454     {
1455         $bio  = null;
1456
1457         if (!empty($object->poco)) {
1458             $note = $object->poco->note;
1459         } else if (array_key_exists('bio', $hints)) {
1460             $note = $hints['bio'];
1461         }
1462
1463         if (!empty($note)) {
1464             if (Profile::bioTooLong($note)) {
1465                 // XXX: truncate ok?
1466                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1467             } else {
1468                 $bio = $note;
1469             }
1470         }
1471
1472         // @todo Try to get bio info some other way?
1473
1474         return $bio;
1475     }
1476
1477     protected static function getActivityObjectNickname($object, $hints=array())
1478     {
1479         if ($object->poco) {
1480             if (!empty($object->poco->preferredUsername)) {
1481                 return common_nicknamize($object->poco->preferredUsername);
1482             }
1483         }
1484
1485         if (!empty($object->nickname)) {
1486             return common_nicknamize($object->nickname);
1487         }
1488
1489         if (array_key_exists('nickname', $hints)) {
1490             return $hints['nickname'];
1491         }
1492
1493         // Try the profile url (like foo.example.com or example.com/user/foo)
1494
1495         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1496
1497         if (!empty($profileUrl)) {
1498             $nickname = self::nicknameFromURI($profileUrl);
1499         }
1500
1501         // Try the URI (may be a tag:, http:, acct:, ...
1502
1503         if (empty($nickname)) {
1504             $nickname = self::nicknameFromURI($object->id);
1505         }
1506
1507         // Try a Webfinger if one was passed (way) down
1508
1509         if (empty($nickname)) {
1510             if (array_key_exists('webfinger', $hints)) {
1511                 $nickname = self::nicknameFromURI($hints['webfinger']);
1512             }
1513         }
1514
1515         // Try the name
1516
1517         if (empty($nickname)) {
1518             $nickname = common_nicknamize($object->title);
1519         }
1520
1521         return $nickname;
1522     }
1523
1524     protected static function nicknameFromURI($uri)
1525     {
1526         preg_match('/(\w+):/', $uri, $matches);
1527
1528         $protocol = $matches[1];
1529
1530         switch ($protocol) {
1531         case 'acct':
1532         case 'mailto':
1533             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1534                 return common_canonical_nickname($matches[1]);
1535             }
1536             return null;
1537         case 'http':
1538             return common_url_to_nickname($uri);
1539             break;
1540         default:
1541             return null;
1542         }
1543     }
1544
1545     /**
1546      * Look up, and if necessary create, an Ostatus_profile for the remote
1547      * entity with the given webfinger address.
1548      * This should never return null -- you will either get an object or
1549      * an exception will be thrown.
1550      *
1551      * @param string $addr webfinger address
1552      * @return Ostatus_profile
1553      * @throws Exception on error conditions
1554      * @throws OStatusShadowException if this reference would obscure a local user/group
1555      */
1556     public static function ensureWebfinger($addr)
1557     {
1558         // First, try the cache
1559
1560         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1561
1562         if ($uri !== false) {
1563             if (is_null($uri)) {
1564                 // Negative cache entry
1565                 throw new Exception('Not a valid webfinger address.');
1566             }
1567             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1568             if (!empty($oprofile)) {
1569                 return $oprofile;
1570             }
1571         }
1572
1573         // Try looking it up
1574
1575         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1576
1577         if (!empty($oprofile)) {
1578             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1579             return $oprofile;
1580         }
1581
1582         // Now, try some discovery
1583
1584         $disco = new Discovery();
1585
1586         try {
1587             $xrd = $disco->lookup($addr);
1588         } catch (Exception $e) {
1589             // Save negative cache entry so we don't waste time looking it up again.
1590             // @fixme distinguish temporary failures?
1591             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1592             throw new Exception('Not a valid webfinger address.');
1593         }
1594
1595         $hints = array('webfinger' => $addr);
1596
1597         $dhints = DiscoveryHints::fromXRD($xrd);
1598
1599         $hints = array_merge($hints, $dhints);
1600
1601         // If there's an Hcard, let's grab its info
1602
1603         if (array_key_exists('hcard', $hints)) {
1604             if (!array_key_exists('profileurl', $hints) ||
1605                 $hints['hcard'] != $hints['profileurl']) {
1606                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1607                 $hints = array_merge($hcardHints, $hints);
1608             }
1609         }
1610
1611         // If we got a feed URL, try that
1612
1613         if (array_key_exists('feedurl', $hints)) {
1614             try {
1615                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1616                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1617                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1618                 return $oprofile;
1619             } catch (Exception $e) {
1620                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1621                 // keep looking
1622             }
1623         }
1624
1625         // If we got a profile page, try that!
1626
1627         if (array_key_exists('profileurl', $hints)) {
1628             try {
1629                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1630                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1631                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1632                 return $oprofile;
1633             } catch (OStatusShadowException $e) {
1634                 // We've ended up with a remote reference to a local user or group.
1635                 // @fixme ideally we should be able to say who it was so we can
1636                 // go back and refer to it the regular way
1637                 throw $e;
1638             } catch (Exception $e) {
1639                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1640                 // keep looking
1641                 //
1642                 // @fixme this means an error discovering from profile page
1643                 // may give us a corrupt entry using the webfinger URI, which
1644                 // will obscure the correct page-keyed profile later on.
1645             }
1646         }
1647
1648         // XXX: try hcard
1649         // XXX: try FOAF
1650
1651         if (array_key_exists('salmon', $hints)) {
1652
1653             $salmonEndpoint = $hints['salmon'];
1654
1655             // An account URL, a salmon endpoint, and a dream? Not much to go
1656             // on, but let's give it a try
1657
1658             $uri = 'acct:'.$addr;
1659
1660             $profile = new Profile();
1661
1662             $profile->nickname = self::nicknameFromUri($uri);
1663             $profile->created  = common_sql_now();
1664
1665             if (isset($profileUrl)) {
1666                 $profile->profileurl = $profileUrl;
1667             }
1668
1669             $profile_id = $profile->insert();
1670
1671             if (!$profile_id) {
1672                 common_log_db_error($profile, 'INSERT', __FILE__);
1673                 throw new Exception("Couldn't save profile for '$addr'");
1674             }
1675
1676             $oprofile = new Ostatus_profile();
1677
1678             $oprofile->uri        = $uri;
1679             $oprofile->salmonuri  = $salmonEndpoint;
1680             $oprofile->profile_id = $profile_id;
1681             $oprofile->created    = common_sql_now();
1682
1683             if (isset($feedUrl)) {
1684                 $profile->feeduri = $feedUrl;
1685             }
1686
1687             $result = $oprofile->insert();
1688
1689             if (!$result) {
1690                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1691                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1692             }
1693
1694             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1695             return $oprofile;
1696         }
1697
1698         throw new Exception("Couldn't find a valid profile for '$addr'");
1699     }
1700
1701     /**
1702      * Store the full-length scrubbed HTML of a remote notice to an attachment
1703      * file on our server. We'll link to this at the end of the cropped version.
1704      *
1705      * @param string $title plaintext for HTML page's title
1706      * @param string $rendered HTML fragment for HTML page's body
1707      * @return File
1708      */
1709     function saveHTMLFile($title, $rendered)
1710     {
1711         $final = sprintf("<!DOCTYPE html>\n" .
1712                          '<html><head>' .
1713                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
1714                          '<title>%s</title>' .
1715                          '</head>' .
1716                          '<body>%s</body></html>',
1717                          htmlspecialchars($title),
1718                          $rendered);
1719
1720         $filename = File::filename($this->localProfile(),
1721                                    'ostatus', // ignored?
1722                                    'text/html');
1723
1724         $filepath = File::path($filename);
1725
1726         file_put_contents($filepath, $final);
1727
1728         $file = new File;
1729
1730         $file->filename = $filename;
1731         $file->url      = File::url($filename);
1732         $file->size     = filesize($filepath);
1733         $file->date     = time();
1734         $file->mimetype = 'text/html';
1735
1736         $file_id = $file->insert();
1737
1738         if ($file_id === false) {
1739             common_log_db_error($file, "INSERT", __FILE__);
1740             throw new ServerException(_('Could not store HTML content of long post as file.'));
1741         }
1742
1743         return $file;
1744     }
1745 }
1746
1747 /**
1748  * Exception indicating we've got a remote reference to a local user,
1749  * not a remote user!
1750  *
1751  * If we can ue a local profile after all, it's available as $e->profile.
1752  */
1753 class OStatusShadowException extends Exception
1754 {
1755     public $profile;
1756
1757     /**
1758      * @param Profile $profile
1759      * @param string $message
1760      */
1761     function __construct($profile, $message) {
1762         $this->profile = $profile;
1763         parent::__construct($message);
1764     }
1765 }
1766