MediaWiki master
RestrictionStore.php
Go to the documentation of this file.
1<?php
2
4
20use stdClass;
25
32
34 public const CONSTRUCTOR_OPTIONS = [
39 ];
40
42 private $options;
43
45 private $wanCache;
46
48 private $loadBalancer;
49
51 private $linkCache;
52
54 private $linksMigration;
55
57 private $commentStore;
58
60 private $hookContainer;
61
63 private $hookRunner;
64
66 private $pageStore;
67
79 private $cache = [];
80
91 public function __construct(
92 ServiceOptions $options,
93 WANObjectCache $wanCache,
94 ILoadBalancer $loadBalancer,
95 LinkCache $linkCache,
96 LinksMigration $linksMigration,
97 CommentStore $commentStore,
98 HookContainer $hookContainer,
99 PageStore $pageStore
100 ) {
101 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
102 $this->options = $options;
103 $this->wanCache = $wanCache;
104 $this->loadBalancer = $loadBalancer;
105 $this->linkCache = $linkCache;
106 $this->linksMigration = $linksMigration;
107 $this->commentStore = $commentStore;
108 $this->hookContainer = $hookContainer;
109 $this->hookRunner = new HookRunner( $hookContainer );
110 $this->pageStore = $pageStore;
111 }
112
124 public function getRestrictions( PageIdentity $page, string $action ): array {
125 $page->assertWiki( PageIdentity::LOCAL );
126
127 // Optimization: Avoid repeatedly fetching page restrictions (from cache or DB)
128 // for repeated PermissionManager::userCan calls, if this action cannot be restricted
129 // in the first place. This is primarily to improve batch rendering on RecentChanges,
130 // where as of writing this will save 0.5s on a 8.0s response. (T341319)
131 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
132 if ( !in_array( $action, $restrictionTypes ) ) {
133 return [];
134 }
135
136 $restrictions = $this->getAllRestrictions( $page );
137 return $restrictions[$action] ?? [];
138 }
139
147 public function getAllRestrictions( PageIdentity $page ): array {
148 $page->assertWiki( PageIdentity::LOCAL );
149
150 if ( !$this->areRestrictionsLoaded( $page ) ) {
151 $this->loadRestrictions( $page );
152 }
153 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
154 }
155
164 public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
165 $page->assertWiki( PageIdentity::LOCAL );
166
167 if ( !$this->areRestrictionsLoaded( $page ) ) {
168 $this->loadRestrictions( $page );
169 }
170 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
171 }
172
187 public function getCreateProtection( PageIdentity $page ): ?array {
188 $page->assertWiki( PageIdentity::LOCAL );
189
190 $protection = $this->getCreateProtectionInternal( $page );
191 // TODO: the remapping below probably need to be migrated into other method one day
192 if ( $protection ) {
193 if ( $protection['permission'] == 'sysop' ) {
194 $protection['permission'] = 'editprotected'; // B/C
195 }
196 if ( $protection['permission'] == 'autoconfirmed' ) {
197 $protection['permission'] = 'editsemiprotected'; // B/C
198 }
199 }
200 return $protection;
201 }
202
209 public function deleteCreateProtection( PageIdentity $page ): void {
210 $page->assertWiki( PageIdentity::LOCAL );
211
212 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
213 $dbw->newDeleteQueryBuilder()
214 ->deleteFrom( 'protected_titles' )
215 ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
216 ->caller( __METHOD__ )->execute();
217 $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
218 }
219
228 public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
229 $page->assertWiki( PageIdentity::LOCAL );
230
231 $restrictions = $this->getRestrictions( $page, $action );
232 $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
233 if ( !$restrictions || !$semi ) {
234 // Not protected, or all protection is full protection
235 return false;
236 }
237
238 // Remap autoconfirmed to editsemiprotected for BC
239 foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
240 $semi[$key] = 'autoconfirmed';
241 }
242 foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
243 $restrictions[$key] = 'autoconfirmed';
244 }
245
246 return !array_diff( $restrictions, $semi );
247 }
248
256 public function isProtected( PageIdentity $page, string $action = '' ): bool {
257 $page->assertWiki( PageIdentity::LOCAL );
258
259 // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
260 if ( $page->getNamespace() === NS_SPECIAL ) {
261 return true;
262 }
263
264 // Check regular protection levels
265 $applicableTypes = $this->listApplicableRestrictionTypes( $page );
266
267 if ( $action === '' ) {
268 foreach ( $applicableTypes as $type ) {
269 if ( $this->isProtected( $page, $type ) ) {
270 return true;
271 }
272 }
273 return false;
274 }
275
276 if ( !in_array( $action, $applicableTypes ) ) {
277 return false;
278 }
279
280 return (bool)array_diff(
281 array_intersect(
282 $this->getRestrictions( $page, $action ),
283 $this->options->get( MainConfigNames::RestrictionLevels )
284 ),
285 [ '' ]
286 );
287 }
288
295 public function isCascadeProtected( PageIdentity $page ): bool {
296 $page->assertWiki( PageIdentity::LOCAL );
297
298 return $this->getCascadeProtectionSourcesInternal( $page, true );
299 }
300
307 public function listApplicableRestrictionTypes( PageIdentity $page ): array {
308 $page->assertWiki( PageIdentity::LOCAL );
309
310 if ( !$page->canExist() ) {
311 return [];
312 }
313
314 $types = $this->listAllRestrictionTypes( $page->exists() );
315
316 if ( $page->getNamespace() !== NS_FILE ) {
317 // Remove the upload restriction for non-file titles
318 $types = array_values( array_diff( $types, [ 'upload' ] ) );
319 }
320
321 if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
322 $this->hookRunner->onTitleGetRestrictionTypes(
323 Title::newFromPageIdentity( $page ), $types );
324 }
325
326 return $types;
327 }
328
336 public function listAllRestrictionTypes( bool $exists = true ): array {
337 $types = $this->options->get( MainConfigNames::RestrictionTypes );
338 if ( $exists ) {
339 // Remove the create restriction for existing titles
340 return array_values( array_diff( $types, [ 'create' ] ) );
341 }
342
343 // Only the create restrictions apply to non-existing titles
344 return array_values( array_intersect( $types, [ 'create' ] ) );
345 }
346
355 public function loadRestrictions(
356 PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
357 ): void {
358 $page->assertWiki( PageIdentity::LOCAL );
359
360 if ( !$page->canExist() ) {
361 return;
362 }
363
364 $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
365
366 if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
367 return;
368 }
369
370 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
371
372 $cacheEntry['restrictions'] = [];
373
374 // XXX Work around https://phabricator.wikimedia.org/T287575
375 if ( $readLatest ) {
376 $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
377 }
378 $id = $page->getId();
379 if ( $id ) {
380 $fname = __METHOD__;
381 $loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
382 return iterator_to_array(
383 $dbr->newSelectQueryBuilder()
384 ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
385 ->from( 'page_restrictions' )
386 ->where( [ 'pr_page' => $id ] )
387 ->caller( $fname )->fetchResultSet()
388 );
389 };
390
391 if ( $readLatest ) {
392 $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
393 $rows = $loadRestrictionsFromDb( $dbr );
394 } else {
395 $this->linkCache->addLinkObj( $page );
396 $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
397 if ( !$latestRev ) {
398 // This method can get called in the middle of page creation
399 // (WikiPage::doUserEditContent) where a page might have an
400 // id but no revisions, while checking the "autopatrol" permission.
401 $rows = [];
402 } else {
403 $rows = $this->wanCache->getWithSetCallback(
404 // Page protections always leave a new null revision
405 $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
406 $this->wanCache::TTL_DAY,
407 function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
408 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
409 $setOpts += Database::getCacheSetOptions( $dbr );
410 if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
411 // TODO: cleanup Title cache and caller assumption mess in general
412 $ttl = WANObjectCache::TTL_UNCACHEABLE;
413 }
414
415 return $loadRestrictionsFromDb( $dbr );
416 }
417 );
418 }
419 }
420
421 $this->loadRestrictionsFromRows( $page, $rows );
422 } else {
423 $titleProtection = $this->getCreateProtectionInternal( $page );
424
425 if ( $titleProtection ) {
426 $now = wfTimestampNow();
427 $expiry = $titleProtection['expiry'];
428
429 if ( !$expiry || $expiry > $now ) {
430 // Apply the restrictions
431 $cacheEntry['expiry']['create'] = $expiry ?: null;
432 $cacheEntry['restrictions']['create'] =
433 explode( ',', trim( $titleProtection['permission'] ) );
434 } else {
435 // Get rid of the old restrictions
436 $cacheEntry['create_protection'] = null;
437 }
438 } else {
439 $cacheEntry['expiry']['create'] = 'infinity';
440 }
441 }
442 }
443
452 PageIdentity $page, array $rows
453 ): void {
454 $page->assertWiki( PageIdentity::LOCAL );
455
456 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
457
458 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
459
460 foreach ( $restrictionTypes as $type ) {
461 $cacheEntry['restrictions'][$type] = [];
462 $cacheEntry['expiry'][$type] = 'infinity';
463 }
464
465 $cacheEntry['cascade'] = false;
466
467 if ( !$rows ) {
468 return;
469 }
470
471 // New restriction format -- load second to make them override old-style restrictions.
472 $now = wfTimestampNow();
473
474 // Cycle through all the restrictions.
475 foreach ( $rows as $row ) {
476 // Don't take care of restrictions types that aren't allowed
477 if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
478 continue;
479 }
480
481 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
482 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
483
484 // Only apply the restrictions if they haven't expired!
485 // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
486 // string consisting of 14 digits. Likewise for the ?: below.
487 if ( !$expiry || $expiry > $now ) {
488 $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
489 $cacheEntry['restrictions'][$row->pr_type]
490 = explode( ',', trim( $row->pr_level ) );
491 if ( $row->pr_cascade ) {
492 $cacheEntry['cascade'] = true;
493 }
494 }
495 }
496 }
497
508 private function getCreateProtectionInternal( PageIdentity $page ): ?array {
509 // Can't protect pages in special namespaces
510 if ( !$page->canExist() ) {
511 return null;
512 }
513
514 // Can't apply this type of protection to pages that exist.
515 if ( $page->exists() ) {
516 return null;
517 }
518
519 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
520
521 if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
522 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
523 $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
524 $row = $dbr->selectRow(
525 [ 'protected_titles' ] + $commentQuery['tables'],
526 [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
527 [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
528 __METHOD__,
529 [],
530 $commentQuery['joins']
531 );
532
533 if ( $row ) {
534 $cacheEntry['create_protection'] = [
535 'user' => $row->pt_user,
536 'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
537 'permission' => $row->pt_create_perm,
538 'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
539 ];
540 } else {
541 $cacheEntry['create_protection'] = null;
542 }
543
544 }
545
546 return $cacheEntry['create_protection'];
547 }
548
557 public function getCascadeProtectionSources( PageIdentity $page ): array {
558 $page->assertWiki( PageIdentity::LOCAL );
559
560 return $this->getCascadeProtectionSourcesInternal( $page, false );
561 }
562
571 private function getCascadeProtectionSourcesInternal(
572 PageIdentity $page, bool $shortCircuit = false
573 ) {
574 if ( !$page->canExist() ) {
575 return $shortCircuit ? false : [ [], [] ];
576 }
577
578 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
579
580 if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
581 return $cacheEntry['cascade_sources'];
582 } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
583 return $cacheEntry['has_cascading'];
584 }
585
586 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
587 $queryBuilder = $dbr->newSelectQueryBuilder();
588 $queryBuilder->select( [ 'pr_expiry' ] )
589 ->from( 'page_restrictions' )
590 ->where( [ 'pr_cascade' => 1 ] );
591
592 if ( $page->getNamespace() === NS_FILE ) {
593 // Files transclusion may receive cascading protection in the future
594 // see https://phabricator.wikimedia.org/T241453
595 $queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
596 $queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
597 } else {
598 $queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
599 $queryBuilder->andWhere(
600 $this->linksMigration->getLinksConditions(
601 'templatelinks',
602 TitleValue::newFromPage( $page )
603 )
604 );
605 }
606
607 if ( !$shortCircuit ) {
608 $queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
609 $queryBuilder->join( 'page', null, 'page_id=pr_page' );
610 }
611
612 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
613
614 $sources = [];
615 $pageRestrictions = [];
616 $now = wfTimestampNow();
617
618 foreach ( $res as $row ) {
619 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
620 if ( $expiry > $now ) {
621 if ( $shortCircuit ) {
622 $cacheEntry['has_cascading'] = true;
623 return true;
624 }
625
626 $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
627 $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
628 // Add groups needed for each restriction type if its not already there
629 // Make sure this restriction type still exists
630
631 if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
632 $pageRestrictions[$row->pr_type] = [];
633 }
634
635 if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
636 $pageRestrictions[$row->pr_type][] = $row->pr_level;
637 }
638 }
639 }
640
641 $cacheEntry['has_cascading'] = (bool)$sources;
642
643 if ( $shortCircuit ) {
644 return false;
645 }
646
647 $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
648 return [ $sources, $pageRestrictions ];
649 }
650
656 public function areRestrictionsLoaded( PageIdentity $page ): bool {
657 $page->assertWiki( PageIdentity::LOCAL );
658
659 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
660 }
661
668 public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
669 $page->assertWiki( PageIdentity::LOCAL );
670
671 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
672 }
673
680 public function areRestrictionsCascading( PageIdentity $page ): bool {
681 $page->assertWiki( PageIdentity::LOCAL );
682
683 if ( !$this->areRestrictionsLoaded( $page ) ) {
684 $this->loadRestrictions( $page );
685 }
686 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
687 }
688
696 public function flushRestrictions( PageIdentity $page ): void {
697 $page->assertWiki( PageIdentity::LOCAL );
698
699 unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
700 }
701
702}
const NS_FILE
Definition Defines.php:70
const NS_SPECIAL
Definition Defines.php:53
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Helper class for DAO classes.
Helper class for mapping value objects representing basic entities to cache keys.
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition LinkCache.php:53
Handle database storage of comments such as edit summaries and log reasons.
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Service for compat reading of links tables.
A class containing constants representing the names of configuration variables.
const NamespaceProtection
Name constant for the NamespaceProtection setting, for use with Config::get()
const RestrictionTypes
Name constant for the RestrictionTypes setting, for use with Config::get()
const SemiprotectedRestrictionLevels
Name constant for the SemiprotectedRestrictionLevels setting, for use with Config::get()
const RestrictionLevels
Name constant for the RestrictionLevels setting, for use with Config::get()
Immutable value object representing a page identity.
loadRestrictionsFromRows(PageIdentity $page, array $rows)
Compiles list of active page restrictions for this existing page.
getAllRestrictions(PageIdentity $page)
Returns the restricted actions and their restrictions for the specified page.
listAllRestrictionTypes(bool $exists=true)
Get a filtered list of all restriction types supported by this wiki.
getRestrictions(PageIdentity $page, string $action)
Returns list of restrictions for specified page.
deleteCreateProtection(PageIdentity $page)
Remove any title creation protection due to page existing.
getCascadeProtectionSources(PageIdentity $page)
Cascading protection: Get the source of any cascading restrictions on this page.
getRestrictionExpiry(PageIdentity $page, string $action)
Get the expiry time for the restriction against a given action.
isCascadeProtected(PageIdentity $page)
Cascading protection: Return true if cascading restrictions apply to this page, false if not.
isSemiProtected(PageIdentity $page, string $action='edit')
Is this page "semi-protected" - the only protection levels are listed in $wgSemiprotectedRestrictionL...
__construct(ServiceOptions $options, WANObjectCache $wanCache, ILoadBalancer $loadBalancer, LinkCache $linkCache, LinksMigration $linksMigration, CommentStore $commentStore, HookContainer $hookContainer, PageStore $pageStore)
listApplicableRestrictionTypes(PageIdentity $page)
Returns restriction types for the current page.
isProtected(PageIdentity $page, string $action='')
Does the title correspond to a protected article?
flushRestrictions(PageIdentity $page)
Flush the protection cache in this object and force reload from the database.
areRestrictionsCascading(PageIdentity $page)
Checks if restrictions are cascading for the current page.
loadRestrictions(PageIdentity $page, int $flags=IDBAccessObject::READ_NORMAL)
Load restrictions from page.page_restrictions and the page_restrictions table.
getCreateProtection(PageIdentity $page)
Is this title subject to protection against creation?
areCascadeProtectionSourcesLoaded(PageIdentity $page)
Determines whether cascading protection sources have already been loaded from the database.
Represents the target of a wiki link.
Represents a title within MediaWiki.
Definition Title.php:78
Multi-datacenter aware caching interface.
Interface for database access objects.
Interface for objects (potentially) representing an editable wiki page.
getId( $wikiId=self::LOCAL)
Returns the page ID.
canExist()
Checks whether this PageIdentity represents a "proper" page, meaning that it could exist as an editab...
exists()
Checks if the page currently exists.
getNamespace()
Returns the page's namespace number.
getDBkey()
Get the page title in DB key form.
This class is a delegate to ILBFactory for a given database cluster.
A database connection without write operations.
const DB_REPLICA
Definition defines.php:26
const DB_PRIMARY
Definition defines.php:28