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