We are no longer offering accounts on this server. Consider https://gitlab.freedesktop.org/ as a place to host projects.

Ostatus_profile.php 66.7 KB
Newer Older
1 2
<?php
/*
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
 * StatusNet - the distributed open-source microblogging tool
 * Copyright (C) 2009-2010, StatusNet, Inc.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

20 21 22 23
if (!defined('STATUSNET')) {
    exit(1);
}

24
/**
25
 * @package OStatusPlugin
26 27
 * @maintainer Brion Vibber <brion@status.net>
 */
28

29
class Ostatus_profile extends Managed_DataObject
30
{
31
    public $__table = 'ostatus_profile';
32

33 34
    public $uri;

35
    public $profile_id;
36
    public $group_id;
Shashi Gowda's avatar
Shashi Gowda committed
37
    public $peopletag_id;
38 39

    public $feeduri;
40
    public $salmonuri;
41
    public $avatar; // remote URL of the last avatar we saved
42

43
    public $created;
44
    public $modified;
45 46 47 48 49 50

    public /*static*/ function staticGet($k, $v=null)
    {
        return parent::staticGet(__CLASS__, $k, $v);
    }

51
    /**
52
     * Return table definition for Schema setup and DB_DataObject usage.
53 54 55 56 57
     *
     * @return array array of column definitions
     */
    static function schemaDef()
    {
58 59 60 61 62
        return array(
            'fields' => array(
                'uri' => array('type' => 'varchar', 'length' => 255, 'not null' => true),
                'profile_id' => array('type' => 'integer'),
                'group_id' => array('type' => 'integer'),
Shashi Gowda's avatar
Shashi Gowda committed
63
                'peopletag_id' => array('type' => 'integer'),
64 65 66 67 68 69 70 71 72 73
                'feeduri' => array('type' => 'varchar', 'length' => 255),
                'salmonuri' => array('type' => 'varchar', 'length' => 255),
                'avatar' => array('type' => 'text'),
                'created' => array('type' => 'datetime', 'not null' => true),
                'modified' => array('type' => 'datetime', 'not null' => true),
            ),
            'primary key' => array('uri'),
            'unique keys' => array(
                'ostatus_profile_profile_id_idx' => array('profile_id'),
                'ostatus_profile_group_id_idx' => array('group_id'),
Shashi Gowda's avatar
Shashi Gowda committed
74
                'ostatus_profile_peopletag_id_idx' => array('peopletag_id'),
75 76 77
                'ostatus_profile_feeduri_idx' => array('feeduri'),
            ),
            'foreign keys' => array(
78 79
                'ostatus_profile_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
                'ostatus_profile_group_id_fkey' => array('user_group', array('group_id' => 'id')),
Shashi Gowda's avatar
Shashi Gowda committed
80
                'ostatus_profile_peopletag_id_fkey' => array('profile_list', array('peopletag_id' => 'id')),
81 82
            ),
        );
83 84
    }

85 86 87 88
    /**
     * Fetch the StatusNet-side profile for this feed
     * @return Profile
     */
89
    public function localProfile()
90
    {
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
        if ($this->profile_id) {
            return Profile::staticGet('id', $this->profile_id);
        }
        return null;
    }

    /**
     * Fetch the StatusNet-side profile for this feed
     * @return Profile
     */
    public function localGroup()
    {
        if ($this->group_id) {
            return User_group::staticGet('id', $this->group_id);
        }
        return null;
107 108
    }

Shashi Gowda's avatar
Shashi Gowda committed
109 110 111 112 113 114 115 116 117 118 119 120
    /**
     * Fetch the StatusNet-side peopletag for this feed
     * @return Profile
     */
    public function localPeopletag()
    {
        if ($this->peopletag_id) {
            return Profile_list::staticGet('id', $this->peopletag_id);
        }
        return null;
    }

121 122 123 124 125 126 127 128 129
    /**
     * Returns an ActivityObject describing this remote user or group profile.
     * Can then be used to generate Atom chunks.
     *
     * @return ActivityObject
     */
    function asActivityObject()
    {
        if ($this->isGroup()) {
130
            return ActivityObject::fromGroup($this->localGroup());
Shashi Gowda's avatar
Shashi Gowda committed
131 132
        } else if ($this->isPeopletag()) {
            return ActivityObject::fromPeopletag($this->localPeopletag());
133 134 135 136 137
        } else {
            return ActivityObject::fromProfile($this->localProfile());
        }
    }

138 139 140 141 142 143
    /**
     * Returns an XML string fragment with profile information as an
     * Activity Streams noun object with the given element type.
     *
     * Assumes that 'activity' namespace has been previously defined.
     *
144 145
     * @fixme replace with wrappers on asActivityObject when it's got everything.
     *
146 147 148 149 150 151
     * @param string $element one of 'actor', 'subject', 'object', 'target'
     * @return string
     */
    function asActivityNoun($element)
    {
        if ($this->isGroup()) {
152 153
            $noun = ActivityObject::fromGroup($this->localGroup());
            return $noun->asString('activity:' . $element);
Shashi Gowda's avatar
Shashi Gowda committed
154 155 156
        } else if ($this->isPeopletag()) {
            $noun = ActivityObject::fromPeopletag($this->localPeopletag());
            return $noun->asString('activity:' . $element);
157
        } else {
158 159
            $noun = ActivityObject::fromProfile($this->localProfile());
            return $noun->asString('activity:' . $element);
160 161 162
        }
    }

163
    /**
164
     * @return boolean true if this is a remote group
165 166 167
     */
    function isGroup()
    {
Shashi Gowda's avatar
Shashi Gowda committed
168
        if ($this->profile_id || $this->peopletag_id && !$this->group_id) {
169
            return false;
Shashi Gowda's avatar
Shashi Gowda committed
170
        } else if ($this->group_id && !$this->profile_id && !$this->peopletag_id) {
171
            return true;
Shashi Gowda's avatar
Shashi Gowda committed
172 173
        } else if ($this->group_id && ($this->profile_id || $this->peopletag_id)) {
            // TRANS: Server exception. %s is a URI
Siebrand Mazeland's avatar
Siebrand Mazeland committed
174
            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->uri));
175
        } else {
Shashi Gowda's avatar
Shashi Gowda committed
176
            // TRANS: Server exception. %s is a URI
Siebrand Mazeland's avatar
Siebrand Mazeland committed
177
            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->uri));
