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