git.gnu.io has moved to IP address 209.51.188.249 -- please double check where you are logging in.

activityhandlerplugin.php 21.9 KB
Newer Older
1
<?php
2 3
/*
 * GNU Social - a federating social network
4 5
 * Copyright (C) 2014, Free Software Foundation, Inc.
 *
6 7
 * 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
8
 * the Free Software Foundation, either version 3 of the License, or
9 10
 * (at your option) any later version.
 *
11
 * This program is distributed in the hope that it will be useful,
12 13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
 * GNU Affero General Public License for more details.
15 16 17
 *
 * 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/>.
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
 */

if (!defined('GNUSOCIAL')) { exit(1); }

/**
 * Superclass for plugins which add Activity types and such
 *
 * @category  Activity
 * @package   GNUsocial
 * @author    Mikael Nordfeldth <mmn@hethane.se>
 * @copyright 2014 Free Software Foundation, Inc.
 * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
 * @link      http://gnu.io/social
 */
abstract class ActivityHandlerPlugin extends Plugin
{
34 35 36 37 38 39 40 41 42
    /** 
     * Returns a key string which represents this activity in HTML classes,
     * ids etc, as when offering selection of what type of post to make. 
     * In MicroAppPlugin, this is paired with the user-visible localizable appTitle(). 
     *
     * @return string (compatible with HTML classes)
     */ 
    abstract function tag();

43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
    /**
     * Return a list of ActivityStreams object type IRIs
     * which this micro-app handles. Default implementations
     * of the base class will use this list to check if a
     * given ActivityStreams object belongs to us, via
     * $this->isMyNotice() or $this->isMyActivity.
     *
     * An empty list means any type is ok. (Favorite verb etc.)
     *
     * @return array of strings
     */
    abstract function types();

    /**
     * Return a list of ActivityStreams verb IRIs which
     * this micro-app handles. Default implementations
     * of the base class will use this list to check if a
     * given ActivityStreams verb belongs to us, via
     * $this->isMyNotice() or $this->isMyActivity.
     *
     * All micro-app classes must override this method.
     *
     * @return array of strings
     */
67
    public function verbs() {
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
        return array(ActivityVerb::POST);
    }

    /**
     * Check if a given ActivityStreams activity should be handled by this
     * micro-app plugin.
     *
     * The default implementation checks against the activity type list
     * returned by $this->types(), and requires that exactly one matching
     * object be present. You can override this method to expand
     * your checks or to compare the activity's verb, etc.
     *
     * @param Activity $activity
     * @return boolean
     */
    function isMyActivity(Activity $act) {
        return (count($act->objects) == 1
            && ($act->objects[0] instanceof ActivityObject)
            && $this->isMyVerb($act->verb)
            && $this->isMyType($act->objects[0]->type));
    }

    /**
     * Check if a given notice object should be handled by this micro-app
     * plugin.
93 94 95 96 97 98 99 100
     *
     * The default implementation checks against the activity type list
     * returned by $this->types(). You can override this method to expand
     * your checks, but follow the execution chain to get it right.
     *
     * @param Notice $notice
     * @return boolean
     */
101 102 103 104 105 106
    function isMyNotice(Notice $notice) {
        return $this->isMyVerb($notice->verb) && $this->isMyType($notice->object_type);
    }

    function isMyVerb($verb) {
        $verb = $verb ?: ActivityVerb::POST;    // post is the default verb
107
        return ActivityUtils::compareVerbs($verb, $this->verbs());
108 109 110
    }

    function isMyType($type) {
mmn's avatar
mmn committed
111
        // Third argument to compareTypes is true, to allow for notices with empty object_type for example (verb-only)
112 113
        return count($this->types())===0 || ActivityUtils::compareTypes($type, $this->types());
    }
114 115 116 117 118 119

