includes/Permissions/RestrictionStore.php
<?php
namespace MediaWiki\Permissions;
use DBAccessObjectUtils;
use IDBAccessObject;
use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Cache\LinkCache;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Linker\LinksMigration;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageStore;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use stdClass;
use WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\IReadableDatabase;
/**
* Class RestrictionStore
*
* @since 1.37
*/
class RestrictionStore {
/** @internal */
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::NamespaceProtection,
MainConfigNames::RestrictionLevels,
MainConfigNames::RestrictionTypes,
MainConfigNames::SemiprotectedRestrictionLevels,
];
/** @var ServiceOptions */
private $options;
/** @var WANObjectCache */
private $wanCache;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var LinkCache */
private $linkCache;
/** @var LinksMigration */
private $linksMigration;
/** @var CommentStore */
private $commentStore;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var PageStore */
private $pageStore;
/**
* @var array[] Caching various restrictions data in the following format:
* cache key => [
* string[] `restrictions` => restrictions loaded for pages
* ?string `expiry` => restrictions expiry data for pages
* ?array `create_protection` => value for getCreateProtection
* bool `cascade` => cascade restrictions on this page to included templates and images?
* array[] `cascade_sources` => the results of getCascadeProtectionSources
* bool `has_cascading` => Are cascading restrictions in effect on this page?
* ]
*/
private $cache = [];
/**
* @param ServiceOptions $options
* @param WANObjectCache $wanCache
* @param ILoadBalancer $loadBalancer
* @param LinkCache $linkCache
* @param LinksMigration $linksMigration
* @param CommentStore $commentStore
* @param HookContainer $hookContainer
* @param PageStore $pageStore
*/
public function __construct(
ServiceOptions $options,
WANObjectCache $wanCache,
ILoadBalancer $loadBalancer,
LinkCache $linkCache,
LinksMigration $linksMigration,
CommentStore $commentStore,
HookContainer $hookContainer,
PageStore $pageStore
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->wanCache = $wanCache;
$this->loadBalancer = $loadBalancer;
$this->linkCache = $linkCache;
$this->linksMigration = $linksMigration;
$this->commentStore = $commentStore;
$this->hookContainer = $hookContainer;
$this->hookRunner = new HookRunner( $hookContainer );
$this->pageStore = $pageStore;
}
/**
* Returns list of restrictions for specified page
*
* @param PageIdentity $page Must be local
* @param string $action Action that restrictions need to be checked for
* @return string[] Restriction levels needed to take the action. All levels are required. Note
* that restriction levels are normally user rights, but 'sysop' and 'autoconfirmed' are also
* allowed for backwards compatibility. These should be mapped to 'editprotected' and
* 'editsemiprotected' respectively. Returns an empty array if there are no restrictions set
* for this action (including for unrecognized actions).
*/
public function getRestrictions( PageIdentity $page, string $action ): array {
$page->assertWiki( PageIdentity::LOCAL );
// Optimization: Avoid repeatedly fetching page restrictions (from cache or DB)
// for repeated PermissionManager::userCan calls, if this action cannot be restricted
// in the first place. This is primarily to improve batch rendering on RecentChanges,
// where as of writing this will save 0.5s on a 8.0s response. (T341319)
$restrictionTypes = $this->listApplicableRestrictionTypes( $page );
if ( !in_array( $action, $restrictionTypes ) ) {
return [];
}
$restrictions = $this->getAllRestrictions( $page );
return $restrictions[$action] ?? [];
}
/**
* Returns the restricted actions and their restrictions for the specified page
*
* @param PageIdentity $page Must be local
* @return string[][] Keys are actions, values are arrays as returned by
* RestrictionStore::getRestrictions(). Empty if no restrictions are in place.
*/
public function getAllRestrictions( PageIdentity $page ): array {
$page->assertWiki( PageIdentity::LOCAL );
if ( !$this->areRestrictionsLoaded( $page ) ) {
$this->loadRestrictions( $page );
}
return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
}
/**
* Get the expiry time for the restriction against a given action
*
* @param PageIdentity $page Must be local
* @param string $action
* @return ?string 14-char timestamp, or 'infinity' if the page is protected forever or not
* protected at all, or null if the action is not recognized.
*/
public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
$page->assertWiki( PageIdentity::LOCAL );
if ( !$this->areRestrictionsLoaded( $page ) ) {
$this->loadRestrictions( $page );
}
return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
}
/**
* Is this title subject to protection against creation?
*
* @param PageIdentity $page Must be local
* @return ?array Null if no restrictions. Otherwise an array with the following keys:
* - user: user id
* - expiry: 14-digit timestamp or 'infinity'
* - permission: string (pt_create_perm)
* - reason: string
* @internal Only to be called by Title::getTitleProtection. When that is discontinued, this
* will be too, in favor of getRestrictions( $page, 'create' ). If someone wants to know who
* protected it or the reason, there should be a method that exposes that for all restriction
* types.
*/
public function getCreateProtection( PageIdentity $page ): ?array {
$page->assertWiki( PageIdentity::LOCAL );
$protection = $this->getCreateProtectionInternal( $page );
// TODO: the remapping below probably need to be migrated into other method one day
if ( $protection ) {
if ( $protection['permission'] == 'sysop' ) {
$protection['permission'] = 'editprotected'; // B/C
}
if ( $protection['permission'] == 'autoconfirmed' ) {
$protection['permission'] = 'editsemiprotected'; // B/C
}
}
return $protection;
}
/**
* Remove any title creation protection due to page existing
*
* @param PageIdentity $page Must be local
* @internal Only to be called by WikiPage::onArticleCreate.
*/
public function deleteCreateProtection( PageIdentity $page ): void {
$page->assertWiki( PageIdentity::LOCAL );
$dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'protected_titles' )
->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
->caller( __METHOD__ )->execute();
$this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
}
/**
* Is this page "semi-protected" - the *only* protection levels are listed in
* $wgSemiprotectedRestrictionLevels?
*
* @param PageIdentity $page Must be local
* @param string $action Action to check (default: edit)
* @return bool
*/
public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
$page->assertWiki( PageIdentity::LOCAL );
$restrictions = $this->getRestrictions( $page, $action );
$semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
if ( !$restrictions || !$semi ) {
// Not protected, or all protection is full protection
return false;
}
// Remap autoconfirmed to editsemiprotected for BC
foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
$semi[$key] = 'autoconfirmed';
}
foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
$restrictions[$key] = 'autoconfirmed';
}
return !array_diff( $restrictions, $semi );
}
/**
* Does the title correspond to a protected article?
*
* @param PageIdentity $page Must be local
* @param string $action The action the page is protected from, by default checks all actions.
* @return bool
*/
public function isProtected( PageIdentity $page, string $action = '' ): bool {
$page->assertWiki( PageIdentity::LOCAL );
// Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
if ( $page->getNamespace() === NS_SPECIAL ) {
return true;
}
// Check regular protection levels
$applicableTypes = $this->listApplicableRestrictionTypes( $page );
if ( $action === '' ) {
foreach ( $applicableTypes as $type ) {
if ( $this->isProtected( $page, $type ) ) {
return true;
}
}
return false;
}
if ( !in_array( $action, $applicableTypes ) ) {
return false;
}
return (bool)array_diff(
array_intersect(
$this->getRestrictions( $page, $action ),
$this->options->get( MainConfigNames::RestrictionLevels )
),
[ '' ]
);
}
/**
* Cascading protection: Return true if cascading restrictions apply to this page, false if not.
*
* @param PageIdentity $page Must be local
* @return bool If the page is subject to cascading restrictions.
*/
public function isCascadeProtected( PageIdentity $page ): bool {
$page->assertWiki( PageIdentity::LOCAL );
return $this->getCascadeProtectionSourcesInternal( $page, true );
}
/**
* Returns restriction types for the current page
*
* @param PageIdentity $page Must be local
* @return string[] Applicable restriction types
*/
public function listApplicableRestrictionTypes( PageIdentity $page ): array {
$page->assertWiki( PageIdentity::LOCAL );
if ( !$page->canExist() ) {
return [];
}
$types = $this->listAllRestrictionTypes( $page->exists() );
if ( $page->getNamespace() !== NS_FILE ) {
// Remove the upload restriction for non-file titles
$types = array_values( array_diff( $types, [ 'upload' ] ) );
}
if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
$this->hookRunner->onTitleGetRestrictionTypes(
Title::newFromPageIdentity( $page ), $types );
}
return $types;
}
/**
* Get a filtered list of all restriction types supported by this wiki.
*
* @param bool $exists True to get all restriction types that apply to titles that do exist,
* false for all restriction types that apply to titles that do not exist
* @return string[]
*/
public function listAllRestrictionTypes( bool $exists = true ): array {
$types = $this->options->get( MainConfigNames::RestrictionTypes );
if ( $exists ) {
// Remove the create restriction for existing titles
return array_values( array_diff( $types, [ 'create' ] ) );
}
// Only the create restrictions apply to non-existing titles
return array_values( array_intersect( $types, [ 'create' ] ) );
}
/**
* Load restrictions from page.page_restrictions and the page_restrictions table
*
* @param PageIdentity $page Must be local
* @param int $flags IDBAccessObject::READ_XXX constants (e.g., READ_LATEST to read from
* primary DB)
* @internal Public for use in WikiPage only
*/
public function loadRestrictions(
PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
): void {
$page->assertWiki( PageIdentity::LOCAL );
if ( !$page->canExist() ) {
return;
}
$readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
return;
}
$cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
$cacheEntry['restrictions'] = [];
// XXX Work around https://phabricator.wikimedia.org/T287575
if ( $readLatest ) {
$page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
}
$id = $page->getId();
if ( $id ) {
$fname = __METHOD__;
$loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
return iterator_to_array(
$dbr->newSelectQueryBuilder()
->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
->from( 'page_restrictions' )
->where( [ 'pr_page' => $id ] )
->caller( $fname )->fetchResultSet()
);
};
if ( $readLatest ) {
$dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
$rows = $loadRestrictionsFromDb( $dbr );
} else {
$this->linkCache->addLinkObj( $page );
$latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
if ( !$latestRev ) {
// This method can get called in the middle of page creation
// (WikiPage::doUserEditContent) where a page might have an
// id but no revisions, while checking the "autopatrol" permission.
$rows = [];
} else {
$rows = $this->wanCache->getWithSetCallback(
// Page protections always leave a new null revision
$this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
$this->wanCache::TTL_DAY,
function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
$setOpts += Database::getCacheSetOptions( $dbr );
if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
// TODO: cleanup Title cache and caller assumption mess in general
$ttl = WANObjectCache::TTL_UNCACHEABLE;
}
return $loadRestrictionsFromDb( $dbr );
}
);
}
}
$this->loadRestrictionsFromRows( $page, $rows );
} else {
$titleProtection = $this->getCreateProtectionInternal( $page );
if ( $titleProtection ) {
$now = wfTimestampNow();
$expiry = $titleProtection['expiry'];
if ( !$expiry || $expiry > $now ) {
// Apply the restrictions
$cacheEntry['expiry']['create'] = $expiry ?: null;
$cacheEntry['restrictions']['create'] =
explode( ',', trim( $titleProtection['permission'] ) );
} else {
// Get rid of the old restrictions
$cacheEntry['create_protection'] = null;
}
} else {
$cacheEntry['expiry']['create'] = 'infinity';
}
}
}
/**
* Compiles list of active page restrictions for this existing page.
* Public for usage by LiquidThreads.
*
* @param PageIdentity $page Must be local
* @param stdClass[] $rows Array of db result objects
*/
public function loadRestrictionsFromRows(
PageIdentity $page, array $rows
): void {
$page->assertWiki( PageIdentity::LOCAL );
$cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
$restrictionTypes = $this->listApplicableRestrictionTypes( $page );
foreach ( $restrictionTypes as $type ) {
$cacheEntry['restrictions'][$type] = [];
$cacheEntry['expiry'][$type] = 'infinity';
}
$cacheEntry['cascade'] = false;
if ( !$rows ) {
return;
}
// New restriction format -- load second to make them override old-style restrictions.
$now = wfTimestampNow();
// Cycle through all the restrictions.
foreach ( $rows as $row ) {
// Don't take care of restrictions types that aren't allowed
if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
continue;
}
$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
$expiry = $dbr->decodeExpiry( $row->pr_expiry );
// Only apply the restrictions if they haven't expired!
// XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
// string consisting of 14 digits. Likewise for the ?: below.
if ( !$expiry || $expiry > $now ) {
$cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
$cacheEntry['restrictions'][$row->pr_type]
= explode( ',', trim( $row->pr_level ) );
if ( $row->pr_cascade ) {
$cacheEntry['cascade'] = true;
}
}
}
}
/**
* Fetch title protection settings
*
* To work correctly, $this->loadRestrictions() needs to have access to the actual protections
* in the database without munging 'sysop' => 'editprotected' and 'autoconfirmed' =>
* 'editsemiprotected'.
*
* @param PageIdentity $page Must be local
* @return ?array Same format as getCreateProtection().
*/
private function getCreateProtectionInternal( PageIdentity $page ): ?array {
// Can't protect pages in special namespaces
if ( !$page->canExist() ) {
return null;
}
// Can't apply this type of protection to pages that exist.
if ( $page->exists() ) {
return null;
}
$cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
$commentQuery = $this->commentStore->getJoin( 'pt_reason' );
$row = $dbr->newSelectQueryBuilder()
->select( [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] )
->from( 'protected_titles' )
->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
->queryInfo( $commentQuery )
->caller( __METHOD__ )
->fetchRow();
if ( $row ) {
$cacheEntry['create_protection'] = [
'user' => $row->pt_user,
'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
'permission' => $row->pt_create_perm,
'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
];
} else {
$cacheEntry['create_protection'] = null;
}
}
return $cacheEntry['create_protection'];
}
/**
* Cascading protection: Get the source of any cascading restrictions on this page.
*
* @param PageIdentity $page Must be local
* @return array[] Two elements: First is an array of PageIdentity objects of the pages from
* which cascading restrictions have come, which may be empty. Second is an array like that
* returned by getAllRestrictions().
*/
public function getCascadeProtectionSources( PageIdentity $page ): array {
$page->assertWiki( PageIdentity::LOCAL );
return $this->getCascadeProtectionSourcesInternal( $page, false );
}
/**
* Cascading protection: Get the source of any cascading restrictions on this page.
*
* @param PageIdentity $page Must be local
* @param bool $shortCircuit If true, just return true or false instead of the actual lists.
* @return array|bool If $shortCircuit is true, return true if there is some cascading
* protection and false otherwise. Otherwise, same as getCascadeProtectionSources().
*/
private function getCascadeProtectionSourcesInternal(
PageIdentity $page, bool $shortCircuit = false
) {
if ( !$page->canExist() ) {
return $shortCircuit ? false : [ [], [] ];
}
$cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
return $cacheEntry['cascade_sources'];
} elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
return $cacheEntry['has_cascading'];
}
$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
$queryBuilder = $dbr->newSelectQueryBuilder();
$queryBuilder->select( [ 'pr_expiry' ] )
->from( 'page_restrictions' )
->where( [ 'pr_cascade' => 1 ] );
if ( $page->getNamespace() === NS_FILE ) {
// Files transclusion may receive cascading protection in the future
// see https://phabricator.wikimedia.org/T241453
$queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
$queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
} else {
$queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
$queryBuilder->andWhere(
$this->linksMigration->getLinksConditions(
'templatelinks',
TitleValue::newFromPage( $page )
)
);
}
if ( !$shortCircuit ) {
$queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
$queryBuilder->join( 'page', null, 'page_id=pr_page' );
}
$res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
$sources = [];
$pageRestrictions = [];
$now = wfTimestampNow();
foreach ( $res as $row ) {
$expiry = $dbr->decodeExpiry( $row->pr_expiry );
if ( $expiry > $now ) {
if ( $shortCircuit ) {
$cacheEntry['has_cascading'] = true;
return true;
}
$sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
$row->page_namespace, $row->page_title, PageIdentity::LOCAL );
// Add groups needed for each restriction type if its not already there
// Make sure this restriction type still exists
if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
$pageRestrictions[$row->pr_type] = [];
}
if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
$pageRestrictions[$row->pr_type][] = $row->pr_level;
}
}
}
$cacheEntry['has_cascading'] = (bool)$sources;
if ( $shortCircuit ) {
return false;
}
$cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
return [ $sources, $pageRestrictions ];
}
/**
* @param PageIdentity $page Must be local
* @return bool Whether or not the page's restrictions have already been loaded from the
* database
*/
public function areRestrictionsLoaded( PageIdentity $page ): bool {
$page->assertWiki( PageIdentity::LOCAL );
return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
}
/**
* Determines whether cascading protection sources have already been loaded from the database.
*
* @param PageIdentity $page Must be local
* @return bool
*/
public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
$page->assertWiki( PageIdentity::LOCAL );
return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
}
/**
* Checks if restrictions are cascading for the current page
*
* @param PageIdentity $page Must be local
* @return bool
*/
public function areRestrictionsCascading( PageIdentity $page ): bool {
$page->assertWiki( PageIdentity::LOCAL );
if ( !$this->areRestrictionsLoaded( $page ) ) {
$this->loadRestrictions( $page );
}
return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
}
/**
* Flush the protection cache in this object and force reload from the database. This is used
* when updating protection from WikiPage::doUpdateRestrictions().
*
* @param PageIdentity $page Must be local
* @internal
*/
public function flushRestrictions( PageIdentity $page ): void {
$page->assertWiki( PageIdentity::LOCAL );
unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
}
}