MediaWiki  master
RestrictionStore.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Permissions;
4 
7 use LinkCache;
20 use stdClass;
21 use WANObjectCache;
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  $restrictions = $this->getAllRestrictions( $page );
128  return $restrictions[$action] ?? [];
129  }
130 
138  public function getAllRestrictions( PageIdentity $page ): array {
139  $page->assertWiki( PageIdentity::LOCAL );
140 
141  if ( !$this->areRestrictionsLoaded( $page ) ) {
142  $this->loadRestrictions( $page );
143  }
144  return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
145  }
146 
155  public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
156  $page->assertWiki( PageIdentity::LOCAL );
157 
158  if ( !$this->areRestrictionsLoaded( $page ) ) {
159  $this->loadRestrictions( $page );
160  }
161  return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
162  }
163 
178  public function getCreateProtection( PageIdentity $page ): ?array {
179  $page->assertWiki( PageIdentity::LOCAL );
180 
181  $protection = $this->getCreateProtectionInternal( $page );
182  // TODO: the remapping below probably need to be migrated into other method one day
183  if ( $protection ) {
184  if ( $protection['permission'] == 'sysop' ) {
185  $protection['permission'] = 'editprotected'; // B/C
186  }
187  if ( $protection['permission'] == 'autoconfirmed' ) {
188  $protection['permission'] = 'editsemiprotected'; // B/C
189  }
190  }
191  return $protection;
192  }
193 
200  public function deleteCreateProtection( PageIdentity $page ): void {
201  $page->assertWiki( PageIdentity::LOCAL );
202 
203  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
204  $dbw->newDeleteQueryBuilder()
205  ->deleteFrom( 'protected_titles' )
206  ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
207  ->caller( __METHOD__ )->execute();
208  $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
209  }
210 
219  public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
220  $page->assertWiki( PageIdentity::LOCAL );
221 
222  $restrictions = $this->getRestrictions( $page, $action );
223  $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
224  if ( !$restrictions || !$semi ) {
225  // Not protected, or all protection is full protection
226  return false;
227  }
228 
229  // Remap autoconfirmed to editsemiprotected for BC
230  foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
231  $semi[$key] = 'autoconfirmed';
232  }
233  foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
234  $restrictions[$key] = 'autoconfirmed';
235  }
236 
237  return !array_diff( $restrictions, $semi );
238  }
239 
247  public function isProtected( PageIdentity $page, string $action = '' ): bool {
248  $page->assertWiki( PageIdentity::LOCAL );
249 
250  // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
251  if ( $page->getNamespace() === NS_SPECIAL ) {
252  return true;
253  }
254 
255  // Check regular protection levels
256  $applicableTypes = $this->listApplicableRestrictionTypes( $page );
257 
258  if ( $action === '' ) {
259  foreach ( $applicableTypes as $type ) {
260  if ( $this->isProtected( $page, $type ) ) {
261  return true;
262  }
263  }
264  return false;
265  }
266 
267  if ( !in_array( $action, $applicableTypes ) ) {
268  return false;
269  }
270 
271  return (bool)array_diff(
272  array_intersect(
273  $this->getRestrictions( $page, $action ),
274  $this->options->get( MainConfigNames::RestrictionLevels )
275  ),
276  [ '' ]
277  );
278  }
279 
286  public function isCascadeProtected( PageIdentity $page ): bool {
287  $page->assertWiki( PageIdentity::LOCAL );
288 
289  return $this->getCascadeProtectionSourcesInternal( $page, true );
290  }
291 
298  public function listApplicableRestrictionTypes( PageIdentity $page ): array {
299  $page->assertWiki( PageIdentity::LOCAL );
300 
301  if ( !$page->canExist() ) {
302  return [];
303  }
304 
305  $types = $this->listAllRestrictionTypes( $page->exists() );
306 
307  if ( $page->getNamespace() !== NS_FILE ) {
308  // Remove the upload restriction for non-file titles
309  $types = array_values( array_diff( $types, [ 'upload' ] ) );
310  }
311 
312  if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
313  $this->hookRunner->onTitleGetRestrictionTypes(
314  Title::newFromPageIdentity( $page ), $types );
315  }
316 
317  return $types;
318  }
319 
327  public function listAllRestrictionTypes( bool $exists = true ): array {
328  $types = $this->options->get( MainConfigNames::RestrictionTypes );
329  if ( $exists ) {
330  // Remove the create restriction for existing titles
331  return array_values( array_diff( $types, [ 'create' ] ) );
332  }
333 
334  // Only the create restrictions apply to non-existing titles
335  return array_values( array_intersect( $types, [ 'create' ] ) );
336  }
337 
346  public function loadRestrictions(
347  PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
348  ): void {
349  $page->assertWiki( PageIdentity::LOCAL );
350 
351  if ( !$page->canExist() ) {
352  return;
353  }
354 
355  $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
356 
357  if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
358  return;
359  }
360 
361  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
362 
363  $cacheEntry['restrictions'] = [];
364 
365  // XXX Work around https://phabricator.wikimedia.org/T287575
366  if ( $readLatest ) {
367  $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
368  }
369  $id = $page->getId();
370  if ( $id ) {
371  $fname = __METHOD__;
372  $loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
373  return iterator_to_array(
374  $dbr->newSelectQueryBuilder()
375  ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
376  ->from( 'page_restrictions' )
377  ->where( [ 'pr_page' => $id ] )
378  ->caller( $fname )->fetchResultSet()
379  );
380  };
381 
382  if ( $readLatest ) {
383  $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
384  $rows = $loadRestrictionsFromDb( $dbr );
385  } else {
386  $this->linkCache->addLinkObj( $page );
387  $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
388  if ( !$latestRev ) {
389  // This method can get called in the middle of page creation
390  // (WikiPage::doUserEditContent) where a page might have an
391  // id but no revisions, while checking the "autopatrol" permission.
392  $rows = [];
393  } else {
394  $rows = $this->wanCache->getWithSetCallback(
395  // Page protections always leave a new null revision
396  $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
397  $this->wanCache::TTL_DAY,
398  function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
399  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
400  $setOpts += Database::getCacheSetOptions( $dbr );
401  if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
402  // TODO: cleanup Title cache and caller assumption mess in general
403  $ttl = WANObjectCache::TTL_UNCACHEABLE;
404  }
405 
406  return $loadRestrictionsFromDb( $dbr );
407  }
408  );
409  }
410  }
411 
412  $this->loadRestrictionsFromRows( $page, $rows );
413  } else {
414  $titleProtection = $this->getCreateProtectionInternal( $page );
415 
416  if ( $titleProtection ) {
417  $now = wfTimestampNow();
418  $expiry = $titleProtection['expiry'];
419 
420  if ( !$expiry || $expiry > $now ) {
421  // Apply the restrictions
422  $cacheEntry['expiry']['create'] = $expiry ?: null;
423  $cacheEntry['restrictions']['create'] =
424  explode( ',', trim( $titleProtection['permission'] ) );
425  } else {
426  // Get rid of the old restrictions
427  $cacheEntry['create_protection'] = null;
428  }
429  } else {
430  $cacheEntry['expiry']['create'] = 'infinity';
431  }
432  }
433  }
434 
442  public function loadRestrictionsFromRows(
443  PageIdentity $page, array $rows
444  ): void {
445  $page->assertWiki( PageIdentity::LOCAL );
446 
447  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
448 
449  $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
450 
451  foreach ( $restrictionTypes as $type ) {
452  $cacheEntry['restrictions'][$type] = [];
453  $cacheEntry['expiry'][$type] = 'infinity';
454  }
455 
456  $cacheEntry['cascade'] = false;
457 
458  if ( !$rows ) {
459  return;
460  }
461 
462  // New restriction format -- load second to make them override old-style restrictions.
463  $now = wfTimestampNow();
464 
465  // Cycle through all the restrictions.
466  foreach ( $rows as $row ) {
467  // Don't take care of restrictions types that aren't allowed
468  if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
469  continue;
470  }
471 
472  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
473  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
474 
475  // Only apply the restrictions if they haven't expired!
476  // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
477  // string consisting of 14 digits. Likewise for the ?: below.
478  if ( !$expiry || $expiry > $now ) {
479  $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
480  $cacheEntry['restrictions'][$row->pr_type]
481  = explode( ',', trim( $row->pr_level ) );
482  if ( $row->pr_cascade ) {
483  $cacheEntry['cascade'] = true;
484  }
485  }
486  }
487  }
488 
499  private function getCreateProtectionInternal( PageIdentity $page ): ?array {
500  // Can't protect pages in special namespaces
501  if ( !$page->canExist() ) {
502  return null;
503  }
504 
505  // Can't apply this type of protection to pages that exist.
506  if ( $page->exists() ) {
507  return null;
508  }
509 
510  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
511 
512  if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
513  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
514  $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
515  $row = $dbr->selectRow(
516  [ 'protected_titles' ] + $commentQuery['tables'],
517  [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
518  [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
519  __METHOD__,
520  [],
521  $commentQuery['joins']
522  );
523 
524  if ( $row ) {
525  $cacheEntry['create_protection'] = [
526  'user' => $row->pt_user,
527  'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
528  'permission' => $row->pt_create_perm,
529  'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
530  ];
531  } else {
532  $cacheEntry['create_protection'] = null;
533  }
534 
535  }
536 
537  return $cacheEntry['create_protection'];
538  }
539 
548  public function getCascadeProtectionSources( PageIdentity $page ): array {
549  $page->assertWiki( PageIdentity::LOCAL );
550 
551  return $this->getCascadeProtectionSourcesInternal( $page, false );
552  }
553 
562  private function getCascadeProtectionSourcesInternal(
563  PageIdentity $page, bool $shortCircuit = false
564  ) {
565  if ( !$page->canExist() ) {
566  return $shortCircuit ? false : [ [], [] ];
567  }
568 
569  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
570 
571  if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
572  return $cacheEntry['cascade_sources'];
573  } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
574  return $cacheEntry['has_cascading'];
575  }
576 
577  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
578  $queryBuilder = $dbr->newSelectQueryBuilder();
579  $queryBuilder->select( [ 'pr_expiry' ] )
580  ->from( 'page_restrictions' )
581  ->where( [ 'pr_cascade' => 1 ] );
582 
583  if ( $page->getNamespace() === NS_FILE ) {
584  // Files transclusion may receive cascading protection in the future
585  // see https://phabricator.wikimedia.org/T241453
586  $queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
587  $queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
588  } else {
589  $queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
590  $queryBuilder->andWhere(
591  $this->linksMigration->getLinksConditions(
592  'templatelinks',
593  TitleValue::newFromPage( $page )
594  )
595  );
596  }
597 
598  if ( !$shortCircuit ) {
599  $queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
600  $queryBuilder->join( 'page', null, 'page_id=pr_page' );
601  }
602 
603  $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
604 
605  $sources = [];
606  $pageRestrictions = [];
607  $now = wfTimestampNow();
608 
609  foreach ( $res as $row ) {
610  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
611  if ( $expiry > $now ) {
612  if ( $shortCircuit ) {
613  $cacheEntry['has_cascading'] = true;
614  return true;
615  }
616 
617  $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
618  $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
619  // Add groups needed for each restriction type if its not already there
620  // Make sure this restriction type still exists
621 
622  if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
623  $pageRestrictions[$row->pr_type] = [];
624  }
625 
626  if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
627  $pageRestrictions[$row->pr_type][] = $row->pr_level;
628  }
629  }
630  }
631 
632  $cacheEntry['has_cascading'] = (bool)$sources;
633 
634  if ( $shortCircuit ) {
635  return false;
636  }
637 
638  $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
639  return [ $sources, $pageRestrictions ];
640  }
641 
647  public function areRestrictionsLoaded( PageIdentity $page ): bool {
648  $page->assertWiki( PageIdentity::LOCAL );
649 
650  return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
651  }
652 
659  public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
660  $page->assertWiki( PageIdentity::LOCAL );
661 
662  return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
663  }
664 
671  public function areRestrictionsCascading( PageIdentity $page ): bool {
672  $page->assertWiki( PageIdentity::LOCAL );
673 
674  if ( !$this->areRestrictionsLoaded( $page ) ) {
675  $this->loadRestrictions( $page );
676  }
677  return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
678  }
679 
687  public function flushRestrictions( PageIdentity $page ): void {
688  $page->assertWiki( PageIdentity::LOCAL );
689 
690  unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
691  }
692 
693 }
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:88
Helper class for DAO classes.
static hasFlags( $bitfield, $flags)
Cache for article titles (prefixed DB keys) and ids linked from one source.
Definition: LinkCache.php:45
Helper class for mapping value objects representing basic entities to cache keys.
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...
Definition: HookRunner.php:568
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.
Definition: TitleValue.php:44
Represents a title within MediaWiki.
Definition: Title.php:76
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.
assertWiki( $wikiId)
Throws if $wikiId is not the same as this entity wiki.
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28