Ostatus_profile.php 76.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
class Ostatus_profile extends Managed_DataObject
29
{
30
    public $__table = 'ostatus_profile';
31

32 33
    public $uri;

34
    public $profile_id;
35
    public $group_id;
Shashi Gowda's avatar
Shashi Gowda committed
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 49 50 51
     *
     * @return array array of column definitions
     */
    static function schemaDef()
    {
52 53 54 55 56
        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
57
                'peopletag_id' => array('type' => 'integer'),
58 59 60 61 62 63 64 65 66 67
                '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
68
                'ostatus_profile_peopletag_id_idx' => array('peopletag_id'),
69 70 71
                'ostatus_profile_feeduri_idx' => array('feeduri'),
            ),
            'foreign keys' => array(
72 73
                '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
74
                'ostatus_profile_peopletag_id_fkey' => array('profile_list', array('peopletag_id' => 'id')),
75 76
            ),
        );
77 78
    }

79 80 81 82
    /**
     * Fetch the StatusNet-side profile for this feed
     * @return Profile
     */
83
    public function localProfile()
84
    {
85
        if ($this->profile_id) {
86
            return Profile::getKV('id', $this->profile_id);
87 88 89 90 91 92 93 94 95 96 97
        }
        return null;
    }

    /**
     * Fetch the StatusNet-side profile for this feed
     * @return Profile
     */
    public function localGroup()
    {
        if ($this->group_id) {
98
            return User_group::getKV('id', $this->group_id);
99 100
        }
        return null;
101 102
    }

Shashi Gowda's avatar
Shashi Gowda committed
103 104 105 106 107 108 109
    /**
     * Fetch the StatusNet-side peopletag for this feed
     * @return Profile
     */
    public function localPeopletag()
    {
        if ($this->peopletag_id) {
110
            return Profile_list::getKV('id', $this->peopletag_id);
Shashi Gowda's avatar
Shashi Gowda committed
111 112 113 114
        }
        return null;
    }

115 116 117 118 119 120 121 122 123
    /**
     * 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()) {
124
            return ActivityObject::fromGroup($this->localGroup());
Shashi Gowda's avatar
Shashi Gowda committed
125 126
        } else if ($this->isPeopletag()) {
            return ActivityObject::fromPeopletag($this->localPeopletag());
127 128 129 130 131
        } else {
            return ActivityObject::fromProfile($this->localProfile());
        }
    }

132 133 134 135 136 137
    /**
     * 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.
     *
138
     * @todo FIXME: Replace with wrappers on asActivityObject when it's got everything.
139
     *
140 141 142 143 144 145
     * @param string $element one of 'actor', 'subject', 'object', 'target'
     * @return string
     */
    function asActivityNoun($element)
    {
        if ($this->isGroup()) {
146 147
            $noun = ActivityObject::fromGroup($this->localGroup());
            return $noun->asString('activity:' . $element);
Shashi Gowda's avatar
Shashi Gowda committed
148 149 150
        } else if ($this->isPeopletag()) {
            $noun = ActivityObject::fromPeopletag($this->localPeopletag());
            return $noun->asString('activity:' . $element);
151
        } else {
152 153
            $noun = ActivityObject::fromProfile($this->localProfile());
            return $noun->asString('activity:' . $element);
154 155 156
        }
    }

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

    /**
     * @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
186
            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
187 188
        } else {
            // TRANS: Server exception. %s is a URI
Siebrand Mazeland's avatar
Siebrand Mazeland committed
189
            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->uri));
190
        }
191 192
    }

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

    /**
214 215
     * Check if this remote profile has any active local subscriptions, and
     * if not drop the PuSH subscription feed.
216 217
     *
     * @return bool true on success, false on failure
218
     */
219
    public function unsubscribe() {
220
        $this->garbageCollect();
221 222
    }

223 224 225 226 227 228 229
    /**
     * Check if this remote profile has any active local subscriptions, and
     * if not drop the PuSH subscription feed.
     *
     * @return boolean
     */
    public function garbageCollect()
