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