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