230
    {
231
        $feedsub = FeedSub::getKV('uri', $this->feeduri);
232 233 234 235 236 237 238 239 240 241 242 243 244
        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()
245 246 247 248
    {
        if ($this->isGroup()) {
            $members = $this->localGroup()->getMembers(0, 1);
            $count = $members->N;
Shashi Gowda's avatar
Shashi Gowda committed
249 250 251
        } else if ($this->isPeopletag()) {
            $subscribers = $this->localPeopletag()->getSubscribers(0, 1);
            $count = $subscribers->N;
252
        } else {
Shashi Gowda's avatar
Shashi Gowda committed
253 254 255 256 257
            $profile = $this->localProfile();
            $count = $profile->subscriberCount();
            if ($profile->hasLocalTags()) {
                $count = 1;
            }
258
        }
259 260 261 262 263 264 265 266
        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;
267 268
    }

269 270 271 272
    /**
     * Send an Activity Streams notification to the remote Salmon endpoint,
     * if so configured.
     *
273 274 275
     * @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)
276
     */
Shashi Gowda's avatar
Shashi Gowda committed
277
    public function notify($actor, $verb, $object=null, $target=null)
278
    {
279 280 281 282 283
        if (!($actor instanceof Profile)) {
            $type = gettype($actor);
            if ($type == 'object') {
                $type = get_class($actor);
            }
Siebrand Mazeland's avatar
Siebrand Mazeland committed
284 285 286
            // 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));
287
        }
288
        if ($object == null) {
289
            $object = $this;
290 291
        }
        if ($this->salmonuri) {
292 293 294 295
            $text = 'update';
            $id = TagURI::mint('%s:%s:%s',
                               $verb,
                               $actor->getURI(),
296
                               common_date_iso8601(time()));
297

298
            // @todo FIXME: Consolidate all these NS settings somewhere.
299 300 301 302
            $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',
303
                                'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
304 305
                                'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
                                'xmlns:media' => 'http://purl.org/syndication/atommedia');
306

Brion Vibber's avatar
Brion Vibber committed
307
            $entry = new XMLStringer();
308
            $entry->elementStart('entry', $attributes);
309 310 311
            $entry->element('id', null, $id);
            $entry->element('title', null, $text);
            $entry->element('summary', null, $text);
312
            $entry->element('published', null, common_date_w3dtf(common_sql_now()));
313 314

            $entry->element('activity:verb', null, $verb);
Brion Vibber's avatar
Brion Vibber committed
315 316
            $entry->raw($actor->asAtomAuthor());
            $entry->raw($actor->asActivityActor());
317
            $entry->raw($object->asActivityNoun('object'));
Shashi Gowda's avatar
Shashi Gowda committed
318 319 320
            if ($target != null) {
                $entry->raw($target->asActivityNoun('target'));
            }
Brion Vibber's avatar
Brion Vibber committed
321
            $entry->elementEnd('entry');
322

323
            $xml = $entry->getString();
324
            common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
325 326

            $salmon = new Salmon(); // ?
327
            return $salmon->post($this->salmonuri, $xml, $actor);
328
        }
329
        return false;
330 331
    }

332 333 334 335 336
    /**
     * 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
337
     * @param Profile $actor
338 339
     * @return boolean success
     */
340
    public function notifyActivity($entry, $actor)
341 342
    {
        if ($this->salmonuri) {
343
            $salmon = new Salmon();
344
            return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
345
        }
346

347 348
        return false;
    }
349

350 351 352 353 354 355 356
    /**
     * 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
     */
357
    public function notifyDeferred($entry, $actor)
358 359 360
    {
        if ($this->salmonuri) {
            $data = array('salmonuri' => $this->salmonuri,
361 362
                          'entry' => $this->notifyPrepXml($entry),
                          'actor' => $actor->id);
363

364 365
            $qm = QueueManager::get();
            return $qm->enqueue($data, 'salmon');
366 367
        }

368
        return false;
369 370
    }

371 372 373 374 375 376 377 378 379 380
    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
381 382
            // TRANS: Server exception.
            throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
383 384 385
        }
    }

