Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.27% covered (success)
92.27%
167 / 181
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageChangeEventIngress
92.27% covered (success)
92.27%
167 / 181
66.67% covered (warning)
66.67%
6 / 9
33.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 lookupRedirectTarget
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
7.08
 sendEvents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 handlePageLatestRevisionChangedEvent
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
5
 isContentChangeCause
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 handlePageDeletedEvent
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 handlePageMovedEvent
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 handlePageCreatedEvent
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 handlePageHistoryVisibilityChangedEvent
69.44% covered (warning)
69.44%
25 / 36
0.00% covered (danger)
0.00%
0 / 1
5.71
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 * @author Gabriele Modena <gmodena@wikimedia.org>
21 */
22
23declare( strict_types=1 );
24
25namespace MediaWiki\Extension\EventBus\MediaWikiEventSubscribers;
26
27use InvalidArgumentException;
28use MediaWiki\Deferred\DeferredUpdates;
29use MediaWiki\DomainEvent\DomainEventIngress;
30use MediaWiki\Extension\EventBus\Entity\PageLink;
31use MediaWiki\Extension\EventBus\EventBusFactory;
32use MediaWiki\Extension\EventBus\GlobalEditCountLookup;
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\PageLinkEntitySerializer;
37use MediaWiki\Extension\EventBus\Serializers\MediaWiki\RevisionEntitySerializer;
38use MediaWiki\Extension\EventBus\Serializers\MediaWiki\RevisionSlotsEntitySerializer;
39use MediaWiki\Extension\EventBus\Serializers\MediaWiki\UserEntitySerializer;
40use MediaWiki\Extension\EventBus\StreamNameMapper;
41use MediaWiki\Extension\EventBus\WikibaseItemIdLookup;
42use MediaWiki\Logger\LoggerFactory;
43use MediaWiki\Page\Event\PageCreatedEvent;
44use MediaWiki\Page\Event\PageCreatedListener;
45use MediaWiki\Page\Event\PageDeletedEvent;
46use MediaWiki\Page\Event\PageDeletedListener;
47use MediaWiki\Page\Event\PageHistoryVisibilityChangedEvent;
48use MediaWiki\Page\Event\PageHistoryVisibilityChangedListener;
49use MediaWiki\Page\Event\PageLatestRevisionChangedEvent;
50use MediaWiki\Page\Event\PageLatestRevisionChangedListener;
51use MediaWiki\Page\Event\PageMovedEvent;
52use MediaWiki\Page\Event\PageMovedListener;
53use MediaWiki\Page\PageLookup;
54use MediaWiki\Page\PageReference;
55use MediaWiki\Page\RedirectLookup;
56use MediaWiki\Page\WikiPage;
57use MediaWiki\Revision\RevisionStore;
58use MediaWiki\Storage\PageUpdateCauses;
59use Psr\Log\LoggerInterface;
60use RuntimeException;
61use UnexpectedValueException;
62use Wikimedia\Rdbms\IDBAccessObject;
63use Wikimedia\Timestamp\TimestampException;
64
65/**
66 * Handles PageRevisionUpdated events by forwarding page edits to EventGate.
67 */
68class PageChangeEventIngress extends DomainEventIngress implements
69    PageLatestRevisionChangedListener,
70    PageDeletedListener,
71    PageMovedListener,
72    PageCreatedListener,
73    PageHistoryVisibilityChangedListener
74{
75    public const PAGE_CHANGE_STREAM_NAME_DEFAULT = "mediawiki.page_change.v1";
76
77    /**
78     * Name of the stream that events will be produced to.
79     * @var string
80     */
81    private string $streamName;
82
83    /**
84     * @var LoggerInterface
85     */
86    private LoggerInterface $logger;
87
88    /**
89     * @var EventBusFactory
90     */
91    private EventBusFactory $eventBusFactory;
92
93    /**
94     * @var PageChangeEventSerializer
95     */
96    private PageChangeEventSerializer $pageChangeEventSerializer;
97
98    /**
99     * @var RevisionStore
100     */
101    private RevisionStore $revisionStore;
102
103    /**
104     * @var RedirectLookup
105     */
106    private RedirectLookup $redirectLookup;
107
108    /**
109     * @var PageLookup
110     */
111    private PageLookup $pageLookup;
112
113    public function __construct(
114        EventBusFactory $eventBusFactory,
115        StreamNameMapper $streamNameMapper,
116        EventSerializer $eventSerializer,
117        PageEntitySerializer $pageEntitySerializer,
118        PageLinkEntitySerializer $pageLinkEntitySerializer,
119        UserEntitySerializer $userEntitySerializer,
120        GlobalEditCountLookup $globalEditCountLookup,
121        WikibaseItemIdLookup $wikibaseItemIdLookup,
122        RevisionEntitySerializer $revisionEntitySerializer,
123        RevisionSlotsEntitySerializer $revisionSlotsEntitySerializer,
124        RevisionStore $revisionStore,
125        RedirectLookup $redirectLookup,
126        PageLookup $pageLookup,
127    ) {
128        $this->logger = LoggerFactory::getInstance( 'EventBus.PageChangeEventIngress' );
129
130        $this->streamName = $streamNameMapper->resolve(
131            self::PAGE_CHANGE_STREAM_NAME_DEFAULT
132        );
133
134        $this->eventBusFactory = $eventBusFactory;
135
136        $this->pageChangeEventSerializer = new PageChangeEventSerializer(
137            $eventSerializer,
138            $pageEntitySerializer,
139            $pageLinkEntitySerializer,
140            $userEntitySerializer,
141            $globalEditCountLookup,
142            $wikibaseItemIdLookup,
143            $revisionEntitySerializer,
144            $revisionSlotsEntitySerializer,
145            $revisionStore,
146        );
147
148        $this->revisionStore = $revisionStore;
149        $this->redirectLookup = $redirectLookup;
150        $this->pageLookup = $pageLookup;
151    }
152
153    /**
154     * Returns a redirect target of supplied {@link PageReference}, if any.
155     *
156     * If the page reference does not represent a redirect, `null` is returned.
157     *
158     * See {@link PageLink} for the meaning of its properties.
159     *
160     * TODO visible for testing only, move into RedirectLookup?
161     *
162     * @param PageReference $page
163     * @param PageLookup $pageLookup
164     * @param RedirectLookup $redirectLookup
165     * @return PageLink|null
166     * @see PageLink
167     */
168    public static function lookupRedirectTarget(
169        PageReference $page, PageLookup $pageLookup, RedirectLookup $redirectLookup
170    ): ?PageLink {
171        if ( $page instanceof WikiPage ) {
172            // RedirectLookup doesn't support reading from the primary db, but we
173            // need the value from the new edit. Fetch directly through WikiPage which
174            // was updated with the new value as part of saving the new revision.
175            $redirectLinkTarget = $page->getRedirectTarget();
176        } else {
177            $redirectSourcePageReference =
178                $pageLookup->getPageByReference(
179                    $page,
180                    \Wikimedia\Rdbms\IDBAccessObject::READ_LATEST
181                );
182
183            $redirectLinkTarget =
184                $redirectSourcePageReference != null && $redirectSourcePageReference->isRedirect()
185                    ? $redirectLookup->getRedirectTarget( $redirectSourcePageReference ) : null;
186        }
187
188        if ( $redirectLinkTarget != null ) {
189            if ( !$redirectLinkTarget->isExternal() ) {
190                try {
191                    $redirectTargetPage = $pageLookup->getPageForLink( $redirectLinkTarget );
192
193                    return new PageLink( $redirectLinkTarget, $redirectTargetPage );
194                } catch ( InvalidArgumentException ) {
195                    // silently ignore failed lookup, they are expected for anything but page targets
196                }
197            }
198
199            return new PageLink( $redirectLinkTarget );
200        }
201
202        return null;
203    }
204
205    private function sendEvents(
206        string $streamName,
207        array $events
208    ): void {
209        $eventBus = $this->eventBusFactory->getInstanceForStream( $streamName );
210        DeferredUpdates::addCallableUpdate( static function () use ( $eventBus, $events ) {
211            $eventBus->send( $events );
212        } );
213    }
214
215    /**
216     * Handles a `PageLatestRevisionChangedEvent` and emits a corresponding page change event.
217     *
218     * This method is triggered when a page revision is updated. It filters out
219     * null edits (which do not change the page content) and constructs either
220     * a creation or edit event for downstream consumers, depending on the nature
221     * of the change.
222     *
223     * Null edits are ignored, as they are intended only to trigger side-effects
224     * and do not represent a meaningful change to page content.
225     *
226     * @param PageLatestRevisionChangedEvent $event
227     *   The domain event carrying information about the page revision update, including
228     *   the page ID, revision data, user identity, and edit result.
229     */
230    public function handlePageLatestRevisionChangedEvent(
231        PageLatestRevisionChangedEvent $event
232    ): void {
233        if ( $this->isContentChangeCause( $event ) ) {
234            // Null edits are only useful to trigger side-effects, and would be
235            //   confusing to consumers of these events.  Since these would not be able to
236            //   change page state, they also don't belong in here.  If filtering them out
237            //   breaks a downstream consumer, we should send them to a different stream.
238            if ( $event->getEditResult() && $event->getEditResult()->isNullEdit() ) {
239                return;
240            }
241
242            $performer = $event->getPerformer();
243            $revisionRecord = $event->getLatestRevisionAfter();
244
245            $redirectTarget =
246                self::lookupRedirectTarget(
247                    $event->getPage(),
248                    $this->pageLookup,
249                    $this->redirectLookup
250                );
251
252            $pageChangeEvent = $event->isCreation()
253                ? $this->pageChangeEventSerializer->toCreateEvent(
254                    $this->streamName,
255                    $event->getPage(),
256                    $performer,
257                    $revisionRecord,
258                    $redirectTarget
259                )
260                : $this->pageChangeEventSerializer->toEditEvent(
261                    $this->streamName,
262                    $event->getPage(),
263                    $performer,
264                    $revisionRecord,
265                    $redirectTarget,
266                    $this->revisionStore->getRevisionById(
267                        $event->getPageRecordBefore()->getLatest()
268                    ),
269                    $event->getEditResult(),
270                );
271
272            $this->sendEvents( $this->streamName, [ $pageChangeEvent ] );
273        }
274    }
275
276    /**
277     * Whether $event was emitted as a result of an action that modified content;
278     * this should match the code paths that previously would trigger onPageSaveComplete
279     * callbacks.
280     *
281     * @param PageLatestRevisionChangedEvent $event
282     * @return bool
283     */
284    private function isContentChangeCause( PageLatestRevisionChangedEvent $event ): bool {
285        return $event->getCause() === PageUpdateCauses::CAUSE_EDIT ||
286            $event->getCause() === PageUpdateCauses::CAUSE_IMPORT ||
287            $event->getCause() === PageUpdateCauses::CAUSE_ROLLBACK ||
288            $event->getCause() === PageUpdateCauses::CAUSE_UNDO ||
289            $event->getCause() === PageUpdateCauses::CAUSE_UPLOAD;
290    }
291
292    /**
293     * Handle a page deletion event by creating and sending a corresponding page change event.
294     *
295     * This method processes page deletion events and transforms them into page change events
296     * that can be consumed by event subscribers. It handles both regular deletions and
297     * suppressed deletions (where performer information is withheld).
298     *
299     * The generated event includes:
300     * - Page metadata (ID, title, etc.)
301     * - Deletion details (reason, timestamp, number of revisions deleted)
302     * - Performer information (unless suppressed)
303     * - Redirect target information (if the deleted page was a redirect)
304     *
305     * For suppressed deletions (oversight/revision deletion), performer information
306     * is intentionally omitted from the event for security reasons.
307     * See: https://phabricator.wikimedia.org/T342487
308     *
309     * @param PageDeletedEvent $event The page deletion event to process
310     * @throws TimestampException
311     * @see PageChangeEventSerializer::toDeleteEvent() For the event format
312     */
313    public function handlePageDeletedEvent( PageDeletedEvent $event ): void {
314        $deletedRev = $event->getLatestRevisionBefore();
315
316        // Don't set performer in the event if this delete suppresses the page from other admins.
317        // https://phabricator.wikimedia.org/T342487
318        $performerForEvent = $event->isSuppressed() ? null : $event->getPerformer();
319
320        $redirectTarget = null;
321
322        if ( $event->wasRedirect() ) {
323            $targetBefore = $event->getRedirectTargetBefore();
324            if ( $targetBefore ) {
325                $redirectTarget = new PageLink( $targetBefore );
326            }
327        }
328
329        $pageChangeEvent = $this->pageChangeEventSerializer->toDeleteEvent(
330            $this->streamName,
331            $event->getDeletedPage(),
332            $performerForEvent,
333            $deletedRev,
334            $event->getReason(),
335            $event->getEventTimestamp()->getTimestamp(),
336            $event->getArchivedRevisionCount(),
337            $redirectTarget,
338            $event->isSuppressed()
339        );
340
341        $this->sendEvents( $this->streamName, [ $pageChangeEvent ] );
342    }
343
344    /**
345     * Handles a page moved event by generating and sending a corresponding
346     * page change event.
347     *
348     * This method processes a `PageMovedEvent`, retrieves the necessary page state
349     * before and after the move, obtains user and revision context, identifies
350     * whether a redirect was created, and serializes all this information into
351     * a page change move event.
352     *
353     * @param PageMovedEvent $event The event representing a page move, including
354     *                              references to the page before and after the move,
355     *                              the performing user, reason for the move, and
356     *                              any redirect that may have been created.
357     *
358     * @throws InvalidArgumentException If the moved-to page could not be found
359     *                                  using the latest page data.
360     */
361    public function handlePageMovedEvent( PageMovedEvent $event ): void {
362        if ( !$event->getPageRecordAfter()->exists() ) {
363            throw new InvalidArgumentException(
364                "No page moved from '{$event->getPageRecordBefore()->getDBkey()}"
365                . "to '{$event->getPageRecordAfter()->getDBkey()}'"
366                . " with ID {$event->getPageId()} could be found"
367            );
368        }
369
370        $performer = $event->getPerformer();
371
372        $redirectTarget =
373            self::lookupRedirectTarget(
374                $event->getPageRecordAfter(), $this->pageLookup,
375                $this->redirectLookup
376            );
377
378        // The parentRevision is needed since a page move creates a new revision.
379        $revision = $this->revisionStore->getRevisionById(
380            $event->getPageRecordAfter()->getLatest()
381        );
382        $parentRevision = $this->revisionStore->getRevisionById(
383            $event->getPageRecordBefore()->getLatest()
384        );
385
386        $event = $this->pageChangeEventSerializer->toMoveEvent(
387            $this->streamName,
388            $event->getPageRecordAfter(),
389            $performer,
390            $revision,
391            $parentRevision,
392            $event->getPageRecordBefore(),
393            $event->getReason(),
394            $event->getRedirectPage(),
395            $redirectTarget
396        );
397
398        $this->sendEvents( $this->streamName, [ $event ] );
399    }
400
401    /**
402     * Handles `PageCreatedEvent` emitted after a page as been undeleted
403     * (e.g. a proper undelete into a new page).
404     *
405     * @param PageCreatedEvent $event
406     * @return void
407     * @throws TimestampException
408     */
409    public function handlePageCreatedEvent( PageCreatedEvent $event ): void {
410        if ( $event->getCause() === PageUpdateCauses::CAUSE_UNDELETE ) {
411            $performer = $event->getPerformer();
412
413            $redirectTarget =
414                self::lookupRedirectTarget(
415                    $event->getPageRecordAfter(),
416                    $this->pageLookup,
417                    $this->redirectLookup
418                );
419
420            // TODO: replace with $event->getPageRecordBefore()?->getId();
421            //  once EventBus CI fully adopts php 8.
422            $oldPage = $event->getPageRecordBefore();
423
424            $event = $this->pageChangeEventSerializer->toUndeleteEvent(
425                $this->streamName,
426                $event->getPageRecordAfter(),
427                $performer,
428                $event->getLatestRevisionAfter(),
429                $event->getReason(),
430                $redirectTarget,
431                $event->getEventTimestamp()->getTimestamp(),
432                ( $oldPage !== null ) ? $oldPage->getId() : null
433            );
434
435            $this->sendEvents( $this->streamName, [ $event ] );
436        }
437    }
438
439    /**
440     * Handles `PageHistoryVisibilityChangedEvent` events.
441     *
442     * This method checks whether the visibility of the current revision of a page has changed.
443     * If so, it emits a corresponding `visibility_change` event to the configured stream.
444     *
445     * Notes:
446     * - Uses primary DB reads to prevent leaking suppressed data due to replication lag.
447     * - Emits private events when suppression occurs to match MediaWiki log visibility conventions.
448     *
449     * @param PageHistoryVisibilityChangedEvent $event
450     *
451     * @throws RuntimeException
452     * @throws UnexpectedValueException
453     * @throws TimestampException
454     */
455    public function handlePageHistoryVisibilityChangedEvent( PageHistoryVisibilityChangedEvent $event ): void {
456        $pageId = $event->getPageId();
457        $currentRevId = $event->getCurrentRevisionId();
458
459        // If the visibility was not changed on the current revision of the page,
460        // then we can return early.
461        // PageChange only represents changes to the current state of the page.
462        if ( !$event->wasCurrentRevisionAffected() ) {
463            $this->logger->debug(
464                "Revision visibility on page $pageId current revision $currentRevId " .
465                "was not changed. Not emitting event."
466            );
467            return;
468        }
469
470        // Read from primary since due to replication lag the updated field visibility
471        // might not yet be available on a replica, and we are at risk of leaking
472        // just recently suppressed data.
473        $revisionRecord = $this->revisionStore->getRevisionByPageId(
474            $pageId,
475            $currentRevId,
476            IDBAccessObject::READ_LATEST
477        );
478
479        if ( $revisionRecord === null ) {
480            throw new RuntimeException(
481                "Failed looking up page $pageId revision $currentRevId " .
482                "when checking if a visibility change event should be emitted to stream " .
483                $this->streamName
484            );
485        }
486
487        // current revision's visibility should be the same as we are given in
488        // $visibilityChanges['newBits']. Just in case, assert that this is true.
489        if ( $revisionRecord->getVisibility() != $event->getVisibilityAfter( $currentRevId ) ) {
490            throw new UnexpectedValueException(
491                "Page $pageId revision $currentRevId's' visibility did not match the " .
492                'expected visibility change provided by event. Revision visibility is ' .
493                $revisionRecord->getVisibility() . '. visibility changed to ' .
494                $event->getVisibilityAfter( $currentRevId )
495            );
496        }
497
498        // If this revision is 'suppressed' AKA restricted, then the person performing
499        // 'RevisionDelete' should not be visible in public data.
500        // https://phabricator.wikimedia.org/T342487
501        //
502        // NOTE: This event stream tries to match the visibility of MediaWiki core logs,
503        // where regular delete/revision events are public, and suppress/revision events
504        // are private. In MediaWiki core logs, private events are fully hidden from
505        // the public.  Here, we need to produce a 'private' event to the
506        // mediawiki.page_change stream, to indicate to consumers that
507        // they should also 'suppress' the revision.  When this is done, we need to
508        // make sure that we do not reproduce the data that has been suppressed
509        // in the event itself.  E.g. if the username of the editor of the revision has been
510        // suppressed, we should not include any information about that editor in the event.
511        $performerForEvent = $event->isSuppressed() ? null : $event->getPerformer();
512
513        $event = $this->pageChangeEventSerializer->toVisibilityChangeEvent(
514            $this->streamName,
515            $event->getPage(),
516            $performerForEvent,
517            $revisionRecord,
518            $event->getVisibilityBefore( $currentRevId ),
519            $event->getEventTimestamp()->getTimestamp()
520        );
521
522        $this->sendEvents( $this->streamName, [ $event ] );
523    }
524}