Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.08% |
109 / 121 |
|
50.00% |
7 / 14 |
CRAP | |
0.00% |
0 / 1 |
WatchlistManager | |
90.08% |
109 / 121 |
|
50.00% |
7 / 14 |
56.84 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
clearAllUserNotifications | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
clearTitleUserNotifications | |
73.91% |
17 / 23 |
|
0.00% |
0 / 1 |
13.15 | |||
getTitleNotificationTimestamp | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
isWatchable | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
isWatchedIgnoringRights | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
isWatched | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isTempWatchedIgnoringRights | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
isTempWatched | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
addWatchIgnoringRights | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
addWatch | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
removeWatchIgnoringRights | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
removeWatch | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setWatch | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
8.03 |
1 | <?php |
2 | |
3 | /** |
4 | * This program is free software; you can redistribute it and/or modify |
5 | * it under the terms of the GNU General Public License as published by |
6 | * the Free Software Foundation; either version 2 of the License, or |
7 | * (at your option) any later version. |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License along |
15 | * with this program; if not, write to the Free Software Foundation, Inc., |
16 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * http://www.gnu.org/copyleft/gpl.html |
18 | * |
19 | * @file |
20 | * @author DannyS712 |
21 | */ |
22 | |
23 | namespace MediaWiki\Watchlist; |
24 | |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\Linker\LinkTarget; |
28 | use MediaWiki\Page\PageIdentity; |
29 | use MediaWiki\Page\PageReference; |
30 | use MediaWiki\Page\WikiPageFactory; |
31 | use MediaWiki\Permissions\Authority; |
32 | use MediaWiki\Revision\RevisionLookup; |
33 | use MediaWiki\Revision\RevisionRecord; |
34 | use MediaWiki\Status\Status; |
35 | use MediaWiki\Title\NamespaceInfo; |
36 | use MediaWiki\User\TalkPageNotificationManager; |
37 | use MediaWiki\User\User; |
38 | use MediaWiki\User\UserFactory; |
39 | use MediaWiki\User\UserIdentity; |
40 | use StatusValue; |
41 | use Wikimedia\ParamValidator\TypeDef\ExpiryDef; |
42 | use Wikimedia\Rdbms\ReadOnlyMode; |
43 | |
44 | /** |
45 | * WatchlistManager service |
46 | * |
47 | * @since 1.35 |
48 | */ |
49 | class WatchlistManager { |
50 | |
51 | /** |
52 | * @internal For use by ServiceWiring |
53 | */ |
54 | public const OPTION_ENOTIF = 'isEnotifEnabled'; |
55 | |
56 | /** @var bool */ |
57 | private $isEnotifEnabled; |
58 | |
59 | /** @var HookRunner */ |
60 | private $hookRunner; |
61 | |
62 | /** @var ReadOnlyMode */ |
63 | private $readOnlyMode; |
64 | |
65 | /** @var RevisionLookup */ |
66 | private $revisionLookup; |
67 | |
68 | /** @var TalkPageNotificationManager */ |
69 | private $talkPageNotificationManager; |
70 | |
71 | /** @var WatchedItemStoreInterface */ |
72 | private $watchedItemStore; |
73 | |
74 | /** @var UserFactory */ |
75 | private $userFactory; |
76 | |
77 | /** @var NamespaceInfo */ |
78 | private $nsInfo; |
79 | |
80 | /** @var WikiPageFactory */ |
81 | private $wikiPageFactory; |
82 | |
83 | /** |
84 | * @var array |
85 | * |
86 | * Cache for getTitleNotificationTimestamp |
87 | * |
88 | * Keys need to reflect both the specific user and the title: |
89 | * |
90 | * Since only users have watchlists, the user is represented with `u⧼user id⧽` |
91 | * |
92 | * Since the method accepts LinkTarget objects, cannot rely on the object's toString, |
93 | * since it is different for TitleValue and Title. Implement a simplified string |
94 | * representation of the string that TitleValue uses: `⧼namespace number⧽:⧼db key⧽` |
95 | * |
96 | * Entries are in the form of |
97 | * u⧼user id⧽-⧼namespace number⧽:⧼db key⧽ => ⧼timestamp or false⧽ |
98 | */ |
99 | private $notificationTimestampCache = []; |
100 | |
101 | /** |
102 | * @param array{isEnotifEnabled:bool} $options |
103 | * @param HookContainer $hookContainer |
104 | * @param ReadOnlyMode $readOnlyMode |
105 | * @param RevisionLookup $revisionLookup |
106 | * @param TalkPageNotificationManager $talkPageNotificationManager |
107 | * @param WatchedItemStoreInterface $watchedItemStore |
108 | * @param UserFactory $userFactory |
109 | * @param NamespaceInfo $nsInfo |
110 | * @param WikiPageFactory $wikiPageFactory |
111 | */ |
112 | public function __construct( |
113 | array $options, |
114 | HookContainer $hookContainer, |
115 | ReadOnlyMode $readOnlyMode, |
116 | RevisionLookup $revisionLookup, |
117 | TalkPageNotificationManager $talkPageNotificationManager, |
118 | WatchedItemStoreInterface $watchedItemStore, |
119 | UserFactory $userFactory, |
120 | NamespaceInfo $nsInfo, |
121 | WikiPageFactory $wikiPageFactory |
122 | ) { |
123 | $this->isEnotifEnabled = $options[ self::OPTION_ENOTIF ]; |
124 | $this->hookRunner = new HookRunner( $hookContainer ); |
125 | $this->readOnlyMode = $readOnlyMode; |
126 | $this->revisionLookup = $revisionLookup; |
127 | $this->talkPageNotificationManager = $talkPageNotificationManager; |
128 | $this->watchedItemStore = $watchedItemStore; |
129 | $this->userFactory = $userFactory; |
130 | $this->nsInfo = $nsInfo; |
131 | $this->wikiPageFactory = $wikiPageFactory; |
132 | } |
133 | |
134 | /** |
135 | * Resets all of the given user's page-change notification timestamps. |
136 | * If e-notif e-mails are on, they will receive notification mails on |
137 | * the next change of any watched page. |
138 | * |
139 | * @note If the user doesn't have 'editmywatchlist', this will do nothing. |
140 | * |
141 | * @param Authority|UserIdentity $performer deprecated passing UserIdentity since 1.37 |
142 | */ |
143 | public function clearAllUserNotifications( $performer ) { |
144 | if ( $this->readOnlyMode->isReadOnly() ) { |
145 | // Cannot change anything in read only |
146 | return; |
147 | } |
148 | |
149 | if ( !$performer instanceof Authority ) { |
150 | $performer = $this->userFactory->newFromUserIdentity( $performer ); |
151 | } |
152 | |
153 | $user = $performer->getUser(); |
154 | |
155 | // NOTE: Has to be before `editmywatchlist` user right check, to ensure |
156 | // anonymous / temporary users have their talk page notifications cleared (T345031). |
157 | if ( !$this->isEnotifEnabled ) { |
158 | $this->talkPageNotificationManager->removeUserHasNewMessages( $user ); |
159 | return; |
160 | } |
161 | |
162 | if ( !$performer->isAllowed( 'editmywatchlist' ) ) { |
163 | // User isn't allowed to edit the watchlist |
164 | return; |
165 | } |
166 | |
167 | if ( !$user->isRegistered() ) { |
168 | return; |
169 | } |
170 | |
171 | $this->watchedItemStore->resetAllNotificationTimestampsForUser( $user ); |
172 | |
173 | // We also need to clear here the "you have new message" notification for the own |
174 | // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates(). |
175 | } |
176 | |
177 | /** |
178 | * Clear the user's notification timestamp for the given title. |
179 | * If e-notif e-mails are on, they will receive notification mails on |
180 | * the next change of the page if it's watched etc. |
181 | * |
182 | * @note If the user doesn't have 'editmywatchlist', this will do nothing. |
183 | * |
184 | * @param Authority|UserIdentity $performer deprecated passing UserIdentity since 1.37 |
185 | * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.37 |
186 | * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed. |
187 | * @param RevisionRecord|null $oldRev The revision record associated with $oldid, or null if |
188 | * the latest revision is used |
189 | */ |
190 | public function clearTitleUserNotifications( |
191 | $performer, |
192 | $title, |
193 | int $oldid = 0, |
194 | ?RevisionRecord $oldRev = null |
195 | ) { |
196 | if ( $this->readOnlyMode->isReadOnly() ) { |
197 | // Cannot change anything in read only |
198 | return; |
199 | } |
200 | |
201 | if ( !$performer instanceof Authority ) { |
202 | $performer = $this->userFactory->newFromUserIdentity( $performer ); |
203 | } |
204 | |
205 | $userIdentity = $performer->getUser(); |
206 | $userTalkPage = ( |
207 | $title->getNamespace() === NS_USER_TALK && |
208 | $title->getDBkey() === strtr( $userIdentity->getName(), ' ', '_' ) |
209 | ); |
210 | |
211 | if ( $userTalkPage ) { |
212 | if ( !$oldid ) { |
213 | $oldRev = null; |
214 | } elseif ( !$oldRev ) { |
215 | $oldRev = $this->revisionLookup->getRevisionById( $oldid ); |
216 | } |
217 | // NOTE: Has to be called before isAllowed() check, to ensure users with no watchlist |
218 | // access (by default, temporary and anonymous users) can clear their talk page |
219 | // notification (T345031). |
220 | $this->talkPageNotificationManager->clearForPageView( $userIdentity, $oldRev ); |
221 | } |
222 | |
223 | if ( !$this->isEnotifEnabled ) { |
224 | return; |
225 | } |
226 | |
227 | if ( !$userIdentity->isRegistered() ) { |
228 | // Nothing else to do |
229 | return; |
230 | } |
231 | |
232 | // NOTE: Has to be checked after the TalkPageNotificationManager::clearForPageView call, to |
233 | // ensure users with no watchlist access (by default, temporary and anonymous users) can |
234 | // clear their talk page notification (T345031). |
235 | if ( !$performer->isAllowed( 'editmywatchlist' ) ) { |
236 | // User isn't allowed to edit the watchlist |
237 | return; |
238 | } |
239 | |
240 | // Only update the timestamp if the page is being watched. |
241 | // The query to find out if it is watched is cached both in memcached and per-invocation, |
242 | // and when it does have to be executed, it can be on a replica DB |
243 | // If this is the user's newtalk page, we always update the timestamp |
244 | $force = $userTalkPage ? 'force' : ''; |
245 | $this->watchedItemStore->resetNotificationTimestamp( $userIdentity, $title, $force, $oldid ); |
246 | } |
247 | |
248 | /** |
249 | * Get the timestamp when this page was updated since the user last saw it. |
250 | * |
251 | * @param UserIdentity $user |
252 | * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.37 |
253 | * @return string|bool|null String timestamp, false if not watched, null if nothing is unseen |
254 | */ |
255 | public function getTitleNotificationTimestamp( UserIdentity $user, $title ) { |
256 | if ( !$user->isRegistered() ) { |
257 | return false; |
258 | } |
259 | |
260 | $cacheKey = 'u' . $user->getId() . '-' . |
261 | $title->getNamespace() . ':' . $title->getDBkey(); |
262 | |
263 | // avoid isset here, as it'll return false for null entries |
264 | if ( array_key_exists( $cacheKey, $this->notificationTimestampCache ) ) { |
265 | return $this->notificationTimestampCache[ $cacheKey ]; |
266 | } |
267 | |
268 | $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $title ); |
269 | if ( $watchedItem ) { |
270 | $timestamp = $watchedItem->getNotificationTimestamp(); |
271 | } else { |
272 | $timestamp = false; |
273 | } |
274 | |
275 | $this->notificationTimestampCache[ $cacheKey ] = $timestamp; |
276 | return $timestamp; |
277 | } |
278 | |
279 | /** |
280 | * @since 1.37 |
281 | * @param PageReference $target |
282 | * @return bool |
283 | */ |
284 | public function isWatchable( PageReference $target ): bool { |
285 | if ( !$this->nsInfo->isWatchable( $target->getNamespace() ) ) { |
286 | return false; |
287 | } |
288 | |
289 | if ( $target instanceof PageIdentity && !$target->canExist() ) { |
290 | // Catch "improper" Title instances |
291 | return false; |
292 | } |
293 | |
294 | return true; |
295 | } |
296 | |
297 | /** |
298 | * Check if the page is watched by the user. |
299 | * @since 1.37 |
300 | * @param UserIdentity $userIdentity |
301 | * @param PageIdentity $target |
302 | * @return bool |
303 | */ |
304 | public function isWatchedIgnoringRights( UserIdentity $userIdentity, PageIdentity $target ): bool { |
305 | if ( $this->isWatchable( $target ) ) { |
306 | return $this->watchedItemStore->isWatched( $userIdentity, $target ); |
307 | } |
308 | return false; |
309 | } |
310 | |
311 | /** |
312 | * Check if the page is watched by the user and the user has permission to view their |
313 | * watchlist. |
314 | * @since 1.37 |
315 | * @param Authority $performer |
316 | * @param PageIdentity $target |
317 | * @return bool |
318 | */ |
319 | public function isWatched( Authority $performer, PageIdentity $target ): bool { |
320 | if ( $performer->isAllowed( 'viewmywatchlist' ) ) { |
321 | return $this->isWatchedIgnoringRights( $performer->getUser(), $target ); |
322 | } |
323 | return false; |
324 | } |
325 | |
326 | /** |
327 | * Check if the article is temporarily watched by the user. |
328 | * @since 1.37 |
329 | * @param UserIdentity $userIdentity |
330 | * @param PageIdentity $target |
331 | * @return bool |
332 | */ |
333 | public function isTempWatchedIgnoringRights( UserIdentity $userIdentity, PageIdentity $target ): bool { |
334 | if ( $this->isWatchable( $target ) ) { |
335 | return $this->watchedItemStore->isTempWatched( $userIdentity, $target ); |
336 | } |
337 | return false; |
338 | } |
339 | |
340 | /** |
341 | * Check if the page is temporarily watched by the user and the user has permission to view |
342 | * their watchlist. |
343 | * @since 1.37 |
344 | * @param Authority $performer |
345 | * @param PageIdentity $target |
346 | * @return bool |
347 | */ |
348 | public function isTempWatched( Authority $performer, PageIdentity $target ): bool { |
349 | if ( $performer->isAllowed( 'viewmywatchlist' ) ) { |
350 | return $this->isTempWatchedIgnoringRights( $performer->getUser(), $target ); |
351 | } |
352 | return false; |
353 | } |
354 | |
355 | /** |
356 | * Watch a page. Calls the WatchArticle and WatchArticleComplete hooks. |
357 | * @since 1.37 |
358 | * @param UserIdentity $userIdentity |
359 | * @param PageIdentity $target |
360 | * @param string|null $expiry Optional expiry timestamp in any format acceptable to wfTimestamp(), |
361 | * null will not create expiries, or leave them unchanged should they already exist. |
362 | * @return StatusValue |
363 | */ |
364 | public function addWatchIgnoringRights( |
365 | UserIdentity $userIdentity, |
366 | PageIdentity $target, |
367 | ?string $expiry = null |
368 | ): StatusValue { |
369 | if ( !$this->isWatchable( $target ) ) { |
370 | return StatusValue::newFatal( 'watchlistnotwatchable' ); |
371 | } |
372 | |
373 | $wikiPage = $this->wikiPageFactory->newFromTitle( $target ); |
374 | $title = $wikiPage->getTitle(); |
375 | |
376 | // TODO: update hooks to take Authority |
377 | $status = Status::newFatal( 'hookaborted' ); |
378 | $user = $this->userFactory->newFromUserIdentity( $userIdentity ); |
379 | if ( $this->hookRunner->onWatchArticle( $user, $wikiPage, $status, $expiry ) ) { |
380 | $status = StatusValue::newGood(); |
381 | $this->watchedItemStore->addWatch( $userIdentity, $this->nsInfo->getSubjectPage( $title ), $expiry ); |
382 | if ( $this->nsInfo->canHaveTalkPage( $title ) ) { |
383 | $this->watchedItemStore->addWatch( $userIdentity, $this->nsInfo->getTalkPage( $title ), $expiry ); |
384 | } |
385 | $this->hookRunner->onWatchArticleComplete( $user, $wikiPage ); |
386 | } |
387 | |
388 | // eventually user_touched should be factored out of User and this should be replaced |
389 | $user->invalidateCache(); |
390 | |
391 | return $status; |
392 | } |
393 | |
394 | /** |
395 | * Watch a page if the user has permission to edit their watchlist. |
396 | * Calls the WatchArticle and WatchArticleComplete hooks. |
397 | * @since 1.37 |
398 | * @param Authority $performer |
399 | * @param PageIdentity $target |
400 | * @param string|null $expiry Optional expiry timestamp in any format acceptable to wfTimestamp(), |
401 | * null will not create expiries, or leave them unchanged should they already exist. |
402 | * @return StatusValue |
403 | */ |
404 | public function addWatch( |
405 | Authority $performer, |
406 | PageIdentity $target, |
407 | ?string $expiry = null |
408 | ): StatusValue { |
409 | if ( !$performer->isAllowed( 'editmywatchlist' ) ) { |
410 | // TODO: this function should be moved out of User |
411 | return User::newFatalPermissionDeniedStatus( 'editmywatchlist' ); |
412 | } |
413 | |
414 | return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry ); |
415 | } |
416 | |
417 | /** |
418 | * Stop watching a page. Calls the UnwatchArticle and UnwatchArticleComplete hooks. |
419 | * @since 1.37 |
420 | * @param UserIdentity $userIdentity |
421 | * @param PageIdentity $target |
422 | * @return StatusValue |
423 | */ |
424 | public function removeWatchIgnoringRights( |
425 | UserIdentity $userIdentity, |
426 | PageIdentity $target |
427 | ): StatusValue { |
428 | if ( !$this->isWatchable( $target ) ) { |
429 | return StatusValue::newFatal( 'watchlistnotwatchable' ); |
430 | } |
431 | |
432 | $wikiPage = $this->wikiPageFactory->newFromTitle( $target ); |
433 | $title = $wikiPage->getTitle(); |
434 | |
435 | // TODO: update hooks to take Authority |
436 | $status = Status::newFatal( 'hookaborted' ); |
437 | $user = $this->userFactory->newFromUserIdentity( $userIdentity ); |
438 | if ( $this->hookRunner->onUnwatchArticle( $user, $wikiPage, $status ) ) { |
439 | $status = StatusValue::newGood(); |
440 | $this->watchedItemStore->removeWatch( $userIdentity, $this->nsInfo->getSubjectPage( $title ) ); |
441 | if ( $this->nsInfo->canHaveTalkPage( $title ) ) { |
442 | $this->watchedItemStore->removeWatch( $userIdentity, $this->nsInfo->getTalkPage( $title ) ); |
443 | } |
444 | $this->hookRunner->onUnwatchArticleComplete( $user, $wikiPage ); |
445 | } |
446 | |
447 | // eventually user_touched should be factored out of User and this should be replaced |
448 | $user->invalidateCache(); |
449 | |
450 | return $status; |
451 | } |
452 | |
453 | /** |
454 | * Stop watching a page if the user has permission to edit their watchlist. |
455 | * Calls the UnwatchArticle and UnwatchArticleComplete hooks. |
456 | * @since 1.37 |
457 | * @param Authority $performer |
458 | * @param PageIdentity $target |
459 | * @return StatusValue |
460 | */ |
461 | public function removeWatch( |
462 | Authority $performer, |
463 | PageIdentity $target |
464 | ): StatusValue { |
465 | if ( !$performer->isAllowed( 'editmywatchlist' ) ) { |
466 | // TODO: this function should be moved out of User |
467 | return User::newFatalPermissionDeniedStatus( 'editmywatchlist' ); |
468 | } |
469 | |
470 | return $this->removeWatchIgnoringRights( $performer->getUser(), $target ); |
471 | } |
472 | |
473 | /** |
474 | * Watch or unwatch a page, calling watch/unwatch hooks as appropriate. |
475 | * Checks before watching or unwatching to see if the page is already in the requested watch |
476 | * state and if the expiry is the same so it does not act unnecessarily. |
477 | * |
478 | * @param bool $watch Whether to watch or unwatch the page |
479 | * @param Authority $performer who is watching/unwatching |
480 | * @param PageIdentity $target Page to watch/unwatch |
481 | * @param string|null $expiry Optional expiry timestamp in any format acceptable to wfTimestamp(), |
482 | * null will not create expiries, or leave them unchanged should they already exist. |
483 | * @return StatusValue |
484 | * @since 1.37 |
485 | */ |
486 | public function setWatch( |
487 | bool $watch, |
488 | Authority $performer, |
489 | PageIdentity $target, |
490 | ?string $expiry = null |
491 | ): StatusValue { |
492 | // User must be registered, and (T371091) not a temp user |
493 | if ( !$performer->getUser()->isRegistered() || $performer->isTemp() ) { |
494 | return StatusValue::newGood(); |
495 | } |
496 | |
497 | // User must be either changing the watch state or at least the expiry. |
498 | |
499 | // Only call addWatchIgnoringRights() or removeWatch() if there's been a change in the watched status. |
500 | $oldWatchedItem = $this->watchedItemStore->getWatchedItem( $performer->getUser(), $target ); |
501 | $changingWatchStatus = (bool)$oldWatchedItem !== $watch; |
502 | if ( $oldWatchedItem && $expiry !== null ) { |
503 | // If there's an old watched item, a non-null change to the expiry requires an UPDATE. |
504 | $oldWatchPeriod = $oldWatchedItem->getExpiry() ?? 'infinity'; |
505 | $changingWatchStatus = $changingWatchStatus || |
506 | $oldWatchPeriod !== ExpiryDef::normalizeExpiry( $expiry, TS_MW ); |
507 | } |
508 | |
509 | if ( $changingWatchStatus ) { |
510 | // If the user doesn't have 'editmywatchlist', we still want to |
511 | // allow them to add but not remove items via edits and such. |
512 | if ( $watch ) { |
513 | return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry ); |
514 | } else { |
515 | return $this->removeWatch( $performer, $target ); |
516 | } |
517 | } |
518 | |
519 | return StatusValue::newGood(); |
520 | } |
521 | } |