summaryrefslogtreecommitdiff
blob: e6c47a2b60969d6d6e6059adaecee59d1da62815 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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
226
227
228
229
230
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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
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
<?php
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionStore;

/**
 * This class represents the controller for notifications
 */
class EchoNotificationController {

	/**
	 * Echo maximum number of users to cache
	 *
	 * @var int $maxRecipientCacheSize
	 */
	static protected $maxRecipientCacheSize = 200;

	/**
	 * Echo event agent per user blacklist
	 *
	 * @var MapCacheLRU
	 */
	static protected $blacklistByUser;

	/**
	 * Echo event agent per wiki blacklist
	 *
	 * @var EchoContainmentList|null
	 */
	static protected $wikiBlacklist;

	/**
	 * Echo event agent per user whitelist, this overwrites $blacklistByUser
	 *
	 * @var MapCacheLRU
	 */
	static protected $whitelistByUser;

	/**
	 * Returns the count passed in, or MWEchoNotifUser::MAX_BADGE_COUNT + 1,
	 * whichever is less.
	 *
	 * @param int $count
	 * @return int Notification count, with ceiling applied
	 */
	public static function getCappedNotificationCount( $count ) {
		if ( $count <= MWEchoNotifUser::MAX_BADGE_COUNT ) {
			return $count;
		} else {
			return MWEchoNotifUser::MAX_BADGE_COUNT + 1;
		}
	}

	/**
	 * Format the notification count as a string.  This should only be used for an
	 * isolated string count, e.g. as displayed in personal tools or returned by the API.
	 *
	 * If using it in sentence context, pass the value from getCappedNotificationCount
	 * into a message and use PLURAL.  Example: notification-bundle-header-page-linked
	 *
	 * @param int $count Notification count
	 * @return string Formatted count, after applying cap then formatting to string
	 */
	public static function formatNotificationCount( $count ) {
		$cappedCount = self::getCappedNotificationCount( $count );

		return wfMessage( 'echo-badge-count' )->numParams( $cappedCount )->text();
	}

	/**
	 * Processes notifications for a newly-created EchoEvent
	 *
	 * @param EchoEvent $event
	 * @param bool $defer Defer to job queue or not
	 */
	public static function notify( $event, $defer = true ) {
		// Defer to job queue if defer to job queue is requested and
		// this event should use job queue
		if ( $defer && $event->getUseJobQueue() ) {
			// defer job insertion till end of request when all primary db transactions
			// have been committed
			DeferredUpdates::addCallableUpdate( function () use ( $event ) {
				// can't use self::, php 5.3 doesn't inherit class scope
				EchoNotificationController::enqueueEvent( $event );
			} );

			return;
		}

		// Check if the event object has valid event type.  Events with invalid
		// event types left in the job queue should not be processed
		if ( !$event->isEnabledEvent() ) {
			return;
		}

		$type = $event->getType();
		$notifyTypes = self::getEventNotifyTypes( $type );
		$userIds = [];
		$userIdsCount = 0;
		foreach ( self::getUsersToNotifyForEvent( $event ) as $user ) {
			$userIds[$user->getId()] = $user->getId();
			$userNotifyTypes = $notifyTypes;
			// Respect the enotifminoredits preference
			// @todo should this be checked somewhere else?
			if (
				!$user->getOption( 'enotifminoredits' ) &&
				self::hasMinorRevision( $event )
			) {
				$notifyTypes = array_diff( $notifyTypes, [ 'email' ] );
			}
			Hooks::run( 'EchoGetNotificationTypes', [ $user, $event, &$userNotifyTypes ] );

			// types such as web, email, etc
			foreach ( $userNotifyTypes as $type ) {
				self::doNotification( $event, $user, $type );
			}

			$userIdsCount++;
			// Process 1000 users per NotificationDeleteJob
			if ( $userIdsCount > 1000 ) {
				self::enqueueDeleteJob( $userIds, $event );
				$userIds = [];
				$userIdsCount = 0;
			}
		}

		// process the userIds left in the array
		if ( $userIds ) {
			self::enqueueDeleteJob( $userIds, $event );
		}
	}