    /**
     * Given a parsed ActivityStreams activity, your plugin
     * gets to figure out how to actually save it into a notice
     * and any additional data structures you require.
     *
120
     * This function is deprecated and in the future, Notice::saveActivity
121 122
     * should be called from onStartHandleFeedEntryWithProfile in this class
     * (which instead turns to saveObjectFromActivity).
123 124 125 126 127 128 129
     *
     * @param Activity $activity
     * @param Profile $actor
     * @param array $options=array()
     *
     * @return Notice the resulting notice
     */
130 131 132
    public function saveNoticeFromActivity(Activity $activity, Profile $actor, array $options=array())
    {
        // Any plugin which has not implemented saveObjectFromActivity _must_
133 134
        // override this function until they are migrated (this function will
        // be deleted when all plugins are migrated to saveObjectFromActivity).
135 136 137 138 139 140 141 142

        if (isset($this->oldSaveNew)) {
            throw new ServerException('A function has been called for new saveActivity functionality, but is still set with an oldSaveNew configuration');
        }

        return Notice::saveActivity($activity, $actor, $options);
    }

143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
    /**
    * Given a parsed ActivityStreams activity, your plugin gets
    * to figure out itself how to store the additional data into
    * the database, besides the base data stored by the core.
    *
    * This will handle just about all events where an activity
    * object gets saved, whether it is via AtomPub, OStatus
    * (PuSH and Salmon transports), or ActivityStreams-based
    * backup/restore of account data.
    *
    * You should be able to accept as input the output from an
    * asActivity() call on the stored object. Where applicable,
    * try to use existing ActivityStreams structures and object
    * types, and be liberal in accepting input from what might
    * be other compatible apps.
    *
    * All micro-app classes must override this method.
    *
    * @fixme are there any standard options?
    *
    * @param Activity $activity
164
    * @param Notice   $stored       The notice in our database for this certain object
165 166
    * @param array $options=array()
    *
167 168
    * @return object    If the verb handling plugin creates an object, it can be returned here (otherwise true)
    * @throws exception On any error.
169
    */
170
    protected function saveObjectFromActivity(Activity $activity, Notice $stored, array $options=array())
171 172 173 174
    {
        throw new ServerException('This function should be abstract when all plugins have migrated to saveObjectFromActivity');
    }

175 176 177 178
    /*
     * This usually gets called from Notice::saveActivity after a Notice object has been created,
     * so it contains a proper id and a uri for the object to be saved.
     */
179
    public function onStoreActivityObject(Activity $act, Notice $stored, array $options, &$object) {
180 181 182 183 184 185 186 187
        // $this->oldSaveNew is there during a migration period of plugins, to start using
        // Notice::saveActivity instead of Notice::saveNew
        if (!$this->isMyActivity($act) || isset($this->oldSaveNew)) {
            return true;
        }
        $object = $this->saveObjectFromActivity($act, $stored, $options);
        return false;
    }
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222

    /**
     * Given an existing Notice object, your plugin gets to
     * figure out how to arrange it into an ActivityStreams
     * object.
     *
     * This will be how your specialized notice gets output in
     * Atom feeds and JSON-based ActivityStreams output, including
     * account backup/restore and OStatus (PuSH and Salmon transports).
     *
     * You should be able to round-trip data from this format back
     * through $this->saveNoticeFromActivity(). Where applicable, try
     * to use existing ActivityStreams structures and object types,
     * and consider interop with other compatible apps.
     *
     * All micro-app classes must override this method.
     *
     * @fixme this outputs an ActivityObject, not an Activity. Any compat issues?
     *
     * @param Notice $notice
     *
     * @return ActivityObject
     */
    abstract function activityObjectFromNotice(Notice $notice);

    /**
     * When a notice is deleted, you'll be called here for a chance
     * to clean up any related resources.
     *
     * All micro-app classes must override this method.
     *
     * @param Notice $notice
     */
    abstract function deleteRelated(Notice $notice);

223 224 225
    protected function notifyMentioned(Notice $stored, array &$mentioned_ids)
    {
        // pass through silently by default
226 227 228

        // If we want to stop any other plugin from notifying based on this activity, return false instead.
        return true;
229 230
    }

231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
    /**
     * Called when generating Atom XML ActivityStreams output from an
     * ActivityObject belonging to this plugin. Gives the plugin
     * a chance to add custom output.
     *
     * Note that you can only add output of additional XML elements,
     * not change existing stuff here.
     *
     * If output is already handled by the base Activity classes,
     * you can leave this base implementation as a no-op.
     *
     * @param ActivityObject $obj
     * @param XMLOutputter $out to add elements at end of object
     */
    function activityObjectOutputAtom(ActivityObject $obj, XMLOutputter $out)
    {
        // default is a no-op
    }

