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