	/**
	 * Check if an event is associated with a minor revision.
	 *
	 * @param EchoEvent $event
	 * @return bool
	 */
	private static function hasMinorRevision( EchoEvent $event ) {
		$revId = $event->getExtraParam( 'revid' );
		if ( !$revId ) {
			return false;
		}

		$revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
		$rev = $revisionStore->getRevisionById( $revId, RevisionStore::READ_LATEST );
		if ( !$rev ) {
			$logger = LoggerFactory::getInstance( 'Echo' );
			$logger->debug(
				'Notifying for event {eventId}. Revision \'{revId}\' not found.',
				[
					'eventId' => $event->getId(),
					'revId' => $revId,
				]
			);
			return false;
		}

		return $rev->isMinor();
	}

	/**
	 * Schedule a job to check and delete older notifications
	 *
	 * @param int[] $userIds
	 * @param EchoEvent $event
	 */
	public static function enqueueDeleteJob( array $userIds, EchoEvent $event ) {
		// Do nothing if there is no user
		if ( !$userIds ) {
			return;
		}

		$job = new EchoNotificationDeleteJob(
			$event->getTitle() ?: Title::newMainPage(),
			[
				'userIds' => $userIds
			]
		);
		JobQueueGroup::singleton()->push( $job );
	}

	/**
	 * Get the notify types for this event, eg, web/email
	 *
	 * @param string $eventType Event type
	 * @return string[] List of notify types that apply for
	 *  this event type
	 */
	public static function getEventNotifyTypes( $eventType ) {
		global $wgDefaultNotifyTypeAvailability,
			$wgEchoNotifications;

		$attributeManager = EchoAttributeManager::newFromGlobalVars();

		$category = $attributeManager->getNotificationCategory( $eventType );

		// If the category is displayed in preferences, we should go by that, rather
		// than overrides that are inconsistent with what the user saw in preferences.
		$isTypeSpecificConsidered = !$attributeManager->isCategoryDisplayedInPreferences(
			$category
		);

		$notifyTypes = $wgDefaultNotifyTypeAvailability;

		if ( $isTypeSpecificConsidered && isset( $wgEchoNotifications[$eventType]['notify-type-availability'] ) ) {
			$notifyTypes = array_merge(
				$notifyTypes,
				$wgEchoNotifications[$eventType]['notify-type-availability']
			);
		}

		// Category settings for availability are considered in EchoNotifier
		return array_keys( array_filter( $notifyTypes ) );
	}

	/**
	 * Push $event onto the mediawiki job queue
	 *
	 * @param EchoEvent $event
	 */
	public static function enqueueEvent( EchoEvent $event ) {
		$job = new EchoNotificationJob(
			$event->getTitle() ?: Title::newMainPage(),
			[
				'eventId' => $event->getId(),
			]
		);
		JobQueueGroup::singleton()->push( $job );
	}

	/**
	 * Implements blacklist per active wiki expected to be initialized
	 * from InitializeSettings.php
	 *
	 * @param EchoEvent $event The event to test for exclusion
	 * @param User $user recipient of the notification for per-user blacklists
	 * @return bool True when the event agent is blacklisted
	 */
	public static function isBlacklistedByUser( EchoEvent $event, User $user ) {
		global $wgEchoAgentBlacklist, $wgEchoPerUserBlacklist;

		$clusterCache = ObjectCache::getLocalClusterInstance();

		if ( !$event->getAgent() ) {
			return false;
		}

		// Ensure we have a list of blacklists
		if ( self::$blacklistByUser === null ) {
			self::$blacklistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
		}

		// Ensure we have a blacklist for the user
		if ( !self::$blacklistByUser->has( $user->getId() ) ) {
			$blacklist = new EchoContainmentSet( $user );

			// Add the config setting
			$blacklist->addArray( $wgEchoAgentBlacklist );

			// Add wiki-wide blacklist
			$wikiBlacklist = self::getWikiBlacklist();
			if ( $wikiBlacklist !== null ) {
				$blacklist->add( $wikiBlacklist );
			}

			// Add to blacklist from user preference
			if ( $wgEchoPerUserBlacklist ) {
				$blacklist->addFromUserOption( 'echo-notifications-blacklist' );
			}

			// Add user's blacklist to dictionary if user wasn't already there
			self::$blacklistByUser->set( $user->getId(), $blacklist );
		} else {
			// Just get the user's blacklist if it's already there
			$blacklist = self::$blacklistByUser->get( $user->getId() );
		}
		return $blacklist->contains( $event->getAgent()->getName() );
	}

