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