]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
There could be unresolvable FeedSub entries
[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 if (!defined('STATUSNET')) {
21     exit(1);
22 }
23
24 /**
25  * @package OStatusPlugin
26  * @maintainer Brion Vibber <brion@status.net>
27  */
28 class Ostatus_profile extends Managed_DataObject
29 {
30     public $__table = 'ostatus_profile';
31
32     public $uri;
33
34     public $profile_id;
35     public $group_id;
36     public $peopletag_id;
37
38     public $feeduri;
39     public $salmonuri;
40     public $avatar; // remote URL of the last avatar we saved
41
42     public $created;
43     public $modified;
44
45     /**
46      * Return table definition for Schema setup and DB_DataObject usage.
47      *
48      * @return array array of column definitions
49      */
50     static function schemaDef()
51     {
52         return array(
53             'fields' => array(
54                 'uri' => array('type' => 'varchar', 'length' => 255, 'not null' => true),
55                 'profile_id' => array('type' => 'integer'),
56                 'group_id' => array('type' => 'integer'),
57                 'peopletag_id' => array('type' => 'integer'),
58                 'feeduri' => array('type' => 'varchar', 'length' => 255),
59                 'salmonuri' => array('type' => 'varchar', 'length' => 255),
60                 'avatar' => array('type' => 'text'),
61                 'created' => array('type' => 'datetime', 'not null' => true),
62                 'modified' => array('type' => 'datetime', 'not null' => true),
63             ),
64             'primary key' => array('uri'),
65             'unique keys' => array(
66                 'ostatus_profile_profile_id_key' => array('profile_id'),
67                 'ostatus_profile_group_id_key' => array('group_id'),
68                 'ostatus_profile_peopletag_id_key' => array('peopletag_id'),
69                 'ostatus_profile_feeduri_key' => array('feeduri'),
70             ),
71             'foreign keys' => array(
72                 'ostatus_profile_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
73                 'ostatus_profile_group_id_fkey' => array('user_group', array('group_id' => 'id')),
74                 'ostatus_profile_peopletag_id_fkey' => array('profile_list', array('peopletag_id' => 'id')),
75             ),
76         );
77     }
78
79     public function getUri()
80     {
81         return $this->uri;
82     }
83
84     /**
85      * Fetch the locally stored profile for this feed
86      * @return Profile
87      * @throws NoProfileException if it was not found
88      */
89     public function localProfile()
90     {
91         $profile = Profile::getKV('id', $this->profile_id);
92         if ($profile instanceof Profile) {
93             return $profile;
94         }
95         throw new NoProfileException($this->profile_id);
96     }
97
98     /**
99      * Fetch the StatusNet-side profile for this feed
100      * @return Profile
101      */
102     public function localGroup()
103     {
104         if ($this->group_id) {
105             return User_group::getKV('id', $this->group_id);
106         }
107         return null;
108     }
109
110     /**
111      * Fetch the StatusNet-side peopletag for this feed
112      * @return Profile
113      */
114     public function localPeopletag()
115     {
116         if ($this->peopletag_id) {
117             return Profile_list::getKV('id', $this->peopletag_id);
118         }
119         return null;
120     }
121
122     /**
123      * Returns an ActivityObject describing this remote user or group profile.
124      * Can then be used to generate Atom chunks.
125      *
126      * @return ActivityObject
127      */
128     function asActivityObject()
129     {
130         if ($this->isGroup()) {
131             return ActivityObject::fromGroup($this->localGroup());
132         } else if ($this->isPeopletag()) {
133             return ActivityObject::fromPeopletag($this->localPeopletag());
134         } else {
135             return ActivityObject::fromProfile($this->localProfile());
136         }
137     }
138
139     /**
140      * Returns an XML string fragment with profile information as an
141      * Activity Streams noun object with the given element type.
142      *
143      * Assumes that 'activity' namespace has been previously defined.
144      *
145      * @todo FIXME: Replace with wrappers on asActivityObject when it's got everything.
146      *
147      * @param string $element one of 'actor', 'subject', 'object', 'target'
148      * @return string
149      */
150     function asActivityNoun($element)
151     {
152         if ($this->isGroup()) {
153             $noun = ActivityObject::fromGroup($this->localGroup());
154             return $noun->asString('activity:' . $element);
155         } else if ($this->isPeopletag()) {
156             $noun = ActivityObject::fromPeopletag($this->localPeopletag());
157             return $noun->asString('activity:' . $element);
158         } else {
159             $noun = ActivityObject::fromProfile($this->localProfile());
160             return $noun->asString('activity:' . $element);
161         }
162     }
163
164     /**
165      * @return boolean true if this is a remote group
166      */
167     function isGroup()
168     {
169         if ($this->profile_id || $this->peopletag_id && !$this->group_id) {
170             return false;
171         } else if ($this->group_id && !$this->profile_id && !$this->peopletag_id) {
172             return true;
173         } else if ($this->group_id && ($this->profile_id || $this->peopletag_id)) {
174             // TRANS: Server exception. %s is a URI
175             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->getUri()));
176         } else {
177             // TRANS: Server exception. %s is a URI
178             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->getUri()));
179         }
180     }
181
182     /**
183      * @return boolean true if this is a remote peopletag
184      */
185     function isPeopletag()
186     {
187         if ($this->profile_id || $this->group_id && !$this->peopletag_id) {
188             return false;
189         } else if ($this->peopletag_id && !$this->profile_id && !$this->group_id) {
190             return true;
191         } else if ($this->peopletag_id && ($this->profile_id || $this->group_id)) {
192             // TRANS: Server exception. %s is a URI
193             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->getUri()));
194         } else {
195             // TRANS: Server exception. %s is a URI
196             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->getUri()));
197         }
198     }
199
200     /**
201      * Send a subscription request to the hub for this feed.
202      * The hub will later send us a confirmation POST to /main/push/callback.
203      *
204      * @return void
205      * @throws ServerException if feed state is not valid or subscription fails.
206      */
207     public function subscribe()
208     {
209         $feedsub = FeedSub::ensureFeed($this->feeduri);
210         if ($feedsub->sub_state == 'active') {
211             // Active subscription, we don't need to do anything.
212             return;
213         }
214
215         // Inactive or we got left in an inconsistent state.
216         // Run a subscription request to make sure we're current!
217         return $feedsub->subscribe();
218     }
219
220     /**
221      * Check if this remote profile has any active local subscriptions, and
222      * if not drop the PuSH subscription feed.
223      *
224      * @return boolean true if subscription is removed, false if there are still subscribers to the feed
225      * @throws Exception of various kinds on failure.
226      */
227     public function unsubscribe() {
228         return $this->garbageCollect();
229     }
230
231     /**
232      * Check if this remote profile has any active local subscriptions, and
233      * if not drop the PuSH subscription feed.
234      *
235      * @return boolean true if subscription is removed, false if there are still subscribers to the feed
236      * @throws Exception of various kinds on failure.
237      */
238     public function garbageCollect()
239     {
240         $feedsub = FeedSub::getKV('uri', $this->feeduri);
241         if ($feedsub instanceof FeedSub) {
242             return $feedsub->garbageCollect();
243         }
244         // Since there's no FeedSub we can assume it's already garbage collected
245         return true;
246     }
247
248     /**
249      * Check if this remote profile has any active local subscriptions, so the
250      * PuSH subscription layer can decide if it can drop the feed.
251      *
252      * This gets called via the FeedSubSubscriberCount event when running
253      * FeedSub::garbageCollect().
254      *
255      * @return int
256      * @throws NoProfileException if there is no local profile for the object
257      */
258     public function subscriberCount()
259     {
260         if ($this->isGroup()) {
261             $members = $this->localGroup()->getMembers(0, 1);
262             $count = $members->N;
263         } else if ($this->isPeopletag()) {
264             $subscribers = $this->localPeopletag()->getSubscribers(0, 1);
265             $count = $subscribers->N;
266         } else {
267             $profile = $this->localProfile();
268             if ($profile->hasLocalTags()) {
269                 $count = 1;
270             } else {
271                 $count = $profile->subscriberCount();
272             }
273         }
274         common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");
275
276         // Other plugins may be piggybacking on OStatus without having
277         // an active group or user-to-user subscription we know about.
278         Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
279         common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");
280
281         return $count;
282     }
283
284     /**
285      * Send an Activity Streams notification to the remote Salmon endpoint,
286      * if so configured.
287      *
288      * @param Profile $actor  Actor who did the activity
289      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
290      * @param Object  $object object of the action; must define asActivityNoun($tag)
291      */
292     public function notify($actor, $verb, $object=null, $target=null)
293     {
294         if (!($actor instanceof Profile)) {
295             $type = gettype($actor);
296             if ($type == 'object') {
297                 $type = get_class($actor);
298             }
299             // TRANS: Server exception.
300             // TRANS: %1$s is the method name the exception occured in, %2$s is the actor type.
301             throw new ServerException(sprintf(_m('Invalid actor passed to %1$s: %2$s.'),__METHOD__,$type));
302         }
303         if ($object == null) {
304             $object = $this;
305         }
306         if ($this->salmonuri) {
307             $text = 'update';
308             $id = TagURI::mint('%s:%s:%s',
309                                $verb,
310                                $actor->getURI(),
311                                common_date_iso8601(time()));
312
313             // @todo FIXME: Consolidate all these NS settings somewhere.
314             $attributes = array('xmlns' => Activity::ATOM,
315                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
316                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
317                                 'xmlns:georss' => 'http://www.georss.org/georss',
318                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
319                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
320                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
321
322             $entry = new XMLStringer();
323             $entry->elementStart('entry', $attributes);
324             $entry->element('id', null, $id);
325             $entry->element('title', null, $text);
326             $entry->element('summary', null, $text);
327             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
328
329             $entry->element('activity:verb', null, $verb);
330             $entry->raw($actor->asAtomAuthor());
331             $entry->raw($actor->asActivityActor());
332             $entry->raw($object->asActivityNoun('object'));
333             if ($target != null) {
334                 $entry->raw($target->asActivityNoun('target'));
335             }
336             $entry->elementEnd('entry');
337
338             $xml = $entry->getString();
339             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
340
341             $salmon = new Salmon(); // ?
342             return $salmon->post($this->salmonuri, $xml, $actor);
343         }
344         return false;
345     }
346
347     /**
348      * Send a Salmon notification ping immediately, and confirm that we got
349      * an acceptable response from the remote site.
350      *
351      * @param mixed $entry XML string, Notice, or Activity
352      * @param Profile $actor
353      * @return boolean success
354      */
355     public function notifyActivity($entry, Profile $actor)
356     {
357         if ($this->salmonuri) {
358             $salmon = new Salmon();
359             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
360         }
361         common_debug(__CLASS__.' error: No salmonuri for Ostatus_profile uri: '.$this->uri);
362
363         return false;
364     }
365
366     /**
367      * Queue a Salmon notification for later. If queues are disabled we'll
368      * send immediately but won't get the return value.
369      *
370      * @param mixed $entry XML string, Notice, or Activity
371      * @return boolean success
372      */
373     public function notifyDeferred($entry, $actor)
374     {
375         if ($this->salmonuri) {
376             $data = array('salmonuri' => $this->salmonuri,
377                           'entry' => $this->notifyPrepXml($entry),
378                           'actor' => $actor->id);
379
380             $qm = QueueManager::get();
381             return $qm->enqueue($data, 'salmon');
382         }
383
384         return false;
385     }
386
387     protected function notifyPrepXml($entry)
388     {
389         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
390         if (is_string($entry)) {
391             return $entry;
392         } else if ($entry instanceof Activity) {
393             return $preamble . $entry->asString(true);
394         } else if ($entry instanceof Notice) {
395             return $preamble . $entry->asAtomEntry(true, true);
396         } else {
397             // TRANS: Server exception.
398             throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
399         }
400     }
401
402     function getBestName()
403     {
404         if ($this->isGroup()) {
405             return $this->localGroup()->getBestName();
406         } else if ($this->isPeopletag()) {
407             return $this->localPeopletag()->getBestName();
408         } else {
409             return $this->localProfile()->getBestName();
410         }
411     }
412
413     /**
414      * Read and post notices for updates from the feed.
415      * Currently assumes that all items in the feed are new,
416      * coming from a PuSH hub.
417      *
418      * @param DOMDocument $doc
419      * @param string $source identifier ("push")
420      */
421     public function processFeed(DOMDocument $doc, $source)
422     {
423         $feed = $doc->documentElement;
424
425         if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
426             $this->processAtomFeed($feed, $source);
427         } else if ($feed->localName == 'rss') { // @todo FIXME: Check namespace.
428             $this->processRssFeed($feed, $source);
429         } else {
430             // TRANS: Exception.
431             throw new Exception(_m('Unknown feed format.'));
432         }
433     }
434
435     public function processAtomFeed(DOMElement $feed, $source)
436     {
437         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
438         if ($entries->length == 0) {
439             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
440             return;
441         }
442
443         for ($i = 0; $i < $entries->length; $i++) {
444             $entry = $entries->item($i);
445             $this->processEntry($entry, $feed, $source);
446         }
447     }
448
449     public function processRssFeed(DOMElement $rss, $source)
450     {
451         $channels = $rss->getElementsByTagName('channel');
452
453         if ($channels->length == 0) {
454             // TRANS: Exception.
455             throw new Exception(_m('RSS feed without a channel.'));
456         } else if ($channels->length > 1) {
457             common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
458         }
459
460         $channel = $channels->item(0);
461
462         $items = $channel->getElementsByTagName('item');
463
464         for ($i = 0; $i < $items->length; $i++) {
465             $item = $items->item($i);
466             $this->processEntry($item, $channel, $source);
467         }
468     }
469
470     /**
471      * Process a posted entry from this feed source.
472      *
473      * @param DOMElement $entry
474      * @param DOMElement $feed for context
475      * @param string $source identifier ("push" or "salmon")
476      *
477      * @return Notice Notice representing the new (or existing) activity
478      */
479     public function processEntry($entry, $feed, $source)
480     {
481         $activity = new Activity($entry, $feed);
482         return $this->processActivity($activity, $source);
483     }
484
485     // TODO: Make this throw an exception
486     public function processActivity($activity, $source)
487     {
488         $notice = null;
489
490         // The "WithProfile" events were added later.
491
492         if (Event::handle('StartHandleFeedEntryWithProfile', array($activity, $this, &$notice)) &&
493             Event::handle('StartHandleFeedEntry', array($activity))) {
494
495             switch ($activity->verb) {
496             case ActivityVerb::POST:
497                 // @todo process all activity objects
498                 switch ($activity->objects[0]->type) {
499                 case ActivityObject::ARTICLE:
500                 case ActivityObject::BLOGENTRY:
501                 case ActivityObject::NOTE:
502                 case ActivityObject::STATUS:
503                 case ActivityObject::COMMENT:
504                 case null:
505                     $notice = $this->processPost($activity, $source);
506                     break;
507                 default:
508                     // TRANS: Client exception.
509                     throw new ClientException(_m('Cannot handle that kind of post.'));
510                 }
511                 break;
512             case ActivityVerb::SHARE:
513                 $notice = $this->processShare($activity, $source);
514                 break;
515             default:
516                 common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
517             }
518
519             Event::handle('EndHandleFeedEntry', array($activity));
520             Event::handle('EndHandleFeedEntryWithProfile', array($activity, $this, $notice));
521         }
522
523         return $notice;
524     }
525
526     public function processShare($activity, $method)
527     {
528         $notice = null;
529
530         $oprofile = $this->checkAuthorship($activity);
531
532         if (!$oprofile instanceof Ostatus_profile) {
533             common_log(LOG_INFO, "No author matched share activity");
534             return null;
535         }
536
537         // The id URI will be used as a unique identifier for the notice,
538         // protecting against duplicate saves. It isn't required to be a URL;
539         // tag: URIs for instance are found in Google Buzz feeds.
540         $dupe = Notice::getKV('uri', $activity->id);
541         if ($dupe instanceof Notice) {
542             common_log(LOG_INFO, "OStatus: ignoring duplicate post: {$activity->id}");
543             return $dupe;
544         }
545
546         if (count($activity->objects) != 1) {
547             // TRANS: Client exception thrown when trying to share multiple activities at once.
548             throw new ClientException(_m('Can only handle share activities with exactly one object.'));
549         }
550
551         $shared = $activity->objects[0];
552
553         if (!$shared instanceof Activity) {
554             // TRANS: Client exception thrown when trying to share a non-activity object.
555             throw new ClientException(_m('Can only handle shared activities.'));
556         }
557
558         $sharedId = $shared->id;
559         if (!empty($shared->objects[0]->id)) {
560             // Because StatusNet since commit 8cc4660 sets $shared->id to a TagURI which
561             // fucks up federation, because the URI is no longer recognised by the origin.
562             // So we set it to the object ID if it exists, otherwise we trust $shared->id
563             $sharedId = $shared->objects[0]->id;
564         }
565         if (empty($sharedId)) {
566             throw new ClientException(_m('Shared activity does not have an id'));
567         }
568
569         // First check if we have the shared activity. This has to be done first, because
570         // we can't use these functions to "ensureActivityObjectProfile" of a local user,
571         // who might be the creator of the shared activity in question.
572         $sharedNotice = Notice::getKV('uri', $sharedId);
573         if (!$sharedNotice instanceof Notice) {
574             // If no locally stored notice is found, process it!
575             // TODO: Remember to check Deleted_notice!
576             // TODO: If a post is shared that we can't retrieve - what to do?
577             try {
578                 $other = self::ensureActivityObjectProfile($shared->actor);
579                 $sharedNotice = $other->processActivity($shared, $method);
580                 if (!$sharedNotice instanceof Notice) {
581                     // And if we apparently can't get the shared notice, we'll abort the whole thing.
582                     // TRANS: Client exception thrown when saving an activity share fails.
583                     // TRANS: %s is a share ID.
584                     throw new ClientException(sprintf(_m('Failed to save activity %s.'), $sharedId));
585                 }
586             } catch (FeedSubException $e) {
587                 // Remote feed could not be found or verified, should we
588                 // transform this into an "RT @user Blah, blah, blah..."?
589                 common_log(LOG_INFO, __METHOD__ . ' got a ' . get_class($e) . ': ' . $e->getMessage());
590                 return null;
591             }
592         }
593
594         // We'll want to save a web link to the original notice, if provided.
595
596         $sourceUrl = null;
597         if ($activity->link) {
598             $sourceUrl = $activity->link;
599         } else if ($activity->link) {
600             $sourceUrl = $activity->link;
601         } else if (preg_match('!^https?://!', $activity->id)) {
602             $sourceUrl = $activity->id;
603         }
604
605         // Use summary as fallback for content
606
607         if (!empty($activity->content)) {
608             $sourceContent = $activity->content;
609         } else if (!empty($activity->summary)) {
610             $sourceContent = $activity->summary;
611         } else if (!empty($activity->title)) {
612             $sourceContent = $activity->title;
613         } else {
614             // @todo FIXME: Fetch from $sourceUrl?
615             // TRANS: Client exception. %s is a source URI.
616             throw new ClientException(sprintf(_m('No content for notice %s.'), $activity->id));
617         }
618
619         // Get (safe!) HTML and text versions of the content
620
621         $rendered = $this->purify($sourceContent);
622         $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
623
624         $shortened = common_shorten_links($content);
625
626         // If it's too long, try using the summary, and make the
627         // HTML an attachment.
628
629         $attachment = null;
630
631         if (Notice::contentTooLong($shortened)) {
632             $attachment = $this->saveHTMLFile($activity->title, $rendered);
633             $summary = html_entity_decode(strip_tags($activity->summary), ENT_QUOTES, 'UTF-8');
634             if (empty($summary)) {
635                 $summary = $content;
636             }
637             $shortSummary = common_shorten_links($summary);
638             if (Notice::contentTooLong($shortSummary)) {
639                 $url = common_shorten_url($sourceUrl);
640                 $shortSummary = substr($shortSummary,
641                                        0,
642                                        Notice::maxContent() - (mb_strlen($url) + 2));
643                 $content = $shortSummary . ' ' . $url;
644
645                 // We mark up the attachment link specially for the HTML output
646                 // so we can fold-out the full version inline.
647
648                 // @todo FIXME i18n: This tooltip will be saved with the site's default language
649                 // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
650                 // TRANS: this will usually be replaced with localised text from StatusNet core messages.
651                 $showMoreText = _m('Show more');
652                 $attachUrl = common_local_url('attachment',
653                                               array('attachment' => $attachment->id));
654                 $rendered = common_render_text($shortSummary) .
655                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
656                             ' class="attachment more"' .
657                             ' title="'. htmlspecialchars($showMoreText) . '">' .
658                             '&#8230;' .
659                             '</a>';
660             }
661         }
662
663         $options = array('is_local' => Notice::REMOTE,
664                          'url' => $sourceUrl,
665                          'uri' => $activity->id,
666                          'rendered' => $rendered,
667                          'replies' => array(),
668                          'groups' => array(),
669                          'peopletags' => array(),
670                          'tags' => array(),
671                          'urls' => array(),
672                          'repeat_of' => $sharedNotice->id,
673                          'scope' => $sharedNotice->scope);
674
675         // Check for optional attributes...
676
677         if (!empty($activity->time)) {
678             $options['created'] = common_sql_date($activity->time);
679         }
680
681         if ($activity->context) {
682             // TODO: context->attention
683             list($options['groups'], $options['replies'])
684                 = $this->filterAttention($oprofile, $activity->context->attention);
685
686             // Maintain direct reply associations
687             // @todo FIXME: What about conversation ID?
688             if (!empty($activity->context->replyToID)) {
689                 $orig = Notice::getKV('uri',
690                                           $activity->context->replyToID);
691                 if ($orig instanceof Notice) {
692                     $options['reply_to'] = $orig->id;
693                 }
694             }
695
696             $location = $activity->context->location;
697             if ($location) {
698                 $options['lat'] = $location->lat;
699                 $options['lon'] = $location->lon;
700                 if ($location->location_id) {
701                     $options['location_ns'] = $location->location_ns;
702                     $options['location_id'] = $location->location_id;
703                 }
704             }
705         }
706
707         if ($this->isPeopletag()) {
708             $options['peopletags'][] = $this->localPeopletag();
709         }
710
711         // Atom categories <-> hashtags
712         foreach ($activity->categories as $cat) {
713             if ($cat->term) {
714                 $term = common_canonical_tag($cat->term);
715                 if ($term) {
716                     $options['tags'][] = $term;
717                 }
718             }
719         }
720
721         // Atom enclosures -> attachment URLs
722         foreach ($activity->enclosures as $href) {
723             // @todo FIXME: Save these locally or....?
724             $options['urls'][] = $href;
725         }
726
727         $notice = Notice::saveNew($oprofile->profile_id,
728                                   $content,
729                                   'ostatus',
730                                   $options);
731
732         return $notice;
733     }
734
735     /**
736      * Process an incoming post activity from this remote feed.
737      * @param Activity $activity
738      * @param string $method 'push' or 'salmon'
739      * @return mixed saved Notice or false
740      * @todo FIXME: Break up this function, it's getting nasty long
741      */
742     public function processPost($activity, $method)
743     {
744         $notice = null;
745
746         $oprofile = $this->checkAuthorship($activity);
747
748         if (!$oprofile instanceof Ostatus_profile) {
749             return null;
750         }
751
752         // It's not always an ActivityObject::NOTE, but... let's just say it is.
753
754         $note = $activity->objects[0];
755
756         // The id URI will be used as a unique identifier for the notice,
757         // protecting against duplicate saves. It isn't required to be a URL;
758         // tag: URIs for instance are found in Google Buzz feeds.
759         $sourceUri = $note->id;
760         $dupe = Notice::getKV('uri', $sourceUri);
761         if ($dupe instanceof Notice) {
762             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
763             return $dupe;
764         }
765
766         // We'll also want to save a web link to the original notice, if provided.
767         $sourceUrl = null;
768         if ($note->link) {
769             $sourceUrl = $note->link;
770         } else if ($activity->link) {
771             $sourceUrl = $activity->link;
772         } else if (preg_match('!^https?://!', $note->id)) {
773             $sourceUrl = $note->id;
774         }
775
776         // Use summary as fallback for content
777
778         if (!empty($note->content)) {
779             $sourceContent = $note->content;
780         } else if (!empty($note->summary)) {
781             $sourceContent = $note->summary;
782         } else if (!empty($note->title)) {
783             $sourceContent = $note->title;
784         } else {
785             // @todo FIXME: Fetch from $sourceUrl?
786             // TRANS: Client exception. %s is a source URI.
787             throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
788         }
789
790         // Get (safe!) HTML and text versions of the content
791
792         $rendered = $this->purify($sourceContent);
793         $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
794
795         $shortened = common_shorten_links($content);
796
797         // If it's too long, try using the summary, and make the
798         // HTML an attachment.
799
800         $attachment = null;
801
802         if (Notice::contentTooLong($shortened)) {
803             $attachment = $this->saveHTMLFile($note->title, $rendered);
804             $summary = html_entity_decode(strip_tags($note->summary), ENT_QUOTES, 'UTF-8');
805             if (empty($summary)) {
806                 $summary = $content;
807             }
808             $shortSummary = common_shorten_links($summary);
809             if (Notice::contentTooLong($shortSummary)) {
810                 $url = common_shorten_url($sourceUrl);
811                 $shortSummary = substr($shortSummary,
812                                        0,
813                                        Notice::maxContent() - (mb_strlen($url) + 2));
814                 $content = $shortSummary . ' ' . $url;
815
816                 // We mark up the attachment link specially for the HTML output
817                 // so we can fold-out the full version inline.
818
819                 // @todo FIXME i18n: This tooltip will be saved with the site's default language
820                 // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
821                 // TRANS: this will usually be replaced with localised text from StatusNet core messages.
822                 $showMoreText = _m('Show more');
823                 $attachUrl = common_local_url('attachment',
824                                               array('attachment' => $attachment->id));
825                 $rendered = common_render_text($shortSummary) .
826                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
827                             ' class="attachment more"' .
828                             ' title="'. htmlspecialchars($showMoreText) . '">' .
829                             '&#8230;' .
830                             '</a>';
831             }
832         }
833
834         $options = array('is_local' => Notice::REMOTE,
835                         'url' => $sourceUrl,
836                         'uri' => $sourceUri,
837                         'rendered' => $rendered,
838                         'replies' => array(),
839                         'groups' => array(),
840                         'peopletags' => array(),
841                         'tags' => array(),
842                         'urls' => array());
843
844         // Check for optional attributes...
845
846         if (!empty($activity->time)) {
847             $options['created'] = common_sql_date($activity->time);
848         }
849
850         if ($activity->context) {
851             // TODO: context->attention
852             list($options['groups'], $options['replies'])
853                 = $this->filterAttention($oprofile, $activity->context->attention);
854
855             // Maintain direct reply associations
856             // @todo FIXME: What about conversation ID?
857             if (!empty($activity->context->replyToID)) {
858                 $orig = Notice::getKV('uri', $activity->context->replyToID);
859                 if ($orig instanceof Notice) {
860                     $options['reply_to'] = $orig->id;
861                 }
862             }
863
864             $location = $activity->context->location;
865             if ($location) {
866                 $options['lat'] = $location->lat;
867                 $options['lon'] = $location->lon;
868                 if ($location->location_id) {
869                     $options['location_ns'] = $location->location_ns;
870                     $options['location_id'] = $location->location_id;
871                 }
872             }
873         }
874
875         if ($this->isPeopletag()) {
876             $options['peopletags'][] = $this->localPeopletag();
877         }
878
879         // Atom categories <-> hashtags
880         foreach ($activity->categories as $cat) {
881             if ($cat->term) {
882                 $term = common_canonical_tag($cat->term);
883                 if ($term) {
884                     $options['tags'][] = $term;
885                 }
886             }
887         }
888
889         // Atom enclosures -> attachment URLs
890         foreach ($activity->enclosures as $href) {
891             // @todo FIXME: Save these locally or....?
892             $options['urls'][] = $href;
893         }
894
895         try {
896             $saved = Notice::saveNew($oprofile->profile_id,
897                                      $content,
898                                      'ostatus',
899                                      $options);
900             if ($saved instanceof Notice) {
901                 Ostatus_source::saveNew($saved, $this, $method);
902                 if (!empty($attachment)) {
903                     File_to_post::processNew($attachment->id, $saved->id);
904                 }
905             }
906         } catch (Exception $e) {
907             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
908             throw $e;
909         }
910         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
911         return $saved;
912     }
913
914     /**
915      * Clean up HTML
916      */
917     protected function purify($html)
918     {
919         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
920         $config = array('safe' => 1,
921                         'deny_attribute' => 'id,style,on*');
922         return htmLawed($html, $config);
923     }
924
925     /**
926      * Filters a list of recipient ID URIs to just those for local delivery.
927      * @param Ostatus_profile local profile of sender
928      * @param array in/out &$attention_uris set of URIs, will be pruned on output
929      * @return array of group IDs
930      */
931     protected function filterAttention($sender, array $attention)
932     {
933         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', array_keys($attention)));
934         $groups = array();
935         $replies = array();
936         foreach ($attention as $recipient=>$type) {
937             // Is the recipient a local user?
938             $user = User::getKV('uri', $recipient);
939             if ($user instanceof User) {
940                 // @todo FIXME: Sender verification, spam etc?
941                 $replies[] = $recipient;
942                 continue;
943             }
944
945             // Is the recipient a local group?
946             // TODO: $group = User_group::getKV('uri', $recipient);
947             $id = OStatusPlugin::localGroupFromUrl($recipient);
948             if ($id) {
949                 $group = User_group::getKV('id', $id);
950                 if ($group instanceof User_group) {
951                     try {
952                         // Deliver to all members of this local group if allowed.
953                         $profile = $sender->localProfile();
954                         if ($profile->isMember($group)) {
955                             $groups[] = $group->id;
956                         } else {
957                             common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
958                         }
959                     } catch (NoProfileException $e) {
960                         // Sender has no profile! Do some garbage collection, please.
961                     }
962                     continue;
963                 } else {
964                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
965                 }
966             }
967
968             // Is the recipient a remote user or group?
969             try {
970                 $oprofile = self::ensureProfileURI($recipient);
971                 if ($oprofile->isGroup()) {
972                     // Deliver to local members of this remote group.
973                     // @todo FIXME: Sender verification?
974                     $groups[] = $oprofile->group_id;
975                 } else {
976                     // may be canonicalized or something
977                     $replies[] = $oprofile->getUri();
978                 }
979                 continue;
980             } catch (Exception $e) {
981                 // Neither a recognizable local nor remote user!
982                 common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
983             }
984
985         }
986         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
987         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
988         return array($groups, $replies);
989     }
990
991     /**
992      * Look up and if necessary create an Ostatus_profile for the remote entity
993      * with the given profile page URL. This should never return null -- you
994      * will either get an object or an exception will be thrown.
995      *
996      * @param string $profile_url
997      * @return Ostatus_profile
998      * @throws Exception on various error conditions
999      * @throws OStatusShadowException if this reference would obscure a local user/group
1000      */
1001     public static function ensureProfileURL($profile_url, $hints=array())
1002     {
1003         $oprofile = self::getFromProfileURL($profile_url);
1004
1005         if ($oprofile instanceof Ostatus_profile) {
1006             return $oprofile;
1007         }
1008
1009         $hints['profileurl'] = $profile_url;
1010
1011         // Fetch the URL
1012         // XXX: HTTP caching
1013
1014         $client = new HTTPClient();
1015         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
1016         $response = $client->get($profile_url);
1017
1018         if (!$response->isOk()) {
1019             // TRANS: Exception. %s is a profile URL.
1020             throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
1021         }
1022
1023         // Check if we have a non-canonical URL
1024
1025         $finalUrl = $response->getUrl();
1026
1027         if ($finalUrl != $profile_url) {
1028
1029             $hints['profileurl'] = $finalUrl;
1030
1031             $oprofile = self::getFromProfileURL($finalUrl);
1032
1033             if ($oprofile instanceof Ostatus_profile) {
1034                 return $oprofile;
1035             }
1036         }
1037
1038         // Try to get some hCard data
1039
1040         $body = $response->getBody();
1041
1042         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
1043
1044         if (!empty($hcardHints)) {
1045             $hints = array_merge($hints, $hcardHints);
1046         }
1047
1048         // Check if they've got an LRDD header
1049
1050         $lrdd = LinkHeader::getLink($response, 'lrdd');
1051         try {
1052             $xrd = new XML_XRD();
1053             $xrd->loadFile($lrdd);
1054             $xrdHints = DiscoveryHints::fromXRD($xrd);
1055             $hints = array_merge($hints, $xrdHints);
1056         } catch (Exception $e) {
1057             // No hints available from XRD
1058         }
1059
1060         // If discovery found a feedurl (probably from LRDD), use it.
1061
1062         if (array_key_exists('feedurl', $hints)) {
1063             return self::ensureFeedURL($hints['feedurl'], $hints);
1064         }
1065
1066         // Get the feed URL from HTML
1067
1068         $discover = new FeedDiscovery();
1069
1070         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
1071
1072         if (!empty($feedurl)) {
1073             $hints['feedurl'] = $feedurl;
1074             return self::ensureFeedURL($feedurl, $hints);
1075         }
1076
1077         // TRANS: Exception. %s is a URL.
1078         throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
1079     }
1080
1081     /**
1082      * Look up the Ostatus_profile, if present, for a remote entity with the
1083      * given profile page URL. Will return null for both unknown and invalid
1084      * remote profiles.
1085      *
1086      * @return mixed Ostatus_profile or null
1087      * @throws OStatusShadowException for local profiles
1088      */
1089     static function getFromProfileURL($profile_url)
1090     {
1091         $profile = Profile::getKV('profileurl', $profile_url);
1092         if (!$profile instanceof Profile) {
1093             return null;
1094         }
1095
1096         // Is it a known Ostatus profile?
1097         $oprofile = Ostatus_profile::getKV('profile_id', $profile->id);
1098         if ($oprofile instanceof Ostatus_profile) {
1099             return $oprofile;
1100         }
1101
1102         // Is it a local user?
1103         $user = User::getKV('id', $profile->id);
1104         if ($user instanceof User) {
1105             // @todo i18n FIXME: use sprintf and add i18n (?)
1106             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
1107         }
1108
1109         // Continue discovery; it's a remote profile
1110         // for OMB or some other protocol, may also
1111         // support OStatus
1112
1113         return null;
1114     }
1115
1116     /**
1117      * Look up and if necessary create an Ostatus_profile for remote entity
1118      * with the given update feed. This should never return null -- you will
1119      * either get an object or an exception will be thrown.
1120      *
1121      * @return Ostatus_profile
1122      * @throws Exception
1123      */
1124     public static function ensureFeedURL($feed_url, $hints=array())
1125     {
1126         $discover = new FeedDiscovery();
1127
1128         $feeduri = $discover->discoverFromFeedURL($feed_url);
1129         $hints['feedurl'] = $feeduri;
1130
1131         $huburi = $discover->getHubLink();
1132         $hints['hub'] = $huburi;
1133
1134         // XXX: NS_REPLIES is deprecated anyway, so let's remove it in the future.
1135         $salmonuri = $discover->getAtomLink(Salmon::REL_SALMON)
1136                         ?: $discover->getAtomLink(Salmon::NS_REPLIES);
1137         $hints['salmon'] = $salmonuri;
1138
1139         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1140             // We can only deal with folks with a PuSH hub
1141             throw new FeedSubNoHubException();
1142         }
1143
1144         $feedEl = $discover->root;
1145
1146         if ($feedEl->tagName == 'feed') {
1147             return self::ensureAtomFeed($feedEl, $hints);
1148         } else if ($feedEl->tagName == 'channel') {
1149             return self::ensureRssChannel($feedEl, $hints);
1150         } else {
1151             throw new FeedSubBadXmlException($feeduri);
1152         }
1153     }
1154
1155     /**
1156      * Look up and, if necessary, create an Ostatus_profile for the remote
1157      * profile with the given Atom feed - actually loaded from the feed.
1158      * This should never return null -- you will either get an object or
1159      * an exception will be thrown.
1160      *
1161      * @param DOMElement $feedEl root element of a loaded Atom feed
1162      * @param array $hints additional discovery information passed from higher levels
1163      * @todo FIXME: Should this be marked public?
1164      * @return Ostatus_profile
1165      * @throws Exception
1166      */
1167     public static function ensureAtomFeed($feedEl, $hints)
1168     {
1169         $author = ActivityUtils::getFeedAuthor($feedEl);
1170
1171         if (empty($author)) {
1172             // XXX: make some educated guesses here
1173             // TRANS: Feed sub exception.
1174             throw new FeedSubException(_m('Cannot find enough profile '.
1175                                           'information to make a feed.'));
1176         }
1177
1178         return self::ensureActivityObjectProfile($author, $hints);
1179     }
1180
1181     /**
1182      * Look up and, if necessary, create an Ostatus_profile for the remote
1183      * profile with the given RSS feed - actually loaded from the feed.
1184      * This should never return null -- you will either get an object or
1185      * an exception will be thrown.
1186      *
1187      * @param DOMElement $feedEl root element of a loaded RSS feed
1188      * @param array $hints additional discovery information passed from higher levels
1189      * @todo FIXME: Should this be marked public?
1190      * @return Ostatus_profile
1191      * @throws Exception
1192      */
1193     public static function ensureRssChannel($feedEl, $hints)
1194     {
1195         // Special-case for Posterous. They have some nice metadata in their
1196         // posterous:author elements. We should use them instead of the channel.
1197
1198         $items = $feedEl->getElementsByTagName('item');
1199
1200         if ($items->length > 0) {
1201             $item = $items->item(0);
1202             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
1203             if (!empty($authorEl)) {
1204                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
1205                 // Posterous has multiple authors per feed, and multiple feeds
1206                 // per author. We check if this is the "main" feed for this author.
1207                 if (array_key_exists('profileurl', $hints) &&
1208                     !empty($obj->poco) &&
1209                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
1210                     return self::ensureActivityObjectProfile($obj, $hints);
1211                 }
1212             }
1213         }
1214
1215         // @todo FIXME: We should check whether this feed has elements
1216         // with different <author> or <dc:creator> elements, and... I dunno.
1217         // Do something about that.
1218
1219         $obj = ActivityObject::fromRssChannel($feedEl);
1220
1221         return self::ensureActivityObjectProfile($obj, $hints);
1222     }
1223
1224     /**
1225      * Download and update given avatar image
1226      *
1227      * @param string $url
1228      * @throws Exception in various failure cases
1229      */
1230     protected function updateAvatar($url)
1231     {
1232         if ($url == $this->avatar) {
1233             // We've already got this one.
1234             return;
1235         }
1236         if (!common_valid_http_url($url)) {
1237             // TRANS: Server exception. %s is a URL.
1238             throw new ServerException(sprintf(_m('Invalid avatar URL %s.'), $url));
1239         }
1240
1241         if ($this->isGroup()) {
1242             // FIXME: throw exception for localGroup
1243             $self = $this->localGroup();
1244         } else {
1245             // this throws an exception already
1246             $self = $this->localProfile();
1247         }
1248         if (!$self) {
1249             throw new ServerException(sprintf(
1250                 // TRANS: Server exception. %s is a URI.
1251                 _m('Tried to update avatar for unsaved remote profile %s.'),
1252                 $this->getUri()));
1253         }
1254
1255         // @todo FIXME: This should be better encapsulated
1256         // ripped from oauthstore.php (for old OMB client)
1257         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1258         try {
1259             if (!copy($url, $temp_filename)) {
1260                 // TRANS: Server exception. %s is a URL.
1261                 throw new ServerException(sprintf(_m('Unable to fetch avatar from %s.'), $url));
1262             }
1263
1264             if ($this->isGroup()) {
1265                 $id = $this->group_id;
1266             } else {
1267                 $id = $this->profile_id;
1268             }
1269             // @todo FIXME: Should we be using different ids?
1270             $imagefile = new ImageFile($id, $temp_filename);
1271             $filename = Avatar::filename($id,
1272                                          image_type_to_extension($imagefile->type),
1273                                          null,
1274                                          common_timestamp());
1275             rename($temp_filename, Avatar::path($filename));
1276         } catch (Exception $e) {
1277             unlink($temp_filename);
1278             throw $e;
1279         }
1280         // @todo FIXME: Hardcoded chmod is lame, but seems to be necessary to
1281         // keep from accidentally saving images from command-line (queues)
1282         // that can't be read from web server, which causes hard-to-notice
1283         // problems later on:
1284         //
1285         // http://status.net/open-source/issues/2663
1286         chmod(Avatar::path($filename), 0644);
1287
1288         $self->setOriginal($filename);
1289
1290         $orig = clone($this);
1291         $this->avatar = $url;
1292         $this->update($orig);
1293     }
1294
1295     /**
1296      * Pull avatar URL from ActivityObject or profile hints
1297      *
1298      * @param ActivityObject $object
1299      * @param array $hints
1300      * @return mixed URL string or false
1301      */
1302     public static function getActivityObjectAvatar($object, $hints=array())
1303     {
1304         if ($object->avatarLinks) {
1305             $best = false;
1306             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1307             foreach ($object->avatarLinks as $avatar) {
1308                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1309                     // Exact match!
1310                     $best = $avatar;
1311                     break;
1312                 }
1313                 if (!$best || $avatar->width > $best->width) {
1314                     $best = $avatar;
1315                 }
1316             }
1317             return $best->url;
1318         } else if (array_key_exists('avatar', $hints)) {
1319             return $hints['avatar'];
1320         }
1321         return false;
1322     }
1323
1324     /**
1325      * Get an appropriate avatar image source URL, if available.
1326      *
1327      * @param ActivityObject $actor
1328      * @param DOMElement $feed
1329      * @return string
1330      */
1331     protected static function getAvatar($actor, $feed)
1332     {
1333         $url = '';
1334         $icon = '';
1335         if ($actor->avatar) {
1336             $url = trim($actor->avatar);
1337         }
1338         if (!$url) {
1339             // Check <atom:logo> and <atom:icon> on the feed
1340             $els = $feed->childNodes();
1341             if ($els && $els->length) {
1342                 for ($i = 0; $i < $els->length; $i++) {
1343                     $el = $els->item($i);
1344                     if ($el->namespaceURI == Activity::ATOM) {
1345                         if (empty($url) && $el->localName == 'logo') {
1346                             $url = trim($el->textContent);
1347                             break;
1348                         }
1349                         if (empty($icon) && $el->localName == 'icon') {
1350                             // Use as a fallback
1351                             $icon = trim($el->textContent);
1352                         }
1353                     }
1354                 }
1355             }
1356             if ($icon && !$url) {
1357                 $url = $icon;
1358             }
1359         }
1360         if ($url) {
1361             $opts = array('allowed_schemes' => array('http', 'https'));
1362             if (common_valid_http_url($url)) {
1363                 return $url;
1364             }
1365         }
1366
1367         return Plugin::staticPath('OStatus', 'images/96px-Feed-icon.svg.png');
1368     }
1369
1370     /**
1371      * Fetch, or build if necessary, an Ostatus_profile for the actor
1372      * in a given Activity Streams activity.
1373      * This should never return null -- you will either get an object or
1374      * an exception will be thrown.
1375      *
1376      * @param Activity $activity
1377      * @param string $feeduri if we already know the canonical feed URI!
1378      * @param string $salmonuri if we already know the salmon return channel URI
1379      * @return Ostatus_profile
1380      * @throws Exception
1381      */
1382     public static function ensureActorProfile($activity, $hints=array())
1383     {
1384         return self::ensureActivityObjectProfile($activity->actor, $hints);
1385     }
1386
1387     /**
1388      * Fetch, or build if necessary, an Ostatus_profile for the profile
1389      * in a given Activity Streams object (can be subject, actor, or object).
1390      * This should never return null -- you will either get an object or
1391      * an exception will be thrown.
1392      *
1393      * @param ActivityObject $object
1394      * @param array $hints additional discovery information passed from higher levels
1395      * @return Ostatus_profile
1396      * @throws Exception
1397      */
1398     public static function ensureActivityObjectProfile($object, $hints=array())
1399     {
1400         $profile = self::getActivityObjectProfile($object);
1401         if ($profile) {
1402             $profile->updateFromActivityObject($object, $hints);
1403         } else {
1404             $profile = self::createActivityObjectProfile($object, $hints);
1405         }
1406         return $profile;
1407     }
1408
1409     /**
1410      * @param Activity $activity
1411      * @return mixed matching Ostatus_profile or false if none known
1412      * @throws ServerException if feed info invalid
1413      */
1414     public static function getActorProfile($activity)
1415     {
1416         return self::getActivityObjectProfile($activity->actor);
1417     }
1418
1419     /**
1420      * @param ActivityObject $activity
1421      * @return mixed matching Ostatus_profile or false if none known
1422      * @throws ServerException if feed info invalid
1423      */
1424     protected static function getActivityObjectProfile($object)
1425     {
1426         $uri = self::getActivityObjectProfileURI($object);
1427         return Ostatus_profile::getKV('uri', $uri);
1428     }
1429
1430     /**
1431      * Get the identifier URI for the remote entity described
1432      * by this ActivityObject. This URI is *not* guaranteed to be
1433      * a resolvable HTTP/HTTPS URL.
1434      *
1435      * @param ActivityObject $object
1436      * @return string
1437      * @throws ServerException if feed info invalid
1438      */
1439     protected static function getActivityObjectProfileURI($object)
1440     {
1441         if ($object->id) {
1442             if (ActivityUtils::validateUri($object->id)) {
1443                 return $object->id;
1444             }
1445         }
1446
1447         // If the id is missing or invalid (we've seen feeds mistakenly listing
1448         // things like local usernames in that field) then we'll use the profile
1449         // page link, if valid.
1450         if ($object->link && common_valid_http_url($object->link)) {
1451             return $object->link;
1452         }
1453         // TRANS: Server exception.
1454         throw new ServerException(_m('No author ID URI found.'));
1455     }
1456
1457     /**
1458      * @todo FIXME: Validate stuff somewhere.
1459      */
1460
1461     /**
1462      * Create local ostatus_profile and profile/user_group entries for
1463      * the provided remote user or group.
1464      * This should never return null -- you will either get an object or
1465      * an exception will be thrown.
1466      *
1467      * @param ActivityObject $object
1468      * @param array $hints
1469      *
1470      * @return Ostatus_profile
1471      */
1472     protected static function createActivityObjectProfile($object, $hints=array())
1473     {
1474         $homeuri = $object->id;
1475         $discover = false;
1476
1477         if (!$homeuri) {
1478             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1479             // TRANS: Exception.
1480             throw new Exception(_m('No profile URI.'));
1481         }
1482
1483         $user = User::getKV('uri', $homeuri);
1484         if ($user instanceof User) {
1485             // TRANS: Exception.
1486             throw new Exception(_m('Local user cannot be referenced as remote.'));
1487         }
1488
1489         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1490             // TRANS: Exception.
1491             throw new Exception(_m('Local group cannot be referenced as remote.'));
1492         }
1493
1494         $ptag = Profile_list::getKV('uri', $homeuri);
1495         if ($ptag instanceof Profile_list) {
1496             $local_user = User::getKV('id', $ptag->tagger);
1497             if ($local_user instanceof User) {
1498                 // TRANS: Exception.
1499                 throw new Exception(_m('Local list cannot be referenced as remote.'));
1500             }
1501         }
1502
1503         if (array_key_exists('feedurl', $hints)) {
1504             $feeduri = $hints['feedurl'];
1505         } else {
1506             $discover = new FeedDiscovery();
1507             $feeduri = $discover->discoverFromURL($homeuri);
1508         }
1509
1510         if (array_key_exists('salmon', $hints)) {
1511             $salmonuri = $hints['salmon'];
1512         } else {
1513             if (!$discover) {
1514                 $discover = new FeedDiscovery();
1515                 $discover->discoverFromFeedURL($hints['feedurl']);
1516             }
1517             // XXX: NS_REPLIES is deprecated anyway, so let's remove it in the future.
1518             $salmonuri = $discover->getAtomLink(Salmon::REL_SALMON)
1519                             ?: $discover->getAtomLink(Salmon::NS_REPLIES);
1520         }
1521
1522         if (array_key_exists('hub', $hints)) {
1523             $huburi = $hints['hub'];
1524         } else {
1525             if (!$discover) {
1526                 $discover = new FeedDiscovery();
1527                 $discover->discoverFromFeedURL($hints['feedurl']);
1528             }
1529             $huburi = $discover->getHubLink();
1530         }
1531
1532         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1533             // We can only deal with folks with a PuSH hub
1534             throw new FeedSubNoHubException();
1535         }
1536
1537         $oprofile = new Ostatus_profile();
1538
1539         $oprofile->uri        = $homeuri;
1540         $oprofile->feeduri    = $feeduri;
1541         $oprofile->salmonuri  = $salmonuri;
1542
1543         $oprofile->created    = common_sql_now();
1544         $oprofile->modified   = common_sql_now();
1545
1546         if ($object->type == ActivityObject::PERSON) {
1547             $profile = new Profile();
1548             $profile->created = common_sql_now();
1549             self::updateProfile($profile, $object, $hints);
1550
1551             $oprofile->profile_id = $profile->insert();
1552             if ($oprofile->profile_id === false) {
1553                 // TRANS: Server exception.
1554                 throw new ServerException(_m('Cannot save local profile.'));
1555             }
1556         } else if ($object->type == ActivityObject::GROUP) {
1557             $profile = new Profile();
1558             $profile->query('BEGIN');
1559
1560             $group = new User_group();
1561             $group->uri = $homeuri;
1562             $group->created = common_sql_now();
1563             self::updateGroup($group, $object, $hints);
1564
1565             // TODO: We should do this directly in User_group->insert()!
1566             // currently it's duplicated in User_group->update()
1567             // AND User_group->register()!!!
1568             $fields = array(/*group field => profile field*/
1569                         'nickname'      => 'nickname',
1570                         'fullname'      => 'fullname',
1571                         'mainpage'      => 'profileurl',
1572                         'homepage'      => 'homepage',
1573                         'description'   => 'bio',
1574                         'location'      => 'location',
1575                         'created'       => 'created',
1576                         'modified'      => 'modified',
1577                         );
1578             foreach ($fields as $gf=>$pf) {
1579                 $profile->$pf = $group->$gf;
1580             }
1581             $profile_id = $profile->insert();
1582             if ($profile_id === false) {
1583                 $profile->query('ROLLBACK');
1584                 throw new ServerException(_('Profile insertion failed.'));
1585             }
1586
1587             $group->profile_id = $profile_id;
1588
1589             $oprofile->group_id = $group->insert();
1590             if ($oprofile->group_id === false) {
1591                 $profile->query('ROLLBACK');
1592                 // TRANS: Server exception.
1593                 throw new ServerException(_m('Cannot save local profile.'));
1594             }
1595
1596             $profile->query('COMMIT');
1597         } else if ($object->type == ActivityObject::_LIST) {
1598             $ptag = new Profile_list();
1599             $ptag->uri = $homeuri;
1600             $ptag->created = common_sql_now();
1601             self::updatePeopletag($ptag, $object, $hints);
1602
1603             $oprofile->peopletag_id = $ptag->insert();
1604             if ($oprofile->peopletag_id === false) {
1605                 // TRANS: Server exception.
1606                 throw new ServerException(_m('Cannot save local list.'));
1607             }
1608         }
1609
1610         $ok = $oprofile->insert();
1611
1612         if ($ok === false) {
1613             // TRANS: Server exception.
1614             throw new ServerException(_m('Cannot save OStatus profile.'));
1615         }
1616
1617         $avatar = self::getActivityObjectAvatar($object, $hints);
1618
1619         if ($avatar) {
1620             try {
1621                 $oprofile->updateAvatar($avatar);
1622             } catch (Exception $ex) {
1623                 // Profile is saved, but Avatar is messed up. We're
1624                 // just going to continue.
1625                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1626             }
1627         }
1628
1629         return $oprofile;
1630     }
1631
1632     /**
1633      * Save any updated profile information to our local copy.
1634      * @param ActivityObject $object
1635      * @param array $hints
1636      */
1637     public function updateFromActivityObject($object, $hints=array())
1638     {
1639         if ($this->isGroup()) {
1640             $group = $this->localGroup();
1641             self::updateGroup($group, $object, $hints);
1642         } else if ($this->isPeopletag()) {
1643             $ptag = $this->localPeopletag();
1644             self::updatePeopletag($ptag, $object, $hints);
1645         } else {
1646             $profile = $this->localProfile();
1647             self::updateProfile($profile, $object, $hints);
1648         }
1649
1650         $avatar = self::getActivityObjectAvatar($object, $hints);
1651         if ($avatar && !isset($ptag)) {
1652             try {
1653                 $this->updateAvatar($avatar);
1654             } catch (Exception $ex) {
1655                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1656             }
1657         }
1658     }
1659
1660     public static function updateProfile($profile, $object, $hints=array())
1661     {
1662         $orig = clone($profile);
1663
1664         // Existing nickname is better than nothing.
1665
1666         if (!array_key_exists('nickname', $hints)) {
1667             $hints['nickname'] = $profile->nickname;
1668         }
1669
1670         $nickname = self::getActivityObjectNickname($object, $hints);
1671
1672         if (!empty($nickname)) {
1673             $profile->nickname = $nickname;
1674         }
1675
1676         if (!empty($object->title)) {
1677             $profile->fullname = $object->title;
1678         } else if (array_key_exists('fullname', $hints)) {
1679             $profile->fullname = $hints['fullname'];
1680         }
1681
1682         if (!empty($object->link)) {
1683             $profile->profileurl = $object->link;
1684         } else if (array_key_exists('profileurl', $hints)) {
1685             $profile->profileurl = $hints['profileurl'];
1686         } else if (common_valid_http_url($object->id)) {
1687             $profile->profileurl = $object->id;
1688         }
1689
1690         $bio = self::getActivityObjectBio($object, $hints);
1691
1692         if (!empty($bio)) {
1693             $profile->bio = $bio;
1694         }
1695
1696         $location = self::getActivityObjectLocation($object, $hints);
1697
1698         if (!empty($location)) {
1699             $profile->location = $location;
1700         }
1701
1702         $homepage = self::getActivityObjectHomepage($object, $hints);
1703
1704         if (!empty($homepage)) {
1705             $profile->homepage = $homepage;
1706         }
1707
1708         if (!empty($object->geopoint)) {
1709             $location = ActivityContext::locationFromPoint($object->geopoint);
1710             if (!empty($location)) {
1711                 $profile->lat = $location->lat;
1712                 $profile->lon = $location->lon;
1713             }
1714         }
1715
1716         // @todo FIXME: tags/categories
1717         // @todo tags from categories
1718
1719         if ($profile->id) {
1720             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1721             $profile->update($orig);
1722         }
1723     }
1724
1725     protected static function updateGroup(User_group $group, $object, $hints=array())
1726     {
1727         $orig = clone($group);
1728
1729         $group->nickname = self::getActivityObjectNickname($object, $hints);
1730         $group->fullname = $object->title;
1731
1732         if (!empty($object->link)) {
1733             $group->mainpage = $object->link;
1734         } else if (array_key_exists('profileurl', $hints)) {
1735             $group->mainpage = $hints['profileurl'];
1736         }
1737
1738         // @todo tags from categories
1739         $group->description = self::getActivityObjectBio($object, $hints);
1740         $group->location = self::getActivityObjectLocation($object, $hints);
1741         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1742
1743         if ($group->id) {   // If no id, we haven't called insert() yet, so don't run update()
1744             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1745             $group->update($orig);
1746         }
1747     }
1748
1749     protected static function updatePeopletag($tag, $object, $hints=array()) {
1750         $orig = clone($tag);
1751
1752         $tag->tag = $object->title;
1753
1754         if (!empty($object->link)) {
1755             $tag->mainpage = $object->link;
1756         } else if (array_key_exists('profileurl', $hints)) {
1757             $tag->mainpage = $hints['profileurl'];
1758         }
1759
1760         $tag->description = $object->summary;
1761         $tagger = self::ensureActivityObjectProfile($object->owner);
1762         $tag->tagger = $tagger->profile_id;
1763
1764         if ($tag->id) {
1765             common_log(LOG_DEBUG, "Updating OStatus peopletag $tag->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1766             $tag->update($orig);
1767         }
1768     }
1769
1770     protected static function getActivityObjectHomepage($object, $hints=array())
1771     {
1772         $homepage = null;
1773         $poco     = $object->poco;
1774
1775         if (!empty($poco)) {
1776             $url = $poco->getPrimaryURL();
1777             if ($url && $url->type == 'homepage') {
1778                 $homepage = $url->value;
1779             }
1780         }
1781
1782         // @todo Try for a another PoCo URL?
1783
1784         return $homepage;
1785     }
1786
1787     protected static function getActivityObjectLocation($object, $hints=array())
1788     {
1789         $location = null;
1790
1791         if (!empty($object->poco) &&
1792             isset($object->poco->address->formatted)) {
1793             $location = $object->poco->address->formatted;
1794         } else if (array_key_exists('location', $hints)) {
1795             $location = $hints['location'];
1796         }
1797
1798         if (!empty($location)) {
1799             if (mb_strlen($location) > 255) {
1800                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1801             }
1802         }
1803
1804         // @todo Try to find location some othe way? Via goerss point?
1805
1806         return $location;
1807     }
1808
1809     protected static function getActivityObjectBio($object, $hints=array())
1810     {
1811         $bio  = null;
1812
1813         if (!empty($object->poco)) {
1814             $note = $object->poco->note;
1815         } else if (array_key_exists('bio', $hints)) {
1816             $note = $hints['bio'];
1817         }
1818
1819         if (!empty($note)) {
1820             if (Profile::bioTooLong($note)) {
1821                 // XXX: truncate ok?
1822                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1823             } else {
1824                 $bio = $note;
1825             }
1826         }
1827
1828         // @todo Try to get bio info some other way?
1829
1830         return $bio;
1831     }
1832
1833     public static function getActivityObjectNickname($object, $hints=array())
1834     {
1835         if ($object->poco) {
1836             if (!empty($object->poco->preferredUsername)) {
1837                 return common_nicknamize($object->poco->preferredUsername);
1838             }
1839         }
1840
1841         if (!empty($object->nickname)) {
1842             return common_nicknamize($object->nickname);
1843         }
1844
1845         if (array_key_exists('nickname', $hints)) {
1846             return $hints['nickname'];
1847         }
1848
1849         // Try the profile url (like foo.example.com or example.com/user/foo)
1850         if (!empty($object->link)) {
1851             $profileUrl = $object->link;
1852         } else if (!empty($hints['profileurl'])) {
1853             $profileUrl = $hints['profileurl'];
1854         }
1855
1856         if (!empty($profileUrl)) {
1857             $nickname = self::nicknameFromURI($profileUrl);
1858         }
1859
1860         // Try the URI (may be a tag:, http:, acct:, ...
1861
1862         if (empty($nickname)) {
1863             $nickname = self::nicknameFromURI($object->id);
1864         }
1865
1866         // Try a Webfinger if one was passed (way) down
1867
1868         if (empty($nickname)) {
1869             if (array_key_exists('webfinger', $hints)) {
1870                 $nickname = self::nicknameFromURI($hints['webfinger']);
1871             }
1872         }
1873
1874         // Try the name
1875
1876         if (empty($nickname)) {
1877             $nickname = common_nicknamize($object->title);
1878         }
1879
1880         return $nickname;
1881     }
1882
1883     protected static function nicknameFromURI($uri)
1884     {
1885         if (preg_match('/(\w+):/', $uri, $matches)) {
1886             $protocol = $matches[1];
1887         } else {
1888             return null;
1889         }
1890
1891         switch ($protocol) {
1892         case 'acct':
1893         case 'mailto':
1894             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1895                 return common_canonical_nickname($matches[1]);
1896             }
1897             return null;
1898         case 'http':
1899             return common_url_to_nickname($uri);
1900             break;
1901         default:
1902             return null;
1903         }
1904     }
1905
1906     /**
1907      * Look up, and if necessary create, an Ostatus_profile for the remote
1908      * entity with the given webfinger address.
1909      * This should never return null -- you will either get an object or
1910      * an exception will be thrown.
1911      *
1912      * @param string $addr webfinger address
1913      * @return Ostatus_profile
1914      * @throws Exception on error conditions
1915      * @throws OStatusShadowException if this reference would obscure a local user/group
1916      */
1917     public static function ensureWebfinger($addr)
1918     {
1919         // First, try the cache
1920
1921         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1922
1923         if ($uri !== false) {
1924             if (is_null($uri)) {
1925                 // Negative cache entry
1926                 // TRANS: Exception.
1927                 throw new Exception(_m('Not a valid webfinger address.'));
1928             }
1929             $oprofile = Ostatus_profile::getKV('uri', $uri);
1930             if ($oprofile instanceof Ostatus_profile) {
1931                 return $oprofile;
1932             }
1933         }
1934
1935         // Try looking it up
1936         $oprofile = Ostatus_profile::getKV('uri', 'acct:'.$addr);
1937
1938         if ($oprofile instanceof Ostatus_profile) {
1939             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->getUri());
1940             return $oprofile;
1941         }
1942
1943         // Now, try some discovery
1944
1945         $disco = new Discovery();
1946
1947         try {
1948             $xrd = $disco->lookup($addr);
1949         } catch (Exception $e) {
1950             // Save negative cache entry so we don't waste time looking it up again.
1951             // @todo FIXME: Distinguish temporary failures?
1952             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1953             // TRANS: Exception.
1954             throw new Exception(_m('Not a valid webfinger address.'));
1955         }
1956
1957         $hints = array('webfinger' => $addr);
1958
1959         $dhints = DiscoveryHints::fromXRD($xrd);
1960
1961         $hints = array_merge($hints, $dhints);
1962
1963         // If there's an Hcard, let's grab its info
1964         if (array_key_exists('hcard', $hints)) {
1965             if (!array_key_exists('profileurl', $hints) ||
1966                 $hints['hcard'] != $hints['profileurl']) {
1967                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1968                 $hints = array_merge($hcardHints, $hints);
1969             }
1970         }
1971
1972         // If we got a feed URL, try that
1973         if (array_key_exists('feedurl', $hints)) {
1974             try {
1975                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1976                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1977                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->getUri());
1978                 return $oprofile;
1979             } catch (Exception $e) {
1980                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1981                 // keep looking
1982             }
1983         }
1984
1985         // If we got a profile page, try that!
1986         if (array_key_exists('profileurl', $hints)) {
1987             try {
1988                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1989                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1990                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->getUri());
1991                 return $oprofile;
1992             } catch (OStatusShadowException $e) {
1993                 // We've ended up with a remote reference to a local user or group.
1994                 // @todo FIXME: Ideally we should be able to say who it was so we can
1995                 // go back and refer to it the regular way
1996                 throw $e;
1997             } catch (Exception $e) {
1998                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1999                 // keep looking
2000                 //
2001                 // @todo FIXME: This means an error discovering from profile page
2002                 // may give us a corrupt entry using the webfinger URI, which
2003                 // will obscure the correct page-keyed profile later on.
2004             }
2005         }
2006
2007         // XXX: try hcard
2008         // XXX: try FOAF
2009
2010         if (array_key_exists('salmon', $hints)) {
2011             $salmonEndpoint = $hints['salmon'];
2012
2013             // An account URL, a salmon endpoint, and a dream? Not much to go
2014             // on, but let's give it a try
2015
2016             $uri = 'acct:'.$addr;
2017
2018             $profile = new Profile();
2019
2020             $profile->nickname = self::nicknameFromUri($uri);
2021             $profile->created  = common_sql_now();
2022
2023             if (isset($profileUrl)) {
2024                 $profile->profileurl = $profileUrl;
2025             }
2026
2027             $profile_id = $profile->insert();
2028
2029             if ($profile_id === false) {
2030                 common_log_db_error($profile, 'INSERT', __FILE__);
2031                 // TRANS: Exception. %s is a webfinger address.
2032                 throw new Exception(sprintf(_m('Could not save profile for "%s".'),$addr));
2033             }
2034
2035             $oprofile = new Ostatus_profile();
2036
2037             $oprofile->uri        = $uri;
2038             $oprofile->salmonuri  = $salmonEndpoint;
2039             $oprofile->profile_id = $profile_id;
2040             $oprofile->created    = common_sql_now();
2041
2042             if (isset($feedUrl)) {
2043                 $profile->feeduri = $feedUrl;
2044             }
2045
2046             $result = $oprofile->insert();
2047
2048             if ($result === false) {
2049                 common_log_db_error($oprofile, 'INSERT', __FILE__);
2050                 // TRANS: Exception. %s is a webfinger address.
2051                 throw new Exception(sprintf(_m('Could not save OStatus profile for "%s".'),$addr));
2052             }
2053
2054             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->getUri());
2055             return $oprofile;
2056         }
2057
2058         // TRANS: Exception. %s is a webfinger address.
2059         throw new Exception(sprintf(_m('Could not find a valid profile for "%s".'),$addr));
2060     }
2061
2062     /**
2063      * Store the full-length scrubbed HTML of a remote notice to an attachment
2064      * file on our server. We'll link to this at the end of the cropped version.
2065      *
2066      * @param string $title plaintext for HTML page's title
2067      * @param string $rendered HTML fragment for HTML page's body
2068      * @return File
2069      */
2070     function saveHTMLFile($title, $rendered)
2071     {
2072         $final = sprintf("<!DOCTYPE html>\n" .
2073                          '<html><head>' .
2074                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
2075                          '<title>%s</title>' .
2076                          '</head>' .
2077                          '<body>%s</body></html>',
2078                          htmlspecialchars($title),
2079                          $rendered);
2080
2081         $filename = File::filename($this->localProfile(),
2082                                    'ostatus', // ignored?
2083                                    'text/html');
2084
2085         $filepath = File::path($filename);
2086
2087         file_put_contents($filepath, $final);
2088
2089         $file = new File;
2090
2091         $file->filename = $filename;
2092         $file->url      = File::url($filename);
2093         $file->size     = filesize($filepath);
2094         $file->date     = time();
2095         $file->mimetype = 'text/html';
2096
2097         $file_id = $file->insert();
2098
2099         if ($file_id === false) {
2100             common_log_db_error($file, "INSERT", __FILE__);
2101             // TRANS: Server exception.
2102             throw new ServerException(_m('Could not store HTML content of long post as file.'));
2103         }
2104
2105         return $file;
2106     }
2107
2108     static function ensureProfileURI($uri)
2109     {
2110         $oprofile = null;
2111
2112         // First, try to query it
2113
2114         $oprofile = Ostatus_profile::getKV('uri', $uri);
2115
2116         if ($oprofile instanceof Ostatus_profile) {
2117             return $oprofile;
2118         }
2119
2120         // If unfound, do discovery stuff
2121         if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
2122             $protocol = $match[1];
2123             switch ($protocol) {
2124             case 'http':
2125             case 'https':
2126                 $oprofile = self::ensureProfileURL($uri);
2127                 break;
2128             case 'acct':
2129             case 'mailto':
2130                 $rest = $match[2];
2131                 $oprofile = self::ensureWebfinger($rest);
2132                 break;
2133             default:
2134                 // TRANS: Server exception.
2135                 // TRANS: %1$s is a protocol, %2$s is a URI.
2136                 throw new ServerException(sprintf(_m('Unrecognized URI protocol for profile: %1$s (%2$s).'),
2137                                                   $protocol,
2138                                                   $uri));
2139                 break;
2140             }
2141         } else {
2142             // TRANS: Server exception. %s is a URI.
2143             throw new ServerException(sprintf(_m('No URI protocol for profile: %s.'),$uri));
2144         }
2145
2146         return $oprofile;
2147     }
2148
2149     function checkAuthorship($activity)
2150     {
2151         if ($this->isGroup() || $this->isPeopletag()) {
2152             // A group or propletag feed will contain posts from multiple authors.
2153             $oprofile = self::ensureActorProfile($activity);
2154             if ($oprofile->isGroup() || $oprofile->isPeopletag()) {
2155                 // Groups can't post notices in StatusNet.
2156                 common_log(LOG_WARNING,
2157                     "OStatus: skipping post with group listed ".
2158                     "as author: " . $oprofile->getUri() . " in feed from " . $this->getUri());
2159                 return false;
2160             }
2161         } else {
2162             $actor = $activity->actor;
2163
2164             if (empty($actor)) {
2165                 // OK here! assume the default
2166             } else if ($actor->id == $this->getUri() || $actor->link == $this->getUri()) {
2167                 $this->updateFromActivityObject($actor);
2168             } else if ($actor->id) {
2169                 // We have an ActivityStreams actor with an explicit ID that doesn't match the feed owner.
2170                 // This isn't what we expect from mainline OStatus person feeds!
2171                 // Group feeds go down another path, with different validation...
2172                 // Most likely this is a plain ol' blog feed of some kind which
2173                 // doesn't match our expectations. We'll take the entry, but ignore
2174                 // the <author> info.
2175                 common_log(LOG_WARNING, "Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for " . $this->getUri());
2176             } else {
2177                 // Plain <author> without ActivityStreams actor info.
2178                 // We'll just ignore this info for now and save the update under the feed's identity.
2179             }
2180
2181             $oprofile = $this;
2182         }
2183
2184         return $oprofile;
2185     }
2186 }
2187
2188 /**
2189  * Exception indicating we've got a remote reference to a local user,
2190  * not a remote user!
2191  *
2192  * If we can ue a local profile after all, it's available as $e->profile.
2193  */
2194 class OStatusShadowException extends Exception
2195 {
2196     public $profile;
2197
2198     /**
2199      * @param Profile $profile
2200      * @param string $message
2201      */
2202     function __construct($profile, $message) {
2203         $this->profile = $profile;
2204         parent::__construct($message);
2205     }
2206 }