Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.08% covered (success)
90.08%
109 / 121
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchlistManager
90.08% covered (success)
90.08%
109 / 121
50.00% covered (danger)
50.00%
7 / 14
55.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 clearAllUserNotifications
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 clearTitleUserNotifications
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
13.15
 getTitleNotificationTimestamp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 isWatchable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 isWatchedIgnoringRights
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 isWatched
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isTempWatchedIgnoringRights
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 isTempWatched
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addWatchIgnoringRights
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 addWatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeWatchIgnoringRights
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 removeWatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setWatch
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
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
23namespace MediaWiki\Watchlist;
24
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\Linker\LinkTarget;
28use MediaWiki\Page\PageIdentity;
29use MediaWiki\Page\PageReference;
30use MediaWiki\Page\WikiPageFactory;
31use MediaWiki\Permissions\Authority;
32use MediaWiki\Revision\RevisionLookup;
33use MediaWiki\Revision\RevisionRecord;
34use MediaWiki\Status\Status;
35use MediaWiki\Title\NamespaceInfo;
36use MediaWiki\User\TalkPageNotificationManager;
37use MediaWiki\User\User;
38use MediaWiki\User\UserFactory;
39use MediaWiki\User\UserIdentity;
40use StatusValue;
41use WatchedItemStoreInterface;
42use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
43use Wikimedia\Rdbms\ReadOnlyMode;
44
45/**
46 * WatchlistManager service
47 *
48 * @since 1.35
49 */
50class 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}