MediaWiki master
RestrictionStore.php
Go to the documentation of this file.
1<?php
2
4
20use stdClass;
25
30
32 public const CONSTRUCTOR_OPTIONS = [
37 ];
38
40 private $options;
41
43 private $wanCache;
44
46 private $loadBalancer;
47
49 private $linkCache;
50
52 private $linksMigration;
53
55 private $commentStore;
56
58 private $hookContainer;
59
61 private $hookRunner;
62
64 private $pageStore;
65
77 private $cache = [];
78
89 public function __construct(
90 ServiceOptions $options,
91 WANObjectCache $wanCache,
92 ILoadBalancer $loadBalancer,
93 LinkCache $linkCache,
94 LinksMigration $linksMigration,
95 CommentStore $commentStore,
96 HookContainer $hookContainer,
97 PageStore $pageStore
98 ) {
99 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
100 $this->options = $options;
101 $this->wanCache = $wanCache;
102 $this->loadBalancer = $loadBalancer;
103 $this->linkCache = $linkCache;
104 $this->linksMigration = $linksMigration;
105 $this->commentStore = $commentStore;
106 $this->hookContainer = $hookContainer;
107 $this->hookRunner = new HookRunner( $hookContainer );
108 $this->pageStore = $pageStore;
109 }
110
122 public function getRestrictions( PageIdentity $page, string $action ): array {
123 $page->assertWiki( PageIdentity::LOCAL );
124
125 // Optimization: Avoid repeatedly fetching page restrictions (from cache or DB)
126 // for repeated PermissionManager::userCan calls, if this action cannot be restricted
127 // in the first place. This is primarily to improve batch rendering on RecentChanges,
128 // where as of writing this will save 0.5s on a 8.0s response. (T341319)
129 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
130 if ( !in_array( $action, $restrictionTypes ) ) {
131 return [];
132 }
133
134 $restrictions = $this->getAllRestrictions( $page );
135 return $restrictions[$action] ?? [];
136 }
137
145 public function getAllRestrictions( PageIdentity $page ): array {
146 $page->assertWiki( PageIdentity::LOCAL );
147
148 if ( !$this->areRestrictionsLoaded( $page ) ) {
149 $this->loadRestrictions( $page );
150 }
151 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
152 }
153
162 public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
163 $page->assertWiki( PageIdentity::LOCAL );
164
165 if ( !$this->areRestrictionsLoaded( $page ) ) {
166 $this->loadRestrictions( $page );
167 }
168 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
169 }
170
185 public function getCreateProtection( PageIdentity $page ): ?array {
186 $page->assertWiki( PageIdentity::LOCAL );
187
188 $protection = $this->getCreateProtectionInternal( $page );
189 // TODO: the remapping below probably need to be migrated into other method one day
190 if ( $protection ) {
191 if ( $protection['permission'] == 'sysop' ) {
192 $protection['permission'] = 'editprotected'; // B/C
193 }
194 if ( $protection['permission'] == 'autoconfirmed' ) {
195 $protection['permission'] = 'editsemiprotected'; // B/C
196 }
197 }
198 return $protection;
199 }
200
207 public function deleteCreateProtection( PageIdentity $page ): void {
208 $page->assertWiki( PageIdentity::LOCAL );
209
210 $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
211 $dbw->newDeleteQueryBuilder()
212 ->deleteFrom( 'protected_titles' )
213 ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
214 ->caller( __METHOD__ )->execute();
215 $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
216 }
217
226 public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
227 $page->assertWiki( PageIdentity::LOCAL );
228
229 $restrictions = $this->getRestrictions( $page, $action );
230 $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
231 if ( !$restrictions || !$semi ) {
232 // Not protected, or all protection is full protection
233 return false;
234 }
235
236 // Remap autoconfirmed to editsemiprotected for BC
237 foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
238 $semi[$key] = 'autoconfirmed';
239 }
240 foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
241 $restrictions[$key] = 'autoconfirmed';
242 }
243
244 return !array_diff( $restrictions, $semi );
245 }
246
254 public function isProtected( PageIdentity $page, string $action = '' ): bool {
255 $page->assertWiki( PageIdentity::LOCAL );
256
257 // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
258 if ( $page->getNamespace() === NS_SPECIAL ) {
259 return true;
260 }
261
262 // Check regular protection levels
263 $applicableTypes = $this->listApplicableRestrictionTypes( $page );
264
265 if ( $action === '' ) {
266 foreach ( $applicableTypes as $type ) {
267 if ( $this->isProtected( $page, $type ) ) {
268 return true;
269 }
270 }
271 return false;
272 }
273
274 if ( !in_array( $action, $applicableTypes ) ) {
275 return false;
276 }
277
278 return (bool)array_diff(
279 array_intersect(
280 $this->getRestrictions( $page, $action ),
281 $this->options->get( MainConfigNames::RestrictionLevels )
282 ),
283 [ '' ]
284 );
285 }
286
293 public function isCascadeProtected( PageIdentity $page ): bool {
294 $page->assertWiki( PageIdentity::LOCAL );
295
296 return $this->getCascadeProtectionSourcesInternal( $page, true );
297 }
298
305 public function listApplicableRestrictionTypes( PageIdentity $page ): array {
306 $page->assertWiki( PageIdentity::LOCAL );
307
308 if ( !$page->canExist() ) {
309 return [];
310 }
311
312 $types = $this->listAllRestrictionTypes( $page->exists() );
313
314 if ( $page->getNamespace() !== NS_FILE ) {
315 // Remove the upload restriction for non-file titles
316 $types = array_values( array_diff( $types, [ 'upload' ] ) );
317 }
318
319 if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
320 $this->hookRunner->onTitleGetRestrictionTypes(
321 Title::newFromPageIdentity( $page ), $types );
322 }
323
324 return $types;
325 }
326
334 public function listAllRestrictionTypes( bool $exists = true ): array {
335 $types = $this->options->get( MainConfigNames::RestrictionTypes );
336 if ( $exists ) {
337 // Remove the create restriction for existing titles
338 return array_values( array_diff( $types, [ 'create' ] ) );
339 }
340
341 // Only the create restrictions apply to non-existing titles
342 return array_values( array_intersect( $types, [ 'create' ] ) );
343 }
344
353 public function loadRestrictions(
354 PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
355 ): void {
356 $page->assertWiki( PageIdentity::LOCAL );
357
358 if ( !$page->canExist() ) {
359 return;
360 }
361
362 $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
363
364 if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
365 return;
366 }
367
368 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
369
370 $cacheEntry['restrictions'] = [];
371
372 // XXX Work around https://phabricator.wikimedia.org/T287575
373 if ( $readLatest ) {
374 $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
375 }
376 $id = $page->getId();
377 if ( $id ) {
378 $fname = __METHOD__;
379 $loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
380 return iterator_to_array(
381 $dbr->newSelectQueryBuilder()
382 ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
383 ->from( 'page_restrictions' )
384 ->where( [ 'pr_page' => $id ] )
385 ->caller( $fname )->fetchResultSet()
386 );
387 };
388
389 if ( $readLatest ) {
390 $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
391 $rows = $loadRestrictionsFromDb( $dbr );
392 } else {
393 $this->pageStore->getPageForLink( TitleValue::newFromPage( $page ) )->getId();
394 $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
395 if ( !$latestRev ) {
396 // This method can get called in the middle of page creation
397 // (WikiPage::doUserEditContent) where a page might have an
398 // id but no revisions, while checking the "autopatrol" permission.
399 $rows = [];
400 } else {
401 $rows = $this->wanCache->getWithSetCallback(
402 // Page protections always leave a new null revision
403 $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
404 $this->wanCache::TTL_DAY,
405 function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
406 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
407 $setOpts += Database::getCacheSetOptions( $dbr );
408 if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
409 // TODO: cleanup Title cache and caller assumption mess in general
410 $ttl = WANObjectCache::TTL_UNCACHEABLE;
411 }
412
413 return $loadRestrictionsFromDb( $dbr );
414 }
415 );
416 }
417 }
418
419 $this->loadRestrictionsFromRows( $page, $rows );
420 } else {
421 $titleProtection = $this->getCreateProtectionInternal( $page );
422
423 if ( $titleProtection ) {
424 $now = wfTimestampNow();
425 $expiry = $titleProtection['expiry'];
426
427 if ( !$expiry || $expiry > $now ) {
428 // Apply the restrictions
429 $cacheEntry['expiry']['create'] = $expiry ?: null;
430 $cacheEntry['restrictions']['create'] =
431 explode( ',', trim( $titleProtection['permission'] ) );
432 } else {
433 // Get rid of the old restrictions
434 $cacheEntry['create_protection'] = null;
435 }
436 } else {
437 $cacheEntry['expiry']['create'] = 'infinity';
438 }
439 }
440 }
441
450 PageIdentity $page, array $rows
451 ): void {
452 $page->assertWiki( PageIdentity::LOCAL );
453
454 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
455
456 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
457
458 foreach ( $restrictionTypes as $type ) {
459 $cacheEntry['restrictions'][$type] = [];
460 $cacheEntry['expiry'][$type] = 'infinity';
461 }
462
463 $cacheEntry['cascade'] = false;
464
465 if ( !$rows ) {
466 return;
467 }
468
469 // New restriction format -- load second to make them override old-style restrictions.
470 $now = wfTimestampNow();
471
472 // Cycle through all the restrictions.
473 foreach ( $rows as $row ) {
474 // Don't take care of restrictions types that aren't allowed
475 if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
476 continue;
477 }
478
479 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
480 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
481
482 // Only apply the restrictions if they haven't expired!
483 // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
484 // string consisting of 14 digits. Likewise for the ?: below.
485 if ( !$expiry || $expiry > $now ) {
486 $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
487 $cacheEntry['restrictions'][$row->pr_type]
488 = explode( ',', trim( $row->pr_level ) );
489 if ( $row->pr_cascade ) {
490 $cacheEntry['cascade'] = true;
491 }
492 }
493 }
494 }
495
506 private function getCreateProtectionInternal( PageIdentity $page ): ?array {
507 // Can't protect pages in special namespaces
508 if ( !$page->canExist() ) {
509 return null;
510 }
511
512 // Can't apply this type of protection to pages that exist.
513 if ( $page->exists() ) {
514 return null;
515 }
516
517 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
518
519 if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
520 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
521 $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
522 $row = $dbr->newSelectQueryBuilder()
523 ->select( [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] )
524 ->from( 'protected_titles' )
525 ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
526 ->queryInfo( $commentQuery )
527 ->caller( __METHOD__ )
528 ->fetchRow();
529
530 if ( $row ) {
531 $cacheEntry['create_protection'] = [
532 'user' => $row->pt_user,
533 'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
534 'permission' => $row->pt_create_perm,
535 'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
536 ];
537 } else {
538 $cacheEntry['create_protection'] = null;
539 }
540
541 }
542
543 return $cacheEntry['create_protection'];
544 }
545
554 public function getCascadeProtectionSources( PageIdentity $page ): array {
555 $page->assertWiki( PageIdentity::LOCAL );
556
557 return $this->getCascadeProtectionSourcesInternal( $page, false );
558 }
559
568 private function getCascadeProtectionSourcesInternal(
569 PageIdentity $page, bool $shortCircuit = false
570 ) {
571 if ( !$page->canExist() ) {
572 return $shortCircuit ? false : [ [], [] ];
573 }
574
575 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
576
577 if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
578 return $cacheEntry['cascade_sources'];
579 } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
580 return $cacheEntry['has_cascading'];
581 }
582
583 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
584 $queryBuilder = $dbr->newSelectQueryBuilder();
585 $queryBuilder->select( [ 'pr_expiry' ] )
586 ->from( 'page_restrictions' )
587 ->where( [ 'pr_cascade' => 1 ] );
588
589 if ( $page->getNamespace() === NS_FILE ) {
590 // Files transclusion may receive cascading protection in the future
591 // see https://phabricator.wikimedia.org/T241453
592 $queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
593 $queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
594 } else {
595 $queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
596 $queryBuilder->andWhere(
597 $this->linksMigration->getLinksConditions(
598 'templatelinks',
599 TitleValue::newFromPage( $page )
600 )
601 );
602 }
603
604 if ( !$shortCircuit ) {
605 $queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
606 $queryBuilder->join( 'page', null, 'page_id=pr_page' );
607 }
608
609 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
610
611 $sources = [];
612 $pageRestrictions = [];
613 $now = wfTimestampNow();
614
615 foreach ( $res as $row ) {
616 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
617 if ( $expiry > $now ) {
618 if ( $shortCircuit ) {
619 $cacheEntry['has_cascading'] = true;
620 return true;
621 }
622
623 $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
624 $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
625 // Add groups needed for each restriction type if its not already there
626 // Make sure this restriction type still exists
627
628 if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
629 $pageRestrictions[$row->pr_type] = [];
630 }
631
632 if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
633 $pageRestrictions[$row->pr_type][] = $row->pr_level;
634 }
635 }
636 }
637
638 $cacheEntry['has_cascading'] = (bool)$sources;
639
640 if ( $shortCircuit ) {
641 return false;
642 }
643
644 $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
645 return [ $sources, $pageRestrictions ];
646 }
647
653 public function areRestrictionsLoaded( PageIdentity $page ): bool {
654 $page->assertWiki( PageIdentity::LOCAL );
655
656 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
657 }
658
665 public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
666 $page->assertWiki( PageIdentity::LOCAL );
667
668 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
669 }
670
677 public function areRestrictionsCascading( PageIdentity $page ): bool {
678 $page->assertWiki( PageIdentity::LOCAL );
679
680 if ( !$this->areRestrictionsLoaded( $page ) ) {
681 $this->loadRestrictions( $page );
682 }
683 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
684 }
685
693 public function flushRestrictions( PageIdentity $page ): void {
694 $page->assertWiki( PageIdentity::LOCAL );
695
696 unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
697 }
698
699}
const NS_FILE
Definition Defines.php:71
const NS_SPECIAL
Definition Defines.php:54
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:52
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:79
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