	/**
	 * @return EchoContainmentList|null
	 */
	protected static function getWikiBlacklist() {
		$clusterCache = ObjectCache::getLocalClusterInstance();
		global $wgEchoOnWikiBlacklist;
		if ( !$wgEchoOnWikiBlacklist ) {
			return null;
		}
		if ( self::$wikiBlacklist === null ) {
			self::$wikiBlacklist = new EchoCachedList(
				$clusterCache,
				$clusterCache->makeKey( "echo_on_wiki_blacklist" ),
				new EchoOnWikiList( NS_MEDIAWIKI, $wgEchoOnWikiBlacklist )
			);
		}

		return self::$wikiBlacklist;
	}

	/**
	 * Implements per-user whitelist sourced from a user wiki page
	 *
	 * @param EchoEvent $event The event to test for inclusion in whitelist
	 * @param User $user The user that owns the whitelist
	 * @return bool True when the event agent is in the user whitelist
	 */
	public static function isWhitelistedByUser( EchoEvent $event, User $user ) {
		$clusterCache = ObjectCache::getLocalClusterInstance();
		global $wgEchoPerUserWhitelistFormat;

		if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
			return false;
		}

		$userId = $user->getID();
		if ( $userId === 0 ) {
			return false; // anonymous user
		}

		// Ensure we have a list of whitelists
		if ( self::$whitelistByUser === null ) {
			self::$whitelistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
		}

