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