    /**
     * Called when generating JSON ActivityStreams output from an
     * ActivityObject belonging to this plugin. Gives the plugin
     * a chance to add custom output.
     *
     * Modify the array contents to your heart's content, and it'll
     * all get serialized out as JSON.
     *
     * If output is already handled by the base Activity classes,
     * you can leave this base implementation as a no-op.
     *
     * @param ActivityObject $obj
     * @param array &$out JSON-targeted array which can be modified
     */
    public function activityObjectOutputJson(ActivityObject $obj, array &$out)
    {
        // default is a no-op
    }

    /**
     * When a notice is deleted, delete the related objects
     * by calling the overridable $this->deleteRelated().
     *
     * @param Notice $notice Notice being deleted
     *
     * @return boolean hook value
     */
277
    public function onNoticeDeleteRelated(Notice $notice)
278
    {
279
        if ($this->isMyNotice($notice)) {
280 281 282 283 284
            try {
                $this->deleteRelated($notice);
            } catch (AlreadyFulfilledException $e) {
                // Nothing to see here, it's obviously already gone...
            }
285 286 287 288 289 290 291 292 293 294 295 296 297
        }

        // Always continue this event in our activity handling plugins.
        return true;
    }

    /**
     * @param Notice $stored            The notice being distributed
     * @param array  &$mentioned_ids    List of profiles (from $stored->getReplies())
     */
    public function onStartNotifyMentioned(Notice $stored, array &$mentioned_ids)
    {
        if (!$this->isMyNotice($stored)) {
298 299 300
            return true;
        }

301
        return $this->notifyMentioned($stored, $mentioned_ids);
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317
    }

    /**
     * Render a notice as one of our objects
     *
     * @param Notice         $notice  Notice to render
     * @param ActivityObject &$object Empty object to fill
     *
     * @return boolean hook value
     */
    function onStartActivityObjectFromNotice(Notice $notice, &$object)
    {
        if (!$this->isMyNotice($notice)) {
            return true;
        }

318
        $object = $this->activityObjectFromNotice($notice);
319 320 321 322 323 324 325
        return false;
    }

    /**
     * Handle a posted object from PuSH
     *
     * @param Activity        $activity activity to handle
326
     * @param Profile         $actor Profile for the feed
327 328 329
     *
     * @return boolean hook value
     */
330
    function onStartHandleFeedEntryWithProfile(Activity $activity, Profile $profile, &$notice)
331 332 333 334 335
    {
        if (!$this->isMyActivity($activity)) {
            return true;
        }

336 337
        // We are guaranteed to get a Profile back from checkAuthorship (or it throws an exception)
        $profile = ActivityUtils::checkAuthorship($activity, $profile);
338 339 340 341 342

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

        $options = array('uri' => $object->id,
                         'url' => $object->link,
343
                         'self' => $object->selfLink,
344 345 346
                         'is_local' => Notice::REMOTE,
                         'source' => 'ostatus');

347 348 349 350 351
        if (!isset($this->oldSaveNew)) {
            $notice = Notice::saveActivity($activity, $profile, $options);
        } else {
            $notice = $this->saveNoticeFromActivity($activity, $profile, $options);
        }
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369

        return false;
    }

    /**
     * Handle a posted object from Salmon
     *
     * @param Activity $activity activity to handle
     * @param mixed    $target   user or group targeted
     *
     * @return boolean hook value
     */