386 387 388 389
    function getBestName()
    {
        if ($this->isGroup()) {
            return $this->localGroup()->getBestName();
Shashi Gowda's avatar
Shashi Gowda committed
390 391
        } else if ($this->isPeopletag()) {
            return $this->localPeopletag()->getBestName();
392 393 394 395 396
        } else {
            return $this->localProfile()->getBestName();
        }
    }

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

409 410
        if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
            $this->processAtomFeed($feed, $source);
411
        } else if ($feed->localName == 'rss') { // @todo FIXME: Check namespace.
412 413
            $this->processRssFeed($feed, $source);
        } else {
414
            // TRANS: Exception.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
415
            throw new Exception(_m('Unknown feed format.'));
416
        }
417
    }
418

419 420
    public function processAtomFeed(DOMElement $feed, $source)
    {
421 422 423 424 425 426 427 428
        $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);
429
            $this->processEntry($entry, $feed, $source);
430 431 432
        }
    }

433 434 435 436 437
    public function processRssFeed(DOMElement $rss, $source)
    {
        $channels = $rss->getElementsByTagName('channel');

        if ($channels->length == 0) {
438
            // TRANS: Exception.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
439
            throw new Exception(_m('RSS feed without a channel.'));
440 441 442 443 444 445 446 447 448 449 450 451 452 453
        } 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);
        }
    }

454 455 456 457 458
    /**
     * Process a posted entry from this feed source.
     *
     * @param DOMElement $entry
     * @param DOMElement $feed for context
459
     * @param string $source identifier ("push" or "salmon")
460 461
     *
     * @return Notice Notice representing the new (or existing) activity
462
     */
463
    public function processEntry($entry, $feed, $source)
464 465
    {
        $activity = new Activity($entry, $feed);
466
        return $this->processActivity($activity, $source);
Evan Prodromou's avatar
Evan Prodromou committed
467 468
    }

469
    // TODO: Make this throw an exception
Evan Prodromou's avatar
Evan Prodromou committed
470 471
    public function processActivity($activity, $source)
    {
472 473
        $notice = null;

Evan Prodromou's avatar
Evan Prodromou committed
474
        // The "WithProfile" events were added later.
475

476
        if (Event::handle('StartHandleFeedEntryWithProfile', array($activity, $this, &$notice)) &&
Evan Prodromou's avatar
Evan Prodromou committed
477
            Event::handle('StartHandleFeedEntry', array($activity))) {
Evan Prodromou's avatar
Evan Prodromou committed
478 479 480 481 482 483 484 485 486 487 488

            switch ($activity->verb) {
            case ActivityVerb::POST:
                // @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:
                case null:
489
                    $notice = $this->processPost($activity, $source);
Evan Prodromou's avatar
Evan Prodromou committed
490 491 492 493
                    break;
                default:
                    // TRANS: Client exception.
                    throw new ClientException(_m('Cannot handle that kind of post.'));
494 495
                }
                break;
Evan Prodromou's avatar
Evan Prodromou committed
496
            case ActivityVerb::SHARE:
497
                $notice = $this->processShare($activity, $source);
Evan Prodromou's avatar
Evan Prodromou committed
498
                break;
499
            default:
Evan Prodromou's avatar
Evan Prodromou committed
500
                common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
501
            }
502

503
            Event::handle('EndHandleFeedEntry', array($activity));
504
            Event::handle('EndHandleFeedEntryWithProfile', array($activity, $this, $notice));
505
        }
506

507
        return $notice;
508 509
    }