Shashi Gowda's avatar
Shashi Gowda committed
178 179 180 181 182 183 184 185 186 187 188 189 190 191
        }
    }

    /**
     * @return boolean true if this is a remote peopletag
     */
    function isPeopletag()
    {
        if ($this->profile_id || $this->group_id && !$this->peopletag_id) {
            return false;
        } else if ($this->peopletag_id && !$this->profile_id && !$this->group_id) {
            return true;
        } else if ($this->peopletag_id && ($this->profile_id || $this->group_id)) {
            // TRANS: Server exception. %s is a URI
Siebrand Mazeland's avatar
Siebrand Mazeland committed
192
            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->uri));
Shashi Gowda's avatar
Shashi Gowda committed
193 194
        } else {
            // TRANS: Server exception. %s is a URI
Siebrand Mazeland's avatar
Siebrand Mazeland committed
195
            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->uri));
196
        }
197 198
    }

199
    /**
200 201
     * Send a subscription request to the hub for this feed.
     * The hub will later send us a confirmation POST to /main/push/callback.
202
     *
203 204
     * @return bool true on success, false on failure
     * @throws ServerException if feed state is not valid
205
     */
206
    public function subscribe()
207
    {
208
        $feedsub = FeedSub::ensureFeed($this->feeduri);
209 210
        if ($feedsub->sub_state == 'active') {
            // Active subscription, we don't need to do anything.
211
            return true;
212 213 214
        } else {
            // Inactive or we got left in an inconsistent state.
            // Run a subscription request to make sure we're current!
215
            return $feedsub->subscribe();
216 217 218 219
        }
    }

    /**
220 221
     * Check if this remote profile has any active local subscriptions, and
     * if not drop the PuSH subscription feed.
222 223
     *
     * @return bool true on success, false on failure
224
     */
225
    public function unsubscribe() {
226
        $this->garbageCollect();
227 228
    }

