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