Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
112 / 154
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeTrackingEventIngress
72.73% covered (warning)
72.73%
112 / 154
36.36% covered (danger)
36.36%
4 / 11
67.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 newForTesting
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getEditFlags
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 handlePageLatestRevisionChangedEvent
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
9.00
 generateCategoryMembershipChanges
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 anyChangedSlotSupportsCategories
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 updateChangeTagsAfterPageUpdated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateRecentChangesAfterPageUpdated
48.39% covered (danger)
48.39%
15 / 31
0.00% covered (danger)
0.00%
0 / 1
2.55
 updateUserEditTrackerAfterPageUpdated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateNewTalkAfterPageUpdated
47.62% covered (danger)
47.62%
10 / 21
0.00% covered (danger)
0.00%
0 / 1
24.37
 updateRevertTagAfterPageUpdated
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
5
1<?php
2
3namespace MediaWiki\RecentChanges;
4
5use LogicException;
6use MediaWiki\ChangeTags\ChangeTagsStore;
7use MediaWiki\Config\Config;
8use MediaWiki\Content\IContentHandlerFactory;
9use MediaWiki\DomainEvent\DomainEventIngress;
10use MediaWiki\HookContainer\HookContainer;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\JobQueue\JobQueueGroup;
13use MediaWiki\JobQueue\Jobs\CategoryMembershipChangeJob;
14use MediaWiki\JobQueue\Jobs\RevertedTagUpdateJob;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Page\Event\PageLatestRevisionChangedEvent;
17use MediaWiki\Page\Event\PageLatestRevisionChangedListener;
18use MediaWiki\Page\WikiPageFactory;
19use MediaWiki\Permissions\PermissionManager;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Storage\EditResult;
22use MediaWiki\User\TalkPageNotificationManager;
23use MediaWiki\User\User;
24use MediaWiki\User\UserEditTracker;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\User\UserNameUtils;
27
28/**
29 * The ingress subscriber for the change tracking component. It updates change
30 * tracking state according to domain events coming from other components.
31 *
32 * @internal
33 */
34class ChangeTrackingEventIngress
35    extends DomainEventIngress
36    implements PageLatestRevisionChangedListener
37{
38
39    /**
40     * The events handled by this ingress subscriber.
41     * @see registerListeners()
42     */
43    public const EVENTS = [
44        PageLatestRevisionChangedEvent::TYPE
45    ];
46
47    /**
48     * Object spec used for lazy instantiation.
49     * Using this spec with DomainEventSource::registerSubscriber defers
50     * instantiation until one of the listed events is dispatched.
51     * Declaring it as a constant avoids the overhead of using reflection
52     * for auto-wiring.
53     */
54    public const OBJECT_SPEC = [
55        'class' => self::class,
56        'services' => [ // see __construct
57            'ChangeTagsStore',
58            'UserEditTracker',
59            'PermissionManager',
60            'WikiPageFactory',
61            'HookContainer',
62            'UserNameUtils',
63            'TalkPageNotificationManager',
64            'MainConfig',
65            'JobQueueGroup',
66            'ContentHandlerFactory',
67            'RecentChangeFactory',
68        ],
69        'events' => [ // see registerListeners()
70            PageLatestRevisionChangedEvent::TYPE
71        ],
72    ];
73
74    private ChangeTagsStore $changeTagsStore;
75    private UserEditTracker $userEditTracker;
76    private PermissionManager $permissionManager;
77    private WikiPageFactory $wikiPageFactory;
78    private HookRunner $hookRunner;
79    private UserNameUtils $userNameUtils;
80    private TalkPageNotificationManager $talkPageNotificationManager;
81    private JobQueueGroup $jobQueueGroup;
82    private IContentHandlerFactory $contentHandlerFactory;
83    private RecentChangeFactory $recentChangeFactory;
84    private bool $useRcPatrol;
85    private bool $rcWatchCategoryMembership;
86
87    public function __construct(
88        ChangeTagsStore $changeTagsStore,
89        UserEditTracker $userEditTracker,
90        PermissionManager $permissionManager,
91        WikiPageFactory $wikiPageFactory,
92        HookContainer $hookContainer,
93        UserNameUtils $userNameUtils,
94        TalkPageNotificationManager $talkPageNotificationManager,
95        Config $mainConfig,
96        JobQueueGroup $jobQueueGroup,
97        IContentHandlerFactory $contentHandlerFactory,
98        RecentChangeFactory $recentChangeFactory
99    ) {
100        // NOTE: keep in sync with self::OBJECT_SPEC
101        $this->changeTagsStore = $changeTagsStore;
102        $this->userEditTracker = $userEditTracker;
103        $this->permissionManager = $permissionManager;
104        $this->wikiPageFactory = $wikiPageFactory;
105        $this->hookRunner = new HookRunner( $hookContainer );
106        $this->userNameUtils = $userNameUtils;
107        $this->talkPageNotificationManager = $talkPageNotificationManager;
108        $this->jobQueueGroup = $jobQueueGroup;
109        $this->contentHandlerFactory = $contentHandlerFactory;
110        $this->recentChangeFactory = $recentChangeFactory;
111
112        $this->useRcPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
113        $this->rcWatchCategoryMembership = $mainConfig->get(
114            MainConfigNames::RCWatchCategoryMembership
115        );
116    }
117
118    public static function newForTesting(
119        ChangeTagsStore $changeTagsStore,
120        UserEditTracker $userEditTracker,
121        PermissionManager $permissionManager,
122        WikiPageFactory $wikiPageFactory,
123        HookContainer $hookContainer,
124        UserNameUtils $userNameUtils,
125        TalkPageNotificationManager $talkPageNotificationManager,
126        Config $mainConfig,
127        JobQueueGroup $jobQueueGroup,
128        IContentHandlerFactory $contentHandlerFactory,
129        RecentChangeFactory $recentChangeFactory
130    ): self {
131        $ingress = new self(
132            $changeTagsStore,
133            $userEditTracker,
134            $permissionManager,
135            $wikiPageFactory,
136            $hookContainer,
137            $userNameUtils,
138            $talkPageNotificationManager,
139            $mainConfig,
140            $jobQueueGroup,
141            $contentHandlerFactory,
142            $recentChangeFactory
143        );
144        $ingress->initSubscriber( self::OBJECT_SPEC );
145        return $ingress;
146    }
147
148    private static function getEditFlags( PageLatestRevisionChangedEvent $event ): int {
149        $flags = $event->isCreation() ? EDIT_NEW : EDIT_UPDATE;
150
151        $flags |= (int)$event->isBotUpdate() * EDIT_FORCE_BOT;
152        $flags |= (int)$event->isSilent() * EDIT_SILENT;
153        $flags |= (int)$event->isImplicit() * EDIT_IMPLICIT;
154        $flags |= (int)$event->getLatestRevisionAfter()->isMinor() * EDIT_MINOR;
155
156        return $flags;
157    }
158
159    /**
160     * Listener method for PageLatestRevisionChangedEvent, to be registered with an
161     * DomainEventSource.
162     *
163     * @noinspection PhpUnused
164     */
165    public function handlePageLatestRevisionChangedEvent( PageLatestRevisionChangedEvent $event ): void {
166        if ( $event->changedLatestRevisionId()
167            && !$event->isSilent()
168        ) {
169            $this->updateRecentChangesAfterPageUpdated(
170                $event->getLatestRevisionAfter(),
171                $event->getLatestRevisionBefore(),
172                $event->isBotUpdate(),
173                $event->getPatrolStatus(),
174                $event->getTags(),
175                $event->getEditResult()
176            );
177        } elseif ( $event->getTags() ) {
178            $this->updateChangeTagsAfterPageUpdated(
179                $event->getTags(),
180                $event->getLatestRevisionAfter()->getId(),
181            );
182        }
183
184        if ( $event->isEffectiveContentChange() ) {
185            $this->generateCategoryMembershipChanges( $event );
186
187            if ( !$event->isImplicit() ) {
188                $this->updateUserEditTrackerAfterPageUpdated(
189                    $event->getPerformer()
190                );
191
192                $this->updateNewTalkAfterPageUpdated( $event );
193            }
194        }
195
196        if ( $event->isRevert() && $event->isEffectiveContentChange() ) {
197            $this->updateRevertTagAfterPageUpdated( $event );
198        }
199    }
200
201    /**
202     * Create RC entries for category changes that resulted from this update
203     * if the relevant config is enabled.
204     * This should only be triggered for actual edits, not reconciliation events (T390636).
205     *
206     * @param PageLatestRevisionChangedEvent $event
207     */
208    private function generateCategoryMembershipChanges( PageLatestRevisionChangedEvent $event ): void {
209        if ( $this->rcWatchCategoryMembership
210            && !$event->hasCause( PageLatestRevisionChangedEvent::CAUSE_UNDELETE )
211            && $this->anyChangedSlotSupportsCategories( $event )
212        ) {
213            // Note: jobs are pushed after deferred updates, so the job should be able to see
214            // the recent change entry (also done via deferred updates) and carry over any
215            // bot/deletion/IP flags, etc.
216            $this->jobQueueGroup->lazyPush(
217                CategoryMembershipChangeJob::newSpec(
218                    $event->getPage(),
219                    $event->getLatestRevisionAfter()->getTimestamp(),
220                    $event->hasCause( PageLatestRevisionChangedEvent::CAUSE_IMPORT )
221                )
222            );
223        }
224    }
225
226    /**
227     * Determine whether any slots changed in this update supports categories.
228     *
229     * @param PageLatestRevisionChangedEvent $event
230     *
231     * @return bool
232     */
233    private function anyChangedSlotSupportsCategories( PageLatestRevisionChangedEvent $event ): bool {
234        $slotsUpdate = $event->getSlotsUpdate();
235        foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
236            $model = $slotsUpdate->getModifiedSlot( $role )->getModel();
237
238            if ( $this->contentHandlerFactory->getContentHandler( $model )->supportsCategories() ) {
239                return true;
240            }
241        }
242
243        return false;
244    }
245
246    private function updateChangeTagsAfterPageUpdated( array $tags, int $revId ) {
247        $this->changeTagsStore->addTags( $tags, null, $revId );
248    }
249
250    private function updateRecentChangesAfterPageUpdated(
251        RevisionRecord $newRevisionRecord,
252        ?RevisionRecord $oldRevisionRecord,
253        bool $forceBot,
254        int $patrolStatus,
255        array $tags,
256        ?EditResult $editResult
257    ) {
258        if ( !$oldRevisionRecord ) {
259            $recentChange = $this->recentChangeFactory->createNewPageRecentChange(
260                $newRevisionRecord->getTimestamp(),
261                $newRevisionRecord->getPage(),
262                $newRevisionRecord->isMinor(),
263                $newRevisionRecord->getUser( RevisionRecord::RAW ),
264                $newRevisionRecord->getComment( RevisionRecord::RAW )->text,
265                $forceBot, // $event->hasFlag( EDIT_FORCE_BOT ),
266                '',
267                $newRevisionRecord->getSize(),
268                $newRevisionRecord->getId(),
269                $patrolStatus,
270                $tags
271            );
272        } else {
273            $recentChange = $this->recentChangeFactory->createEditRecentChange(
274                $newRevisionRecord->getTimestamp(),
275                $newRevisionRecord->getPage(),
276                $newRevisionRecord->isMinor(),
277                $newRevisionRecord->getUser( RevisionRecord::RAW ),
278                $newRevisionRecord->getComment( RevisionRecord::RAW )->text,
279                $oldRevisionRecord->getId(),
280                $forceBot,
281                '',
282                $oldRevisionRecord->getSize(),
283                $newRevisionRecord->getSize(),
284                $newRevisionRecord->getId(),
285                $patrolStatus,
286                $tags,
287                $editResult
288            );
289        }
290
291        $this->recentChangeFactory->insertRecentChange( $recentChange );
292    }
293
294    private function updateUserEditTrackerAfterPageUpdated( UserIdentity $author ) {
295        $this->userEditTracker->incrementUserEditCount( $author );
296    }
297
298    /**
299     * Listener method for PageLatestRevisionChangedEvent, to be registered with a DomainEventSource.
300     *
301     * @noinspection PhpUnused
302     */
303    private function updateNewTalkAfterPageUpdated( PageLatestRevisionChangedEvent $event ) {
304        // If this is another user's talk page, update newtalk.
305        // Don't do this if $options['changed'] = false (null-edits) nor if
306        // it's a minor edit and the user making the edit doesn't generate notifications for those.
307        $page = $event->getPage();
308        $revRecord = $event->getLatestRevisionAfter();
309        $recipientName = $page->getDBkey();
310        $recipientName = $this->userNameUtils->isIP( $recipientName )
311            ? $recipientName
312            : $this->userNameUtils->getCanonical( $page->getDBkey() );
313
314        if ( $page->getNamespace() === NS_USER_TALK
315            && !( $revRecord->isMinor()
316                && $this->permissionManager->userHasRight(
317                    $event->getAuthor(), 'nominornewtalk' ) )
318            && $recipientName != $event->getAuthor()->getName()
319        ) {
320            $recipient = User::newFromName( $recipientName, false );
321            if ( !$recipient ) {
322                wfDebug( __METHOD__ . ": invalid username" );
323            } else {
324                $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
325
326                // Allow extensions to prevent user notification
327                // when a new message is added to their talk page
328                if ( $this->hookRunner->onArticleEditUpdateNewTalk( $wikiPage, $recipient ) ) {
329                    if ( $this->userNameUtils->isIP( $recipientName ) ) {
330                        // An anonymous user
331                        $this->talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
332                    } elseif ( $recipient->isRegistered() ) {
333                        $this->talkPageNotificationManager->setUserHasNewMessages( $recipient, $revRecord );
334                    } else {
335                        wfDebug( __METHOD__ . ": don't need to notify a nonexistent user" );
336                    }
337                }
338            }
339        }
340    }
341
342    private function updateRevertTagAfterPageUpdated( PageLatestRevisionChangedEvent $event ) {
343        $patrolStatus = $event->getPatrolStatus();
344        $wikiPage = $this->wikiPageFactory->newFromTitle( $event->getPage() );
345
346        // Should the reverted tag update be scheduled right away?
347        // The revert is approved if either patrolling is disabled or the
348        // edit is patrolled or autopatrolled.
349        $approved = !$this->useRcPatrol ||
350            $patrolStatus === RecentChange::PRC_PATROLLED ||
351            $patrolStatus === RecentChange::PRC_AUTOPATROLLED;
352
353        $editResult = $event->getEditResult();
354
355        if ( !$editResult ) {
356            // Reverts should always have an EditResult.
357            throw new LogicException( 'Missing EditResult in revert' );
358        }
359
360        $revisionRecord = $event->getLatestRevisionAfter();
361
362        // Allow extensions to override the patrolling subsystem.
363        $this->hookRunner->onBeforeRevertedTagUpdate(
364            $wikiPage,
365            $event->getAuthor(),
366            $revisionRecord->getComment( RevisionRecord::RAW ),
367            self::getEditFlags( $event ),
368            $revisionRecord,
369            $editResult,
370            $approved
371        );
372
373        // Schedule a deferred update for marking reverted edits if applicable.
374        if ( $approved ) {
375            // Enqueue the job
376            $this->jobQueueGroup->lazyPush(
377                RevertedTagUpdateJob::newSpec(
378                    $revisionRecord->getId(),
379                    $editResult
380                )
381            );
382        }
383    }
384
385}