229 230 231 232 233 234 235
    /**
     * Check if this remote profile has any active local subscriptions, and
     * if not drop the PuSH subscription feed.
     *
     * @return boolean
     */
    public function garbageCollect()
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
    {
        $feedsub = FeedSub::staticGet('uri', $this->feeduri);
        return $feedsub->garbageCollect();
    }

    /**
     * Check if this remote profile has any active local subscriptions, so the
     * PuSH subscription layer can decide if it can drop the feed.
     *
     * This gets called via the FeedSubSubscriberCount event when running
     * FeedSub::garbageCollect().
     *
     * @return int
     */
    public function subscriberCount()
251 252 253 254
    {
        if ($this->isGroup()) {
            $members = $this->localGroup()->getMembers(0, 1);
            $count = $members->N;
Shashi Gowda's avatar
Shashi Gowda committed
255 256 257
        } else if ($this->isPeopletag()) {
            $subscribers = $this->localPeopletag()->getSubscribers(0, 1);
            $count = $subscribers->N;
258
        } else {
Shashi Gowda's avatar
Shashi Gowda committed
259 260 261 262 263
            $profile = $this->localProfile();
            $count = $profile->subscriberCount();
            if ($profile->hasLocalTags()) {
                $count = 1;
            }
264
        }
265 266 267 268 269 270 271 272
        common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");

        // Other plugins may be piggybacking on OStatus without having
        // an active group or user-to-user subscription we know about.
        Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
        common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");

        return $count;
273 274
    }

275 276 277 278
    /**
     * Send an Activity Streams notification to the remote Salmon endpoint,
     * if so configured.
     *
279 280 281
     * @param Profile $actor  Actor who did the activity
     * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
     * @param Object  $object object of the action; must define asActivityNoun($tag)
282
     */
Shashi Gowda's avatar
Shashi Gowda committed
283
    public function notify($actor, $verb, $object=null, $target=null)
284
    {
285 286 287 288 289
        if (!($actor instanceof Profile)) {
            $type = gettype($actor);
            if ($type == 'object') {
                $type = get_class($actor);
            }
Siebrand Mazeland's avatar
Siebrand Mazeland committed
290 291 292
            // TRANS: Server exception.
            // TRANS: %1$s is the method name the exception occured in, %2$s is the actor type.
            throw new ServerException(sprintf(_m('Invalid actor passed to %1$s: %2$s.'),__METHOD__,$type));
293
        }
294
        if ($object == null) {
295
            $object = $this;
296 297
        }
        if ($this->salmonuri) {
298 299 300 301
            $text = 'update';
            $id = TagURI::mint('%s:%s:%s',
                               $verb,
                               $actor->getURI(),
302
                               common_date_iso8601(time()));
303

304 305 306 307 308
            // @fixme consolidate all these NS settings somewhere
            $attributes = array('xmlns' => Activity::ATOM,
                                'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
                                'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
                                'xmlns:georss' => 'http://www.georss.org/georss',
309
                                'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
310 311
                                'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
                                'xmlns:media' => 'http://purl.org/syndication/atommedia');
312

Brion Vibber's avatar
Brion Vibber committed
313
            $entry = new XMLStringer();
314
            $entry->elementStart('entry', $attributes);
315 316 317
            $entry->element('id', null, $id);
            $entry->element('title', null, $text);
            $entry->element('summary', null, $text);
318
            $entry->element('published', null, common_date_w3dtf(common_sql_now()));
319 320

            $entry->element('activity:verb', null, $verb);
Brion Vibber's avatar
Brion Vibber committed
321 322
            $entry->raw($actor->asAtomAuthor());
            $entry->raw($actor->asActivityActor());
323
            $entry->raw($object->asActivityNoun('object'));
Shashi Gowda's avatar
Shashi Gowda committed
324 325 326
            if ($target != null) {
                $entry->raw($target->asActivityNoun('target'));
            }
Brion Vibber's avatar
Brion Vibber committed
327
            $entry->elementEnd('entry');
328

329
            $xml = $entry->getString();
330
            common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
331 332

            $salmon = new Salmon(); // ?
333
            return $salmon->post($this->salmonuri, $xml, $actor);
334
        }
335
        return false;
336 337
    }