Evan Prodromou's avatar
Evan Prodromou committed
510 511
    public function processShare($activity, $method)
    {
512 513
        $notice = null;

Evan Prodromou's avatar
Evan Prodromou committed
514 515 516 517
        $oprofile = $this->checkAuthorship($activity);

        if (empty($oprofile)) {
            common_log(LOG_INFO, "No author matched share activity");
518
            return null;
Evan Prodromou's avatar
Evan Prodromou committed
519 520
        }

521 522 523 524 525 526 527 528 529
        // The id URI will be used as a unique identifier 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.
        $dupe = Notice::getKV('uri', $activity->id);
        if ($dupe instanceof Notice) {
            common_log(LOG_INFO, "OStatus: ignoring duplicate post: {$activity->id}");
            return $dupe;
        }

Evan Prodromou's avatar
Evan Prodromou committed
530
        if (count($activity->objects) != 1) {
531 532
            // TRANS: Client exception thrown when trying to share multiple activities at once.
            throw new ClientException(_m('Can only handle share activities with exactly one object.'));
Evan Prodromou's avatar
Evan Prodromou committed
533 534 535 536 537
        }

        $shared = $activity->objects[0];

        if (!($shared instanceof Activity)) {
538 539
            // TRANS: Client exception thrown when trying to share a non-activity object.
            throw new ClientException(_m('Can only handle shared activities.'));
Evan Prodromou's avatar
Evan Prodromou committed
540 541
        }

542 543 544 545 546 547 548 549 550 551 552
        $sharedId = $shared->id;
        if (!empty($shared->objects[0]->id)) {
            // Because StatusNet since commit 8cc4660 sets $shared->id to a TagURI which
            // fucks up federation, because the URI is no longer recognised by the origin.
            // ...but it might still be empty (not present) because $shared->id is set.
            $sharedId = $shared->objects[0]->id;
        }
        if (empty($sharedId)) {
            throw new ClientException(_m('Shared activity does not have an id'));
        }

553 554 555 556 557 558 559 560 561 562
        // First check if we have the shared activity. This has to be done first, because
        // we can't use these functions to "ensureActivityObjectProfile" of a local user,
        // who might be the creator of the shared activity in question.
        $sharedNotice = Notice::getKV('uri', $sharedId);
        if (!($sharedNotice instanceof Notice)) {
            // If no local notice is found, process it!
            // TODO: Remember to check Deleted_notice!
            $other = Ostatus_profile::ensureActivityObjectProfile($shared->actor);
            $sharedNotice = $other->processActivity($shared, $method);
        }
563

564 565
        if (!($sharedNotice instanceof Notice)) {
            // And if we apparently can't get the shared notice, we'll abort the whole thing.
566 567
            // TRANS: Client exception thrown when saving an activity share fails.
            // TRANS: %s is a share ID.
568
            throw new ClientException(sprintf(_m('Failed to save activity %s.'), $sharedId));
Evan Prodromou's avatar
Evan Prodromou committed
569 570
        }

571
        // We'll want to save a web link to the original notice, if provided.
572

Evan Prodromou's avatar
Evan Prodromou committed
573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590
        $sourceUrl = null;
        if ($activity->link) {
            $sourceUrl = $activity->link;
        } else if ($activity->link) {
            $sourceUrl = $activity->link;
        } else if (preg_match('!^https?://!', $activity->id)) {
            $sourceUrl = $activity->id;
        }

        // Use summary as fallback for content

        if (!empty($activity->content)) {
            $sourceContent = $activity->content;
        } else if (!empty($activity->summary)) {
            $sourceContent = $activity->summary;
        } else if (!empty($activity->title)) {
            $sourceContent = $activity->title;
        } else {
591
            // @todo FIXME: Fetch from $sourceUrl?
Evan Prodromou's avatar
Evan Prodromou committed
592
            // TRANS: Client exception. %s is a source URI.
593
            throw new ClientException(sprintf(_m('No content for notice %s.'), $activity->id));
Evan Prodromou's avatar
Evan Prodromou committed
594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641
        }

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

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

        $shortened = common_shorten_links($content);

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

        $attachment = null;

        if (Notice::contentTooLong($shortened)) {
            $attachment = $this->saveHTMLFile($activity->title, $rendered);
            $summary = html_entity_decode(strip_tags($activity->summary), ENT_QUOTES, 'UTF-8');
            if (empty($summary)) {
                $summary = $content;
            }
            $shortSummary = common_shorten_links($summary);
            if (Notice::contentTooLong($shortSummary)) {
                $url = common_shorten_url($sourceUrl);
                $shortSummary = substr($shortSummary,
                                       0,
                                       Notice::maxContent() - (mb_strlen($url) + 2));
                $content = $shortSummary . ' ' . $url;

                // We mark up the attachment link specially for the HTML output
                // so we can fold-out the full version inline.

                // @todo FIXME i18n: This tooltip will be saved with the site's default language
                // 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.
                $showMoreText = _m('Show more');
                $attachUrl = common_local_url('attachment',
                                              array('attachment' => $attachment->id));
                $rendered = common_render_text($shortSummary) .
                            '<a href="' . htmlspecialchars($attachUrl) .'"'.
                            ' class="attachment more"' .
                            ' title="'. htmlspecialchars($showMoreText) . '">' .
                            '&#8230;' .
                            '</a>';
            }
        }

        $options = array('is_local' => Notice::REMOTE,
                         'url' => $sourceUrl,
642
                         'uri' => $activity->id,
Evan Prodromou's avatar
Evan Prodromou committed
643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658
                         'rendered' => $rendered,
                         'replies' => array(),
                         'groups' => array(),
                         'peopletags' => array(),
                         'tags' => array(),
                         'urls' => array(),
                         'repeat_of' => $sharedNotice->id,
                         'scope' => $sharedNotice->scope);

        // Check for optional attributes...

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

        if ($activity->context) {
659 660 661
            // TODO: context->attention
            list($options['groups'], $options['replies'])
                = $this->filterReplies($oprofile, $activity->context->attention);
Evan Prodromou's avatar
Evan Prodromou committed
662 663

            // Maintain direct reply associations
664
            // @todo FIXME: What about conversation ID?
Evan Prodromou's avatar
Evan Prodromou committed
665
            if (!empty($activity->context->replyToID)) {
666
                $orig = Notice::getKV('uri',
Evan Prodromou's avatar
Evan Prodromou committed
667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699
                                          $activity->context->replyToID);
                if (!empty($orig)) {
                    $options['reply_to'] = $orig->id;
                }
            }

            $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;
                }
            }
        }

        if ($this->isPeopletag()) {
            $options['peopletags'][] = $this->localPeopletag();
        }

        // Atom categories <-> hashtags
        foreach ($activity->categories as $cat) {
            if ($cat->term) {
                $term = common_canonical_tag($cat->term);
                if ($term) {
                    $options['tags'][] = $term;
                }
            }
        }

        // Atom enclosures -> attachment URLs
        foreach ($activity->enclosures as $href) {
700
            // @todo FIXME: Save these locally or....?
Evan Prodromou's avatar
Evan Prodromou committed
701 702 703
            $options['urls'][] = $href;
        }

