MediaWiki  master
RestrictionStore.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Permissions;
4 
5 use CommentStore;
8 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(
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 and upload restrictions apply to non-existing titles
338  return array_values( array_intersect( $types, [ 'create', 'upload' ] ) );
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->select(
378  'page_restrictions',
379  [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ],
380  [ 'pr_page' => $id ],
381  $fname
382  )
383  );
384  };
385 
386  if ( $readLatest ) {
387  $dbr = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
388  $rows = $loadRestrictionsFromDb( $dbr );
389  } else {
390  $this->linkCache->addLinkObj( $page );
391  $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
392  $rows = $this->wanCache->getWithSetCallback(
393  // Page protections always leave a new null revision
394  $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
395  $this->wanCache::TTL_DAY,
396  function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
397  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
398  $setOpts += Database::getCacheSetOptions( $dbr );
399  if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
400  // TODO: cleanup Title cache and caller assumption mess in general
401  $ttl = WANObjectCache::TTL_UNCACHEABLE;
402  }
403 
404  return $loadRestrictionsFromDb( $dbr );
405  }
406  );
407  }
408 
409  $this->loadRestrictionsFromRows( $page, $rows );
410  } else {
411  $titleProtection = $this->getCreateProtectionInternal( $page );
412 
413  if ( $titleProtection ) {
414  $now = wfTimestampNow();
415  $expiry = $titleProtection['expiry'];
416 
417  if ( !$expiry || $expiry > $now ) {
418  // Apply the restrictions
419  $cacheEntry['expiry']['create'] = $expiry ?: null;
420  $cacheEntry['restrictions']['create'] =
421  explode( ',', trim( $titleProtection['permission'] ) );
422  } else {
423  // Get rid of the old restrictions
424  $cacheEntry['create_protection'] = null;
425  }
426  } else {
427  $cacheEntry['expiry']['create'] = 'infinity';
428  }
429  }
430  }
431 
439  public function loadRestrictionsFromRows(
440  PageIdentity $page, array $rows
441  ): void {
442  $page->assertWiki( PageIdentity::LOCAL );
443 
444  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
445 
446  $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
447 
448  foreach ( $restrictionTypes as $type ) {
449  $cacheEntry['restrictions'][$type] = [];
450  $cacheEntry['expiry'][$type] = 'infinity';
451  }
452 
453  $cacheEntry['cascade'] = false;
454 
455  if ( !$rows ) {
456  return;
457  }
458 
459  // New restriction format -- load second to make them override old-style restrictions.
460  $now = wfTimestampNow();
461 
462  // Cycle through all the restrictions.
463  foreach ( $rows as $row ) {
464  // Don't take care of restrictions types that aren't allowed
465  if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
466  continue;
467  }
468 
469  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
470  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
471 
472  // Only apply the restrictions if they haven't expired!
473  // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
474  // string consisting of 14 digits. Likewise for the ?: below.
475  if ( !$expiry || $expiry > $now ) {
476  $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
477  $cacheEntry['restrictions'][$row->pr_type]
478  = explode( ',', trim( $row->pr_level ) );
479  if ( $row->pr_cascade ) {
480  $cacheEntry['cascade'] = true;
481  }
482  }
483  }
484  }
485 
496  private function getCreateProtectionInternal( PageIdentity $page ): ?array {
497  // Can't protect pages in special namespaces
498  if ( !$page->canExist() ) {
499  return null;
500  }
501 
502  // Can't apply this type of protection to pages that exist.
503  if ( $page->exists() ) {
504  return null;
505  }
506 
507  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
508 
509  if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
510  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
511  $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
512  $row = $dbr->selectRow(
513  [ 'protected_titles' ] + $commentQuery['tables'],
514  [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
515  [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
516  __METHOD__,
517  [],
518  $commentQuery['joins']
519  );
520 
521  if ( $row ) {
522  $cacheEntry['create_protection'] = [
523  'user' => $row->pt_user,
524  'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
525  'permission' => $row->pt_create_perm,
526  'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
527  ];
528  } else {
529  $cacheEntry['create_protection'] = null;
530  }
531 
532  }
533 
534  return $cacheEntry['create_protection'];
535  }
536 
547  public function getCascadeProtectionSources( PageIdentity $page ): array {
548  $page->assertWiki( PageIdentity::LOCAL );
549 
550  return $this->getCascadeProtectionSourcesInternal( $page, false );
551  }
552 
562  PageIdentity $page, bool $shortCircuit = false
563  ) {
564  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
565 
566  if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
567  return $cacheEntry['cascade_sources'];
568  } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
569  return $cacheEntry['has_cascading'];
570  }
571 
572  if ( $page->getNamespace() === NS_FILE ) {
573  // Files transclusion may receive cascading protection in the future
574  // see https://phabricator.wikimedia.org/T241453
575  $tables = [ 'imagelinks', 'page_restrictions' ];
576  $where_clauses = [
577  'il_to' => $page->getDBkey(),
578  'il_from=pr_page',
579  'pr_cascade' => 1
580  ];
581  } else {
582  $tables = [ 'templatelinks', 'page_restrictions' ];
583  $where_clauses = $this->linksMigration->getLinksConditions(
584  'templatelinks',
585  TitleValue::newFromPage( $page )
586  );
587  $where_clauses[] = 'tl_from=pr_page';
588  $where_clauses['pr_cascade'] = 1;
589  }
590 
591  if ( $shortCircuit ) {
592  $cols = [ 'pr_expiry' ];
593  } else {
594  $cols = [ 'pr_page', 'page_namespace', 'page_title',
595  'pr_expiry', 'pr_type', 'pr_level' ];
596  $where_clauses[] = 'page_id=pr_page';
597  $tables[] = 'page';
598  }
599 
600  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
601  $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ );
602 
603  $sources = [];
604  $pageRestrictions = [];
605  $now = wfTimestampNow();
606 
607  foreach ( $res as $row ) {
608  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
609  if ( $expiry > $now ) {
610  if ( $shortCircuit ) {
611  $cacheEntry['has_cascading'] = true;
612  return true;
613  }
614 
615  $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
616  $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
617  // Add groups needed for each restriction type if its not already there
618  // Make sure this restriction type still exists
619 
620  if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
621  $pageRestrictions[$row->pr_type] = [];
622  }
623 
624  if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
625  $pageRestrictions[$row->pr_type][] = $row->pr_level;
626  }
627  }
628  }
629 
630  $cacheEntry['has_cascading'] = (bool)$sources;
631 
632  if ( $shortCircuit ) {
633  return false;
634  }
635 
636  $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
637  return [ $sources, $pageRestrictions ];
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: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
Handle database storage of comments such as edit summaries and log reasons.
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.
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:562
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.
getCascadeProtectionSourcesInternal(PageIdentity $page, bool $shortCircuit=false)
Cascading protection: Get the source of any cascading restrictions on this page.
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.
array[] $cache
Caching various restrictions data in the following format: cache key => [ string[] restrictions => re...
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.
getCreateProtectionInternal(PageIdentity $page)
Fetch title protection settings.
string null $action
Cache what action this request is.
Definition: MediaWiki.php:47
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:49
static castFromPageIdentity(?PageIdentity $pageIdentity)
Return a Title for a given PageIdentity.
Definition: Title.php:319
Multi-datacenter aware caching interface.
Interface for database access objects.
Interface for objects (potentially) representing an editable wiki page.
exists()
Checks if the page currently exists.
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...
getDBkey()
Get the page title in DB key form.
getNamespace()
Returns the page's namespace number.
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:39
Database cluster connection, tracking, load balancing, and transaction manager interface.
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