MediaWiki REL1_37
RestrictionStore.php
Go to the documentation of this file.
1<?php
2
4
8use LinkCache;
16use stdClass;
17use Title;
22
29
31 public const CONSTRUCTOR_OPTIONS = [
32 'NamespaceProtection',
33 'RestrictionLevels',
34 'RestrictionTypes',
35 'SemiprotectedRestrictionLevels',
36 ];
37
39 private $options;
40
42 private $wanCache;
43
46
48 private $linkCache;
49
52
55
57 private $hookRunner;
58
60 private $pageStore;
61
74 private $cache = [];
75
85 public function __construct(
93 ) {
94 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
95 $this->options = $options;
96 $this->wanCache = $wanCache;
97 $this->loadBalancer = $loadBalancer;
98 $this->linkCache = $linkCache;
99 $this->commentStore = $commentStore;
100 $this->hookContainer = $hookContainer;
101 $this->hookRunner = new HookRunner( $hookContainer );
102 $this->pageStore = $pageStore;
103 }
104
116 public function getRestrictions( PageIdentity $page, string $action ): array {
117 $page->assertWiki( PageIdentity::LOCAL );
118
119 $restrictions = $this->getAllRestrictions( $page );
120 return $restrictions[$action] ?? [];
121 }
122
130 public function getAllRestrictions( PageIdentity $page ): array {
131 $page->assertWiki( PageIdentity::LOCAL );
132
133 if ( !$this->areRestrictionsLoaded( $page ) ) {
134 $this->loadRestrictions( $page );
135 }
136 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
137 }
138
148 public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
149 $page->assertWiki( PageIdentity::LOCAL );
150
151 if ( !$this->areRestrictionsLoaded( $page ) ) {
152 $this->loadRestrictions( $page );
153 }
154 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
155 }
156
171 public function getCreateProtection( PageIdentity $page ): ?array {
172 $page->assertWiki( PageIdentity::LOCAL );
173
174 $protection = $this->getCreateProtectionInternal( $page );
175 // TODO: the remapping below probably need to be migrated into other method one day
176 if ( $protection ) {
177 if ( $protection['permission'] == 'sysop' ) {
178 $protection['permission'] = 'editprotected'; // B/C
179 }
180 if ( $protection['permission'] == 'autoconfirmed' ) {
181 $protection['permission'] = 'editsemiprotected'; // B/C
182 }
183 }
184 return $protection;
185 }
186
193 public function deleteCreateProtection( PageIdentity $page ): void {
194 $page->assertWiki( PageIdentity::LOCAL );
195
196 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
197 $dbw->delete(
198 'protected_titles',
199 [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
200 __METHOD__
201 );
202 $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
203 }
204
213 public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
214 $page->assertWiki( PageIdentity::LOCAL );
215
216 $restrictions = $this->getRestrictions( $page, $action );
217 $semi = $this->options->get( 'SemiprotectedRestrictionLevels' );
218 if ( !$restrictions || !$semi ) {
219 // Not protected, or all protection is full protection
220 return false;
221 }
222
223 // Remap autoconfirmed to editsemiprotected for BC
224 foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
225 $semi[$key] = 'autoconfirmed';
226 }
227 foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
228 $restrictions[$key] = 'autoconfirmed';
229 }
230
231 return !array_diff( $restrictions, $semi );
232 }
233
241 public function isProtected( PageIdentity $page, string $action = '' ): bool {
242 $page->assertWiki( PageIdentity::LOCAL );
243
244 // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
245 if ( $page->getNamespace() === NS_SPECIAL ) {
246 return true;
247 }
248
249 // Check regular protection levels
250 $applicableTypes = $this->listApplicableRestrictionTypes( $page );
251
252 if ( $action === '' ) {
253 foreach ( $applicableTypes as $type ) {
254 if ( $this->isProtected( $page, $type ) ) {
255 return true;
256 }
257 }
258 return false;
259 }
260
261 if ( !in_array( $action, $applicableTypes ) ) {
262 return false;
263 }
264
265 return (bool)array_diff(
266 array_intersect(
267 $this->getRestrictions( $page, $action ),
268 $this->options->get( 'RestrictionLevels' )
269 ),
270 [ '' ]
271 );
272 }
273
280 public function isCascadeProtected( PageIdentity $page ): bool {
281 $page->assertWiki( PageIdentity::LOCAL );
282
283 return $this->getCascadeProtectionSourcesInternal( $page, true );
284 }
285
292 public function listApplicableRestrictionTypes( PageIdentity $page ): array {
293 $page->assertWiki( PageIdentity::LOCAL );
294
295 if ( !$page->canExist() ) {
296 return [];
297 }
298
299 $types = $this->listAllRestrictionTypes( $page->exists() );
300
301 if ( $page->getNamespace() !== NS_FILE ) {
302 // Remove the upload restriction for non-file titles
303 $types = array_values( array_diff( $types, [ 'upload' ] ) );
304 }
305
306 if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
307 $this->hookRunner->onTitleGetRestrictionTypes(
308 Title::castFromPageIdentity( $page ), $types );
309 }
310
311 return $types;
312 }
313
321 public function listAllRestrictionTypes( bool $exists = true ): array {
322 $types = $this->options->get( 'RestrictionTypes' );
323 if ( $exists ) {
324 // Remove the create restriction for existing titles
325 return array_values( array_diff( $types, [ 'create' ] ) );
326 }
327
328 // Only the create and upload restrictions apply to non-existing titles
329 return array_values( array_intersect( $types, [ 'create', 'upload' ] ) );
330 }
331
343 public function loadRestrictions(
344 PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL, ?string $oldRestrictions = null
345 ): void {
346 $page->assertWiki( PageIdentity::LOCAL );
347
348 if ( !$page->canExist() ) {
349 return;
350 }
351
352 $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
353
354 if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
355 return;
356 }
357
358 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
359
360 $cacheEntry['restrictions'] = [];
361
362 // XXX Work around https://phabricator.wikimedia.org/T287575
363 if ( $readLatest ) {
364 $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
365 }
366 $id = $page->getId();
367 if ( $id ) {
368 $fname = __METHOD__;
369 $loadRestrictionsFromDb = static function ( IDatabase $dbr ) use ( $fname, $id ) {
370 return iterator_to_array(
371 $dbr->select(
372 'page_restrictions',
373 [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ],
374 [ 'pr_page' => $id ],
375 $fname
376 )
377 );
378 };
379
380 if ( $readLatest ) {
381 $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
382 $rows = $loadRestrictionsFromDb( $dbr );
383 } else {
384 $this->linkCache->addLinkObj( $page );
385 $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
386 $rows = $this->wanCache->getWithSetCallback(
387 // Page protections always leave a new null revision
388 $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
389 $this->wanCache::TTL_DAY,
390 function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
391 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
392 $setOpts += Database::getCacheSetOptions( $dbr );
393 if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
394 // TODO: cleanup Title cache and caller assumption mess in general
395 $ttl = WANObjectCache::TTL_UNCACHEABLE;
396 }
397
398 return $loadRestrictionsFromDb( $dbr );
399 }
400 );
401 }
402
403 $this->loadRestrictionsFromRows( $page, $rows, $oldRestrictions );
404 } else {
405 $titleProtection = $this->getCreateProtectionInternal( $page );
406
407 if ( $titleProtection ) {
408 $now = wfTimestampNow();
409 $expiry = $titleProtection['expiry'];
410
411 if ( !$expiry || $expiry > $now ) {
412 // Apply the restrictions
413 $cacheEntry['expiry']['create'] = $expiry ?: null;
414 $cacheEntry['restrictions']['create'] =
415 explode( ',', trim( $titleProtection['permission'] ) );
416 } else {
417 // Get rid of the old restrictions
418 $cacheEntry['create_protection'] = null;
419 }
420 } else {
421 $cacheEntry['expiry']['create'] = 'infinity';
422 }
423 }
424 }
425
437 PageIdentity $page, array $rows, ?string $oldRestrictions = null
438 ): void {
439 $page->assertWiki( PageIdentity::LOCAL );
440
441 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
442
443 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
444
445 foreach ( $restrictionTypes as $type ) {
446 $cacheEntry['restrictions'][$type] = [];
447 $cacheEntry['expiry'][$type] = 'infinity';
448 }
449
450 $cacheEntry['cascade'] = false;
451
452 // Backwards-compatibility: also load the restrictions from the page record (old format).
453 // Don't include in test coverage, we're planning to drop support.
454 // @codeCoverageIgnoreStart
455 $cacheEntry['oldRestrictions'] = $oldRestrictions ?? $cacheEntry['oldRestrictions'] ?? null;
456
457 if ( $cacheEntry['oldRestrictions'] === null ) {
458 $this->linkCache->addLinkObj( $page );
459 $cachedOldRestrictions = $this->linkCache->getGoodLinkFieldObj( $page, 'restrictions' );
460 if ( $cachedOldRestrictions !== null ) {
461 $cacheEntry['oldRestrictions'] = $cachedOldRestrictions;
462 }
463 }
464
465 if ( $cacheEntry['oldRestrictions'] ) {
466 $cacheEntry['restrictions'] =
467 $this->convertOldRestrictions( $cacheEntry['oldRestrictions'] );
468 }
469 // @codeCoverageIgnoreEnd
470
471 if ( !$rows ) {
472 return;
473 }
474
475 // New restriction format -- load second to make them override old-style restrictions.
476 $now = wfTimestampNow();
477
478 // Cycle through all the restrictions.
479 foreach ( $rows as $row ) {
480 // Don't take care of restrictions types that aren't allowed
481 if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
482 continue;
483 }
484
485 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
486 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
487
488 // Only apply the restrictions if they haven't expired!
489 // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
490 // string consisting of 14 digits. Likewise for the ?: below.
491 if ( !$expiry || $expiry > $now ) {
492 $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
493 $cacheEntry['restrictions'][$row->pr_type]
494 = explode( ',', trim( $row->pr_level ) );
495 if ( $row->pr_cascade ) {
496 $cacheEntry['cascade'] = true;
497 }
498 }
499 }
500 }
501
511 private function convertOldRestrictions( string $oldRestrictions ): array {
512 $ret = [];
513 foreach ( explode( ':', trim( $oldRestrictions ) ) as $restrict ) {
514 $restrictionPair = explode( '=', trim( $restrict ) );
515 if ( count( $restrictionPair ) == 1 ) {
516 // old old format should be treated as edit/move restriction
517 $ret['edit'] = explode( ',', trim( $restrictionPair[0] ) );
518 $ret['move'] = explode( ',', trim( $restrictionPair[0] ) );
519 } else {
520 $restriction = trim( $restrictionPair[1] );
521 if ( $restriction != '' ) { // some old entries are empty
522 $ret[$restrictionPair[0]] = explode( ',', $restriction );
523 }
524 }
525 }
526 return $ret;
527 }
528
539 private function getCreateProtectionInternal( PageIdentity $page ): ?array {
540 // Can't protect pages in special namespaces
541 if ( !$page->canExist() ) {
542 return null;
543 }
544
545 // Can't apply this type of protection to pages that exist.
546 if ( $page->exists() ) {
547 return null;
548 }
549
550 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
551
552 if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
553 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
554 $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
555 $row = $dbr->selectRow(
556 [ 'protected_titles' ] + $commentQuery['tables'],
557 [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
558 [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
559 __METHOD__,
560 [],
561 $commentQuery['joins']
562 );
563
564 if ( $row ) {
565 $cacheEntry['create_protection'] = [
566 'user' => $row->pt_user,
567 'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
568 'permission' => $row->pt_create_perm,
569 'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
570 ];
571 } else {
572 $cacheEntry['create_protection'] = null;
573 }
574
575 }
576
577 return $cacheEntry['create_protection'];
578 }
579
590 public function getCascadeProtectionSources( PageIdentity $page ): array {
591 $page->assertWiki( PageIdentity::LOCAL );
592
593 return $this->getCascadeProtectionSourcesInternal( $page, false );
594 }
595
605 PageIdentity $page, bool $shortCircuit = false
606 ) {
607 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
608
609 if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
610 return $cacheEntry['cascade_sources'];
611 } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
612 return $cacheEntry['has_cascading'];
613 }
614
615 if ( $page->getNamespace() === NS_FILE ) {
616 // Files transclusion may receive cascading protection in the future
617 // see https://phabricator.wikimedia.org/T241453
618 $tables = [ 'imagelinks', 'page_restrictions' ];
619 $where_clauses = [
620 'il_to' => $page->getDBkey(),
621 'il_from=pr_page',
622 'pr_cascade' => 1
623 ];
624 } else {
625 $tables = [ 'templatelinks', 'page_restrictions' ];
626 $where_clauses = [
627 'tl_namespace' => $page->getNamespace(),
628 'tl_title' => $page->getDBkey(),
629 'tl_from=pr_page',
630 'pr_cascade' => 1
631 ];
632 }
633
634 if ( $shortCircuit ) {
635 $cols = [ 'pr_expiry' ];
636 } else {
637 $cols = [ 'pr_page', 'page_namespace', 'page_title',
638 'pr_expiry', 'pr_type', 'pr_level' ];
639 $where_clauses[] = 'page_id=pr_page';
640 $tables[] = 'page';
641 }
642
643 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
644 $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ );
645
646 $sources = [];
647 $pageRestrictions = [];
648 $now = wfTimestampNow();
649
650 foreach ( $res as $row ) {
651 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
652 if ( $expiry > $now ) {
653 if ( $shortCircuit ) {
654 $cacheEntry['has_cascading'] = true;
655 return true;
656 }
657
658 $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
659 $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
660 // Add groups needed for each restriction type if its not already there
661 // Make sure this restriction type still exists
662
663 if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
664 $pageRestrictions[$row->pr_type] = [];
665 }
666
667 if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
668 $pageRestrictions[$row->pr_type][] = $row->pr_level;
669 }
670 }
671 }
672
673 $cacheEntry['has_cascading'] = (bool)$sources;
674
675 if ( $shortCircuit ) {
676 return false;
677 }
678
679 $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
680 return [ $sources, $pageRestrictions ];
681 }
682
688 public function areRestrictionsLoaded( PageIdentity $page ): bool {
689 $page->assertWiki( PageIdentity::LOCAL );
690
691 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
692 }
693
700 public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
701 $page->assertWiki( PageIdentity::LOCAL );
702
703 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
704 }
705
712 public function areRestrictionsCascading( PageIdentity $page ): bool {
713 $page->assertWiki( PageIdentity::LOCAL );
714
715 if ( !$this->areRestrictionsLoaded( $page ) ) {
716 $this->loadRestrictions( $page );
717 }
718 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
719 }
720
728 public function flushRestrictions( PageIdentity $page ): void {
729 $page->assertWiki( PageIdentity::LOCAL );
730
731 unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
732 }
733
744 public function registerOldRestrictions( PageIdentity $page, string $oldRestrictions ): void {
745 $page->assertWiki( PageIdentity::LOCAL );
746
747 $this->cache[CacheKeyHelper::getKeyForPage( $page )]['oldRestrictions'] =
748 $oldRestrictions;
749 }
750
751}
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(ini_get('mbstring.func_overload')) if(!defined('MW_ENTRY_POINT'))
Pre-config setup: Before loading LocalSettings.php.
Definition Setup.php:88
Handle database storage of comments such as edit summaries and log reasons.
Helper class for DAO classes.
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition LinkCache.php:41
Helper class for mapping value objects representing basic entities to cache keys.
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...
Immutable value object representing a page identity.
loadRestrictionsFromRows(PageIdentity $page, array $rows, ?string $oldRestrictions=null)
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.
getCascadeProtectionSourcesInternal(PageIdentity $page, bool $shortCircuit=false)
Cascading protection: Get the source of any cascading restrictions on this page.
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.
array[] $cache
Caching various restrictions data in the following format: cache key => [ string[] restrictions => re...
loadRestrictions(PageIdentity $page, int $flags=IDBAccessObject::READ_NORMAL, ?string $oldRestrictions=null)
Load restrictions from page.page_restrictions and the page_restrictions table.
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...
registerOldRestrictions(PageIdentity $page, string $oldRestrictions)
Register legacy restrictions from page.page_restrictions.
listApplicableRestrictionTypes(PageIdentity $page)
Returns restriction types for the current page.
convertOldRestrictions(string $oldRestrictions)
Given a string formatted like the legacy page.page_restrictions field, return an array of restriction...
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.
getCreateProtection(PageIdentity $page)
Is this title subject to protection against creation?
__construct(ServiceOptions $options, WANObjectCache $wanCache, ILoadBalancer $loadBalancer, LinkCache $linkCache, CommentStore $commentStore, HookContainer $hookContainer, PageStore $pageStore)
areCascadeProtectionSourcesLoaded(PageIdentity $page)
Determines whether cascading protection sources have already been loaded from the database.
getCreateProtectionInternal(PageIdentity $page)
Fetch title protection settings.
Represents a title within MediaWiki.
Definition Title.php:48
Multi-datacenter aware caching interface.
Relational database abstraction object.
Definition Database.php:52
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.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Database cluster connection, tracking, load balancing, and transaction manager interface.
const DB_REPLICA
Definition defines.php:25
const DB_PRIMARY
Definition defines.php:27