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