704 705 706
        $notice = Notice::saveNew($oprofile->profile_id,
                                  $content,
                                  'ostatus',
707
                                  $options);
708 709

        return $notice;
Evan Prodromou's avatar
Evan Prodromou committed
710 711
    }

712 713 714
    /**
     * Process an incoming post activity from this remote feed.
     * @param Activity $activity
715 716
     * @param string $method 'push' or 'salmon'
     * @return mixed saved Notice or false
717
     * @todo FIXME: Break up this function, it's getting nasty long
718
     */
719
    public function processPost($activity, $method)
720
    {
721 722
        $notice = null;

Evan Prodromou's avatar
Evan Prodromou committed
723
        $oprofile = $this->checkAuthorship($activity);
724

Evan Prodromou's avatar
Evan Prodromou committed
725
        if (empty($oprofile)) {
726
            return null;
727
        }
728

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

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

733 734 735
        // 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.
736
        $sourceUri = $note->id;
737
        $dupe = Notice::getKV('uri', $sourceUri);
738
        if ($dupe) {
739
            common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
740
            return $dupe;
741
        }
742

743
        // We'll also want to save a web link to the original notice, if provided.
744
        $sourceUrl = null;
745 746
        if ($note->link) {
            $sourceUrl = $note->link;
747 748
        } else if ($activity->link) {
            $sourceUrl = $activity->link;
749 750 751 752 753 754 755 756 757 758 759 760 761
        } 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 {
762
            // @todo FIXME: Fetch from $sourceUrl?
763
            // TRANS: Client exception. %s is a source URI.
Siebrand Mazeland's avatar
Siebrand Mazeland committed
764
            throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
765 766
        }

767
        // Get (safe!) HTML and text versions of the content
768 769

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

Evan Prodromou's avatar
Evan Prodromou committed
772
        $shortened = common_shorten_links($content);
773 774 775 776 777 778 779

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

        $attachment = null;

        if (Notice::contentTooLong($shortened)) {
780
            $attachment = $this->saveHTMLFile($note->title, $rendered);
781
            $summary = html_entity_decode(strip_tags($note->summary), ENT_QUOTES, 'UTF-8');
782 783 784
            if (empty($summary)) {
                $summary = $content;
            }
Evan Prodromou's avatar
Evan Prodromou committed
785
            $shortSummary = common_shorten_links($summary);
786
            if (Notice::contentTooLong($shortSummary)) {
787
                $url = common_shorten_url($sourceUrl);
788 789 790
                $shortSummary = substr($shortSummary,
                                       0,
                                       Notice::maxContent() - (mb_strlen($url) + 2));
791 792 793 794
                $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
795

796
                // @todo FIXME i18n: This tooltip will be saved with the site's default language
797 798
                // 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
799
                $showMoreText = _m('Show more');
800 801 802
                $attachUrl = common_local_url('attachment',
                                              array('attachment' => $attachment->id));
                $rendered = common_render_text($shortSummary) .
803 804
                            '<a href="' . htmlspecialchars($attachUrl) .'"'.
                            ' class="attachment more"' .
Siebrand Mazeland's avatar
Siebrand Mazeland committed
805
                            ' title="'. htmlspecialchars($showMoreText) . '">' .
806
                            '&#8230;' .
807
                            '</a>';
808 809 810
            }
        }

811
        $options = array('is_local' => Notice::REMOTE,
812
                        'url' => $sourceUrl,
813
                        'uri' => $sourceUri,
814 815
                        'rendered' => $rendered,
                        'replies' => array(),
816
                        'groups' => array(),
Shashi Gowda's avatar
Shashi Gowda committed
817
                        'peopletags' => array(),
818 819
                        'tags' => array(),
                        'urls' => array());
820

821
        // Check for optional attributes...
822

823 824
        if (!empty($activity->time)) {
            $options['created'] = common_sql_date($activity->time);
825 826
        }

827
        if ($activity->context) {
828 829 830
            // TODO: context->attention
            list($options['groups'], $options['replies'])
                = $this->filterReplies($oprofile, $activity->context->attention);
831 832

            // Maintain direct reply associations
833
            // @todo FIXME: What about conversation ID?
834
            if (!empty($activity->context->replyToID)) {
835
                $orig = Notice::getKV('uri',
836 837 838
                                          $activity->context->replyToID);
                if (!empty($orig)) {
                    $options['reply_to'] = $orig->id;
839
                }
840 841 842 843 844 845 846 847 848
            }

            $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;
849 850 851
                }
            }
        }
