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