338 339 340 341 342
    /**
     * Send a Salmon notification ping immediately, and confirm that we got
     * an acceptable response from the remote site.
     *
     * @param mixed $entry XML string, Notice, or Activity
343
     * @param Profile $actor
344 345
     * @return boolean success
     */
346
    public function notifyActivity($entry, $actor)
347 348
    {
        if ($this->salmonuri) {
349
            $salmon = new Salmon();
350
            return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
351
        }
352

353 354
        return false;
    }
355

356 357 358 359 360 361 362
    /**
     * Queue a Salmon notification for later. If queues are disabled we'll
     * send immediately but won't get the return value.
     *
     * @param mixed $entry XML string, Notice, or Activity
     * @return boolean success
     */
363
    public function notifyDeferred($entry, $actor)
364 365 366
    {
        if ($this->salmonuri) {
            $data = array('salmonuri' => $this->salmonuri,
367 368
                          'entry' => $this->notifyPrepXml($entry),
                          'actor' => $actor->id);
369

370 371
            $qm = QueueManager::get();
            return $qm->enqueue($data, 'salmon');
372 373
        }

374
        return false;
375 376
    }

377 378 379 380 381 382 383 384 385 386
    protected function notifyPrepXml($entry)
    {
        $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
        if (is_string($entry)) {
            return $entry;
        } else if ($entry instanceof Activity) {
            return $preamble . $entry->asString(true);
        } else if ($entry instanceof Notice) {
            return $preamble . $entry->asAtomEntry(true, true);
        } else {
Siebrand Mazeland's avatar
Siebrand Mazeland committed
387 388
            // TRANS: Server exception.
            throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
389 390 391
        }
    }

392 393 394 395
    function getBestName()
    {
        if ($this->isGroup()) {
            return $this->localGroup()->getBestName();
Shashi Gowda's avatar
Shashi Gowda committed
396 397
        } else if ($this->isPeopletag()) {
            return $this->localPeopletag()->getBestName();
398 399 400 401 402
        } else {
            return $this->localProfile()->getBestName();
        }
    }

403 404 405 406 407
    /**
     * Read and post notices for updates from the feed.
     * Currently assumes that all items in the feed are new,
     * coming from a PuSH hub.
     *
408 409
     * @param DOMDocument $doc
     * @param string $source identifier ("push")
410
     */
411
    public function processFeed(DOMDocument $doc, $source)
412
    {
413 414
        $feed = $doc->documentElement;

415 416 417 418 419
        if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
            $this->processAtomFeed($feed, $source);
        } else if ($feed->localName == 'rss') { // @fixme check namespace
            $this->processRssFeed($feed, $source);
        } else {
420
            // TRANS: Exception.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
421
            throw new Exception(_m('Unknown feed format.'));
422
        }
423
    }
424

425 426
    public function processAtomFeed(DOMElement $feed, $source)
    {
427 428 429 430 431 432 433 434
        $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
        if ($entries->length == 0) {
            common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
            return;
        }

        for ($i = 0; $i < $entries->length; $i++) {
            $entry = $entries->item($i);
435
            $this->processEntry($entry, $feed, $source);
436 437 438
        }
    }

439 440 441 442 443
    public function processRssFeed(DOMElement $rss, $source)
    {
        $channels = $rss->getElementsByTagName('channel');

        if ($channels->length == 0) {
444
            // TRANS: Exception.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
445
            throw new Exception(_m('RSS feed without a channel.'));
446 447 448 449 450 451 452 453 454 455 456 457 458 459
        } else if ($channels->length > 1) {
            common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
        }

        $channel = $channels->item(0);

        $items = $channel->getElementsByTagName('item');

        for ($i = 0; $i < $items->length; $i++) {
            $item = $items->item($i);
            $this->processEntry($item, $channel, $source);
        }
    }

460 461 462 463 464
    /**
     * Process a posted entry from this feed source.
     *
     * @param DOMElement $entry
     * @param DOMElement $feed for context
465
     * @param string $source identifier ("push" or "salmon")
466
     */
467
    public function processEntry($entry, $feed, $source)
