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