Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.28% covered (danger)
40.28%
87 / 216
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationController
40.28% covered (danger)
40.28%
87 / 216
26.67% covered (danger)
26.67%
4 / 15
1144.80
0.00% covered (danger)
0.00%
0 / 1
 getCappedNotificationCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatNotificationCount
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 notify
8.33% covered (danger)
8.33%
3 / 36
0.00% covered (danger)
0.00%
0 / 1
104.20
 hasMinorRevision
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 enqueueDeleteJob
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getEventNotifyTypes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getEventParams
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 enqueueEvent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isBlacklistedByUser
11.11% covered (danger)
11.11%
2 / 18
0.00% covered (danger)
0.00%
0 / 1
65.89
 isPageLinkedTitleMutedByUser
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 getWikiBlacklist
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 isWhitelistedByUser
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 doNotification
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 evaluateUserCallable
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getUsersToNotifyForEvent
70.21% covered (warning)
70.21%
33 / 47
0.00% covered (danger)
0.00%
0 / 1
20.95
1<?php
2
3namespace MediaWiki\Extension\Notifications\Controller;
4
5use IDBAccessObject;
6use InvalidArgumentException;
7use Iterator;
8use MapCacheLRU;
9use MediaWiki\Deferred\DeferredUpdates;
10use MediaWiki\Extension\Notifications\AttributeManager;
11use MediaWiki\Extension\Notifications\CachedList;
12use MediaWiki\Extension\Notifications\ContainmentList;
13use MediaWiki\Extension\Notifications\ContainmentSet;
14use MediaWiki\Extension\Notifications\Hooks\HookRunner;
15use MediaWiki\Extension\Notifications\Iterator\FilteredSequentialIterator;
16use MediaWiki\Extension\Notifications\Jobs\NotificationDeleteJob;
17use MediaWiki\Extension\Notifications\Jobs\NotificationJob;
18use MediaWiki\Extension\Notifications\Model\Event;
19use MediaWiki\Extension\Notifications\NotifUser;
20use MediaWiki\Extension\Notifications\OnWikiList;
21use MediaWiki\Extension\Notifications\Services;
22use MediaWiki\Logger\LoggerFactory;
23use MediaWiki\MediaWikiServices;
24use MediaWiki\Title\Title;
25use MediaWiki\User\User;
26use MediaWiki\User\UserIdentity;
27
28/**
29 * This class represents the controller for notifications
30 */
31class NotificationController {
32
33    /**
34     * Echo maximum number of users to cache
35     *
36     * @var int
37     */
38    protected static $maxRecipientCacheSize = 200;
39
40    /**
41     * Max number of users for which we in-process cache titles.
42     *
43     * @var int
44     */
45    protected static $maxUsersTitleCacheSize = 200;
46
47    /**
48     * Echo event agent per user blacklist
49     *
50     * @var MapCacheLRU
51     */
52    protected static $blacklistByUser;
53
54    /**
55     * Echo event agent per page linked event title mute list.
56     *
57     * @var MapCacheLRU
58     */
59    protected static $mutedPageLinkedTitlesCache;
60
61    /**
62     * Echo event agent per wiki blacklist
63     *
64     * @var ContainmentList|null
65     */
66    protected static $wikiBlacklist;
67
68    /**
69     * Echo event agent per user whitelist, this overwrites $blacklistByUser
70     *
71     * @var MapCacheLRU
72     */
73    protected static $whitelistByUser;
74
75    /**
76     * Returns the count passed in, or NotifUser::MAX_BADGE_COUNT + 1,
77     * whichever is less.
78     *
79     * @param int $count
80     * @return int Notification count, with ceiling applied
81     */
82    public static function getCappedNotificationCount( int $count ): int {
83        return min( $count, NotifUser::MAX_BADGE_COUNT + 1 );
84    }
85
86    /**
87     * Format the notification count as a string.  This should only be used for an
88     * isolated string count, e.g. as displayed in personal tools or returned by the API.
89     *
90     * If using it in sentence context, pass the value from getCappedNotificationCount
91     * into a message and use PLURAL.  Example: notification-bundle-header-page-linked
92     *
93     * @param int $count Notification count
94     * @return string Formatted count, after applying cap then formatting to string
95     */
96    public static function formatNotificationCount( $count ) {
97        $cappedCount = self::getCappedNotificationCount( $count );
98
99        return wfMessage( 'echo-badge-count' )->numParams( $cappedCount )->text();
100    }
101
102    /**
103     * Processes notifications for a newly-created Event
104     *
105     * @param Event $event
106     * @param bool $defer Defer to job queue or not
107     */
108    public static function notify( $event, $defer = true ) {
109        // Defer to job queue if defer to job queue is requested and
110        // this event should use job queue
111        if ( $defer && $event->getUseJobQueue() ) {
112            // defer job insertion till end of request when all primary db transactions
113            // have been committed
114            DeferredUpdates::addCallableUpdate( function () use ( $event ) {
115                self::enqueueEvent( $event );
116            } );
117
118            return;
119        }
120
121        // Check if the event object has valid event type.  Events with invalid
122        // event types left in the job queue should not be processed
123        if ( !$event->isEnabledEvent() ) {
124            return;
125        }
126
127        $type = $event->getType();
128        $notifyTypes = self::getEventNotifyTypes( $type );
129        $userIds = [];
130        $userIdsCount = 0;
131        $services = MediaWikiServices::getInstance();
132        $hookRunner = new HookRunner( $services->getHookContainer() );
133        $userOptionsLookup = $services->getUserOptionsLookup();
134        /** @var bool|null $hasMinorRevision */
135        $hasMinorRevision = null;
136        /** @var User $user */
137        foreach ( self::getUsersToNotifyForEvent( $event ) as $user ) {
138            $userIds[$user->getId()] = $user->getId();
139            $userNotifyTypes = $notifyTypes;
140            // Respect the enotifminoredits preference
141            // @todo should this be checked somewhere else?
142            if ( !$userOptionsLookup->getOption( $user, 'enotifminoredits' ) ) {
143                if ( $hasMinorRevision === null ) {
144                    // Do this only once per event
145                    $hasMinorRevision = self::hasMinorRevision( $event );
146                }
147                if ( $hasMinorRevision ) {
148                    $userNotifyTypes = array_diff( $userNotifyTypes, [ 'email' ] );
149                }
150            }
151            $hookRunner->onEchoGetNotificationTypes( $user, $event, $userNotifyTypes );
152
153            // types such as web, email, etc
154            foreach ( $userNotifyTypes as $type ) {
155                self::doNotification( $event, $user, $type );
156            }
157
158            $userIdsCount++;
159            // Process 1000 users per NotificationDeleteJob
160            if ( $userIdsCount > 1000 ) {
161                self::enqueueDeleteJob( $userIds, $event );
162                $userIds = [];
163                $userIdsCount = 0;
164            }
165
166            $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
167            $stats->increment( 'echo.notification.all' );
168            $stats->increment( "echo.notification.$type" );
169        }
170
171        // process the userIds left in the array
172        if ( $userIds ) {
173            self::enqueueDeleteJob( $userIds, $event );
174        }
175    }
176
177    /**
178     * Check if an event is associated with a minor revision.
179     *
180     * @param Event $event
181     * @return bool
182     */
183    private static function hasMinorRevision( Event $event ) {
184        $revId = $event->getExtraParam( 'revid' );
185        if ( !$revId ) {
186            return false;
187        }
188
189        $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
190        $rev = $revisionStore->getRevisionById( $revId, IDBAccessObject::READ_LATEST );
191        if ( !$rev ) {
192            $logger = LoggerFactory::getInstance( 'Echo' );
193            $logger->debug(
194                'Notifying for event {eventId}. Revision \'{revId}\' not found.',
195                [
196                    'eventId' => $event->getId(),
197                    'revId' => $revId,
198                ]
199            );
200            return false;
201        }
202
203        return $rev->isMinor();
204    }
205
206    /**
207     * Schedule a job to check and delete older notifications
208     *
209     * @param int[] $userIds
210     * @param Event $event
211     */
212    public static function enqueueDeleteJob( array $userIds, Event $event ) {
213        // Do nothing if there is no user
214        if ( !$userIds ) {
215            return;
216        }
217
218        $jobQueueGroup = MediaWikiServices::getInstance()->getJobQueueGroup();
219        $job = new NotificationDeleteJob(
220            $event->getTitle() ?: Title::newMainPage(),
221            [
222                'userIds' => $userIds
223            ],
224            $jobQueueGroup
225        );
226        $jobQueueGroup->push( $job );
227    }
228
229    /**
230     * Get the notify types for this event, eg, web/email
231     *
232     * @param string $eventType Event type
233     * @return string[] List of notify types that apply for
234     *  this event type
235     */
236    public static function getEventNotifyTypes( $eventType ) {
237        $attributeManager = Services::getInstance()->getAttributeManager();
238
239        $category = $attributeManager->getNotificationCategory( $eventType );
240
241        return array_keys( array_filter(
242            $attributeManager->getNotifyTypeAvailabilityForCategory( $category )
243        ) );
244    }
245
246    /**
247     * Helper function to extract event task params
248     * @param Event $event
249     * @return array Event params
250     */
251    public static function getEventParams( Event $event ) {
252        $delay = $event->getExtraParam( 'delay' );
253        $rootJobSignature = $event->getExtraParam( 'rootJobSignature' );
254        $rootJobTimestamp = $event->getExtraParam( 'rootJobTimestamp' );
255
256        return [ 'eventId' => $event->getId() ]
257            + ( $delay ? [ 'jobReleaseTimestamp' => (int)wfTimestamp() + $delay ] : [] )
258            + ( $rootJobSignature ? [ 'rootJobSignature' => $rootJobSignature ] : [] )
259            + ( $rootJobTimestamp ? [ 'rootJobTimestamp' => $rootJobTimestamp ] : [] );
260    }
261
262    /**
263     * Push $event onto the mediawiki job queue
264     *
265     * @param Event $event
266     */
267    public static function enqueueEvent( Event $event ) {
268        $queue = MediaWikiServices::getInstance()->getJobQueueGroup();
269        $params = self::getEventParams( $event );
270
271        $job = new NotificationJob(
272            $event->getTitle() ?: Title::newMainPage(), $params
273        );
274
275        if ( isset( $params[ 'jobReleaseTimestamp' ] ) && !$queue->get( $job->getType() )->delayedJobsEnabled() ) {
276            $logger = LoggerFactory::getInstance( 'Echo' );
277            $logger->warning(
278                'Delayed jobs are not enabled. Skipping enqueuing event {id} of type {type}.',
279                [
280                    'id' => $event->getId(),
281                    'type' => $event->getType()
282                ]
283            );
284            return;
285        }
286
287        $queue->push( $job );
288    }
289
290    /**
291     * Implements blacklist per active wiki expected to be initialized
292     * from InitializeSettings.php
293     *
294     * @param Event $event The event to test for exclusion
295     * @param User $user recipient of the notification for per-user blacklists
296     * @return bool True when the event agent is blacklisted
297     */
298    public static function isBlacklistedByUser( Event $event, User $user ) {
299        global $wgEchoAgentBlacklist, $wgEchoPerUserBlacklist;
300
301        if ( !$event->getAgent() ) {
302            return false;
303        }
304
305        // Ensure we have a list of blacklists
306        if ( self::$blacklistByUser === null ) {
307            self::$blacklistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
308        }
309
310        // Ensure we have a blacklist for the user
311        if ( !self::$blacklistByUser->has( (string)$user->getId() ) ) {
312            $blacklist = new ContainmentSet( $user );
313
314            // Add the config setting
315            $blacklist->addArray( $wgEchoAgentBlacklist );
316
317            // Add wiki-wide blacklist
318            $wikiBlacklist = self::getWikiBlacklist();
319            if ( $wikiBlacklist !== null ) {
320                $blacklist->add( $wikiBlacklist );
321            }
322
323            // Add to blacklist from user preference
324            if ( $wgEchoPerUserBlacklist ) {
325                $blacklist->addFromUserOption( 'echo-notifications-blacklist' );
326            }
327
328            // Add user's blacklist to dictionary if user wasn't already there
329            self::$blacklistByUser->set( (string)$user->getId(), $blacklist );
330        } else {
331            // Just get the user's blacklist if it's already there
332            $blacklist = self::$blacklistByUser->get( (string)$user->getId() );
333        }
334        return $blacklist->contains( $event->getAgent()->getName() ) ||
335            ( $wgEchoPerUserBlacklist &&
336                $event->getType() === 'page-linked' &&
337                self::isPageLinkedTitleMutedByUser( $event->getTitle(), $user ) );
338    }
339
340    /**
341     * Check if a title is in the user's page-linked event blacklist.
342     *
343     * @param Title $title
344     * @param User $user
345     * @return bool
346     */
347    public static function isPageLinkedTitleMutedByUser( Title $title, User $user ) {
348        if ( self::$mutedPageLinkedTitlesCache === null ) {
349            self::$mutedPageLinkedTitlesCache = new MapCacheLRU( self::$maxUsersTitleCacheSize );
350        }
351        if ( !self::$mutedPageLinkedTitlesCache->has( (string)$user->getId() ) ) {
352            $pageLinkedTitleMutedList = new ContainmentSet( $user );
353            $pageLinkedTitleMutedList->addTitleIDsFromUserOption(
354                'echo-notifications-page-linked-title-muted-list'
355            );
356            self::$mutedPageLinkedTitlesCache->set( (string)$user->getId(), $pageLinkedTitleMutedList );
357        } else {
358            $pageLinkedTitleMutedList = self::$mutedPageLinkedTitlesCache->get( (string)$user->getId() );
359        }
360        return $pageLinkedTitleMutedList->contains( (string)$title->getArticleID() );
361    }
362
363    /**
364     * @return ContainmentList|null
365     */
366    protected static function getWikiBlacklist() {
367        global $wgEchoOnWikiBlacklist;
368        if ( !$wgEchoOnWikiBlacklist ) {
369            return null;
370        }
371        if ( self::$wikiBlacklist === null ) {
372            $clusterCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
373            self::$wikiBlacklist = new CachedList(
374                $clusterCache,
375                $clusterCache->makeKey( "echo_on_wiki_blacklist" ),
376                new OnWikiList( NS_MEDIAWIKI, $wgEchoOnWikiBlacklist )
377            );
378        }
379
380        return self::$wikiBlacklist;
381    }
382
383    /**
384     * Implements per-user whitelist sourced from a user wiki page
385     *
386     * @param Event $event The event to test for inclusion in whitelist
387     * @param User $user The user that owns the whitelist
388     * @return bool True when the event agent is in the user whitelist
389     */
390    public static function isWhitelistedByUser( Event $event, User $user ) {
391        $clusterCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
392        global $wgEchoPerUserWhitelistFormat;
393
394        if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
395            return false;
396        }
397
398        $userId = $user->getId();
399        if ( $userId === 0 ) {
400            // anonymous user
401            return false;
402        }
403
404        // Ensure we have a list of whitelists
405        if ( self::$whitelistByUser === null ) {
406            self::$whitelistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
407        }
408
409        // Ensure we have a whitelist for the user
410        if ( !self::$whitelistByUser->has( (string)$userId ) ) {
411            $whitelist = new ContainmentSet( $user );
412            self::$whitelistByUser->set( (string)$userId, $whitelist );
413            $whitelist->addOnWiki(
414                NS_USER,
415                sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
416                $clusterCache,
417                $clusterCache->makeKey( "echo_on_wiki_whitelist_" . $userId )
418            );
419        } else {
420            // Just get the user's whitelist
421            $whitelist = self::$whitelistByUser->get( (string)$userId );
422        }
423        return $whitelist->contains( $event->getAgent()->getName() );
424    }
425
426    /**
427     * Processes a single notification for an Event
428     *
429     * @param Event $event
430     * @param User $user The user to be notified.
431     * @param string $type The type of notification delivery to process, e.g. 'email'.
432     */
433    public static function doNotification( $event, $user, $type ) {
434        global $wgEchoNotifiers;
435
436        if ( !isset( $wgEchoNotifiers[$type] ) ) {
437            throw new InvalidArgumentException( "Invalid notification type $type" );
438        }
439
440        // Don't send any notifications to anonymous users
441        if ( !$user->isRegistered() ) {
442            throw new InvalidArgumentException( "Cannot notify anonymous user: {$user->getName()}" );
443        }
444
445        ( $wgEchoNotifiers[$type] )( $user, $event );
446    }
447
448    /**
449     * Returns an array each element of which is the result of a
450     * user-locator|user-filters attached to the event type.
451     *
452     * @param Event $event
453     * @param string $locator Either AttributeManager::ATTR_LOCATORS or AttributeManager::ATTR_FILTERS
454     * @return array
455     */
456    public static function evaluateUserCallable( Event $event, $locator = AttributeManager::ATTR_LOCATORS ) {
457        $attributeManager = Services::getInstance()->getAttributeManager();
458        $type = $event->getType();
459        $result = [];
460        foreach ( $attributeManager->getUserCallable( $type, $locator ) as $callable ) {
461            // locator options can be set per-event by using an array with
462            // name as first parameter.
463            if ( is_array( $callable ) ) {
464                $options = $callable;
465                $spliced = array_splice( $options, 0, 1, [ $event ] );
466                $callable = reset( $spliced );
467            } else {
468                $options = [ $event ];
469            }
470            if ( is_callable( $callable ) ) {
471                $result[] = $callable( ...$options );
472            } else {
473                wfDebugLog( __CLASS__, __FUNCTION__ . ": Invalid $locator returned for $type" );
474            }
475        }
476
477        return $result;
478    }
479
480    /**
481     * Retrieves an array of User objects to be notified for an Event.
482     *
483     * @param Event $event
484     * @return Iterator values are User objects
485     */
486    public static function getUsersToNotifyForEvent( Event $event ) {
487        $notify = new FilteredSequentialIterator;
488        foreach ( self::evaluateUserCallable( $event, AttributeManager::ATTR_LOCATORS ) as $users ) {
489            $notify->add( $users );
490        }
491
492        // Hook for injecting more users.
493        // @deprecated
494        $users = [];
495        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
496            ->onEchoGetDefaultNotifiedUsers( $event, $users );
497        if ( $users ) {
498            $notify->add( $users );
499        }
500
501        // Exclude certain users
502        foreach ( self::evaluateUserCallable( $event, AttributeManager::ATTR_FILTERS ) as $users ) {
503            // the result of the callback can be both an iterator or array
504            $users = is_array( $users ) ? $users : iterator_to_array( $users );
505            $notify->addFilter( static function ( UserIdentity $user ) use ( $users ) {
506                // we need to check if $user is in $users, but they're not
507                // guaranteed to be the same object, so I'll compare ids.
508                $userId = $user->getId();
509                $userIds = array_map( static function ( UserIdentity $user ) {
510                    return $user->getId();
511                }, $users );
512                return !in_array( $userId, $userIds );
513            } );
514        }
515
516        // Filter non-User, anon and duplicate users
517        $seen = [];
518        $fname = __METHOD__;
519        $notify->addFilter( static function ( $user ) use ( &$seen, $fname ) {
520            if ( !$user instanceof User ) {
521                wfDebugLog( $fname, 'Expected all User instances, received: ' .
522                    ( is_object( $user ) ? get_class( $user ) : gettype( $user ) )
523                );
524
525                return false;
526            }
527            if ( !$user->isRegistered() || isset( $seen[$user->getId()] ) ) {
528                return false;
529            }
530            $seen[$user->getId()] = true;
531
532            return true;
533        } );
534
535        // Don't notify the person who initiated the event unless the event allows it
536        if ( !$event->canNotifyAgent() && $event->getAgent() ) {
537            $agentId = $event->getAgent()->getId();
538            $notify->addFilter( static function ( $user ) use ( $agentId ) {
539                return $user->getId() != $agentId;
540            } );
541        }
542
543        // Apply blacklists and whitelists.
544        $notify->addFilter( function ( $user ) use ( $event ) {
545            $title = $event->getTitle();
546
547            if ( self::isBlacklistedByUser( $event, $user ) &&
548                (
549                    $title === null ||
550                    !(
551                        // Still notify for posts on the user's talk page
552                        // (but not subpages, T288112)
553                        $title->getText() === $user->getName() &&
554                        $title->getNamespace() === NS_USER_TALK
555                    )
556                )
557            ) {
558                return self::isWhitelistedByUser( $event, $user );
559            }
560
561            return true;
562        } );
563
564        return $notify->getIterator();
565    }
566}