468 469 470
    {
        $activity = new Activity($entry, $feed);

Evan Prodromou's avatar
Evan Prodromou committed
471 472
        if (Event::handle('StartHandleFeedEntryWithProfile', array($activity, $this)) &&
            Event::handle('StartHandleFeedEntry', array($activity))) {
473 474 475 476 477 478 479
            // @todo process all activity objects
            switch ($activity->objects[0]->type) {
            case ActivityObject::ARTICLE:
            case ActivityObject::BLOGENTRY:
            case ActivityObject::NOTE:
            case ActivityObject::STATUS:
            case ActivityObject::COMMENT:
480
            case null:
481 482 483 484 485 486 487
                if ($activity->verb == ActivityVerb::POST) {
                    $this->processPost($activity, $source);
                } else {
                    common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
                }
                break;
            default:
Siebrand Mazeland's avatar
Siebrand Mazeland committed
488
                // TRANS: Client exception.
489
                throw new ClientException(_m('Cannot handle that kind of post.'));
490
            }
491

492
            Event::handle('EndHandleFeedEntry', array($activity));
Evan Prodromou's avatar
Evan Prodromou committed
493
            Event::handle('EndHandleFeedEntryWithProfile', array($activity, $this));
494 495 496 497 498 499
        }
    }

    /**
     * Process an incoming post activity from this remote feed.
     * @param Activity $activity
500 501
     * @param string $method 'push' or 'salmon'
     * @return mixed saved Notice or false
502
     * @fixme break up this function, it's getting nasty long
503
     */
504
    public function processPost($activity, $method)