    function onStartHandleSalmonTarget(Activity $activity, $target)
    {
        if (!$this->isMyActivity($activity)) {
            return true;
        }
370 371 372 373 374
        if (!isset($this->oldSaveNew)) {
            // Handle saveActivity in OStatus class for incoming salmon, remove this event
            // handler when all plugins have gotten rid of "oldSaveNew".
            return true;
        }
375

mmn's avatar
mmn committed
376
        $this->log(LOG_INFO, get_called_class()." checking {$activity->id} as a valid Salmon slap.");
377

378
        if ($target instanceof User_group || $target->isGroup()) {
379 380 381 382 383 384
            $uri = $target->getUri();
            if (!array_key_exists($uri, $activity->context->attention)) {
                // @todo FIXME: please document (i18n).
                // TRANS: Client exception thrown when ...
                throw new ClientException(_('Object not posted to this group.'));
            }
385
        } elseif ($target instanceof Profile && $target->isLocal()) {
386
            $original = null;
387
            // FIXME: Shouldn't favorites show up with a 'target' activityobject?
388
            if (!ActivityUtils::compareVerbs($activity->verb, array(ActivityVerb::POST)) && isset($activity->objects[0])) {
389 390 391 392 393
                // If this is not a post, it's a verb targeted at something (such as a Favorite attached to a note)
                if (!empty($activity->objects[0]->id)) {
                    $activity->context->replyToID = $activity->objects[0]->id;
                }
            }
394 395 396
            if (!empty($activity->context->replyToID)) {
                $original = Notice::getKV('uri', $activity->context->replyToID);
            }
397 398
            if ((!$original instanceof Notice || $original->profile_id != $target->id)
                    && !array_key_exists($target->getUri(), $activity->context->attention)) {
399 400 401 402 403 404 405 406 407
                // @todo FIXME: Please document (i18n).
                // TRANS: Client exception when ...
                throw new ClientException(_('Object not posted to this user.'));
            }
        } else {
            // TRANS: Server exception thrown when a micro app plugin uses a target that cannot be handled.
            throw new ServerException(_('Do not know how to handle this kind of target.'));
        }

408 409
        $oactor = Ostatus_profile::ensureActivityObjectProfile($activity->actor);
        $actor = $oactor->localProfile();
410

411
        // FIXME: will this work in all cases? I made it work for Favorite...
412
        if (ActivityUtils::compareVerbs($activity->verb, array(ActivityVerb::POST))) {
413 414 415 416
            $object = $activity->objects[0];
        } else {
            $object = $activity;
        }
417 418 419

        $options = array('uri' => $object->id,
                         'url' => $object->link,
420
                         'self' => $object->selfLink,
421 422 423
                         'is_local' => Notice::REMOTE,
                         'source' => 'ostatus');

424
        $notice = $this->saveNoticeFromActivity($activity, $actor, $options);
425 426 427 428 429 430 431

        return false;
    }

    /**
     * Handle object posted via AtomPub
     *
432
     * @param Activity  $activity Activity that was posted
433
     * @param Profile   $scoped   Profile of user posting
434 435 436 437
     * @param Notice   &$notice   Resulting notice
     *
     * @return boolean hook value
     */
438
    public function onStartAtomPubNewActivity(Activity $activity, Profile $scoped, Notice &$notice=null)
439 440 441 442 443 444 445
    {
        if (!$this->isMyActivity($activity)) {
            return true;
        }

        $options = array('source' => 'atompub');

446 447
        $notice = $this->saveNoticeFromActivity($activity, $scoped, $options);

448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471
        return false;
    }

    /**
     * Handle object imported from a backup file
     *
     * @param User           $user     User to import for
     * @param ActivityObject $author   Original author per import file
     * @param Activity       $activity Activity to import
     * @param boolean        $trusted  Is this a trusted user?
     * @param boolean        &$done    Is this done (success or unrecoverable error)
     *
     * @return boolean hook value
     */
    function onStartImportActivity($user, $author, Activity $activity, $trusted, &$done)
    {
        if (!$this->isMyActivity($activity)) {
            return true;
        }

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

        $options = array('uri' => $object->id,
                         'url' => $object->link,
472
                         'self' => $object->selfLink,
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525
                         'source' => 'restore');

        // $user->getProfile() is a Profile
        $saved = $this->saveNoticeFromActivity($activity,
                                               $user->getProfile(),
                                               $options);

        if (!empty($saved)) {
            $done = true;
        }

        return false;
    }

    /**
     * Event handler gives the plugin a chance to add custom
     * Atom XML ActivityStreams output from a previously filled-out
     * ActivityObject.
     *
     * The atomOutput method is called if it's one of
     * our matching types.
     *
     * @param ActivityObject $obj
     * @param XMLOutputter $out to add elements at end of object
     * @return boolean hook return value
     */
    function onEndActivityObjectOutputAtom(ActivityObject $obj, XMLOutputter $out)
    {
        if (in_array($obj->type, $this->types())) {
            $this->activityObjectOutputAtom($obj, $out);
        }
        return true;
    }