852

Shashi Gowda's avatar
Shashi Gowda committed
853 854 855 856
        if ($this->isPeopletag()) {
            $options['peopletags'][] = $this->localPeopletag();
        }

857 858 859 860 861 862 863 864 865 866
        // Atom categories <-> hashtags
        foreach ($activity->categories as $cat) {
            if ($cat->term) {
                $term = common_canonical_tag($cat->term);
                if ($term) {
                    $options['tags'][] = $term;
                }
            }
        }

867 868
        // Atom enclosures -> attachment URLs
        foreach ($activity->enclosures as $href) {
869
            // @todo FIXME: Save these locally or....?
870 871 872
            $options['urls'][] = $href;
        }

873
        try {
874
            $saved = Notice::saveNew($oprofile->profile_id,
875 876
                                     $content,
                                     'ostatus',
877 878 879
                                     $options);
            if ($saved) {
                Ostatus_source::saveNew($saved, $this, $method);
880 881 882
                if (!empty($attachment)) {
                    File_to_post::processNew($attachment->id, $saved->id);
                }
883
            }
884
        } catch (Exception $e) {
885 886
            common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
            throw $e;
887
        }
888 889 890
        common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
        return $saved;
    }
891

892 893 894 895 896
    /**
     * Clean up HTML
     */
    protected function purify($html)
    {
897
        require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
898 899
        $config = array('safe' => 1,
                        'deny_attribute' => 'id,style,on*');
900
        return htmLawed($html, $config);
901 902 903 904 905 906 907 908
    }

    /**
     * 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
     */