505
    {
Evan Prodromou's avatar
Evan Prodromou committed
506
        $oprofile = $this->checkAuthorship($activity);
507

Evan Prodromou's avatar
Evan Prodromou committed
508 509
        if (empty($oprofile)) {
            return false;
510
        }
511

512 513
        // It's not always an ActivityObject::NOTE, but... let's just say it is.

Zach Copley's avatar
Zach Copley committed
514
        $note = $activity->objects[0];
515

516 517 518
        // The id URI will be used as a unique identifier for for the notice,
        // protecting against duplicate saves. It isn't required to be a URL;
        // tag: URIs for instance are found in Google Buzz feeds.
519
        $sourceUri = $note->id;
520 521
        $dupe = Notice::staticGet('uri', $sourceUri);
        if ($dupe) {
522
            common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
523
            return false;
524
        }
525

526
        // We'll also want to save a web link to the original notice, if provided.
527
        $sourceUrl = null;
528 529
        if ($note->link) {
            $sourceUrl = $note->link;
530 531
        } else if ($activity->link) {
            $sourceUrl = $activity->link;
532 533 534 535 536 537 538 539 540 541 542 543 544 545
        } else if (preg_match('!^https?://!', $note->id)) {
            $sourceUrl = $note->id;
        }

        // Use summary as fallback for content

        if (!empty($note->content)) {
            $sourceContent = $note->content;
        } else if (!empty($note->summary)) {
            $sourceContent = $note->summary;
        } else if (!empty($note->title)) {
            $sourceContent = $note->title;
        } else {
            // @fixme fetch from $sourceUrl?
546
            // TRANS: Client exception. %s is a source URI.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
547
            throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
548 549
        }

550
        // Get (safe!) HTML and text versions of the content
551 552

        $rendered = $this->purify($sourceContent);
553
        $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
554

Evan Prodromou's avatar
Evan Prodromou committed
555
        $shortened = common_shorten_links($content);
556 557 558 559 560 561 562

        // If it's too long, try using the summary, and make the
        // HTML an attachment.

        $attachment = null;

        if (Notice::contentTooLong($shortened)) {
563
            $attachment = $this->saveHTMLFile($note->title, $rendered);
564
            $summary = html_entity_decode(strip_tags($note->summary), ENT_QUOTES, 'UTF-8');
565 566 567
            if (empty($summary)) {
                $summary = $content;
            }
Evan Prodromou's avatar
Evan Prodromou committed
568
            $shortSummary = common_shorten_links($summary);
569
            if (Notice::contentTooLong($shortSummary)) {
570
                $url = common_shorten_url($sourceUrl);
571 572 573
                $shortSummary = substr($shortSummary,
                                       0,
                                       Notice::maxContent() - (mb_strlen($url) + 2));
574 575 576 577
                $content = $shortSummary . ' ' . $url;

                // We mark up the attachment link specially for the HTML output
                // so we can fold-out the full version inline.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
578

579
                // @todo FIXME i18n: This tooltip will be saved with the site's default language
580 581
                // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
                // TRANS: this will usually be replaced with localised text from StatusNet core messages.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
582
                $showMoreText = _m('Show more');
583 584 585
                $attachUrl = common_local_url('attachment',
                                              array('attachment' => $attachment->id));
                $rendered = common_render_text($shortSummary) .
586 587
                            '<a href="' . htmlspecialchars($attachUrl) .'"'.
                            ' class="attachment more"' .
Siebrand Mazeland's avatar
Siebrand Mazeland committed
588
                            ' title="'. htmlspecialchars($showMoreText) . '">' .
589
                            '&#8230;' .
590
                            '</a>';
591 592 593
            }
        }

594
        $options = array('is_local' => Notice::REMOTE_OMB,
595
                        'url' => $sourceUrl,
596
                        'uri' => $sourceUri,
597 598
                        'rendered' => $rendered,
                        'replies' => array(),
599
                        'groups' => array(),
Shashi Gowda's avatar
Shashi Gowda committed
600
                        'peopletags' => array(),
601 602
                        'tags' => array(),
                        'urls' => array());
603

604
        // Check for optional attributes...
605

606 607
        if (!empty($activity->time)) {
            $options['created'] = common_sql_date($activity->time);
608 609
        }

610
        if ($activity->context) {
611 612 613 614 615 616 617 618 619 620 621 622
            // Any individual or group attn: targets?
            $replies = $activity->context->attention;
            $options['groups'] = $this->filterReplies($oprofile, $replies);
            $options['replies'] = $replies;

            // Maintain direct reply associations
            // @fixme what about conversation ID?
            if (!empty($activity->context->replyToID)) {
                $orig = Notice::staticGet('uri',
                                          $activity->context->replyToID);
                if (!empty($orig)) {
                    $options['reply_to'] = $orig->id;
623
                }
624 625 626 627 628 629 630 631 632
            }

            $location = $activity->context->location;
            if ($location) {
                $options['lat'] = $location->lat;
                $options['lon'] = $location->lon;
                if ($location->location_id) {
                    $options['location_ns'] = $location->location_ns;
                    $options['location_id'] = $location->location_id;
633 634 635
                }
            }
        }
636

Shashi Gowda's avatar
Shashi Gowda committed
637 638 639 640
        if ($this->isPeopletag()) {
            $options['peopletags'][] = $this->localPeopletag();
        }

641 642 643 644 645 646 647 648 649 650
        // Atom categories <-> hashtags
        foreach ($activity->categories as $cat) {
            if ($cat->term) {
                $term = common_canonical_tag($cat->term);
                if ($term) {
                    $options['tags'][] = $term;
                }
            }
        }

651 652 653 654 655 656
        // Atom enclosures -> attachment URLs
        foreach ($activity->enclosures as $href) {
            // @fixme save these locally or....?
            $options['urls'][] = $href;
        }

657
        try {
658
            $saved = Notice::saveNew($oprofile->profile_id,
659 660
                                     $content,
                                     'ostatus',
661 662 663
                                     $options);
            if ($saved) {
                Ostatus_source::saveNew($saved, $this, $method);
664 665 666
                if (!empty($attachment)) {
                    File_to_post::processNew($attachment->id, $saved->id);
                }
667
            }
668
        } catch (Exception $e) {
669 670
            common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
            throw $e;
671
        }
672 673 674
        common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
        return $saved;
    }
675