    /**
     * Event handler gives the plugin a chance to add custom
     * JSON ActivityStreams output from a previously filled-out
     * ActivityObject.
     *
     * The activityObjectOutputJson method is called if it's one of
     * our matching types.
     *
     * @param ActivityObject $obj
     * @param array &$out JSON-targeted array which can be modified
     * @return boolean hook return value
     */
    function onEndActivityObjectOutputJson(ActivityObject $obj, array &$out)
    {
        if (in_array($obj->type, $this->types())) {
            $this->activityObjectOutputJson($obj, $out);
        }
        return true;
    }
526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557

    public function onStartOpenNoticeListItemElement(NoticeListItem $nli)
    {   
        if (!$this->isMyNotice($nli->notice)) {
            return true;
        }

        $this->openNoticeListItemElement($nli);

        Event::handle('EndOpenNoticeListItemElement', array($nli));
        return false;
    }

    public function onStartCloseNoticeListItemElement(NoticeListItem $nli)
    {   
        if (!$this->isMyNotice($nli->notice)) {
            return true;
        }

        $this->closeNoticeListItemElement($nli);

        Event::handle('EndCloseNoticeListItemElement', array($nli));
        return false;
    }

    protected function openNoticeListItemElement(NoticeListItem $nli)
    {
        $id = (empty($nli->repeat)) ? $nli->notice->id : $nli->repeat->id;
        $class = 'h-entry notice ' . $this->tag();
        if ($nli->notice->scope != 0 && $nli->notice->scope != 1) {
            $class .= ' limited-scope';
        }
558
        try {
mmn's avatar
mmn committed
559
            $class .= ' notice-source-'.common_to_alphanumeric($nli->notice->source);
560 561 562
        } catch (Exception $e) {
            // either source or what we filtered out was a zero-length string
        }
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582
        $nli->out->elementStart('li', array('class' => $class,
                                            'id' => 'notice-' . $id));
    }

    protected function closeNoticeListItemElement(NoticeListItem $nli)
    {
        $nli->out->elementEnd('li');
    }


    // FIXME: This is overriden in MicroAppPlugin but shouldn't have to be
    public function onStartShowNoticeItem(NoticeListItem $nli)
    {   
        if (!$this->isMyNotice($nli->notice)) {
            return true;
        }

        try {
            $this->showNoticeListItem($nli);
        } catch (Exception $e) {
583
            common_log(LOG_ERR, 'Error showing notice '.$nli->getNotice()->getID().': ' . $e->getMessage());
mmn's avatar
mmn committed
584
            $nli->out->element('p', 'error', sprintf(_('Error showing notice: %s'), $e->getMessage()));
585 586 587 588 589 590 591 592
        }

        Event::handle('EndShowNoticeItem', array($nli));
        return false;
    }

    protected function showNoticeListItem(NoticeListItem $nli)
    {
593 594 595
        $nli->showNoticeHeaders();
        $nli->showContent();
        $nli->showNoticeFooter();
596 597
    }

598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617
    public function onStartShowNoticeItemNotice(NoticeListItem $nli)
    {
        if (!$this->isMyNotice($nli->notice)) {
            return true;
        }

        $this->showNoticeItemNotice($nli);

        Event::handle('EndShowNoticeItemNotice', array($nli));
        return false;
    }

    protected function showNoticeItemNotice(NoticeListItem $nli)
    {
        $nli->showNoticeTitle();
        $nli->showAuthor();
        $nli->showAddressees();
        $nli->showContent();
    }

618 619 620 621 622 623
    public function onStartShowNoticeContent(Notice $stored, HTMLOutputter $out, Profile $scoped=null)
    {
        if (!$this->isMyNotice($stored)) {
            return true;
        }

mmn's avatar
mmn committed
624 625 626 627 628
        try {
            $this->showNoticeContent($stored, $out, $scoped);
        } catch (Exception $e) {
            $out->element('div', 'error', $e->getMessage());
        }
629 630
        return false;
    }
631 632 633 634 635

    protected function showNoticeContent(Notice $stored, HTMLOutputter $out, Profile $scoped=null)
    {
        $out->text($stored->getContent());
    }
636
}