Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.75% covered (danger)
6.75%
11 / 163
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageChangeHooks
6.75% covered (danger)
6.75%
11 / 163
0.00% covered (danger)
0.00%
0 / 10
759.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 sendEvents
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onPageSaveComplete
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 onPageMoveComplete
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 onPageDelete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onPageDeleteComplete
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 onPageUndeleteComplete
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleRevisionVisibilitySet
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
72
 isSecretRevisionVisibilityChange
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 lookupRedirectTarget
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @author Andrew Otto <otto@wikimedia.org>
20 */
21
22namespace MediaWiki\Extension\EventBus\HookHandlers\MediaWiki;
23
24use Exception;
25use IDBAccessObject;
26use InvalidArgumentException;
27use ManualLogEntry;
28use MediaWiki\Config\Config;
29use MediaWiki\Content\ContentHandlerFactory;
30use MediaWiki\Deferred\DeferredUpdates;
31use MediaWiki\Extension\EventBus\EventBusFactory;
32use MediaWiki\Extension\EventBus\Redirects\RedirectTarget;
33use MediaWiki\Extension\EventBus\Serializers\EventSerializer;
34use MediaWiki\Extension\EventBus\Serializers\MediaWiki\PageChangeEventSerializer;
35use MediaWiki\Extension\EventBus\Serializers\MediaWiki\PageEntitySerializer;
36use MediaWiki\Extension\EventBus\Serializers\MediaWiki\RevisionEntitySerializer;
37use MediaWiki\Extension\EventBus\Serializers\MediaWiki\RevisionSlotEntitySerializer;
38use MediaWiki\Extension\EventBus\Serializers\MediaWiki\UserEntitySerializer;
39use MediaWiki\Hook\ArticleRevisionVisibilitySetHook;
40use MediaWiki\Hook\PageMoveCompleteHook;
41use MediaWiki\Http\Telemetry;
42use MediaWiki\Logger\LoggerFactory;
43use MediaWiki\Page\Hook\PageDeleteCompleteHook;
44use MediaWiki\Page\Hook\PageDeleteHook;
45use MediaWiki\Page\Hook\PageUndeleteCompleteHook;
46use MediaWiki\Page\PageLookup;
47use MediaWiki\Page\PageReference;
48use MediaWiki\Page\ProperPageIdentity;
49use MediaWiki\Page\RedirectLookup;
50use MediaWiki\Page\WikiPageFactory;
51use MediaWiki\Permissions\Authority;
52use MediaWiki\Revision\RevisionRecord;
53use MediaWiki\Revision\RevisionStore;
54use MediaWiki\Storage\Hook\PageSaveCompleteHook;
55use MediaWiki\Title\TitleFormatter;
56use MediaWiki\User\UserFactory;
57use MediaWiki\User\UserGroupManager;
58use Psr\Log\LoggerInterface;
59use RequestContext;
60use StatusValue;
61use Wikimedia\UUID\GlobalIdGenerator;
62use WikiPage;
63
64/**
65 * HookHandler for sending mediawiki/page/change events
66 * that represent changes to the current state of how a MediaWiki Page
67 * looks to a non-logged-in / anonymous / public user.
68 *
69 * In MediaWiki, what 'state' is part of the Page is not clearly defined,
70 * so we make some choices.
71 * - Updates to past revisions (e.g. deleting old revisions) are not included.
72 * - Information about editing restrictions are not included.
73 * - Content bodies are not included here, although they may be added
74 *   in other streams via enrichment.
75 */
76class PageChangeHooks implements
77    PageSaveCompleteHook,
78    PageMoveCompleteHook,
79    PageDeleteCompleteHook,
80    PageUndeleteCompleteHook,
81    PageDeleteHook,
82    ArticleRevisionVisibilitySetHook
83{
84
85    /**
86     * Key in $mainConfig which will be used to map from EventBus owned 'stream names'
87     * to the names of the stream in EventStreamConfig.
88     * This config can be used to override the name of the stream that this
89     * HookHandler will produce.  This is mostly useful for testing and staging.
90     * NOTE: Logic to look up stream names for EventBus HookHandlers
91     * probably belongs elsewhere.  See README.md for more info.
92     */
93    public const STREAM_NAMES_MAP_CONFIG_KEY = 'EventBusStreamNamesMap';
94
95    /**
96     * Key in STREAM_NAMES_MAP_CONFIG_KEY that maps to the name of the stream in EventStreamConfig
97     * that this HookHandler will produce to.  This is mostly useful for testing and staging;
98     * in normal operation this does not need to be set and PAGE_CHANGE_STREAM_NAME_DEFAULT will be used.
99     */
100    private const STREAM_NAMES_MAP_PAGE_CHANGE_KEY = 'mediawiki_page_change';
101
102    /**
103     * Default value for the mediawiki page_change stream.
104     * This is used unless STREAM_NAMES_MAP_PAGE_CHANGE_KEY is set in
105     * STREAM_NAMES_MAP_CONFIG_KEY in $mainConfig.
106     * Note that this is a versioned stream name.
107     * The version suffix should match the stream's schema's major version.
108     * See: https://wikitech.wikimedia.org/wiki/Event_Platform/Stream_Configuration#Stream_versioning
109     */
110    public const PAGE_CHANGE_STREAM_NAME_DEFAULT = 'mediawiki.page_change.v1';
111
112    /**
113     * Name of the stream that events will be produced to.
114     * @var string
115     */
116    private string $streamName;
117
118    /**
119     * @var LoggerInterface
120     */
121    private LoggerInterface $logger;
122
123    /**
124     * @var EventBusFactory
125     */
126    private EventBusFactory $eventBusFactory;
127
128    /**
129     * @var PageChangeEventSerializer
130     */
131    private PageChangeEventSerializer $pageChangeEventSerializer;
132
133    /**
134     * @var WikiPageFactory
135     */
136    private WikiPageFactory $wikiPageFactory;
137
138    /**
139     * @var UserFactory
140     */
141    private UserFactory $userFactory;
142
143    /**
144     * @var RevisionStore
145     */
146    private RevisionStore $revisionStore;
147
148    /**
149     * @var RedirectLookup
150     */
151    private RedirectLookup $redirectLookup;
152
153    /**
154     * @var PageLookup
155     */
156    private PageLookup $pageLookup;
157
158    /**
159     * Temporarily holds a map of page ID to redirect target between
160     * {@link onPageDelete} and {@link onPageDeleteComplete}.
161     * @var array<int, RedirectTarget>
162     */
163    private array $deletedPageRedirectTarget = [];
164
165    /**
166     * @param EventBusFactory $eventBusFactory
167     * @param Config $mainConfig
168     * @param GlobalIdGenerator $globalIdGenerator
169     * @param UserGroupManager $userGroupManager
170     * @param TitleFormatter $titleFormatter
171     * @param WikiPageFactory $wikiPageFactory
172     * @param UserFactory $userFactory
173     * @param RevisionStore $revisionStore
174     * @param ContentHandlerFactory $contentHandlerFactory
175     * @param RedirectLookup $redirectLookup
176     * @param PageLookup $pageLookup
177     */
178    public function __construct(
179        EventBusFactory $eventBusFactory,
180        Config $mainConfig,
181        GlobalIdGenerator $globalIdGenerator,
182        UserGroupManager $userGroupManager,
183        TitleFormatter $titleFormatter,
184        WikiPageFactory $wikiPageFactory,
185        UserFactory $userFactory,
186        RevisionStore $revisionStore,
187        ContentHandlerFactory $contentHandlerFactory,
188        RedirectLookup $redirectLookup,
189        PageLookup $pageLookup
190    ) {
191        $this->logger = LoggerFactory::getInstance( self::class );
192
193        // If EventBusStreamNamesMap is set, then get it out of mainConfig, else use an empty array.
194        $streamNamesMap = $mainConfig->has( self::STREAM_NAMES_MAP_CONFIG_KEY ) ?
195            $mainConfig->get( self::STREAM_NAMES_MAP_CONFIG_KEY ) : [];
196        // Get the name of the page change stream this HookHandler should produce,
197        // otherwise use PAGE_CHANGE_STREAM_NAME_DEFAULT
198        $this->streamName = $streamNamesMap[self::STREAM_NAMES_MAP_PAGE_CHANGE_KEY]
199            ?? self::PAGE_CHANGE_STREAM_NAME_DEFAULT;
200
201        $this->eventBusFactory = $eventBusFactory;
202
203        $userEntitySerializer = new UserEntitySerializer( $userFactory, $userGroupManager );
204
205        $this->pageChangeEventSerializer = new PageChangeEventSerializer(
206            new EventSerializer( $mainConfig, $globalIdGenerator, Telemetry::getInstance() ),
207            new PageEntitySerializer( $mainConfig, $titleFormatter ),
208            $userEntitySerializer,
209            new RevisionEntitySerializer(
210                new RevisionSlotEntitySerializer( $contentHandlerFactory ),
211                $userEntitySerializer
212            )
213        );
214
215        $this->wikiPageFactory = $wikiPageFactory;
216        $this->userFactory = $userFactory;
217        $this->revisionStore = $revisionStore;
218        $this->redirectLookup = $redirectLookup;
219        $this->pageLookup = $pageLookup;
220    }
221
222    /**
223     * Sends the events to the stream in a DeferredUPdate via the EventBus
224     * configured for the stream.
225     * NOTE: All events here must be destined to be sent $streamName.
226     * Do not use this function to send a batch of events to different streams.
227     *
228     * @param string $streamName
229     *
230     * @param array $events
231     *        This must be given as a list of events.
232     *
233     * @return void
234     * @throws Exception
235     */
236    private function sendEvents(
237        string $streamName,
238        array $events
239    ): void {
240        $eventBus = $this->eventBusFactory->getInstanceForStream( $streamName );
241        DeferredUpdates::addCallableUpdate( static function () use ( $eventBus, $events ) {
242            $eventBus->send( $events );
243        } );
244    }
245
246    /**
247     * @inheritDoc
248     */
249    public function onPageSaveComplete(
250        $wikiPage,
251        $user,
252        $summary,
253        $flags,
254        $revisionRecord,
255        $editResult
256    ) {
257        // Null edits are only useful to trigger side-effects, and would be
258        //   confusing to consumers of these events.  Since these would not be able to
259        //   change page state, they also don't belong in here.  If filtering them out
260        //   breaks a downstream consumer, we should send them to a different stream.
261        if ( $editResult->isNullEdit() ) {
262            return;
263        }
264
265        $performer = $this->userFactory->newFromUserIdentity( $user );
266
267        $redirectTarget = self::lookupRedirectTarget( $wikiPage, $this->pageLookup, $this->redirectLookup );
268
269        if ( $flags & EDIT_NEW ) {
270            // New page state change event for page create
271            $event = $this->pageChangeEventSerializer->toCreateEvent(
272                $this->streamName,
273                $wikiPage,
274                $performer,
275                $revisionRecord,
276                $redirectTarget
277            );
278
279        } else {
280            $event = $this->pageChangeEventSerializer->toEditEvent(
281                $this->streamName,
282                $wikiPage,
283                $performer,
284                $revisionRecord,
285                $redirectTarget,
286                $this->revisionStore->getRevisionById( $revisionRecord->getParentId() )
287            );
288        }
289
290        $this->sendEvents( $this->streamName, [ $event ] );
291    }
292
293    /**
294     * @inheritDoc
295     */
296    public function onPageMoveComplete(
297        $oldTitle,
298        $newTitle,
299        $user,
300        $pageid,
301        $redirid,
302        $reason,
303        $revision
304    ) {
305        // While we have $newTitle, serialization is going to ask for that information from the WikiPage.
306        // We have to read latest to ensure we are seeing the moved page.
307        $wikiPage = $this->wikiPageFactory->newFromID( $pageid, IDBAccessObject::READ_LATEST );
308
309        if ( $wikiPage == null ) {
310            throw new InvalidArgumentException( "No page moved from '$oldTitle' to '$newTitle"
311                . " with ID $pageid could be found" );
312        }
313
314        $performer = $this->userFactory->newFromUserIdentity( $user );
315
316        $redirectTarget = self::lookupRedirectTarget( $wikiPage, $this->pageLookup, $this->redirectLookup );
317
318        $createdRedirectWikiPage = $redirid ? $this->wikiPageFactory->newFromID( $redirid ) : null;
319
320        // The parentRevision is needed since a page move creates a new revision.
321        $parentRevision = $this->revisionStore->getRevisionById( $revision->getParentId() );
322
323        // NOTE: $newTitle not needed by pageChangeEventSerializer,
324        //this is obtained via $wikiPage.
325        $event = $this->pageChangeEventSerializer->toMoveEvent(
326            $this->streamName,
327            $wikiPage,
328            $performer,
329            $revision,
330            $parentRevision,
331            $oldTitle,
332            $reason,
333            $createdRedirectWikiPage,
334            $redirectTarget
335        );
336
337        $this->sendEvents( $this->streamName, [ $event ] );
338    }
339
340    public function onPageDelete(
341        ProperPageIdentity $page,
342        Authority $deleter,
343        string $reason,
344        StatusValue $status,
345        bool $suppress
346    ) {
347        $this->deletedPageRedirectTarget[$page->getId()] =
348            self::lookupRedirectTarget( $page, $this->pageLookup, $this->redirectLookup );
349    }
350
351    // Supercedes ArticleDeleteComplete
352
353    /**
354     * @inheritDoc
355     * @throws Exception
356     */
357    public function onPageDeleteComplete(
358        ProperPageIdentity $page,
359        Authority $deleter,
360        string $reason,
361        int $pageID,
362        RevisionRecord $deletedRev,
363        ManualLogEntry $logEntry,
364        int $archivedRevisionCount
365    ) {
366        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
367        $isSuppression = $logEntry->getType() === 'suppress';
368
369        // Don't set performer in the event if this delete suppresses the page from other admins.
370        // https://phabricator.wikimedia.org/T342487
371        $performerForEvent = $isSuppression ? null : $this->userFactory->newFromAuthority( $deleter );
372
373        $event = $this->pageChangeEventSerializer->toDeleteEvent(
374            $this->streamName,
375            $wikiPage,
376            $performerForEvent,
377            $deletedRev,
378            $reason,
379            $logEntry->getTimestamp(),
380            $archivedRevisionCount,
381            $this->deletedPageRedirectTarget[$page->getId()] ?? null,
382            $isSuppression
383        );
384
385        $this->sendEvents( $this->streamName, [ $event ] );
386
387        unset( $this->deletedPageRedirectTarget[$page->getId()] );
388    }
389
390    /**
391     * @inheritDoc
392     * @throws Exception
393     */
394    public function onPageUndeleteComplete(
395        ProperPageIdentity $page,
396        Authority $restorer,
397        string $reason,
398        RevisionRecord $restoredRev,
399        ManualLogEntry $logEntry,
400        int $restoredRevisionCount,
401        bool $created,
402        array $restoredPageIds
403    ): void {
404        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
405        $performer = $this->userFactory->newFromAuthority( $restorer );
406
407        $redirectTarget = self::lookupRedirectTarget( $wikiPage, $this->pageLookup, $this->redirectLookup );
408
409        // Send page change undelete event
410        $event = $this->pageChangeEventSerializer->toUndeleteEvent(
411            $this->streamName,
412            $wikiPage,
413            $performer,
414            $restoredRev,
415            $reason,
416            $redirectTarget,
417            $logEntry->getTimestamp(),
418            $page->getId()
419        );
420
421        $this->sendEvents( $this->streamName, [ $event ] );
422    }
423
424    /**
425     * @inheritDoc
426     */
427    public function onArticleRevisionVisibilitySet(
428        $title,
429        $revIds,
430        $visibilityChangeMap
431    ) {
432        // https://phabricator.wikimedia.org/T321411
433        $performer = RequestContext::getMain()->getUser();
434        $performer->loadFromId();
435
436        // Only send an event if the visible-ness of the current revision has changed.
437        foreach ( $revIds as $revId ) {
438            // Read from primary since due to replication lag the updated field visibility
439            // might not yet be available on a replica, and we are at risk of leaking
440            // just suppressed data.
441            $revisionRecord = $this->revisionStore->getRevisionById(
442                $revId,
443                IDBAccessObject::READ_LATEST
444            );
445
446            if ( $revisionRecord === null ) {
447                $this->logger->warning(
448                    'revision ' . $revId . ' for page ' . $title->getId() .
449                    ' could not be loaded from database and may have been deleted.' .
450                    ' Cannot create visibility change event for ' . $this->streamName . '.'
451                );
452                continue;
453            } elseif ( !array_key_exists( $revId, $visibilityChangeMap ) ) {
454                // This should not happen, log it.
455                $this->logger->error(
456                    'revision ' . $revId . ' for page ' . $title->getId() .
457                    ' not found in visibilityChangeMap.' .
458                    ' Cannot create visibility change event for ' . $this->streamName . '.'
459                );
460                continue;
461            }
462
463            // If this is the current revision of the page,
464            // then we need to represent the fact that the visibility
465            // properties of the current state of the page has changed.
466            // Emit a page change visibility_change event.
467            if ( $revisionRecord->isCurrent() ) {
468
469                $visibilityChanges = $visibilityChangeMap[$revId];
470
471                // current revision's visibility should be the same as we are given in
472                // $visibilityChanges['newBits']. Just in case, assert that this is true.
473                if ( $revisionRecord->getVisibility() != $visibilityChanges['newBits'] ) {
474                    throw new InvalidArgumentException(
475                        "Current revision $revId's' visibility did not match the expected " .
476                        'visibility change provided by hook. Current revision visibility is ' .
477                        $revisionRecord->getVisibility() . '. visibility changed to ' .
478                        $visibilityChanges['newBits']
479                    );
480                }
481
482                // We only need to emit an event if visibility has actually changed.
483                if ( $visibilityChanges['newBits'] === $visibilityChanges['oldBits'] ) {
484                    $this->logger->warning(
485                        "onArticleRevisionVisibilitySet called on revision $revId " .
486                        'when no effective visibility change was made.'
487                    );
488                }
489
490                $wikiPage = $this->wikiPageFactory->newFromTitle( $title );
491
492                // If this revision is 'suppressed' AKA restricted, then the person performing
493                // 'RevisionDelete' should not be visible in public data.
494                // https://phabricator.wikimedia.org/T342487
495                //
496                // NOTE: This event stream tries to match the visibility of MediaWiki core logs,
497                // where regular delete/revision events are public, and suppress/revision events
498                // are private. In MediaWiki core logs, private events are fully hidden from
499                // the public.  Here, we need to produce a 'private' event to the
500                // mediawiki.page_change stream, to indicate to consumers that
501                // they should also 'suppress' the revision.  When this is done, we need to
502                // make sure that we do not reproduce the data that has been suppressed
503                // in the event itself.  E.g. if the username of the editor of the revision has been
504                // suppressed, we should not include any information about that editor in the event.
505                $performerForEvent = self::isSecretRevisionVisibilityChange(
506                    $visibilityChangeMap[$revId]['oldBits'],
507                    $visibilityChangeMap[$revId]['newBits']
508                ) ? null : $performer;
509
510                $event = $this->pageChangeEventSerializer->toVisibilityChangeEvent(
511                    $this->streamName,
512                    $wikiPage,
513                    $performerForEvent,
514                    $revisionRecord,
515                    $visibilityChanges['oldBits'],
516                    // NOTE: ArticleRevisionVisibilitySet hook does not give us a proper event time.
517                    // The best we can do is use the current timestamp :(
518                    // https://phabricator.wikimedia.org/T321411
519                    wfTimestampNow()
520                );
521
522                $this->sendEvents( $this->streamName, [ $event ] );
523                // No need to search any further for the 'current' revision
524                break;
525            }
526        }
527    }
528
529    /**
530     * This function returns true if the visibility bits between the change require the
531     * info about the change to be redacted.
532     * https://phabricator.wikimedia.org/T342487
533     *
534     * Info about a visibility change is secret (in the secret MW action log)
535     * if the revision was either previously or currently is being suppressed.
536     * The admin performing the action should be hidden in both cases.
537     * The admin performing the action should only be shown if the change is not
538     * affecting the revision's suppression status.
539     * https://phabricator.wikimedia.org/T342487#9292715
540     *
541     * @param int $oldBits
542     * @param int $newBits
543     * @return bool
544     */
545    public static function isSecretRevisionVisibilityChange( int $oldBits, int $newBits ) {
546        return $oldBits & RevisionRecord::DELETED_RESTRICTED ||
547            $newBits & RevisionRecord::DELETED_RESTRICTED;
548    }
549
550    /**
551     * Returns a redirect target of supplied {@link PageReference}, if any.
552     *
553     * If the page reference does not represent a redirect, `null` is returned.
554     *
555     * See {@link RedirectTarget} for the meaning of its properties.
556     *
557     * TODO visible for testing only, move into RedirectLookup?
558     *
559     * @param PageReference $page
560     * @param PageLookup $pageLookup
561     * @param RedirectLookup $redirectLookup
562     * @return RedirectTarget|null
563     * @see RedirectTarget
564     */
565    public static function lookupRedirectTarget(
566        PageReference $page,
567        PageLookup $pageLookup,
568        RedirectLookup $redirectLookup
569    ): ?RedirectTarget {
570        if ( $page instanceof WikiPage ) {
571            // RedirectLookup doesn't support reading from the primary db, but we
572            // need the value from the new edit. Fetch directly through WikiPage which
573            // was updated with the new value as part of saving the new revision.
574            $redirectLinkTarget = $page->getRedirectTarget();
575        } else {
576            $redirectSourcePageReference = $pageLookup->getPageByReference( $page, IDBAccessObject::READ_LATEST );
577
578            $redirectLinkTarget = $redirectSourcePageReference != null && $redirectSourcePageReference->isRedirect()
579                ? $redirectLookup->getRedirectTarget( $redirectSourcePageReference )
580                : null;
581        }
582
583        if ( $redirectLinkTarget != null ) {
584            if ( !$redirectLinkTarget->isExternal() ) {
585                try {
586                    $redirectTargetPage = $pageLookup->getPageForLink( $redirectLinkTarget );
587                    return new RedirectTarget( $redirectLinkTarget, $redirectTargetPage );
588                } catch ( InvalidArgumentException $e ) {
589                    // silently ignore failed lookup, they are expected for anything but page targets
590                }
591            }
592            return new RedirectTarget( $redirectLinkTarget );
593        }
594
595        return null;
596    }
597
598}