		// Ensure we have a whitelist for the user
		if ( !self::$whitelistByUser->has( $userId ) ) {
			$whitelist = new EchoContainmentSet( $user );
			self::$whitelistByUser->set( $userId, $whitelist );
			$whitelist->addOnWiki(
				NS_USER,
				sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
				$clusterCache,
				$clusterCache->makeKey( "echo_on_wiki_whitelist_" . $userId )
			);
		} else {
			// Just get the user's whitelist
			$whitelist = self::$whitelistByUser->get( $userId );
		}
		return $whitelist->contains( $event->getAgent()->getName() );
	}

	/**
	 * Processes a single notification for an EchoEvent
	 *
	 * @param EchoEvent $event
	 * @param User $user The user to be notified.
	 * @param string $type The type of notification delivery to process, e.g. 'email'.
	 * @throws MWException
	 */
	public static function doNotification( $event, $user, $type ) {
		global $wgEchoNotifiers;

		if ( !isset( $wgEchoNotifiers[$type] ) ) {
			throw new MWException( "Invalid notification type $type" );
		}

		// Don't send any notifications to anonymous users
		if ( $user->isAnon() ) {
			throw new MWException( "Cannot notify anonymous user: {$user->getName()}" );
		}

		( $wgEchoNotifiers[$type] )( $user, $event );
	}

	/**
	 * Returns an array each element of which is the result of a
	 * user-locator|user-filters attached to the event type.
	 *
	 * @param EchoEvent $event
	 * @param string $locator Either EchoAttributeManager::ATTR_LOCATORS or EchoAttributeManager::ATTR_FILTERS
	 * @return array
	 */
	public static function evaluateUserCallable( EchoEvent $event, $locator = EchoAttributeManager::ATTR_LOCATORS ) {
		$attributeManager = EchoAttributeManager::newFromGlobalVars();
		$type = $event->getType();
		$result = [];
		foreach ( $attributeManager->getUserCallable( $type, $locator ) as $callable ) {
			// locator options can be set per-event by using an array with
			// name as first parameter.
			if ( is_array( $callable ) ) {
				$options = $callable;
				$spliced = array_splice( $options, 0, 1, [ $event ] );
				$callable = reset( $spliced );
			} else {
				$options = [ $event ];
			}
			if ( is_callable( $callable ) ) {
				$result[] = $callable( ...$options );
			} else {
				wfDebugLog( __CLASS__, __FUNCTION__ . ": Invalid $locator returned for $type" );
			}
		}

		return $result;
	}

	/**
	 * Retrieves an array of User objects to be notified for an EchoEvent.
	 *
	 * @param EchoEvent $event
	 * @return Iterator values are User objects
	 */
	public static function getUsersToNotifyForEvent( EchoEvent $event ) {
		$notify = new EchoFilteredSequentialIterator;
		foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_LOCATORS ) as $users ) {
			$notify->add( $users );
		}

		// Hook for injecting more users.
		// @deprecated
		$users = [];
		Hooks::run( 'EchoGetDefaultNotifiedUsers', [ $event, &$users ] );
		if ( $users ) {
			$notify->add( $users );
		}

		// Exclude certain users
		foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_FILTERS ) as $users ) {
			// the result of the callback can be both an iterator or array
			$users = is_array( $users ) ? $users : iterator_to_array( $users );
			$notify->addFilter( function ( User $user ) use ( $users ) {
				// we need to check if $user is in $users, but they're not
				// guaranteed to be the same object, so I'll compare ids.
				$userId = $user->getId();
				$userIds = array_map( function ( User $user ) {
					return $user->getId();
				}, $users );
				return !in_array( $userId, $userIds );
			} );
		}

		// Filter non-User, anon and duplicate users
		$seen = [];
		$fname = __METHOD__;
		$notify->addFilter( function ( $user ) use ( &$seen, $fname ) {
			if ( !$user instanceof User ) {
				wfDebugLog( $fname, 'Expected all User instances, received:' .
					( is_object( $user ) ? get_class( $user ) : gettype( $user ) )
				);

				return false;
			}
			if ( $user->isAnon() || isset( $seen[$user->getId()] ) ) {
				return false;
			}
			$seen[$user->getId()] = true;

			return true;
		} );

		// Don't notify the person who initiated the event unless the event extra says to do so
		$extra = $event->getExtra();
		if ( ( !isset( $extra['notifyAgent'] ) || !$extra['notifyAgent'] ) && $event->getAgent() ) {
			$agentId = $event->getAgent()->getId();
			$notify->addFilter( function ( $user ) use ( $agentId ) {
				return $user->getId() != $agentId;
			} );
		}

		// Apply blacklists and whitelists.
		$notify->addFilter( function ( $user ) use ( $event ) {
			$title = $event->getTitle();

			if ( self::isBlacklistedByUser( $event, $user ) &&
				(
					$title === null ||
					!(
						// Still notify for posts anywhere in
						// user's talk space
						$title->getRootText() === $user->getName() &&
						$title->getNamespace() === NS_USER_TALK
					)
				)
			) {
				return self::isWhitelistedByUser( $event, $user );
			}

			return true;
		} );

		return $notify->getIterator();
	}

	/**
	 * INTERNAL.  Must be public to be callable by the php error handling methods.
	 *
	 * Converts E_RECOVERABLE_ERROR, such as passing null to a method expecting
	 * a non-null object, into exceptions.
	 * @param int $errno
	 * @param string $errstr
	 * @param string $errfile
	 * @param int $errline
	 * @return bool
	 * @throws EchoCatchableFatalErrorException
	 */
	public static function formatterErrorHandler( $errno, $errstr, $errfile, $errline ) {
		if ( $errno !== E_RECOVERABLE_ERROR ) {
			return false;
		}

		throw new EchoCatchableFatalErrorException( $errno, $errstr, $errfile, $errline );
	}
}