909
    protected function filterReplies($sender, array $attention)
910
    {
911
        common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention));
912 913
        $groups = array();
        $replies = array();
914
        foreach ($attention as $recipient=>$type) {
915
            // Is the recipient a local user?
916
            $user = User::getKV('uri', $recipient);
917
            if ($user instanceof User) {
918
                // @todo FIXME: Sender verification, spam etc?
919 920 921 922 923
                $replies[] = $recipient;
                continue;
            }

            // Is the recipient a local group?
924
            // TODO: $group = User_group::getKV('uri', $recipient);
925 926
            $id = OStatusPlugin::localGroupFromUrl($recipient);
            if ($id) {
927
                $group = User_group::getKV('id', $id);
928
                if ($group instanceof User_group) {
929
                    // Deliver to all members of this local group if allowed.
930 931
                    $profile = $sender->localProfile();
                    if ($profile->isMember($group)) {
932
                        $groups[] = $group->id;
933 934
                    } else {
                        common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
935 936
                    }
                    continue;
937 938
                } else {
                    common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
939 940
                }
            }
941

942 943 944 945 946
            // 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.
947
                    // @todo FIXME: Sender verification?
948 949 950 951 952 953 954 955 956 957
                    $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());
            }
958

959
        }
960 961
        common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
        common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
962
        return array($groups, $replies);
963 964
    }

965
    /**
Brion Vibber's avatar
Brion Vibber committed
966 967 968 969
     * 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.
     *
970 971
     * @param string $profile_url
     * @return Ostatus_profile
972 973
     * @throws Exception on various error conditions
     * @throws OStatusShadowException if this reference would obscure a local user/group
974
     */
975
    public static function ensureProfileURL($profile_url, $hints=array())
976
    {
977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992
        $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
993 994
            // TRANS: Exception. %s is a profile URL.
            throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023
        }

        // 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

1024 1025 1026 1027
        $lrdd = LinkHeader::getLink($response, 'lrdd');
        try {
            $xrd = new XML_XRD();
            $xrd->loadFile($lrdd);
1028 1029
            $xrdHints = DiscoveryHints::fromXRD($xrd);
            $hints = array_merge($hints, $xrdHints);
1030 1031
        } catch (Exception $e) {
            // No hints available from XRD
1032 1033 1034 1035 1036 1037 1038 1039 1040 1041
        }

        // 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

1042
        $discover = new FeedDiscovery();
1043 1044 1045 1046 1047 1048 1049

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

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

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

Brion Vibber's avatar
Brion Vibber committed
1055 1056 1057 1058 1059 1060
    /**
     * 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
1061
     * @throws OStatusShadowException for local profiles
Brion Vibber's avatar
Brion Vibber committed
1062
     */
1063 1064
    static function getFromProfileURL($profile_url)
    {
1065
        $profile = Profile::getKV('profileurl', $profile_url);
1066 1067 1068 1069 1070 1071 1072

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

        // Is it a known Ostatus profile?

1073
        $oprofile = Ostatus_profile::getKV('profile_id', $profile->id);
1074 1075 1076

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

1079 1080
        // Is it a local user?

1081
        $user = User::getKV('id', $profile->id);
1082 1083

        if (!empty($user)) {
Siebrand Mazeland's avatar
Siebrand Mazeland committed
1084
            // @todo i18n FIXME: use sprintf and add i18n (?)
1085
            throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
1086 1087 1088 1089 1090 1091 1092 1093 1094
        }

        // 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
1095 1096 1097 1098 1099 1100 1101 1102
    /**
     * 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
     */
1103 1104 1105 1106 1107 1108 1109
    public static function ensureFeedURL($feed_url, $hints=array())
    {
        $discover = new FeedDiscovery();

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

1110
        $huburi = $discover->getHubLink();
1111
        $hints['hub'] = $huburi;
1112
        $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1113
        $hints['salmon'] = $salmonuri;
1114

1115
        if (!$huburi && !common_config('feedsub', 'fallback_hub')) {