Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.19% covered (warning)
89.19%
528 / 592
52.78% covered (warning)
52.78%
19 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
PermissionManager
89.19% covered (warning)
89.19%
528 / 592
52.78% covered (warning)
52.78%
19 / 36
396.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 userCan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 quickUserCan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPermissionErrors
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 throwPermissionErrors
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isBlockedFrom
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getPermissionErrorsInternal
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
12
 checkPermissionHooks
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
 resultToError
36.36% covered (danger)
36.36%
4 / 11
0.00% covered (danger)
0.00%
0 / 1
35.77
 checkReadPermissions
80.95% covered (warning)
80.95%
34 / 42
0.00% covered (danger)
0.00%
0 / 1
26.66
 missingPermissionError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 newFatalPermissionDeniedStatus
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 isSameSpecialPage
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 checkUserBlock
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 getApplicableBlock
88.33% covered (warning)
88.33%
53 / 60
0.00% covered (danger)
0.00%
0 / 1
25.99
 checkQuickPermissions
96.15% covered (success)
96.15%
50 / 52
0.00% covered (danger)
0.00%
0 / 1
35
 checkPageRestrictions
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 checkCascadingSourcesRestrictions
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 checkActionPermissions
87.84% covered (warning)
87.84%
65 / 74
0.00% covered (danger)
0.00%
0 / 1
38.33
 checkSpecialsAndNSPermissions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 checkSiteConfigPermissions
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
13.82
 checkUserConfigPermissions
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
19
 userHasRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 userHasAnyRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 userHasAllRights
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getUserPermissions
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
10.41
 invalidateUsersRightsCache
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getRightsCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isEveryoneAllowed
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
16.19
 getAllPermissions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getImplicitRights
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isNamespaceProtected
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getNamespaceRestrictionLevels
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
18
 userCanEditRawHtmlPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addTemporaryUserRights
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 overrideUserRightsForTesting
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace MediaWiki\Permissions;
21
22use InvalidArgumentException;
23use LogicException;
24use MediaWiki\Actions\ActionFactory;
25use MediaWiki\Block\AbstractBlock;
26use MediaWiki\Block\Block;
27use MediaWiki\Block\BlockErrorFormatter;
28use MediaWiki\Block\BlockManager;
29use MediaWiki\Cache\UserCache;
30use MediaWiki\Config\ServiceOptions;
31use MediaWiki\Context\IContextSource;
32use MediaWiki\Context\RequestContext;
33use MediaWiki\HookContainer\HookContainer;
34use MediaWiki\HookContainer\HookRunner;
35use MediaWiki\Linker\LinkTarget;
36use MediaWiki\MainConfigNames;
37use MediaWiki\Message\Message;
38use MediaWiki\Page\PageIdentity;
39use MediaWiki\Page\PageReference;
40use MediaWiki\Page\RedirectLookup;
41use MediaWiki\Request\WebRequest;
42use MediaWiki\Session\SessionManager;
43use MediaWiki\SpecialPage\SpecialPage;
44use MediaWiki\SpecialPage\SpecialPageFactory;
45use MediaWiki\Title\NamespaceInfo;
46use MediaWiki\Title\Title;
47use MediaWiki\Title\TitleFormatter;
48use MediaWiki\User\TempUser\TempUserConfig;
49use MediaWiki\User\User;
50use MediaWiki\User\UserFactory;
51use MediaWiki\User\UserGroupManager;
52use MediaWiki\User\UserGroupMembership;
53use MediaWiki\User\UserIdentity;
54use MessageSpecifier;
55use PermissionsError;
56use StatusValue;
57use Wikimedia\ScopedCallback;
58
59/**
60 * A service class for checking permissions
61 * To obtain an instance, use MediaWikiServices::getInstance()->getPermissionManager().
62 *
63 * @since 1.33
64 */
65class PermissionManager {
66
67    /** @var string Does cheap permission checks from replica DBs (usable for GUI creation) */
68    public const RIGOR_QUICK = 'quick';
69
70    /** @var string Does cheap and expensive checks possibly from a replica DB */
71    public const RIGOR_FULL = 'full';
72
73    /** @var string Does cheap and expensive checks, using the primary DB as needed */
74    public const RIGOR_SECURE = 'secure';
75
76    /**
77     * @internal For use by ServiceWiring
78     */
79    public const CONSTRUCTOR_OPTIONS = [
80        MainConfigNames::WhitelistRead,
81        MainConfigNames::WhitelistReadRegexp,
82        MainConfigNames::EmailConfirmToEdit,
83        MainConfigNames::BlockDisablesLogin,
84        MainConfigNames::EnablePartialActionBlocks,
85        MainConfigNames::GroupPermissions,
86        MainConfigNames::RevokePermissions,
87        MainConfigNames::AvailableRights,
88        MainConfigNames::NamespaceProtection,
89        MainConfigNames::RestrictionLevels,
90        MainConfigNames::DeleteRevisionsLimit,
91        MainConfigNames::RateLimits,
92        MainConfigNames::ImplicitRights,
93    ];
94
95    /** @var ServiceOptions */
96    private $options;
97
98    /** @var SpecialPageFactory */
99    private $specialPageFactory;
100
101    /** @var RedirectLookup */
102    private $redirectLookup;
103
104    /** @var NamespaceInfo */
105    private $nsInfo;
106
107    /** @var GroupPermissionsLookup */
108    private $groupPermissionsLookup;
109
110    /** @var UserGroupManager */
111    private $userGroupManager;
112
113    /** @var string[]|null Cached results of getAllPermissions() */
114    private $allRights;
115
116    /** @var string[]|null Cached results of getImplicitRights() */
117    private $implicitRights;
118
119    /** @var BlockManager */
120    private $blockManager;
121
122    /** @var BlockErrorFormatter */
123    private $blockErrorFormatter;
124
125    /** @var HookRunner */
126    private $hookRunner;
127
128    /** @var UserCache */
129    private $userCache;
130
131    /** @var RestrictionStore */
132    private $restrictionStore;
133
134    /** @var TitleFormatter */
135    private $titleFormatter;
136
137    /** @var TempUserConfig */
138    private $tempUserConfig;
139
140    /** @var UserFactory */
141    private $userFactory;
142
143    /** @var ActionFactory */
144    private $actionFactory;
145
146    /** @var string[][] Cached user rights */
147    private $usersRights = [];
148
149    /**
150     * Temporary user rights, valid for the current request only.
151     * @var string[][][] userid => override group => rights
152     */
153    private $temporaryUserRights = [];
154
155    /** @var bool[] Cached rights for isEveryoneAllowed, [ right => allowed ] */
156    private $cachedRights = [];
157
158    /**
159     * Array of core rights.
160     * Each of these should have a corresponding message of the form
161     * "right-$right".
162     * @showinitializer
163     */
164    private const CORE_RIGHTS = [
165        'apihighlimits',
166        'applychangetags',
167        'autoconfirmed',
168        'autocreateaccount',
169        'autopatrol',
170        'bigdelete',
171        'block',
172        'blockemail',
173        'bot',
174        'browsearchive',
175        'changetags',
176        'createaccount',
177        'createpage',
178        'createtalk',
179        'delete',
180        'delete-redirect',
181        'deletechangetags',
182        'deletedhistory',
183        'deletedtext',
184        'deletelogentry',
185        'deleterevision',
186        'edit',
187        'editcontentmodel',
188        'editinterface',
189        'editprotected',
190        'editmyoptions',
191        'editmyprivateinfo',
192        'editmyusercss',
193        'editmyuserjson',
194        'editmyuserjs',
195        'editmyuserjsredirect',
196        'editmywatchlist',
197        'editsemiprotected',
198        'editsitecss',
199        'editsitejson',
200        'editsitejs',
201        'editusercss',
202        'edituserjson',
203        'edituserjs',
204        'hideuser',
205        'import',
206        'importupload',
207        'ipblock-exempt',
208        'managechangetags',
209        'markbotedits',
210        'mergehistory',
211        'minoredit',
212        'move',
213        'movefile',
214        'move-categorypages',
215        'move-rootuserpages',
216        'move-subpages',
217        'nominornewtalk',
218        'noratelimit',
219        'override-export-depth',
220        'pagelang',
221        'patrol',
222        'patrolmarks',
223        'protect',
224        'read',
225        'renameuser',
226        'reupload',
227        'reupload-own',
228        'reupload-shared',
229        'rollback',
230        'sendemail',
231        'siteadmin',
232        'suppressionlog',
233        'suppressredirect',
234        'suppressrevision',
235        'unblockself',
236        'undelete',
237        'unwatchedpages',
238        'upload',
239        'upload_by_url',
240        'userrights',
241        'userrights-interwiki',
242        'viewmyprivateinfo',
243        'viewmywatchlist',
244        'viewsuppressed',
245        'writeapi',
246    ];
247
248    /**
249     * List of implicit rights.
250     * These should not have a corresponding message of the form
251     * "right-$right".
252     * @showinitializer
253     */
254    private const CORE_IMPLICIT_RIGHTS = [
255        'renderfile',
256        'renderfile-nonstandard',
257        'stashedit',
258        'stashbasehtml',
259        'mailpassword',
260        'changeemail',
261        'confirmemail',
262        'linkpurge',
263        'purge',
264    ];
265
266    /**
267     * @param ServiceOptions $options
268     * @param SpecialPageFactory $specialPageFactory
269     * @param NamespaceInfo $nsInfo
270     * @param GroupPermissionsLookup $groupPermissionsLookup
271     * @param UserGroupManager $userGroupManager
272     * @param BlockManager $blockManager
273     * @param BlockErrorFormatter $blockErrorFormatter
274     * @param HookContainer $hookContainer
275     * @param UserCache $userCache
276     * @param RedirectLookup $redirectLookup
277     * @param RestrictionStore $restrictionStore
278     * @param TitleFormatter $titleFormatter
279     * @param TempUserConfig $tempUserConfig
280     * @param UserFactory $userFactory
281     * @param ActionFactory $actionFactory
282     */
283    public function __construct(
284        ServiceOptions $options,
285        SpecialPageFactory $specialPageFactory,
286        NamespaceInfo $nsInfo,
287        GroupPermissionsLookup $groupPermissionsLookup,
288        UserGroupManager $userGroupManager,
289        BlockManager $blockManager,
290        BlockErrorFormatter $blockErrorFormatter,
291        HookContainer $hookContainer,
292        UserCache $userCache,
293        RedirectLookup $redirectLookup,
294        RestrictionStore $restrictionStore,
295        TitleFormatter $titleFormatter,
296        TempUserConfig $tempUserConfig,
297        UserFactory $userFactory,
298        ActionFactory $actionFactory
299    ) {
300        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
301        $this->options = $options;
302        $this->specialPageFactory = $specialPageFactory;
303        $this->nsInfo = $nsInfo;
304        $this->groupPermissionsLookup = $groupPermissionsLookup;
305        $this->userGroupManager = $userGroupManager;
306        $this->blockManager = $blockManager;
307        $this->blockErrorFormatter = $blockErrorFormatter;
308        $this->hookRunner = new HookRunner( $hookContainer );
309        $this->userCache = $userCache;
310        $this->redirectLookup = $redirectLookup;
311        $this->restrictionStore = $restrictionStore;
312        $this->titleFormatter = $titleFormatter;
313        $this->tempUserConfig = $tempUserConfig;
314        $this->userFactory = $userFactory;
315        $this->actionFactory = $actionFactory;
316    }
317
318    /**
319     * Can $user perform $action on a page?
320     *
321     * The method replaced Title::userCan()
322     * The $user parameter need to be superseded by UserIdentity value in future
323     * The $title parameter need to be superseded by PageIdentity value in future
324     *
325     * @param string $action
326     * @param User $user
327     * @param LinkTarget $page
328     * @param string $rigor One of PermissionManager::RIGOR_ constants
329     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
330     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
331     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
332     *
333     * @return bool
334     */
335    public function userCan( $action, User $user, LinkTarget $page, $rigor = self::RIGOR_SECURE ): bool {
336        return !count( $this->getPermissionErrorsInternal( $action, $user, $page, $rigor, true ) );
337    }
338
339    /**
340     * A convenience method for calling PermissionManager::userCan
341     * with PermissionManager::RIGOR_QUICK
342     *
343     * Suitable for use for nonessential UI controls in common cases, but
344     * _not_ for functional access control.
345     * May provide false positives, but should never provide a false negative.
346     *
347     * @see PermissionManager::userCan()
348     *
349     * @param string $action
350     * @param User $user
351     * @param LinkTarget $page
352     * @return bool
353     */
354    public function quickUserCan( $action, User $user, LinkTarget $page ): bool {
355        return $this->userCan( $action, $user, $page, self::RIGOR_QUICK );
356    }
357
358    /**
359     * Can $user perform $action on a page?
360     *
361     * @todo FIXME: This *does not* check throttles (User::pingLimiter()).
362     *
363     * @param string $action Action that permission needs to be checked for
364     * @param User $user User to check
365     * @param LinkTarget $page
366     * @param string $rigor One of PermissionManager::RIGOR_ constants
367     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
368     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
369     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
370     * @param string[] $ignoreErrors Set this to a list of message keys
371     *   whose corresponding errors may be ignored.
372     *
373     * @return array[] Array of arrays of the arguments to wfMessage to explain permissions problems.
374     * @phan-return non-empty-array[]
375     */
376    public function getPermissionErrors(
377        $action,
378        User $user,
379        LinkTarget $page,
380        $rigor = self::RIGOR_SECURE,
381        $ignoreErrors = []
382    ): array {
383        $errors = $this->getPermissionErrorsInternal( $action, $user, $page, $rigor );
384
385        // Remove the errors being ignored.
386        foreach ( $errors as $index => $error ) {
387            $errKey = is_array( $error ) ? $error[0] : $error;
388
389            if ( in_array( $errKey, $ignoreErrors ) ) {
390                unset( $errors[$index] );
391            }
392            if ( $errKey instanceof MessageSpecifier && in_array( $errKey->getKey(), $ignoreErrors ) ) {
393                unset( $errors[$index] );
394            }
395        }
396
397        return array_values( $errors );
398    }
399
400    /**
401     * Like {@link getPermissionErrors}, but immediately throw if there are any errors.
402     *
403     * @param string $action Action that permission needs to be checked for
404     * @param User $user User to check
405     * @param LinkTarget $page
406     * @param string $rigor One of PermissionManager::RIGOR_ constants
407     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
408     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
409     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
410     * @param string[] $ignoreErrors Set this to a list of message keys
411     *   whose corresponding errors may be ignored.
412     *
413     * @throws PermissionsError
414     */
415    public function throwPermissionErrors(
416        $action,
417        User $user,
418        LinkTarget $page,
419        $rigor = self::RIGOR_SECURE,
420        $ignoreErrors = []
421    ): void {
422        $permissionErrors = $this->getPermissionErrors(
423            $action, $user, $page, $rigor, $ignoreErrors );
424        if ( $permissionErrors !== [] ) {
425            throw new PermissionsError( $action, $permissionErrors );
426        }
427    }
428
429    /**
430     * Check if user is blocked from editing a particular article. If the user does not
431     * have a block, this will return false.
432     *
433     * @param User $user
434     * @param PageIdentity|LinkTarget $page Title to check
435     * @param bool $fromReplica Whether to check the replica DB instead of the primary DB
436     * @return bool
437     */
438    public function isBlockedFrom( User $user, $page, $fromReplica = false ): bool {
439        return (bool)$this->getApplicableBlock(
440            'edit',
441            $user,
442            $fromReplica ? self::RIGOR_FULL : self::RIGOR_SECURE,
443            $page,
444            $user->getRequest()
445        );
446    }
447
448    /**
449     * Can $user perform $action on a page? This is an internal function,
450     * with multiple levels of checks depending on performance needs; see $rigor below.
451     * It does not check ReadOnlyMode::isReadOnly().
452     *
453     * @param string $action Action that permission needs to be checked for
454     * @param User $user User to check
455     * @param LinkTarget $page
456     * @param string $rigor One of PermissionManager::RIGOR_ constants
457     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
458     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
459     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
460     * @param bool $short Set this to true to stop after the first permission error.
461     * @return array[] Array of arrays of the arguments to wfMessage to explain permissions problems.
462     */
463    private function getPermissionErrorsInternal(
464        $action,
465        User $user,
466        LinkTarget $page,
467        $rigor = self::RIGOR_SECURE,
468        $short = false
469    ): array {
470        if ( !in_array( $rigor, [ self::RIGOR_QUICK, self::RIGOR_FULL, self::RIGOR_SECURE ] ) ) {
471            throw new InvalidArgumentException( "Invalid rigor parameter '$rigor'." );
472        }
473
474        // With RIGOR_QUICK we can assume automatic account creation will
475        // occur. At a higher rigor level, the caller is required to opt
476        // in by either passing in a temp placeholder user or by actually
477        // creating the account.
478        if ( $rigor === self::RIGOR_QUICK
479            && !$user->isRegistered()
480            && $this->tempUserConfig->isAutoCreateAction( $action )
481        ) {
482            $user = $this->userFactory->newTempPlaceholder();
483        }
484
485        # Read has special handling
486        if ( $action === 'read' ) {
487            $checks = [
488                [ $this, 'checkPermissionHooks' ],
489                [ $this, 'checkReadPermissions' ],
490                [ $this, 'checkUserBlock' ], // for wgBlockDisablesLogin
491            ];
492            # Don't call checkSpecialsAndNSPermissions, checkSiteConfigPermissions
493            # or checkUserConfigPermissions here as it will lead to duplicate
494            # error messages. This is okay to do since anywhere that checks for
495            # create will also check for edit, and those checks are called for edit.
496        } elseif ( $action === 'create' ) {
497            $checks = [
498                [ $this, 'checkQuickPermissions' ],
499                [ $this, 'checkPermissionHooks' ],
500                [ $this, 'checkPageRestrictions' ],
501                [ $this, 'checkCascadingSourcesRestrictions' ],
502                [ $this, 'checkActionPermissions' ],
503                [ $this, 'checkUserBlock' ],
504            ];
505        } else {
506            // Exclude checkUserConfigPermissions on actions that cannot change the
507            // content of the configuration pages.
508            $skipUserConfigActions = [
509                // Allow patrolling per T21818
510                'patrol',
511
512                // Allow admins and oversighters to delete. For user pages we want to avoid the
513                // situation where an unprivileged user can post abusive content on
514                // their subpages and only very highly privileged users could remove it.
515                // See T200176.
516                'delete',
517                'deleterevision',
518                'suppressrevision',
519
520                // Allow admins and oversighters to view deleted content, even if they
521                // cannot restore it. See T202989
522                'deletedhistory',
523                'deletedtext',
524                'viewsuppressed',
525            ];
526
527            $checks = [
528                [ $this, 'checkQuickPermissions' ],
529                [ $this, 'checkPermissionHooks' ],
530                [ $this, 'checkSpecialsAndNSPermissions' ],
531                [ $this, 'checkSiteConfigPermissions' ],
532            ];
533            if ( !in_array( $action, $skipUserConfigActions, true ) ) {
534                $checks[] = [ $this, 'checkUserConfigPermissions' ];
535            }
536            $checks = [
537                ...$checks,
538                [ $this, 'checkPageRestrictions' ],
539                [ $this, 'checkCascadingSourcesRestrictions' ],
540                [ $this, 'checkActionPermissions' ],
541                [ $this, 'checkUserBlock' ]
542            ];
543        }
544
545        $errors = [];
546        foreach ( $checks as $method ) {
547            $errors = $method( $action, $user, $errors, $rigor, $short, $page );
548
549            if ( $short && $errors !== [] ) {
550                break;
551            }
552        }
553        // remove duplicate errors
554        $errors = array_unique( $errors, SORT_REGULAR );
555        if ( $errors ) {
556            $this->hookRunner->onPermissionErrorAudit( $page, $user, $action, $rigor, $errors );
557        }
558
559        return $errors;
560    }
561
562    /**
563     * Check various permission hooks
564     *
565     * @param string $action The action to check
566     * @param User $user User to check
567     * @param array $errors List of current errors
568     * @param string $rigor One of PermissionManager::RIGOR_ constants
569     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
570     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
571     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
572     * @param bool $short Short circuit on first error
573     * @param LinkTarget $page
574     * @return array List of errors
575     */
576    private function checkPermissionHooks(
577        $action,
578        User $user,
579        $errors,
580        $rigor,
581        $short,
582        LinkTarget $page
583    ): array {
584        // TODO: remove when LinkTarget usage will expand further
585        $title = Title::newFromLinkTarget( $page );
586        // Use getUserPermissionsErrors instead
587        $result = '';
588        if ( !$this->hookRunner->onUserCan( $title, $user, $action, $result ) ) {
589            return $result ? [] : [ [ 'badaccess-group0' ] ];
590        }
591        // Check getUserPermissionsErrors hook
592        if ( !$this->hookRunner->onGetUserPermissionsErrors( $title, $user, $action, $result ) ) {
593            $errors = $this->resultToError( $errors, $result );
594        }
595        // Check getUserPermissionsErrorsExpensive hook
596        if (
597            $rigor !== self::RIGOR_QUICK
598            && !( $short && count( $errors ) > 0 )
599            && !$this->hookRunner->onGetUserPermissionsErrorsExpensive(
600                $title, $user, $action, $result )
601        ) {
602            $errors = $this->resultToError( $errors, $result );
603        }
604
605        return $errors;
606    }
607
608    /**
609     * Add the resulting error code to the errors array
610     *
611     * @param array $errors List of current errors
612     * @param array|string|MessageSpecifier|false $result Result of errors
613     * @return array List of errors
614     */
615    private function resultToError( $errors, $result ): array {
616        if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
617            // A single array representing an error
618            $errors[] = $result;
619        } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
620            // A nested array representing multiple errors
621            $errors = array_merge( $errors, $result );
622        } elseif ( $result !== '' && is_string( $result ) ) {
623            // A string representing a message-id
624            $errors[] = [ $result ];
625        } elseif ( $result instanceof MessageSpecifier ) {
626            // A message specifier representing an error
627            $errors[] = [ $result ];
628        } elseif ( $result === false ) {
629            // a generic "We don't want them to do that"
630            $errors[] = [ 'badaccess-group0' ];
631        }
632        return $errors;
633    }
634
635    /**
636     * Check that the user is allowed to read this page.
637     *
638     * @param string $action The action to check
639     * @param User $user User to check
640     * @param array $errors List of current errors
641     * @param string $rigor One of PermissionManager::RIGOR_ constants
642     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
643     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
644     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
645     * @param bool $short Short circuit on first error
646     * @param LinkTarget $page
647     * @return array List of errors
648     */
649    private function checkReadPermissions(
650        $action,
651        User $user,
652        $errors,
653        $rigor,
654        $short,
655        LinkTarget $page
656    ): array {
657        // TODO: remove when LinkTarget usage will expand further
658        $title = Title::newFromLinkTarget( $page );
659
660        $whiteListRead = $this->options->get( MainConfigNames::WhitelistRead );
661        $allowed = false;
662        if ( $this->isEveryoneAllowed( 'read' ) ) {
663            // Shortcut for public wikis, allows skipping quite a bit of code
664            $allowed = true;
665        } elseif ( $this->userHasRight( $user, 'read' ) ) {
666            // If the user is allowed to read pages, he is allowed to read all pages
667            $allowed = true;
668        } elseif ( $this->isSameSpecialPage( 'Userlogin', $page )
669            || $this->isSameSpecialPage( 'PasswordReset', $page )
670            || $this->isSameSpecialPage( 'Userlogout', $page )
671        ) {
672            // Always grant access to the login page.
673            // Even anons need to be able to log in.
674            $allowed = true;
675        } elseif ( $this->isSameSpecialPage( 'RunJobs', $page ) ) {
676            // relies on HMAC key signature alone
677            $allowed = true;
678        } elseif ( is_array( $whiteListRead ) && count( $whiteListRead ) ) {
679            // Time to check the whitelist
680            // Only do these checks if there's something to check against
681            $name = $title->getPrefixedText();
682            $dbName = $title->getPrefixedDBkey();
683
684            // Check for explicit whitelisting with and without underscores
685            if ( in_array( $name, $whiteListRead, true )
686                || in_array( $dbName, $whiteListRead, true )
687            ) {
688                $allowed = true;
689            } elseif ( $page->getNamespace() === NS_MAIN ) {
690                // Old settings might have the title prefixed with
691                // a colon for main-namespace pages
692                if ( in_array( ':' . $name, $whiteListRead ) ) {
693                    $allowed = true;
694                }
695            } elseif ( $title->isSpecialPage() ) {
696                // If it's a special page, ditch the subpage bit and check again
697                $name = $title->getDBkey();
698                [ $name, /* $subpage */ ] =
699                    $this->specialPageFactory->resolveAlias( $name );
700                if ( $name ) {
701                    $pure = SpecialPage::getTitleFor( $name )->getPrefixedText();
702                    if ( in_array( $pure, $whiteListRead, true ) ) {
703                        $allowed = true;
704                    }
705                }
706            }
707        }
708
709        $whitelistReadRegexp = $this->options->get( MainConfigNames::WhitelistReadRegexp );
710        if ( !$allowed && is_array( $whitelistReadRegexp )
711            && $whitelistReadRegexp
712        ) {
713            $name = $title->getPrefixedText();
714            // Check for regex whitelisting
715            foreach ( $whitelistReadRegexp as $listItem ) {
716                if ( preg_match( $listItem, $name ) ) {
717                    $allowed = true;
718                    break;
719                }
720            }
721        }
722
723        if ( !$allowed ) {
724            # If the title is not whitelisted, give extensions a chance to do so...
725            $this->hookRunner->onTitleReadWhitelist( $title, $user, $allowed );
726            if ( !$allowed ) {
727                $errors[] = $this->missingPermissionError( $action, $short );
728            }
729        }
730
731        return $errors;
732    }
733
734    /**
735     * Get a description array for when an action isn't allowed to be performed.
736     *
737     * @param string $action The action to check
738     * @param bool $short Short circuit on first error
739     * @return array Array containing an error message key and any parameters
740     */
741    private function missingPermissionError( string $action, bool $short ): array {
742        // We avoid expensive display logic for quickUserCan's and such
743        if ( $short ) {
744            return [ 'badaccess-group0' ];
745        }
746
747        // TODO: it would be a good idea to replace the method below with something else like
748        // maybe callback injection
749        $context = RequestContext::getMain();
750        $status = $this->newFatalPermissionDeniedStatus( $action, $context );
751        return $status->toLegacyErrorArray()[0];
752    }
753
754    /**
755     * Factory function for fatal permission-denied errors
756     *
757     * @internal for use by UserAuthority
758     *
759     * @param string $permission User right required
760     * @param IContextSource $context
761     *
762     * @return PermissionStatus
763     */
764    public function newFatalPermissionDeniedStatus( $permission, IContextSource $context ): StatusValue {
765        $groups = [];
766        foreach ( $this->groupPermissionsLookup->getGroupsWithPermission( $permission ) as $group ) {
767            $groups[] = UserGroupMembership::getLinkWiki( $group, $context );
768        }
769
770        if ( $groups ) {
771            return PermissionStatus::newFatal(
772                'badaccess-groups',
773                Message::listParam( $groups, 'comma' ),
774                count( $groups )
775            );
776        }
777
778        $status = PermissionStatus::newFatal( 'badaccess-group0' );
779        $status->setPermission( $permission );
780        return $status;
781    }
782
783    /**
784     * Whether a title resolves to the named special page.
785     *
786     * @param string $name The special page name
787     * @param LinkTarget $page
788     * @return bool
789     */
790    private function isSameSpecialPage( $name, LinkTarget $page ): bool {
791        if ( $page->getNamespace() === NS_SPECIAL ) {
792            [ $pageName ] = $this->specialPageFactory->resolveAlias( $page->getDBkey() );
793            if ( $name === $pageName ) {
794                return true;
795            }
796        }
797        return false;
798    }
799
800    /**
801     * Check that the user isn't blocked from editing.
802     *
803     * @param string $action The action to check
804     * @param User $user User to check
805     * @param array $errors List of current errors
806     * @param string $rigor One of PermissionManager::RIGOR_ constants
807     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
808     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
809     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
810     * @param bool $short Short circuit on first error
811     * @param LinkTarget $page
812     * @return array List of errors
813     */
814    private function checkUserBlock(
815        $action,
816        User $user,
817        $errors,
818        $rigor,
819        $short,
820        LinkTarget $page
821    ): array {
822        $block = $this->getApplicableBlock(
823            $action,
824            $user,
825            $rigor,
826            $page,
827            $user->getRequest()
828        );
829
830        if ( $block ) {
831            // @todo FIXME: Pass the relevant context into this function.
832            $context = RequestContext::getMain();
833            $messages = $this->blockErrorFormatter->getMessages(
834                $block,
835                $user,
836                $context->getRequest()->getIP()
837            );
838
839            foreach ( $messages as $message ) {
840                $errors[] = [ $message->getKey(), ...$message->getParams() ];
841            }
842        }
843
844        return $errors;
845    }
846
847    /**
848     * Return the Block object applicable for the given permission check, if any.
849     *
850     * @internal for use by UserAuthority only
851     *
852     * @param string $action The action to check
853     * @param User $user User to check
854     * @param string $rigor One of PermissionManager::RIGOR_ constants
855     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
856     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
857     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
858     * @param LinkTarget|PageReference|null $page
859     * @param WebRequest|null $request The request to get the IP and cookies
860     *   from. If this is null, IP and cookie blocks will not be checked.
861     * @return ?Block
862     */
863    public function getApplicableBlock(
864        string $action,
865        User $user,
866        string $rigor,
867        $page,
868        ?WebRequest $request
869    ): ?Block {
870        // Unblocking handled in SpecialUnblock
871        if ( $rigor === self::RIGOR_QUICK || in_array( $action, [ 'unblock' ] ) ) {
872            return null;
873        }
874
875        // Optimize for a very common case
876        if ( $action === 'read' && !$this->options->get( MainConfigNames::BlockDisablesLogin ) ) {
877            return null;
878        }
879
880        // Implicit rights aren't blockable (T350117, T350202).
881        if ( in_array( $action, $this->getImplicitRights(), true ) ) {
882            return null;
883        }
884
885        $useReplica = $rigor !== self::RIGOR_SECURE;
886        $isExempt = $this->userHasRight( $user, 'ipblock-exempt' );
887        $requestIfNotExempt = $isExempt ? null : $request;
888
889        // Create account blocks are implemented separately due to weird IP exemption rules
890        if ( in_array( $action, [ 'createaccount', 'autocreateaccount' ], true ) ) {
891            return $this->blockManager->getCreateAccountBlock(
892                $user,
893                $requestIfNotExempt,
894                $useReplica
895            );
896        }
897
898        $block = $this->blockManager->getBlock( $user, $requestIfNotExempt, $useReplica );
899        if ( !$block ) {
900            return null;
901        }
902        $userIsHidden = $block->getHideName();
903
904        // Remove elements from the block that explicitly allow the action
905        // (like "read" or "upload").
906        $block = $this->blockManager->filter(
907            $block,
908            static function ( AbstractBlock $originalBlock ) use ( $action ) {
909                // Remove the block if it explicitly allows the action
910                return $originalBlock->appliesToRight( $action ) !== false;
911            }
912        );
913        if ( !$block ) {
914            return null;
915        }
916
917        // Convert the input page to a Title
918        $targetTitle = null;
919        if ( $page ) {
920            $targetTitle = $page instanceof PageReference ?
921                Title::castFromPageReference( $page ) :
922                Title::castFromLinkTarget( $page );
923
924            if ( !$targetTitle->canExist() ) {
925                $targetTitle = null;
926            }
927        }
928
929        // What gets passed into this method is a user right, not an action name.
930        // There is no way to instantiate an action by restriction. However, this
931        // will get the action where the restriction is the same. This may result
932        // in actions being blocked that shouldn't be.
933        $actionInfo = $this->actionFactory->getActionInfo( $action, $targetTitle );
934
935        // Ensure that the retrieved action matches the restriction.
936        if ( $actionInfo && $actionInfo->getRestriction() !== $action ) {
937            $actionInfo = null;
938        }
939
940        // Return null if the action does not require an unblocked user.
941        // If no ActionInfo is returned, assume that the action requires unblock
942        // which is the default.
943        // NOTE: We may get null here even for known actions, if a wiki's main page
944        // is set to a special page, e.g. Special:MyLanguage/Main_Page (T348451, T346036).
945        if ( $actionInfo && !$actionInfo->requiresUnblock() ) {
946            return null;
947        }
948
949        // Remove elements from the block that do not apply to the specific page
950        if ( $targetTitle ) {
951            $targetIsUserTalk = !$userIsHidden && $targetTitle->equals( $user->getTalkPage() );
952            $block = $this->blockManager->filter(
953                $block,
954                static function ( AbstractBlock $originalBlock )
955                use ( $action, $targetTitle, $targetIsUserTalk ) {
956                    if ( $originalBlock->appliesToRight( $action ) ) {
957                        // An action block takes precedence over appliesToTitle().
958                        // Block::appliesToRight('edit') always returns null,
959                        // allowing title-based exemptions to take effect.
960                        return true;
961                    } elseif ( $targetIsUserTalk ) {
962                        // Special handling for a user's own talk page. The block is not aware
963                        // of the user, so this must be done here.
964                        return $originalBlock->appliesToUsertalk( $targetTitle );
965                    } else {
966                        return $originalBlock->appliesToTitle( $targetTitle );
967                    }
968                }
969            );
970        }
971
972        if ( $targetTitle && $block
973            && $block instanceof AbstractBlock // for phan
974        ) {
975            // Allow extensions to let a blocked user access a particular page
976            $allowUsertalk = $block->isUsertalkEditAllowed();
977            $blocked = true;
978            $this->hookRunner->onUserIsBlockedFrom( $user, $targetTitle, $blocked, $allowUsertalk );
979            if ( !$blocked ) {
980                $block = null;
981            }
982        }
983        return $block;
984    }
985
986    /**
987     * Run easy-to-test (or "quick") permissions checks for a given action.
988     *
989     * @param string $action The action to check
990     * @param User $user User to check
991     * @param array $errors List of current errors
992     * @param string $rigor One of PermissionManager::RIGOR_ constants
993     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
994     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
995     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
996     * @param bool $short Short circuit on first error
997     * @param LinkTarget $page
998     * @return array List of errors
999     */
1000    private function checkQuickPermissions(
1001        $action,
1002        User $user,
1003        $errors,
1004        $rigor,
1005        $short,
1006        LinkTarget $page
1007    ): array {
1008        // TODO: remove when LinkTarget usage will expand further
1009        $title = Title::newFromLinkTarget( $page );
1010
1011        if ( !$this->hookRunner->onTitleQuickPermissions( $title, $user, $action,
1012            $errors, $rigor !== self::RIGOR_QUICK, $short )
1013        ) {
1014            return $errors;
1015        }
1016
1017        $isSubPage =
1018            $this->nsInfo->hasSubpages( $title->getNamespace() ) &&
1019            strpos( $title->getText(), '/' ) !== false;
1020
1021        if ( $action === 'create' ) {
1022            if (
1023                ( $this->nsInfo->isTalk( $title->getNamespace() ) &&
1024                    !$this->userHasRight( $user, 'createtalk' ) ) ||
1025                ( !$this->nsInfo->isTalk( $title->getNamespace() ) &&
1026                    !$this->userHasRight( $user, 'createpage' ) )
1027            ) {
1028                $errors[] = $user->isNamed() ? [ 'nocreate-loggedin' ] : [ 'nocreatetext' ];
1029            }
1030        } elseif ( $action === 'move' ) {
1031            if ( !$this->userHasRight( $user, 'move-rootuserpages' )
1032                && $title->getNamespace() === NS_USER && !$isSubPage
1033            ) {
1034                // Show user page-specific message only if the user can move other pages
1035                $errors[] = [ 'cant-move-user-page' ];
1036            }
1037
1038            // Check if user is allowed to move files if it's a file
1039            if ( $title->getNamespace() === NS_FILE &&
1040                !$this->userHasRight( $user, 'movefile' )
1041            ) {
1042                $errors[] = [ 'movenotallowedfile' ];
1043            }
1044
1045            // Check if user is allowed to move category pages if it's a category page
1046            if ( $title->getNamespace() === NS_CATEGORY &&
1047                !$this->userHasRight( $user, 'move-categorypages' )
1048            ) {
1049                $errors[] = [ 'cant-move-category-page' ];
1050            }
1051
1052            if ( !$this->userHasRight( $user, 'move' ) ) {
1053                // User can't move anything
1054                $userCanMove = $this->groupPermissionsLookup
1055                    ->groupHasPermission( 'user', 'move' );
1056                $namedCanMove = $this->groupPermissionsLookup
1057                    ->groupHasPermission( 'named', 'move' );
1058                $autoconfirmedCanMove = $this->groupPermissionsLookup
1059                    ->groupHasPermission( 'autoconfirmed', 'move' );
1060                if ( $user->isAnon()
1061                    && ( $userCanMove || $namedCanMove || $autoconfirmedCanMove )
1062                ) {
1063                    // custom message if logged-in users without any special rights can move
1064                    $errors[] = [ 'movenologintext' ];
1065                } elseif ( $user->isTemp() && ( $namedCanMove || $autoconfirmedCanMove ) ) {
1066                    // Temp user may be able to move if they log in as a proper account
1067                    $errors[] = [ 'movenologintext' ];
1068                } else {
1069                    $errors[] = [ 'movenotallowed' ];
1070                }
1071            }
1072        } elseif ( $action === 'move-target' ) {
1073            if ( !$this->userHasRight( $user, 'move' ) ) {
1074                // User can't move anything
1075                $errors[] = [ 'movenotallowed' ];
1076            } elseif ( !$this->userHasRight( $user, 'move-rootuserpages' )
1077                && $title->getNamespace() === NS_USER
1078                && !$isSubPage
1079            ) {
1080                // Show user page-specific message only if the user can move other pages
1081                $errors[] = [ 'cant-move-to-user-page' ];
1082            } elseif ( !$this->userHasRight( $user, 'move-categorypages' )
1083                && $title->getNamespace() === NS_CATEGORY
1084            ) {
1085                // Show category page-specific message only if the user can move other pages
1086                $errors[] = [ 'cant-move-to-category-page' ];
1087            }
1088        } elseif ( $action === 'autocreateaccount' ) {
1089            // createaccount implies autocreateaccount
1090            if ( !$this->userHasAnyRight( $user, 'autocreateaccount', 'createaccount' ) ) {
1091                $errors[] = $this->missingPermissionError( $action, $short );
1092            }
1093        } elseif ( !$this->userHasRight( $user, $action ) ) {
1094            $errors[] = $this->missingPermissionError( $action, $short );
1095        }
1096
1097        return $errors;
1098    }
1099
1100    /**
1101     * Check for any page_restrictions table requirements on this page.
1102     *
1103     * If the page has multiple restrictions, the user must have
1104     * all of those rights to perform the action in question.
1105     *
1106     * @param string $action The action to check
1107     * @param User $user User to check
1108     * @param array $errors List of current errors
1109     * @param string $rigor One of PermissionManager::RIGOR_ constants
1110     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1111     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1112     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1113     * @param bool $short Short circuit on first error
1114     * @param LinkTarget $page
1115     * @return array List of errors
1116     */
1117    private function checkPageRestrictions(
1118        $action,
1119        User $user,
1120        $errors,
1121        $rigor,
1122        $short,
1123        LinkTarget $page
1124    ): array {
1125        // TODO: remove & rework upon further use of LinkTarget
1126        $title = Title::newFromLinkTarget( $page );
1127        foreach ( $this->restrictionStore->getRestrictions( $title, $action ) as $right ) {
1128            // Backwards compatibility, rewrite sysop -> editprotected
1129            if ( $right === 'sysop' ) {
1130                $right = 'editprotected';
1131            }
1132            // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
1133            if ( $right === 'autoconfirmed' ) {
1134                $right = 'editsemiprotected';
1135            }
1136            if ( $right == '' ) {
1137                continue;
1138            }
1139            if ( !$this->userHasRight( $user, $right ) ) {
1140                $errors[] = [ 'protectedpagetext', $right, $action ];
1141            } elseif ( $this->restrictionStore->areRestrictionsCascading( $title ) &&
1142                !$this->userHasRight( $user, 'protect' )
1143            ) {
1144                $errors[] = [ 'protectedpagetext', 'protect', $action ];
1145            }
1146        }
1147
1148        return $errors;
1149    }
1150
1151    /**
1152     * Check restrictions on cascading pages.
1153     *
1154     * @param string $action The action to check
1155     * @param UserIdentity $user User to check
1156     * @param array $errors List of current errors
1157     * @param string $rigor One of PermissionManager::RIGOR_ constants
1158     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1159     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1160     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1161     * @param bool $short Short circuit on first error
1162     * @param LinkTarget $page
1163     * @return array List of errors
1164     */
1165    private function checkCascadingSourcesRestrictions(
1166        $action,
1167        UserIdentity $user,
1168        $errors,
1169        $rigor,
1170        $short,
1171        LinkTarget $page
1172    ): array {
1173        // TODO: remove & rework upon further use of LinkTarget
1174        $title = Title::newFromLinkTarget( $page );
1175        if ( $rigor !== self::RIGOR_QUICK && !$title->isUserConfigPage() ) {
1176            [ $cascadingSources, $restrictions ] = $this->restrictionStore->getCascadeProtectionSources( $title );
1177            // Cascading protection depends on more than this page...
1178            // Several cascading protected pages may include this page...
1179            // Check each cascading level
1180            // This is only for protection restrictions, not for all actions
1181            if ( isset( $restrictions[$action] ) ) {
1182                foreach ( $restrictions[$action] as $right ) {
1183                    // Backwards compatibility, rewrite sysop -> editprotected
1184                    if ( $right === 'sysop' ) {
1185                        $right = 'editprotected';
1186                    }
1187                    // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected
1188                    if ( $right === 'autoconfirmed' ) {
1189                        $right = 'editsemiprotected';
1190                    }
1191                    if ( $right != '' && !$this->userHasAllRights( $user, 'protect', $right ) ) {
1192                        $wikiPages = '';
1193                        foreach ( $cascadingSources as $pageIdentity ) {
1194                            $wikiPages .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
1195                        }
1196                        $errors[] = [ 'cascadeprotected', count( $cascadingSources ), $wikiPages, $action ];
1197                    }
1198                }
1199            }
1200        }
1201
1202        return $errors;
1203    }
1204
1205    /**
1206     * Check action permissions not already checked in checkQuickPermissions
1207     *
1208     * @param string $action The action to check
1209     * @param User $user User to check
1210     * @param array $errors List of current errors
1211     * @param string $rigor One of PermissionManager::RIGOR_ constants
1212     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1213     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1214     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1215     * @param bool $short Short circuit on first error
1216     * @param LinkTarget $page
1217     * @return array List of errors
1218     */
1219    private function checkActionPermissions(
1220        $action,
1221        User $user,
1222        $errors,
1223        $rigor,
1224        $short,
1225        LinkTarget $page
1226    ): array {
1227        // TODO: remove & rework upon further use of LinkTarget
1228        $title = Title::newFromLinkTarget( $page );
1229
1230        if ( $rigor !== self::RIGOR_QUICK && !defined( 'MW_NO_SESSION' ) ) {
1231            $sessionRestrictions = $user->getRequest()->getSession()->getRestrictions();
1232            if ( $sessionRestrictions ) {
1233                $userCan = $sessionRestrictions->userCan( $title );
1234                if ( !$userCan->isOK() ) {
1235                    $errors[] = [ $userCan->getErrors()[0]['message'] ];
1236                }
1237            }
1238        }
1239
1240        if ( $action === 'protect' ) {
1241            if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
1242                // If they can't edit, they shouldn't protect.
1243                $errors[] = [ 'protect-cantedit' ];
1244            }
1245        } elseif ( $action === 'create' ) {
1246            $createProtection = $this->restrictionStore->getCreateProtection( $title );
1247            if ( $createProtection ) {
1248                if ( $createProtection['permission'] == ''
1249                    || !$this->userHasRight( $user, $createProtection['permission'] )
1250                ) {
1251                    $errors[] = [
1252                        'titleprotected',
1253                        $this->userCache->getProp( $createProtection['user'], 'name' ),
1254                        $createProtection['reason']
1255                    ];
1256                }
1257            }
1258        } elseif ( $action === 'move' ) {
1259            // Check for immobile pages
1260            if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
1261                // Specific message for this case
1262                $nsText = $title->getNsText();
1263                if ( $nsText === '' ) {
1264                    $nsText = wfMessage( 'blanknamespace' )->text();
1265                }
1266                $errors[] = [ 'immobile-source-namespace', $nsText ];
1267            } elseif ( !$title->isMovable() ) {
1268                // Less specific message for rarer cases
1269                $errors[] = [ 'immobile-source-page' ];
1270            }
1271        } elseif ( $action === 'move-target' ) {
1272            if ( !$this->nsInfo->isMovable( $title->getNamespace() ) ) {
1273                $nsText = $title->getNsText();
1274                if ( $nsText === '' ) {
1275                    $nsText = wfMessage( 'blanknamespace' )->text();
1276                }
1277                $errors[] = [ 'immobile-target-namespace', $nsText ];
1278            } elseif ( !$title->isMovable() ) {
1279                $errors[] = [ 'immobile-target-page' ];
1280            }
1281        } elseif ( $action === 'delete' || $action === 'delete-redirect' ) {
1282            $tempErrors = $this->checkPageRestrictions( 'edit', $user, [], $rigor, true, $title );
1283            if ( !$tempErrors ) {
1284                $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit',
1285                    $user, $tempErrors, $rigor, true, $title );
1286            }
1287            if ( $tempErrors ) {
1288                // If protection keeps them from editing, they shouldn't be able to delete.
1289                $errors[] = [ 'deleteprotected' ];
1290            }
1291            if ( $rigor !== self::RIGOR_QUICK
1292                && $action === 'delete'
1293                && $this->options->get( MainConfigNames::DeleteRevisionsLimit )
1294                && !$this->userCan( 'bigdelete', $user, $title )
1295                && $title->isBigDeletion()
1296            ) {
1297                // NOTE: This check is deprecated since 1.37, see T288759
1298                $errors[] = [
1299                    'delete-toobig',
1300                    Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
1301                ];
1302            }
1303        } elseif ( $action === 'undelete' ) {
1304            if ( count( $this->getPermissionErrorsInternal( 'edit', $user, $title, $rigor, true ) ) ) {
1305                // Undeleting implies editing
1306                $errors[] = [ 'undelete-cantedit' ];
1307            }
1308            if ( !$title->exists()
1309                && count( $this->getPermissionErrorsInternal( 'create', $user, $title, $rigor, true ) )
1310            ) {
1311                // Undeleting where nothing currently exists implies creating
1312                $errors[] = [ 'undelete-cantcreate' ];
1313            }
1314        } elseif ( $action === 'edit' ) {
1315            if ( $this->options->get( MainConfigNames::EmailConfirmToEdit )
1316                && !$user->isEmailConfirmed()
1317            ) {
1318                $errors[] = [ 'confirmedittext' ];
1319            }
1320
1321            if ( !$title->exists() ) {
1322                $errors = array_merge(
1323                    $errors,
1324                    $this->getPermissionErrorsInternal(
1325                        'create',
1326                        $user,
1327                        $title,
1328                        $rigor,
1329                        true
1330                    )
1331                );
1332            }
1333        }
1334        return $errors;
1335    }
1336
1337    /**
1338     * Check permissions on special pages & namespaces
1339     *
1340     * @param string $action The action to check
1341     * @param UserIdentity $user User to check
1342     * @param array $errors List of current errors
1343     * @param string $rigor One of PermissionManager::RIGOR_ constants
1344     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1345     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1346     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1347     * @param bool $short Short circuit on first error
1348     * @param LinkTarget $page
1349     * @return array List of errors
1350     */
1351    private function checkSpecialsAndNSPermissions(
1352        $action,
1353        UserIdentity $user,
1354        $errors,
1355        $rigor,
1356        $short,
1357        LinkTarget $page
1358    ): array {
1359        // TODO: remove & rework upon further use of LinkTarget
1360        $title = Title::newFromLinkTarget( $page );
1361
1362        // Only 'createaccount' can be performed on special pages,
1363        // which don't actually exist in the DB.
1364        if ( $title->getNamespace() === NS_SPECIAL
1365            && !in_array( $action, [ 'createaccount', 'autocreateaccount' ], true )
1366        ) {
1367            $errors[] = [ 'ns-specialprotected' ];
1368        }
1369
1370        // Check $wgNamespaceProtection for restricted namespaces
1371        if ( $this->isNamespaceProtected( $title->getNamespace(), $user ) ) {
1372            $ns = $title->getNamespace() === NS_MAIN ?
1373                wfMessage( 'nstab-main' )->text() : $title->getNsText();
1374            $errors[] = $title->getNamespace() === NS_MEDIAWIKI ?
1375                [ 'protectedinterface', $action ] : [ 'namespaceprotected', $ns, $action ];
1376        }
1377
1378        return $errors;
1379    }
1380
1381    /**
1382     * Check sitewide CSS/JSON/JS permissions
1383     *
1384     * @param string $action The action to check
1385     * @param User $user User to check
1386     * @param array $errors List of current errors
1387     * @param string $rigor One of PermissionManager::RIGOR_ constants
1388     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1389     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1390     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1391     * @param bool $short Short circuit on first error
1392     * @param LinkTarget $page
1393     * @return array List of errors
1394     */
1395    private function checkSiteConfigPermissions(
1396        $action,
1397        User $user,
1398        $errors,
1399        $rigor,
1400        $short,
1401        LinkTarget $page
1402    ): array {
1403        // TODO: remove & rework upon further use of LinkTarget
1404        $title = Title::newFromLinkTarget( $page );
1405
1406        if ( $action === 'patrol' ) {
1407            return $errors;
1408        }
1409
1410        if ( in_array( $action, [ 'deletedhistory', 'deletedtext', 'viewsuppressed' ], true ) ) {
1411            // Allow admins and oversighters to view deleted content, even if they
1412            // cannot restore it. See T202989
1413            // Not using the same handling in `getPermissionErrorsInternal` as the checks
1414            // for skipping `checkUserConfigPermissions` since normal admins can delete
1415            // user scripts, but not sitedwide scripts
1416            return $errors;
1417        }
1418
1419        // Sitewide CSS/JSON/JS/RawHTML changes, like all NS_MEDIAWIKI changes, also require the
1420        // editinterface right. That's implemented as a restriction so no check needed here.
1421        if ( $title->isSiteCssConfigPage() && !$this->userHasRight( $user, 'editsitecss' ) ) {
1422            $errors[] = [ 'sitecssprotected', $action ];
1423        } elseif ( $title->isSiteJsonConfigPage() && !$this->userHasRight( $user, 'editsitejson' ) ) {
1424            $errors[] = [ 'sitejsonprotected', $action ];
1425        } elseif ( $title->isSiteJsConfigPage() && !$this->userHasRight( $user, 'editsitejs' ) ) {
1426            $errors[] = [ 'sitejsprotected', $action ];
1427        }
1428        if ( $title->isRawHtmlMessage() && !$this->userCanEditRawHtmlPage( $user ) ) {
1429            $errors[] = [ 'siterawhtmlprotected', $action ];
1430        }
1431
1432        return $errors;
1433    }
1434
1435    /**
1436     * Check CSS/JSON/JS sub-page permissions
1437     *
1438     * @param string $action The action to check
1439     * @param UserIdentity $user User to check
1440     * @param array $errors List of current errors
1441     * @param string $rigor One of PermissionManager::RIGOR_ constants
1442     *   - RIGOR_QUICK  : does cheap permission checks from replica DBs (usable for GUI creation)
1443     *   - RIGOR_FULL   : does cheap and expensive checks possibly from a replica DB
1444     *   - RIGOR_SECURE : does cheap and expensive checks, using the primary DB as needed
1445     * @param bool $short Short circuit on first error
1446     * @param LinkTarget $page
1447     * @return array List of errors
1448     */
1449    private function checkUserConfigPermissions(
1450        $action,
1451        UserIdentity $user,
1452        $errors,
1453        $rigor,
1454        $short,
1455        LinkTarget $page
1456    ): array {
1457        // TODO: remove & rework upon further use of LinkTarget
1458        $title = Title::newFromLinkTarget( $page );
1459
1460        // Protect css/json/js subpages of user pages
1461        // XXX: this might be better using restrictions
1462        if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $title->getText() ) ) {
1463            // Users need editmyuser* to edit their own CSS/JSON/JS subpages.
1464            if (
1465                $title->isUserCssConfigPage()
1466                && !$this->userHasAnyRight( $user, 'editmyusercss', 'editusercss' )
1467            ) {
1468                $errors[] = [ 'mycustomcssprotected', $action ];
1469            } elseif (
1470                $title->isUserJsonConfigPage()
1471                && !$this->userHasAnyRight( $user, 'editmyuserjson', 'edituserjson' )
1472            ) {
1473                $errors[] = [ 'mycustomjsonprotected', $action ];
1474            } elseif (
1475                $title->isUserJsConfigPage()
1476                && !$this->userHasAnyRight( $user, 'editmyuserjs', 'edituserjs' )
1477            ) {
1478                $errors[] = [ 'mycustomjsprotected', $action ];
1479            } elseif (
1480                $title->isUserJsConfigPage()
1481                && !$this->userHasAnyRight( $user, 'edituserjs', 'editmyuserjsredirect' )
1482            ) {
1483                // T207750 - do not allow users to edit a redirect if they couldn't edit the target
1484                $target = $this->redirectLookup->getRedirectTarget( $title );
1485                if ( $target && (
1486                        !$target->inNamespace( NS_USER )
1487                        || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
1488                ) ) {
1489                    $errors[] = [ 'mycustomjsredirectprotected', $action ];
1490                }
1491            }
1492        } else {
1493            // Users need edituser* to edit others' CSS/JSON/JS subpages.
1494            // The checks to exclude deletion/suppression, which cannot be used for
1495            // attacks and should be excluded to avoid the situation where an
1496            // unprivileged user can post abusive content on their subpages
1497            // and only very highly privileged users could remove it,
1498            // are now a part of `getPermissionErrorsInternal` and this method isn't called.
1499            if (
1500                $title->isUserCssConfigPage()
1501                && !$this->userHasRight( $user, 'editusercss' )
1502            ) {
1503                $errors[] = [ 'customcssprotected', $action ];
1504            } elseif (
1505                $title->isUserJsonConfigPage()
1506                && !$this->userHasRight( $user, 'edituserjson' )
1507            ) {
1508                $errors[] = [ 'customjsonprotected', $action ];
1509            } elseif (
1510                $title->isUserJsConfigPage()
1511                && !$this->userHasRight( $user, 'edituserjs' )
1512            ) {
1513                $errors[] = [ 'customjsprotected', $action ];
1514            }
1515        }
1516
1517        return $errors;
1518    }
1519
1520    /**
1521     * Whether the user is generally allowed to perform the given action.
1522     *
1523     * @since 1.34
1524     * @param UserIdentity $user
1525     * @param string $action
1526     * @return bool True if allowed
1527     */
1528    public function userHasRight( UserIdentity $user, $action = '' ): bool {
1529        if ( $action === '' ) {
1530            // In the spirit of DWIM
1531            return true;
1532        }
1533        // Use strict parameter to avoid matching numeric 0 accidentally inserted
1534        // by misconfiguration: 0 == 'foo'
1535        return in_array( $action, $this->getImplicitRights(), true )
1536            || in_array( $action, $this->getUserPermissions( $user ), true );
1537    }
1538
1539    /**
1540     * Whether the user is generally allowed to perform at least one of the actions.
1541     *
1542     * @since 1.34
1543     * @param UserIdentity $user
1544     * @param string ...$actions
1545     * @return bool True if user is allowed to perform *any* of the actions
1546     */
1547    public function userHasAnyRight( UserIdentity $user, ...$actions ): bool {
1548        foreach ( $actions as $action ) {
1549            if ( $this->userHasRight( $user, $action ) ) {
1550                return true;
1551            }
1552        }
1553        return false;
1554    }
1555
1556    /**
1557     * Whether the user is allowed to perform all of the given actions.
1558     *
1559     * @since 1.34
1560     * @param UserIdentity $user
1561     * @param string ...$actions
1562     * @return bool True if user is allowed to perform *all* of the given actions
1563     */
1564    public function userHasAllRights( UserIdentity $user, ...$actions ): bool {
1565        foreach ( $actions as $action ) {
1566            if ( !$this->userHasRight( $user, $action ) ) {
1567                return false;
1568            }
1569        }
1570        return true;
1571    }
1572
1573    /**
1574     * Get the permissions this user has.
1575     *
1576     * @since 1.34
1577     * @param UserIdentity $user
1578     * @return string[] permission names
1579     */
1580    public function getUserPermissions( UserIdentity $user ): array {
1581        $rightsCacheKey = $this->getRightsCacheKey( $user );
1582        if ( !isset( $this->usersRights[ $rightsCacheKey ] ) ) {
1583            $userObj = $this->userFactory->newFromUserIdentity( $user );
1584            $rights = $this->groupPermissionsLookup->getGroupPermissions(
1585                $this->userGroupManager->getUserEffectiveGroups( $user )
1586            );
1587            // Hook requires a full User object
1588            $this->hookRunner->onUserGetRights( $userObj, $rights );
1589
1590            // Deny any rights denied by the user's session, unless this
1591            // endpoint has no sessions.
1592            if ( !defined( 'MW_NO_SESSION' ) ) {
1593                // FIXME: $userObj->getRequest().. need to be replaced with something else
1594                $allowedRights = $userObj->getRequest()->getSession()->getAllowedUserRights();
1595                if ( $allowedRights !== null ) {
1596                    $rights = array_intersect( $rights, $allowedRights );
1597                }
1598            }
1599
1600            // Hook requires a full User object
1601            $this->hookRunner->onUserGetRightsRemove( $userObj, $rights );
1602            // Force reindexation of rights when a hook has unset one of them
1603            $rights = array_values( array_unique( $rights ) );
1604
1605            // If BlockDisablesLogin is true, remove rights that anonymous
1606            // users don't have. This has to be done after the hooks so that
1607            // we know whether the user is exempt. (T129738)
1608            if (
1609                $userObj->isRegistered()
1610                && $this->options->get( MainConfigNames::BlockDisablesLogin )
1611            ) {
1612                $isExempt = in_array( 'ipblock-exempt', $rights, true );
1613                if ( $this->blockManager->getBlock(
1614                    $userObj,
1615                    $isExempt ? null : $userObj->getRequest()
1616                ) ) {
1617                    $anon = $this->userFactory->newAnonymous();
1618                    $rights = array_intersect( $rights, $this->getUserPermissions( $anon ) );
1619                }
1620            }
1621
1622            $this->usersRights[ $rightsCacheKey ] = $rights;
1623        } else {
1624            $rights = $this->usersRights[ $rightsCacheKey ];
1625        }
1626        foreach ( $this->temporaryUserRights[ $user->getId() ] ?? [] as $overrides ) {
1627            $rights = array_values( array_unique( array_merge( $rights, $overrides ) ) );
1628        }
1629        return $rights;
1630    }
1631
1632    /**
1633     * Clear the in-process permission cache for one or all users.
1634     *
1635     * @since 1.34
1636     * @param UserIdentity|null $user If a specific user is provided it will clear
1637     *  the permission cache only for that user.
1638     */
1639    public function invalidateUsersRightsCache( $user = null ): void {
1640        if ( $user !== null ) {
1641            $rightsCacheKey = $this->getRightsCacheKey( $user );
1642            unset( $this->usersRights[ $rightsCacheKey ] );
1643        } else {
1644            $this->usersRights = [];
1645        }
1646    }
1647
1648    /**
1649     * Get a unique key for user rights cache.
1650     *
1651     * @param UserIdentity $user
1652     * @return string
1653     */
1654    private function getRightsCacheKey( UserIdentity $user ): string {
1655        return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
1656    }
1657
1658    /**
1659     * Check if all users may be assumed to have the given permission
1660     *
1661     * We generally assume so if the right is granted to '*' and isn't revoked
1662     * on any group. It doesn't attempt to take grants or other extension
1663     * limitations on rights into account in the general case, though, as that
1664     * would require it to always return false and defeat the purpose.
1665     * Specifically, session-based rights restrictions (such as OAuth or bot
1666     * passwords) are applied based on the current session.
1667     *
1668     * @since 1.34
1669     * @param string $right Right to check
1670     * @return bool
1671     */
1672    public function isEveryoneAllowed( $right ): bool {
1673        // Use the cached results, except in unit tests which rely on
1674        // being able change the permission mid-request
1675        if ( isset( $this->cachedRights[$right] ) ) {
1676            return $this->cachedRights[$right];
1677        }
1678
1679        if ( !isset( $this->options->get( MainConfigNames::GroupPermissions )['*'][$right] )
1680            || !$this->options->get( MainConfigNames::GroupPermissions )['*'][$right]
1681        ) {
1682            $this->cachedRights[$right] = false;
1683            return false;
1684        }
1685
1686        // If it's revoked anywhere, then everyone doesn't have it
1687        foreach ( $this->options->get( MainConfigNames::RevokePermissions ) as $rights ) {
1688            if ( isset( $rights[$right] ) && $rights[$right] ) {
1689                $this->cachedRights[$right] = false;
1690                return false;
1691            }
1692        }
1693
1694        // Remove any rights that aren't allowed to the global-session user,
1695        // unless there are no sessions for this endpoint.
1696        if ( !defined( 'MW_NO_SESSION' ) ) {
1697            // XXX: think what could be done with the below
1698            $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
1699            if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
1700                $this->cachedRights[$right] = false;
1701                return false;
1702            }
1703        }
1704
1705        // Allow extensions to say false
1706        if ( !$this->hookRunner->onUserIsEveryoneAllowed( $right ) ) {
1707            $this->cachedRights[$right] = false;
1708            return false;
1709        }
1710
1711        $this->cachedRights[$right] = true;
1712        return true;
1713    }
1714
1715    /**
1716     * Get a list of all permissions that can be managed through group permissions.
1717     * This does not include implicit rights which are granted to all users automatically.
1718     *
1719     * @see getImplicitRights()
1720     *
1721     * @since 1.34
1722     * @return string[] Array of permission names
1723     */
1724    public function getAllPermissions(): array {
1725        if ( $this->allRights === null ) {
1726            if ( count( $this->options->get( MainConfigNames::AvailableRights ) ) ) {
1727                $this->allRights = array_unique( array_merge(
1728                    self::CORE_RIGHTS,
1729                    $this->options->get( MainConfigNames::AvailableRights )
1730                ) );
1731            } else {
1732                $this->allRights = self::CORE_RIGHTS;
1733            }
1734            $this->hookRunner->onUserGetAllRights( $this->allRights );
1735        }
1736        return $this->allRights;
1737    }
1738
1739    /**
1740     * Get a list of implicit rights.
1741     *
1742     * Rights in this list should be granted to all users implicitly.
1743     *
1744     * Implicit rights are defined to allow rate limits to be imposed
1745     * on permissions
1746     *
1747     * @since 1.41
1748     * @return string[] Array of permission names
1749     */
1750    public function getImplicitRights(): array {
1751        if ( $this->implicitRights === null ) {
1752            $rights = array_unique( array_merge(
1753                self::CORE_IMPLICIT_RIGHTS,
1754                $this->options->get( MainConfigNames::ImplicitRights )
1755            ) );
1756
1757            $this->implicitRights = array_diff( $rights, $this->getAllPermissions() );
1758        }
1759        return $this->implicitRights;
1760    }
1761
1762    /**
1763     * Determine if $user is unable to edit pages in namespace because it has been protected.
1764     *
1765     * @param int $index
1766     * @param UserIdentity $user
1767     * @return bool
1768     */
1769    private function isNamespaceProtected( $index, UserIdentity $user ): bool {
1770        $namespaceProtection = $this->options->get( MainConfigNames::NamespaceProtection );
1771        if ( isset( $namespaceProtection[$index] ) ) {
1772            return !$this->userHasAllRights( $user, ...(array)$namespaceProtection[$index] );
1773        }
1774        return false;
1775    }
1776
1777    /**
1778     * Determine which restriction levels it makes sense to use in a namespace,
1779     * optionally filtered by a user's rights.
1780     *
1781     * @param int $index Namespace ID (index) to check
1782     * @param UserIdentity|null $user User to check
1783     * @return string[]
1784     */
1785    public function getNamespaceRestrictionLevels( $index, UserIdentity $user = null ): array {
1786        if ( !isset( $this->options->get( MainConfigNames::NamespaceProtection )[$index] ) ) {
1787            // All levels are valid if there's no namespace restriction.
1788            // But still filter by user, if necessary
1789            $levels = $this->options->get( MainConfigNames::RestrictionLevels );
1790            if ( $user ) {
1791                $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
1792                    $right = $level;
1793                    if ( $right === 'sysop' ) {
1794                        $right = 'editprotected'; // BC
1795                    }
1796                    if ( $right === 'autoconfirmed' ) {
1797                        $right = 'editsemiprotected'; // BC
1798                    }
1799                    return $this->userHasRight( $user, $right );
1800                } ) );
1801            }
1802            return $levels;
1803        }
1804
1805        // $wgNamespaceProtection can require one or more rights to edit the namespace, which
1806        // may be satisfied by membership in multiple groups each giving a subset of those rights.
1807        // A restriction level is redundant if, for any one of the namespace rights, all groups
1808        // giving that right also give the restriction level's right. Or, conversely, a
1809        // restriction level is not redundant if, for every namespace right, there's at least one
1810        // group giving that right without the restriction level's right.
1811        //
1812        // First, for each right, get a list of groups with that right.
1813        $namespaceRightGroups = [];
1814        foreach ( (array)$this->options->get( MainConfigNames::NamespaceProtection )[$index] as $right ) {
1815            if ( $right === 'sysop' ) {
1816                $right = 'editprotected'; // BC
1817            }
1818            if ( $right === 'autoconfirmed' ) {
1819                $right = 'editsemiprotected'; // BC
1820            }
1821            if ( $right != '' ) {
1822                $namespaceRightGroups[$right] = $this->groupPermissionsLookup->getGroupsWithPermission( $right );
1823            }
1824        }
1825
1826        // Now, go through the protection levels one by one.
1827        $usableLevels = [ '' ];
1828        foreach ( $this->options->get( MainConfigNames::RestrictionLevels ) as $level ) {
1829            $right = $level;
1830            if ( $right === 'sysop' ) {
1831                $right = 'editprotected'; // BC
1832            }
1833            if ( $right === 'autoconfirmed' ) {
1834                $right = 'editsemiprotected'; // BC
1835            }
1836
1837            if ( $right != '' &&
1838                !isset( $namespaceRightGroups[$right] ) &&
1839                ( !$user || $this->userHasRight( $user, $right ) )
1840            ) {
1841                // Do any of the namespace rights imply the restriction right? (see explanation above)
1842                foreach ( $namespaceRightGroups as $groups ) {
1843                    if ( !array_diff( $groups, $this->groupPermissionsLookup->getGroupsWithPermission( $right ) ) ) {
1844                        // Yes, this one does.
1845                        continue 2;
1846                    }
1847                }
1848                // No, keep the restriction level
1849                $usableLevels[] = $level;
1850            }
1851        }
1852
1853        return $usableLevels;
1854    }
1855
1856    /**
1857     * Check if user is allowed to edit sitewide pages that contain raw HTML.
1858     *
1859     * Pages listed in $wgRawHtmlMessages allow raw HTML which can be used to deploy CSS or JS
1860     * code to all users so both rights are required to edit them.
1861     *
1862     * @param UserIdentity $user
1863     * @return bool True if user has both rights
1864     */
1865    private function userCanEditRawHtmlPage( UserIdentity $user ): bool {
1866        return $this->userHasAllRights( $user, 'editsitecss', 'editsitejs' );
1867    }
1868
1869    /**
1870     * Add temporary user rights, only valid for the current function scope.
1871     *
1872     * This is meant for making it possible to programatically trigger certain actions that
1873     * the user wouldn't be able to trigger themselves; e.g. allow users without the bot right
1874     * to make bot-flagged actions through certain special pages.
1875     *
1876     * This returns a "scope guard" variable. Its only purpose is to be stored in a variable
1877     * by the caller, which is automatically closed at the end of the function, at which point
1878     * the rights are revoked again. Alternatively, you can close it earlier by consuming it
1879     * via ScopedCallback::consume().
1880     *
1881     * @since 1.34
1882     * @param UserIdentity $user
1883     * @param string|string[] $rights
1884     * @return ScopedCallback
1885     */
1886    public function addTemporaryUserRights( UserIdentity $user, $rights ) {
1887        $userId = $user->getId();
1888        $nextKey = count( $this->temporaryUserRights[$userId] ?? [] );
1889        $this->temporaryUserRights[$userId][$nextKey] = (array)$rights;
1890        return new ScopedCallback( function () use ( $userId, $nextKey ) {
1891            unset( $this->temporaryUserRights[$userId][$nextKey] );
1892        } );
1893    }
1894
1895    /**
1896     * Override the user permissions cache
1897     *
1898     * @internal For testing only
1899     * @since 1.34
1900     * @param User $user
1901     * @param string[]|string $rights
1902     */
1903    public function overrideUserRightsForTesting( $user, $rights = [] ) {
1904        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
1905            throw new LogicException( __METHOD__ . ' can not be called outside of tests' );
1906        }
1907        $this->usersRights[ $this->getRightsCacheKey( $user ) ] =
1908            is_array( $rights ) ? $rights : [ $rights ];
1909    }
1910
1911}