MediaWiki master
RestrictionStore.php
Go to the documentation of this file.
1<?php
2
4
18use stdClass;
25
30
32 public const CONSTRUCTOR_OPTIONS = [
37 ];
38
39 private ServiceOptions $options;
40 private WANObjectCache $wanCache;
41 private ILoadBalancer $loadBalancer;
42 private LinkCache $linkCache;
43 private LinksMigration $linksMigration;
44 private CommentStore $commentStore;
45 private HookContainer $hookContainer;
46 private HookRunner $hookRunner;
47 private PageStore $pageStore;
48
60 private $cache = [];
61
62 public function __construct(
63 ServiceOptions $options,
64 WANObjectCache $wanCache,
65 ILoadBalancer $loadBalancer,
66 LinkCache $linkCache,
67 LinksMigration $linksMigration,
68 CommentStore $commentStore,
69 HookContainer $hookContainer,
70 PageStore $pageStore
71 ) {
72 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
73 $this->options = $options;
74 $this->wanCache = $wanCache;
75 $this->loadBalancer = $loadBalancer;
76 $this->linkCache = $linkCache;
77 $this->linksMigration = $linksMigration;
78 $this->commentStore = $commentStore;
79 $this->hookContainer = $hookContainer;
80 $this->hookRunner = new HookRunner( $hookContainer );
81 $this->pageStore = $pageStore;
82 }
83
95 public function getRestrictions( PageIdentity $page, string $action ): array {
96 $page->assertWiki( PageIdentity::LOCAL );
97
98 // Optimization: Avoid repeatedly fetching page restrictions (from cache or DB)
99 // for repeated PermissionManager::userCan calls, if this action cannot be restricted
100 // in the first place. This is primarily to improve batch rendering on RecentChanges,
101 // where as of writing this will save 0.5s on a 8.0s response. (T341319)
102 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
103 if ( !in_array( $action, $restrictionTypes ) ) {
104 return [];
105 }
106
107 $restrictions = $this->getAllRestrictions( $page );
108 return $restrictions[$action] ?? [];
109 }
110
118 public function getAllRestrictions( PageIdentity $page ): array {
119 $page->assertWiki( PageIdentity::LOCAL );
120
121 if ( !$this->areRestrictionsLoaded( $page ) ) {
122 $this->loadRestrictions( $page );
123 }
124 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
125 }
126
135 public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
136 $page->assertWiki( PageIdentity::LOCAL );
137
138 if ( !$this->areRestrictionsLoaded( $page ) ) {
139 $this->loadRestrictions( $page );
140 }
141 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
142 }
143
158 public function getCreateProtection( PageIdentity $page ): ?array {
159 $page->assertWiki( PageIdentity::LOCAL );
160
161 $protection = $this->getCreateProtectionInternal( $page );
162 // TODO: the remapping below probably need to be migrated into other method one day
163 if ( $protection ) {
164 if ( $protection['permission'] == 'sysop' ) {
165 $protection['permission'] = 'editprotected'; // B/C
166 }
167 if ( $protection['permission'] == 'autoconfirmed' ) {
168 $protection['permission'] = 'editsemiprotected'; // B/C
169 }
170 }
171 return $protection;
172 }
173
180 public function deleteCreateProtection( PageIdentity $page ): void {
181 $page->assertWiki( PageIdentity::LOCAL );
182
183 $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
184 $dbw->newDeleteQueryBuilder()
185 ->deleteFrom( 'protected_titles' )
186 ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
187 ->caller( __METHOD__ )->execute();
188 $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
189 }
190
199 public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
200 $page->assertWiki( PageIdentity::LOCAL );
201
202 $restrictions = $this->getRestrictions( $page, $action );
203 $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
204 if ( !$restrictions || !$semi ) {
205 // Not protected, or all protection is full protection
206 return false;
207 }
208
209 // Remap autoconfirmed to editsemiprotected for BC
210 foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
211 $semi[$key] = 'autoconfirmed';
212 }
213 foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
214 $restrictions[$key] = 'autoconfirmed';
215 }
216
217 return !array_diff( $restrictions, $semi );
218 }
219
227 public function isProtected( PageIdentity $page, string $action = '' ): bool {
228 $page->assertWiki( PageIdentity::LOCAL );
229
230 // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
231 if ( $page->getNamespace() === NS_SPECIAL ) {
232 return true;
233 }
234
235 // Check regular protection levels
236 $applicableTypes = $this->listApplicableRestrictionTypes( $page );
237
238 if ( $action === '' ) {
239 foreach ( $applicableTypes as $type ) {
240 if ( $this->isProtected( $page, $type ) ) {
241 return true;
242 }
243 }
244 return false;
245 }
246
247 if ( !in_array( $action, $applicableTypes ) ) {
248 return false;
249 }
250
251 return (bool)array_diff(
252 array_intersect(
253 $this->getRestrictions( $page, $action ),
254 $this->options->get( MainConfigNames::RestrictionLevels )
255 ),
256 [ '' ]
257 );
258 }
259
266 public function isCascadeProtected( PageIdentity $page ): bool {
267 $page->assertWiki( PageIdentity::LOCAL );
268
269 return $this->getCascadeProtectionSourcesInternal( $page, true );
270 }
271
278 public function listApplicableRestrictionTypes( PageIdentity $page ): array {
279 $page->assertWiki( PageIdentity::LOCAL );
280
281 if ( !$page->canExist() ) {
282 return [];
283 }
284
285 $types = $this->listAllRestrictionTypes( $page->exists() );
286
287 if ( $page->getNamespace() !== NS_FILE ) {
288 // Remove the upload restriction for non-file titles
289 $types = array_values( array_diff( $types, [ 'upload' ] ) );
290 }
291
292 if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
293 $this->hookRunner->onTitleGetRestrictionTypes(
294 Title::newFromPageIdentity( $page ), $types );
295 }
296
297 return $types;
298 }
299
307 public function listAllRestrictionTypes( bool $exists = true ): array {
308 $types = $this->options->get( MainConfigNames::RestrictionTypes );
309 if ( $exists ) {
310 // Remove the create restriction for existing titles
311 return array_values( array_diff( $types, [ 'create' ] ) );
312 }
313
314 // Only the create restrictions apply to non-existing titles
315 return array_values( array_intersect( $types, [ 'create' ] ) );
316 }
317
326 public function loadRestrictions(
327 PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
328 ): void {
329 $page->assertWiki( PageIdentity::LOCAL );
330
331 if ( !$page->canExist() ) {
332 return;
333 }
334
335 $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
336
337 if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
338 return;
339 }
340
341 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
342
343 $cacheEntry['restrictions'] = [];
344
345 // XXX Work around https://phabricator.wikimedia.org/T287575
346 if ( $readLatest ) {
347 $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
348 }
349 $id = $page->getId();
350 if ( $id ) {
351 $fname = __METHOD__;
352 $loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
353 return iterator_to_array(
354 $dbr->newSelectQueryBuilder()
355 ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
356 ->from( 'page_restrictions' )
357 ->where( [ 'pr_page' => $id ] )
358 ->caller( $fname )->fetchResultSet()
359 );
360 };
361
362 if ( $readLatest ) {
363 $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
364 $rows = $loadRestrictionsFromDb( $dbr );
365 } else {
366 $this->pageStore->getPageForLink( TitleValue::newFromPage( $page ) )->getId();
367 $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
368 if ( !$latestRev ) {
369 // This method can get called in the middle of page creation
370 // (WikiPage::doUserEditContent) where a page might have an
371 // id but no revisions, while checking the "autopatrol" permission.
372 $rows = [];
373 } else {
374 $rows = $this->wanCache->getWithSetCallback(
375 // Page protections always leave a new null revision
376 $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
377 $this->wanCache::TTL_DAY,
378 function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
379 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
380 $setOpts += Database::getCacheSetOptions( $dbr );
381 if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
382 // TODO: cleanup Title cache and caller assumption mess in general
383 $ttl = WANObjectCache::TTL_UNCACHEABLE;
384 }
385
386 return $loadRestrictionsFromDb( $dbr );
387 }
388 );
389 }
390 }
391
392 $this->loadRestrictionsFromRows( $page, $rows );
393 } else {
394 $titleProtection = $this->getCreateProtectionInternal( $page );
395
396 if ( $titleProtection ) {
397 $now = wfTimestampNow();
398 $expiry = $titleProtection['expiry'];
399
400 if ( !$expiry || $expiry > $now ) {
401 // Apply the restrictions
402 $cacheEntry['expiry']['create'] = $expiry ?: null;
403 $cacheEntry['restrictions']['create'] =
404 explode( ',', trim( $titleProtection['permission'] ) );
405 } else {
406 // Get rid of the old restrictions
407 $cacheEntry['create_protection'] = null;
408 }
409 } else {
410 $cacheEntry['expiry']['create'] = 'infinity';
411 }
412 }
413 }
414
423 PageIdentity $page, array $rows
424 ): void {
425 $page->assertWiki( PageIdentity::LOCAL );
426
427 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
428
429 $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
430
431 foreach ( $restrictionTypes as $type ) {
432 $cacheEntry['restrictions'][$type] = [];
433 $cacheEntry['expiry'][$type] = 'infinity';
434 }
435
436 $cacheEntry['cascade'] = false;
437
438 if ( !$rows ) {
439 return;
440 }
441
442 // New restriction format -- load second to make them override old-style restrictions.
443 $now = wfTimestampNow();
444
445 // Cycle through all the restrictions.
446 foreach ( $rows as $row ) {
447 // Don't take care of restrictions types that aren't allowed
448 if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
449 continue;
450 }
451
452 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
453 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
454
455 // Only apply the restrictions if they haven't expired!
456 // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
457 // string consisting of 14 digits. Likewise for the ?: below.
458 if ( !$expiry || $expiry > $now ) {
459 $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
460 $cacheEntry['restrictions'][$row->pr_type]
461 = explode( ',', trim( $row->pr_level ) );
462 if ( $row->pr_cascade ) {
463 $cacheEntry['cascade'] = true;
464 }
465 }
466 }
467 }
468
479 private function getCreateProtectionInternal( PageIdentity $page ): ?array {
480 // Can't protect pages in special namespaces
481 if ( !$page->canExist() ) {
482 return null;
483 }
484
485 // Can't apply this type of protection to pages that exist.
486 if ( $page->exists() ) {
487 return null;
488 }
489
490 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
491
492 if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
493 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
494 $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
495 $row = $dbr->newSelectQueryBuilder()
496 ->select( [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] )
497 ->from( 'protected_titles' )
498 ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
499 ->queryInfo( $commentQuery )
500 ->caller( __METHOD__ )
501 ->fetchRow();
502
503 if ( $row ) {
504 $cacheEntry['create_protection'] = [
505 'user' => $row->pt_user,
506 'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
507 'permission' => $row->pt_create_perm,
508 'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
509 ];
510 } else {
511 $cacheEntry['create_protection'] = null;
512 }
513
514 }
515
516 return $cacheEntry['create_protection'];
517 }
518
527 public function getCascadeProtectionSources( PageIdentity $page ): array {
528 $page->assertWiki( PageIdentity::LOCAL );
529
530 return $this->getCascadeProtectionSourcesInternal( $page, false );
531 }
532
541 private function getCascadeProtectionSourcesInternal(
542 PageIdentity $page, bool $shortCircuit = false
543 ) {
544 if ( !$page->canExist() ) {
545 return $shortCircuit ? false : [ [], [] ];
546 }
547
548 $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
549
550 if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
551 return $cacheEntry['cascade_sources'];
552 } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
553 return $cacheEntry['has_cascading'];
554 }
555
556 $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
557 $queryBuilder = $dbr->newSelectQueryBuilder();
558 $queryBuilder->select( [ 'pr_expiry' ] )
559 ->from( 'page_restrictions' )
560 ->where( [ 'pr_cascade' => 1 ] );
561
562 if ( $page->getNamespace() === NS_FILE ) {
563 // Files transclusion may receive cascading protection in the future
564 // see https://phabricator.wikimedia.org/T241453
565 $queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
566 $queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
567 } else {
568 $queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
569 $queryBuilder->andWhere(
570 $this->linksMigration->getLinksConditions(
571 'templatelinks',
572 TitleValue::newFromPage( $page )
573 )
574 );
575 }
576
577 if ( !$shortCircuit ) {
578 $queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
579 $queryBuilder->join( 'page', null, 'page_id=pr_page' );
580 }
581
582 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
583
584 $sources = [];
585 $pageRestrictions = [];
586 $now = wfTimestampNow();
587
588 foreach ( $res as $row ) {
589 $expiry = $dbr->decodeExpiry( $row->pr_expiry );
590 if ( $expiry > $now ) {
591 if ( $shortCircuit ) {
592 $cacheEntry['has_cascading'] = true;
593 return true;
594 }
595
596 $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
597 $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
598 // Add groups needed for each restriction type if its not already there
599 // Make sure this restriction type still exists
600
601 if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
602 $pageRestrictions[$row->pr_type] = [];
603 }
604
605 if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
606 $pageRestrictions[$row->pr_type][] = $row->pr_level;
607 }
608 }
609 }
610
611 $cacheEntry['has_cascading'] = (bool)$sources;
612
613 if ( $shortCircuit ) {
614 return false;
615 }
616
617 $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
618 return [ $sources, $pageRestrictions ];
619 }
620
626 public function areRestrictionsLoaded( PageIdentity $page ): bool {
627 $page->assertWiki( PageIdentity::LOCAL );
628
629 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
630 }
631
638 public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
639 $page->assertWiki( PageIdentity::LOCAL );
640
641 return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
642 }
643
650 public function areRestrictionsCascading( PageIdentity $page ): bool {
651 $page->assertWiki( PageIdentity::LOCAL );
652
653 if ( !$this->areRestrictionsLoaded( $page ) ) {
654 $this->loadRestrictions( $page );
655 }
656 return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
657 }
658
666 public function flushRestrictions( PageIdentity $page ): void {
667 $page->assertWiki( PageIdentity::LOCAL );
668
669 unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
670 }
671
672}
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 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:78
Multi-datacenter aware caching interface.
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.
Interface for database access objects.
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