676 677 678 679 680
    /**
     * Clean up HTML
     */
    protected function purify($html)
    {
681
        require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
682 683
        $config = array('safe' => 1,
                        'deny_attribute' => 'id,style,on*');
684
        return htmLawed($html, $config);
685 686 687 688 689 690 691 692 693 694
    }

    /**
     * Filters a list of recipient ID URIs to just those for local delivery.
     * @param Ostatus_profile local profile of sender
     * @param array in/out &$attention_uris set of URIs, will be pruned on output
     * @return array of group IDs
     */
    protected function filterReplies($sender, &$attention_uris)
    {
695
        common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
696 697
        $groups = array();
        $replies = array();
698
        foreach (array_unique($attention_uris) as $recipient) {
699 700 701 702 703 704 705 706 707 708
            // Is the recipient a local user?
            $user = User::staticGet('uri', $recipient);
            if ($user) {
                // @fixme sender verification, spam etc?
                $replies[] = $recipient;
                continue;
            }

            // Is the recipient a local group?
            // $group = User_group::staticGet('uri', $recipient);
709 710
            $id = OStatusPlugin::localGroupFromUrl($recipient);
            if ($id) {
711 712 713
                $group = User_group::staticGet('id', $id);
                if ($group) {
                    // Deliver to all members of this local group if allowed.
714 715
                    $profile = $sender->localProfile();
                    if ($profile->isMember($group)) {
716
                        $groups[] = $group->id;
717 718
                    } else {
                        common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
719 720
                    }
                    continue;
721 722
                } else {
                    common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
723 724
                }
            }
725

726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741
            // Is the recipient a remote user or group?
            try {
                $oprofile = Ostatus_profile::ensureProfileURI($recipient);
                if ($oprofile->isGroup()) {
                    // Deliver to local members of this remote group.
                    // @fixme sender verification?
                    $groups[] = $oprofile->group_id;
                } else {
                    // may be canonicalized or something
                    $replies[] = $oprofile->uri;
                }
                continue;
            } catch (Exception $e) {
                // Neither a recognizable local nor remote user!
                common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
            }
742

743
        }
744
        $attention_uris = $replies;
745 746
        common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
        common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
747
        return $groups;
748 749
    }

750
    /**
Brion Vibber's avatar
Brion Vibber committed
751 752 753 754
     * Look up and if necessary create an Ostatus_profile for the remote entity
     * with the given profile page URL. This should never return null -- you
     * will either get an object or an exception will be thrown.
     *
755 756
     * @param string $profile_url
     * @return Ostatus_profile
757 758
     * @throws Exception on various error conditions
     * @throws OStatusShadowException if this reference would obscure a local user/group
759
     */
760
    public static function ensureProfileURL($profile_url, $hints=array())
761
    {
762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777
        $oprofile = self::getFromProfileURL($profile_url);

        if (!empty($oprofile)) {
            return $oprofile;
        }

        $hints['profileurl'] = $profile_url;

        // Fetch the URL
        // XXX: HTTP caching

        $client = new HTTPClient();
        $client->setHeader('Accept', 'text/html,application/xhtml+xml');
        $response = $client->get($profile_url);

        if (!$response->isOk()) {
Siebrand Mazeland's avatar
Siebrand Mazeland committed
778 779
            // TRANS: Exception. %s is a profile URL.
            throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826
        }

        // Check if we have a non-canonical URL

        $finalUrl = $response->getUrl();

        if ($finalUrl != $profile_url) {

            $hints['profileurl'] = $finalUrl;

            $oprofile = self::getFromProfileURL($finalUrl);

            if (!empty($oprofile)) {
                return $oprofile;
            }
        }

        // Try to get some hCard data

        $body = $response->getBody();

        $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);

        if (!empty($hcardHints)) {
            $hints = array_merge($hints, $hcardHints);
        }

        // Check if they've got an LRDD header

        $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');

        if (!empty($lrdd)) {

            $xrd = Discovery::fetchXrd($lrdd);
            $xrdHints = DiscoveryHints::fromXRD($xrd);

            $hints = array_merge($hints, $xrdHints);
        }

        // If discovery found a feedurl (probably from LRDD), use it.

        if (array_key_exists('feedurl', $hints)) {
            return self::ensureFeedURL($hints['feedurl'], $hints);
        }

        // Get the feed URL from HTML

827
        $discover = new FeedDiscovery();
828 829 830 831 832 833 834

        $feedurl = $discover->discoverFromHTML($finalUrl, $body);

        if (!empty($feedurl)) {
            $hints['feedurl'] = $feedurl;
            return self::ensureFeedURL($feedurl, $hints);
        }
Brion Vibber's avatar
Brion Vibber committed
835

836
        // TRANS: Exception. %s is a URL.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
837
        throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
838 839
    }

