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

activityhandlerplugin.php 21 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 107 108 109 110 111 112
    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
        return ActivityUtils::compareTypes($verb, $this->verbs());
    }

    function isMyType($type) {
        return count($this->types())===0 || ActivityUtils::compareTypes($type, $this->types());
    }
113 114 115 116 117 118

    /**
     * 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.
     *
119
     * This function is deprecated and in the future, Notice::saveActivity
120 121
     * should be called from onStartHandleFeedEntryWithProfile in this class
     * (which instead turns to saveObjectFromActivity).
122 123 124 125 126 127 128
     *
     * @param Activity $activity
     * @param Profile $actor
     * @param array $options=array()
     *
     * @return Notice the resulting notice
     */
129 130 131
    public function saveNoticeFromActivity(Activity $activity, Profile $actor, array $options=array())
    {
        // Any plugin which has not implemented saveObjectFromActivity _must_
132 133
        // override this function until they are migrated (this function will
        // be deleted when all plugins are migrated to saveObjectFromActivity).
134 135 136 137 138 139 140 141

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

142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
    /**
    * 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
    * @param Profile $actor
    * @param array $options=array()
    *
    * @return Notice the resulting notice
    */
168
    protected function saveObjectFromActivity(Activity $activity, Notice $stored, array $options=array())
169 170 171 172
    {
        throw new ServerException('This function should be abstract when all plugins have migrated to saveObjectFromActivity');
    }

173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
    /*
     * 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.
     */
    public function onStoreActivityObject(Activity $act, Notice $stored, array $options=array(), &$object) {
        // $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);
        try {
            $act->context->attention = array_merge($act->context->attention, $object->getAttentionArray());
        } catch (Exception $e) {
            common_debug('WARNING: Could not get attention list from object '.get_class($object).'!');
        }
        return false;
    }
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 223 224 225

    /**
     * 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);

226 227 228 229 230
    protected function notifyMentioned(Notice $stored, array &$mentioned_ids)
    {
        // pass through silently by default
    }

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 277 278
    /**
     * 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
     */
    function onNoticeDeleteRelated(Notice $notice)
    {
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293
        if ($this->isMyNotice($notice)) {
            $this->deleteRelated($notice);
        }

        // 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)) {
294 295 296
            return true;
        }

297 298 299 300
        $this->notifyMentioned($stored, $mentioned_ids);

        // If it was _our_ notice, only we should do anything with the mentions.
        return false;
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
    }

    /**
     * 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;
        }

317 318 319 320 321
        try {
            $object = $this->activityObjectFromNotice($notice);
        } catch (NoResultException $e) {
            $object = null; // because getKV returns null on failure
        }
322 323 324 325 326 327 328
        return false;
    }

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

339 340
        // We are guaranteed to get a Profile back from checkAuthorship (or it throws an exception)
        $profile = ActivityUtils::checkAuthorship($activity, $profile);
341 342 343 344 345 346 347 348

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

        $options = array('uri' => $object->id,
                         'url' => $object->link,
                         'is_local' => Notice::REMOTE,
                         'source' => 'ostatus');

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

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

        $this->log(LOG_INFO, "Checking {$activity->id} as a valid Salmon slap.");

375
        if ($target instanceof User_group || $target->isGroup()) {
376 377 378 379 380 381
            $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.'));
            }
382
        } elseif ($target instanceof Profile && $target->isLocal()) {
383
            $original = null;
384 385 386 387 388 389 390
            // FIXME: Shouldn't favorites show up with a 'target' activityobject?
            if (!ActivityUtils::compareTypes($activity->verb, array(ActivityVerb::POST)) && isset($activity->objects[0])) {
                // 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;
                }
            }
391 392 393
            if (!empty($activity->context->replyToID)) {
                $original = Notice::getKV('uri', $activity->context->replyToID);
            }
394 395
            if ((!$original instanceof Notice || $original->profile_id != $target->id)
                    && !array_key_exists($target->getUri(), $activity->context->attention)) {
396 397 398 399 400 401 402 403 404
                // @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.'));
        }

405 406
        $oactor = Ostatus_profile::ensureActivityObjectProfile($activity->actor);
        $actor = $oactor->localProfile();
407

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

        $options = array('uri' => $object->id,
                         'url' => $object->link,
                         'is_local' => Notice::REMOTE,
                         'source' => 'ostatus');

420
        if (!isset($this->oldSaveNew)) {
421
            $notice = Notice::saveActivity($activity, $actor, $options);
422
        } else {
423
            $notice = $this->saveNoticeFromActivity($activity, $actor, $options);
424
        }
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 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 526 527

        return false;
    }

    /**
     * Handle object posted via AtomPub
     *
     * @param Activity &$activity Activity that was posted
     * @param User     $user      User that posted it
     * @param Notice   &$notice   Resulting notice
     *
     * @return boolean hook value
     */
    function onStartAtomPubNewActivity(Activity &$activity, $user, &$notice)
    {
        if (!$this->isMyActivity($activity)) {
            return true;
        }

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

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

        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,
                         '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;
    }
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 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608

    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';
        }
        $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) {
            $nli->out->element('p', 'error', 'Error showing notice: '.htmlspecialchars($e->getMessage()));
        }

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

    protected function showNoticeListItem(NoticeListItem $nli)
    {
        $nli->showNotice();
        $nli->showNoticeAttachments();
        $nli->showNoticeInfo();
        $nli->showNoticeOptions();

        $nli->showNoticeLink();
        $nli->showNoticeSource();
        $nli->showNoticeLocation();
        $nli->showContext();
        $nli->showRepeat();

        $nli->showNoticeOptions();
    }

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

609
        $this->showNoticeContent($stored, $out, $scoped);
610 611
        return false;
    }
612 613 614 615 616

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