MediaWiki  master
RestrictionStore.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Permissions;
4 
7 use LinkCache;
18 use stdClass;
19 use Title;
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 
156  public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
157  $page->assertWiki( PageIdentity::LOCAL );
158 
159  if ( !$this->areRestrictionsLoaded( $page ) ) {
160  $this->loadRestrictions( $page );
161  }
162  return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
163  }
164 
179  public function getCreateProtection( PageIdentity $page ): ?array {
180  $page->assertWiki( PageIdentity::LOCAL );
181 
182  $protection = $this->getCreateProtectionInternal( $page );
183  // TODO: the remapping below probably need to be migrated into other method one day
184  if ( $protection ) {
185  if ( $protection['permission'] == 'sysop' ) {
186  $protection['permission'] = 'editprotected'; // B/C
187  }
188  if ( $protection['permission'] == 'autoconfirmed' ) {
189  $protection['permission'] = 'editsemiprotected'; // B/C
190  }
191  }
192  return $protection;
193  }
194 
201  public function deleteCreateProtection( PageIdentity $page ): void {
202  $page->assertWiki( PageIdentity::LOCAL );
203 
204  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
205  $dbw->delete(
206  'protected_titles',
207  [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
208  __METHOD__
209  );
210  $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
211  }
212 
221  public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
222  $page->assertWiki( PageIdentity::LOCAL );
223 
224  $restrictions = $this->getRestrictions( $page, $action );
225  $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
226  if ( !$restrictions || !$semi ) {
227  // Not protected, or all protection is full protection
228  return false;
229  }
230 
231  // Remap autoconfirmed to editsemiprotected for BC
232  foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
233  $semi[$key] = 'autoconfirmed';
234  }
235  foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
236  $restrictions[$key] = 'autoconfirmed';
237  }
238 
239  return !array_diff( $restrictions, $semi );
240  }
241 
249  public function isProtected( PageIdentity $page, string $action = '' ): bool {
250  $page->assertWiki( PageIdentity::LOCAL );
251 
252  // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
253  if ( $page->getNamespace() === NS_SPECIAL ) {
254  return true;
255  }
256 
257  // Check regular protection levels
258  $applicableTypes = $this->listApplicableRestrictionTypes( $page );
259 
260  if ( $action === '' ) {
261  foreach ( $applicableTypes as $type ) {
262  if ( $this->isProtected( $page, $type ) ) {
263  return true;
264  }
265  }
266  return false;
267  }
268 
269  if ( !in_array( $action, $applicableTypes ) ) {
270  return false;
271  }
272 
273  return (bool)array_diff(
274  array_intersect(
275  $this->getRestrictions( $page, $action ),
276  $this->options->get( MainConfigNames::RestrictionLevels )
277  ),
278  [ '' ]
279  );
280  }
281 
288  public function isCascadeProtected( PageIdentity $page ): bool {
289  $page->assertWiki( PageIdentity::LOCAL );
290 
291  return $this->getCascadeProtectionSourcesInternal( $page, true );
292  }
293 
300  public function listApplicableRestrictionTypes( PageIdentity $page ): array {
301  $page->assertWiki( PageIdentity::LOCAL );
302 
303  if ( !$page->canExist() ) {
304  return [];
305  }
306 
307  $types = $this->listAllRestrictionTypes( $page->exists() );
308 
309  if ( $page->getNamespace() !== NS_FILE ) {
310  // Remove the upload restriction for non-file titles
311  $types = array_values( array_diff( $types, [ 'upload' ] ) );
312  }
313 
314  if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
315  $this->hookRunner->onTitleGetRestrictionTypes(
316  // @phan-suppress-next-line PhanTypeMismatchArgumentNullable castFrom does not return null here
317  Title::castFromPageIdentity( $page ), $types );
318  }
319 
320  return $types;
321  }
322 
330  public function listAllRestrictionTypes( bool $exists = true ): array {
331  $types = $this->options->get( MainConfigNames::RestrictionTypes );
332  if ( $exists ) {
333  // Remove the create restriction for existing titles
334  return array_values( array_diff( $types, [ 'create' ] ) );
335  }
336 
337  // Only the create restrictions apply to non-existing titles
338  return array_values( array_intersect( $types, [ 'create' ] ) );
339  }
340 
349  public function loadRestrictions(
350  PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
351  ): void {
352  $page->assertWiki( PageIdentity::LOCAL );
353 
354  if ( !$page->canExist() ) {
355  return;
356  }
357 
358  $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
359 
360  if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
361  return;
362  }
363 
364  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
365 
366  $cacheEntry['restrictions'] = [];
367 
368  // XXX Work around https://phabricator.wikimedia.org/T287575
369  if ( $readLatest ) {
370  $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
371  }
372  $id = $page->getId();
373  if ( $id ) {
374  $fname = __METHOD__;
375  $loadRestrictionsFromDb = static function ( IDatabase $dbr ) use ( $fname, $id ) {
376  return iterator_to_array(
377  $dbr->newSelectQueryBuilder()
378  ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
379  ->from( 'page_restrictions' )
380  ->where( [ 'pr_page' => $id ] )
381  ->caller( $fname )->fetchResultSet()
382  );
383  };
384 
385  if ( $readLatest ) {
386  $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
387  $rows = $loadRestrictionsFromDb( $dbr );
388  } else {
389  $this->linkCache->addLinkObj( $page );
390  $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
391  if ( !$latestRev ) {
392  // This method can get called in the middle of page creation
393  // (WikiPage::doUserEditContent) where a page might have an
394  // id but no revisions, while checking the "autopatrol" permission.
395  $rows = [];
396  } else {
397  $rows = $this->wanCache->getWithSetCallback(
398  // Page protections always leave a new null revision
399  $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
400  $this->wanCache::TTL_DAY,
401  function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
402  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
403  $setOpts += Database::getCacheSetOptions( $dbr );
404  if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
405  // TODO: cleanup Title cache and caller assumption mess in general
406  $ttl = WANObjectCache::TTL_UNCACHEABLE;
407  }
408 
409  return $loadRestrictionsFromDb( $dbr );
410  }
411  );
412  }
413  }
414 
415  $this->loadRestrictionsFromRows( $page, $rows );
416  } else {
417  $titleProtection = $this->getCreateProtectionInternal( $page );
418 
419  if ( $titleProtection ) {
420  $now = wfTimestampNow();
421  $expiry = $titleProtection['expiry'];
422 
423  if ( !$expiry || $expiry > $now ) {
424  // Apply the restrictions
425  $cacheEntry['expiry']['create'] = $expiry ?: null;
426  $cacheEntry['restrictions']['create'] =
427  explode( ',', trim( $titleProtection['permission'] ) );
428  } else {
429  // Get rid of the old restrictions
430  $cacheEntry['create_protection'] = null;
431  }
432  } else {
433  $cacheEntry['expiry']['create'] = 'infinity';
434  }
435  }
436  }
437 
445  public function loadRestrictionsFromRows(
446  PageIdentity $page, array $rows
447  ): void {
448  $page->assertWiki( PageIdentity::LOCAL );
449 
450  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
451 
452  $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
453 
454  foreach ( $restrictionTypes as $type ) {
455  $cacheEntry['restrictions'][$type] = [];
456  $cacheEntry['expiry'][$type] = 'infinity';
457  }
458 
459  $cacheEntry['cascade'] = false;
460 
461  if ( !$rows ) {
462  return;
463  }
464 
465  // New restriction format -- load second to make them override old-style restrictions.
466  $now = wfTimestampNow();
467 
468  // Cycle through all the restrictions.
469  foreach ( $rows as $row ) {
470  // Don't take care of restrictions types that aren't allowed
471  if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
472  continue;
473  }
474 
475  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
476  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
477 
478  // Only apply the restrictions if they haven't expired!
479  // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
480  // string consisting of 14 digits. Likewise for the ?: below.
481  if ( !$expiry || $expiry > $now ) {
482  $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
483  $cacheEntry['restrictions'][$row->pr_type]
484  = explode( ',', trim( $row->pr_level ) );
485  if ( $row->pr_cascade ) {
486  $cacheEntry['cascade'] = true;
487  }
488  }
489  }
490  }
491 
502  private function getCreateProtectionInternal( PageIdentity $page ): ?array {
503  // Can't protect pages in special namespaces
504  if ( !$page->canExist() ) {
505  return null;
506  }
507 
508  // Can't apply this type of protection to pages that exist.
509  if ( $page->exists() ) {
510  return null;
511  }
512 
513  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
514 
515  if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
516  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
517  $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
518  $row = $dbr->selectRow(
519  [ 'protected_titles' ] + $commentQuery['tables'],
520  [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
521  [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
522  __METHOD__,
523  [],
524  $commentQuery['joins']
525  );
526 
527  if ( $row ) {
528  $cacheEntry['create_protection'] = [
529  'user' => $row->pt_user,
530  'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
531  'permission' => $row->pt_create_perm,
532  'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
533  ];
534  } else {
535  $cacheEntry['create_protection'] = null;
536  }
537 
538  }
539 
540  return $cacheEntry['create_protection'];
541  }
542 
553  public function getCascadeProtectionSources( PageIdentity $page ): array {
554  $page->assertWiki( PageIdentity::LOCAL );
555 
556  return $this->getCascadeProtectionSourcesInternal( $page, false );
557  }
558 
567  private function getCascadeProtectionSourcesInternal(
568  PageIdentity $page, bool $shortCircuit = false
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'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
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:561
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 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
Represents a title within MediaWiki.
Definition: Title.php:52
static castFromPageIdentity(?PageIdentity $pageIdentity)
Return a Title for a given PageIdentity.
Definition: Title.php:322
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:40
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