Brion Vibber's avatar
Brion Vibber committed
840 841 842 843 844 845
    /**
     * Look up the Ostatus_profile, if present, for a remote entity with the
     * given profile page URL. Will return null for both unknown and invalid
     * remote profiles.
     *
     * @return mixed Ostatus_profile or null
846
     * @throws OStatusShadowException for local profiles
Brion Vibber's avatar
Brion Vibber committed
847
     */
848 849 850 851 852 853 854 855 856 857 858 859 860 861
    static function getFromProfileURL($profile_url)
    {
        $profile = Profile::staticGet('profileurl', $profile_url);

        if (empty($profile)) {
            return null;
        }

        // Is it a known Ostatus profile?

        $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);

        if (!empty($oprofile)) {
            return $oprofile;
862
        }
863

864 865 866 867 868
        // Is it a local user?

        $user = User::staticGet('id', $profile->id);

        if (!empty($user)) {
Siebrand Mazeland's avatar
Siebrand Mazeland committed
869
            // @todo i18n FIXME: use sprintf and add i18n (?)
870
            throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
871 872 873 874 875 876 877 878 879
        }

        // Continue discovery; it's a remote profile
        // for OMB or some other protocol, may also
        // support OStatus

        return null;
    }

Brion Vibber's avatar
Brion Vibber committed
880 881 882 883 884 885 886 887
    /**
     * Look up and if necessary create an Ostatus_profile for remote entity
     * with the given update feed. This should never return null -- you will
     * either get an object or an exception will be thrown.
     *
     * @return Ostatus_profile
     * @throws Exception
     */
888 889 890 891 892 893 894
    public static function ensureFeedURL($feed_url, $hints=array())
    {
        $discover = new FeedDiscovery();

        $feeduri = $discover->discoverFromFeedURL($feed_url);
        $hints['feedurl'] = $feeduri;

895
        $huburi = $discover->getHubLink();
896
        $hints['hub'] = $huburi;
897
        $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
898
        $hints['salmon'] = $salmonuri;
899

900
        if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
901 902 903 904
            // We can only deal with folks with a PuSH hub
            throw new FeedSubNoHubException();
        }

Evan Prodromou's avatar
Evan Prodromou committed
905 906 907 908 909 910 911 912 913 914
        $feedEl = $discover->root;

        if ($feedEl->tagName == 'feed') {
            return self::ensureAtomFeed($feedEl, $hints);
        } else if ($feedEl->tagName == 'channel') {
            return self::ensureRssChannel($feedEl, $hints);
        } else {
            throw new FeedSubBadXmlException($feeduri);
        }
    }
915

Brion Vibber's avatar
Brion Vibber committed
916 917 918 919 920 921 922 923 924 925 926 927
    /**
     * Look up and, if necessary, create an Ostatus_profile for the remote
     * profile with the given Atom feed - actually loaded from the feed.
     * This should never return null -- you will either get an object or
     * an exception will be thrown.
     *
     * @param DOMElement $feedEl root element of a loaded Atom feed
     * @param array $hints additional discovery information passed from higher levels
     * @fixme should this be marked public?
     * @return Ostatus_profile
     * @throws Exception
     */
Evan Prodromou's avatar
Evan Prodromou committed
928 929
    public static function ensureAtomFeed($feedEl, $hints)
    {
930
        $author = ActivityUtils::getFeedAuthor($feedEl);
931

932 933 934
        if (empty($author)) {
            // XXX: make some educated guesses here
            // TRANS: Feed sub exception.
935
            throw new FeedSubException(_m('Cannot find enough profile '.
936
                                          'information to make a feed.'));
937
        }
938

939
        return self::ensureActivityObjectProfile($author, $hints);
940 941
    }

Brion Vibber's avatar
Brion Vibber committed
942 943 944 945 946 947 948 949 950 951 952 953
    /**
     * Look up and, if necessary, create an Ostatus_profile for the remote
     * profile with the given RSS feed - actually loaded from the feed.
     * This should never return null -- you will either get an object or
     * an exception will be thrown.
     *
     * @param DOMElement $feedEl root element of a loaded RSS feed
     * @param array $hints additional discovery information passed from higher levels
     * @fixme should this be marked public?
     * @return Ostatus_profile
     * @throws Exception
     */
Evan Prodromou's avatar
Evan Prodromou committed
954 955