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(
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->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  if ( !$latestRev ) {
393  // This method can get called in the middle of page creation
394  // (WikiPage::doUserEditContent) where a page might have an
395  // id but no revisions, while checking the "autopatrol" permission.
396  $rows = [];
397  } else {
398  $rows = $this->wanCache->getWithSetCallback(
399  // Page protections always leave a new null revision
400  $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
401  $this->wanCache::TTL_DAY,
402  function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
403  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
404  $setOpts += Database::getCacheSetOptions( $dbr );
405  if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
406  // TODO: cleanup Title cache and caller assumption mess in general
407  $ttl = WANObjectCache::TTL_UNCACHEABLE;
408  }
409 
410  return $loadRestrictionsFromDb( $dbr );
411  }
412  );
413  }
414  }
415 
416  $this->loadRestrictionsFromRows( $page, $rows );
417  } else {
418  $titleProtection = $this->getCreateProtectionInternal( $page );
419 
420  if ( $titleProtection ) {
421  $now = wfTimestampNow();
422  $expiry = $titleProtection['expiry'];
423 
424  if ( !$expiry || $expiry > $now ) {
425  // Apply the restrictions
426  $cacheEntry['expiry']['create'] = $expiry ?: null;
427  $cacheEntry['restrictions']['create'] =
428  explode( ',', trim( $titleProtection['permission'] ) );
429  } else {
430  // Get rid of the old restrictions
431  $cacheEntry['create_protection'] = null;
432  }
433  } else {
434  $cacheEntry['expiry']['create'] = 'infinity';
435  }
436  }
437  }
438 
446  public function loadRestrictionsFromRows(
447  PageIdentity $page, array $rows
448  ): void {
449  $page->assertWiki( PageIdentity::LOCAL );
450 
451  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
452 
453  $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
454 
455  foreach ( $restrictionTypes as $type ) {
456  $cacheEntry['restrictions'][$type] = [];
457  $cacheEntry['expiry'][$type] = 'infinity';
458  }
459 
460  $cacheEntry['cascade'] = false;
461 
462  if ( !$rows ) {
463  return;
464  }
465 
466  // New restriction format -- load second to make them override old-style restrictions.
467  $now = wfTimestampNow();
468 
469  // Cycle through all the restrictions.
470  foreach ( $rows as $row ) {
471  // Don't take care of restrictions types that aren't allowed
472  if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
473  continue;
474  }
475 
476  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
477  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
478 
479  // Only apply the restrictions if they haven't expired!
480  // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
481  // string consisting of 14 digits. Likewise for the ?: below.
482  if ( !$expiry || $expiry > $now ) {
483  $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
484  $cacheEntry['restrictions'][$row->pr_type]
485  = explode( ',', trim( $row->pr_level ) );
486  if ( $row->pr_cascade ) {
487  $cacheEntry['cascade'] = true;
488  }
489  }
490  }
491  }
492 
503  private function getCreateProtectionInternal( PageIdentity $page ): ?array {
504  // Can't protect pages in special namespaces
505  if ( !$page->canExist() ) {
506  return null;
507  }
508 
509  // Can't apply this type of protection to pages that exist.
510  if ( $page->exists() ) {
511  return null;
512  }
513 
514  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
515 
516  if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
517  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
518  $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
519  $row = $dbr->selectRow(
520  [ 'protected_titles' ] + $commentQuery['tables'],
521  [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
522  [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
523  __METHOD__,
524  [],
525  $commentQuery['joins']
526  );
527 
528  if ( $row ) {
529  $cacheEntry['create_protection'] = [
530  'user' => $row->pt_user,
531  'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
532  'permission' => $row->pt_create_perm,
533  'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
534  ];
535  } else {
536  $cacheEntry['create_protection'] = null;
537  }
538 
539  }
540 
541  return $cacheEntry['create_protection'];
542  }
543 
554  public function getCascadeProtectionSources( PageIdentity $page ): array {
555  $page->assertWiki( PageIdentity::LOCAL );
556 
557  return $this->getCascadeProtectionSourcesInternal( $page, false );
558  }
559 
568  private function getCascadeProtectionSourcesInternal(
569  PageIdentity $page, bool $shortCircuit = false
570  ) {
571  $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
572 
573  if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
574  return $cacheEntry['cascade_sources'];
575  } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
576  return $cacheEntry['has_cascading'];
577  }
578 
579  if ( $page->getNamespace() === NS_FILE ) {
580  // Files transclusion may receive cascading protection in the future
581  // see https://phabricator.wikimedia.org/T241453
582  $tables = [ 'imagelinks', 'page_restrictions' ];
583  $where_clauses = [
584  'il_to' => $page->getDBkey(),
585  'il_from=pr_page',
586  'pr_cascade' => 1
587  ];
588  } else {
589  $tables = [ 'templatelinks', 'page_restrictions' ];
590  $where_clauses = $this->linksMigration->getLinksConditions(
591  'templatelinks',
592  TitleValue::newFromPage( $page )
593  );
594  $where_clauses[] = 'tl_from=pr_page';
595  $where_clauses['pr_cascade'] = 1;
596  }
597 
598  if ( $shortCircuit ) {
599  $cols = [ 'pr_expiry' ];
600  } else {
601  $cols = [ 'pr_page', 'page_namespace', 'page_title',
602  'pr_expiry', 'pr_type', 'pr_level' ];
603  $where_clauses[] = 'page_id=pr_page';
604  $tables[] = 'page';
605  }
606 
607  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
608  $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ );
609 
610  $sources = [];
611  $pageRestrictions = [];
612  $now = wfTimestampNow();
613 
614  foreach ( $res as $row ) {
615  $expiry = $dbr->decodeExpiry( $row->pr_expiry );
616  if ( $expiry > $now ) {
617  if ( $shortCircuit ) {
618  $cacheEntry['has_cascading'] = true;
619  return true;
620  }
621 
622  $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
623  $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
624  // Add groups needed for each restriction type if its not already there
625  // Make sure this restriction type still exists
626 
627  if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
628  $pageRestrictions[$row->pr_type] = [];
629  }
630 
631  if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
632  $pageRestrictions[$row->pr_type][] = $row->pr_level;
633  }
634  }
635  }
636 
637  $cacheEntry['has_cascading'] = (bool)$sources;
638 
639  if ( $shortCircuit ) {
640  return false;
641  }
642 
643  $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
644  return [ $sources, $pageRestrictions ];
645  }
646 
652  public function areRestrictionsLoaded( PageIdentity $page ): bool {
653  $page->assertWiki( PageIdentity::LOCAL );
654 
655  return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
656  }
657 
664  public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
665  $page->assertWiki( PageIdentity::LOCAL );
666 
667  return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
668  }
669 
676  public function areRestrictionsCascading( PageIdentity $page ): bool {
677  $page->assertWiki( PageIdentity::LOCAL );
678 
679  if ( !$this->areRestrictionsLoaded( $page ) ) {
680  $this->loadRestrictions( $page );
681  }
682  return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
683  }
684 
692  public function flushRestrictions( PageIdentity $page ): void {
693  $page->assertWiki( PageIdentity::LOCAL );
694 
695  unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
696  }
697 
698 }
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:564
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: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
Create and track the database connections and transactions for a given database cluster.
$cache
Definition: mcc.php:33
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