Add a DatabaseBlockStore service

The new service replaces the following public DatabaseBlock methods:
* ::delete
* ::insert
* ::update
* ::purgeExpired

Bug: T221075
Change-Id: I5f95db14b1189fd62d2c2bd5dae2cab0a368f401
This commit is contained in:
DannyS712 2020-05-08 06:29:23 +00:00 committed by Thalia
parent 3d64bdacf5
commit 23c3c70d7f
5 changed files with 1073 additions and 286 deletions

View file

@ -30,6 +30,7 @@ use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Block\BlockManager;
use MediaWiki\Block\BlockPermissionCheckerFactory;
use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Config\ConfigRepository;
use MediaWiki\Content\IContentHandlerFactory;
@ -655,6 +656,14 @@ class MediaWikiServices extends ServiceContainer {
return $this->getService( 'CryptHKDF' );
}
/**
* @since 1.36
* @return DatabaseBlockStore
*/
public function getDatabaseBlockStore() : DatabaseBlockStore {
return $this->getService( 'DatabaseBlockStore' );
}
/**
* @since 1.33
* @return DateFormatterFactory

View file

@ -51,6 +51,7 @@ use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Block\BlockManager;
use MediaWiki\Block\BlockPermissionCheckerFactory;
use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Config\ConfigRepository;
use MediaWiki\Config\ServiceOptions;
@ -273,6 +274,22 @@ return [
return new CryptHKDF( $secret, $config->get( 'HKDFAlgorithm' ), $cache, $context );
},
'DatabaseBlockStore' => function ( MediaWikiServices $services ) : DatabaseBlockStore {
return new DatabaseBlockStore(
new ServiceOptions(
DatabaseBlockStore::CONSTRUCTOR_OPTIONS,
$services->getMainConfig()
),
LoggerFactory::getInstance( 'DatabaseBlockStore' ),
$services->getActorMigration(),
$services->getBlockRestrictionStore(),
$services->getCommentStore(),
$services->getHookContainer(),
$services->getDBLoadBalancer(),
$services->getReadOnlyMode()
);
},
'DateFormatterFactory' => function ( MediaWikiServices $services ) : DateFormatterFactory {
return new DateFormatterFactory();
},

View file

@ -23,9 +23,7 @@
namespace MediaWiki\Block;
use ActorMigration;
use AutoCommitUpdate;
use CommentStore;
use DeferredUpdates;
use Hooks;
use Html;
use MediaWiki\Block\Restriction\NamespaceRestriction;
@ -487,25 +485,9 @@ class DatabaseBlock extends AbstractBlock {
* @return bool
*/
public function delete() {
if ( wfReadOnly() ) {
return false;
}
if ( !$this->getId() ) {
throw new MWException(
__METHOD__ . " requires that the mId member be filled\n"
);
}
$dbw = wfGetDB( DB_MASTER );
$this->getBlockRestrictionStore()->deleteByParentBlockId( $this->getId() );
$dbw->delete( 'ipblocks', [ 'ipb_parent_block_id' => $this->getId() ], __METHOD__ );
$this->getBlockRestrictionStore()->deleteByBlockId( $this->getId() );
$dbw->delete( 'ipblocks', [ 'ipb_id' => $this->getId() ], __METHOD__ );
return $dbw->affectedRows() > 0;
return MediaWikiServices::getInstance()
->getDatabaseBlockStore()
->deleteBlock( $this );
}
/**
@ -517,70 +499,9 @@ class DatabaseBlock extends AbstractBlock {
* ('id' => block ID, 'autoIds' => array of autoblock IDs)
*/
public function insert( IDatabase $dbw = null ) {
global $wgBlockDisablesLogin;
if ( !$this->getBlocker() || $this->getBlocker()->getName() === '' ) {
throw new MWException( 'Cannot insert a block without a blocker set' );
}
wfDebug( __METHOD__ . "; timestamp {$this->mTimestamp}" );
if ( $dbw === null ) {
$dbw = wfGetDB( DB_MASTER );
}
self::purgeExpired();
$row = $this->getDatabaseArray( $dbw );
$dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
if ( $affected ) {
$this->setId( $dbw->insertId() );
if ( $this->restrictions ) {
$this->getBlockRestrictionStore()->insert( $this->restrictions );
}
}
# Don't collide with expired blocks.
# Do this after trying to insert to avoid locking.
if ( !$affected ) {
# T96428: The ipb_address index uses a prefix on a field, so
# use a standard SELECT + DELETE to avoid annoying gap locks.
$ids = $dbw->selectFieldValues( 'ipblocks',
'ipb_id',
[
'ipb_address' => $row['ipb_address'],
'ipb_user' => $row['ipb_user'],
'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() )
],
__METHOD__
);
if ( $ids ) {
$dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], __METHOD__ );
$this->getBlockRestrictionStore()->deleteByBlockId( $ids );
$dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
$this->setId( $dbw->insertId() );
if ( $this->restrictions ) {
$this->getBlockRestrictionStore()->insert( $this->restrictions );
}
}
}
if ( $affected ) {
$auto_ipd_ids = $this->doRetroactiveAutoblock();
if ( $wgBlockDisablesLogin && $this->target instanceof User ) {
// Change user login token to force them to be logged out.
$this->target->setToken();
$this->target->saveSettings();
}
return [ 'id' => $this->mId, 'autoIds' => $auto_ipd_ids ];
}
return false;
return MediaWikiServices::getInstance()
->getDatabaseBlockStore()
->insertBlock( $this, $dbw );
}
/**
@ -591,185 +512,9 @@ class DatabaseBlock extends AbstractBlock {
* ('id' => block ID, 'autoIds' => array of autoblock IDs)
*/
public function update() {
wfDebug( __METHOD__ . "; timestamp {$this->mTimestamp}" );
$dbw = wfGetDB( DB_MASTER );
$dbw->startAtomic( __METHOD__ );
$result = $dbw->update(
'ipblocks',
$this->getDatabaseArray( $dbw ),
[ 'ipb_id' => $this->getId() ],
__METHOD__
);
// Only update the restrictions if they have been modified.
if ( $this->restrictions !== null ) {
// An empty array should remove all of the restrictions.
if ( empty( $this->restrictions ) ) {
$success = $this->getBlockRestrictionStore()->deleteByBlockId( $this->getId() );
} else {
$success = $this->getBlockRestrictionStore()->update( $this->restrictions );
}
// Update the result. The first false is the result, otherwise, true.
$result = $result && $success;
}
if ( $this->isAutoblocking() ) {
// update corresponding autoblock(s) (T50813)
$dbw->update(
'ipblocks',
$this->getAutoblockUpdateArray( $dbw ),
[ 'ipb_parent_block_id' => $this->getId() ],
__METHOD__
);
// Only update the restrictions if they have been modified.
if ( $this->restrictions !== null ) {
$this->getBlockRestrictionStore()->updateByParentBlockId( $this->getId(), $this->restrictions );
}
} else {
// autoblock no longer required, delete corresponding autoblock(s)
$this->getBlockRestrictionStore()->deleteByParentBlockId( $this->getId() );
$dbw->delete(
'ipblocks',
[ 'ipb_parent_block_id' => $this->getId() ],
__METHOD__
);
}
$dbw->endAtomic( __METHOD__ );
if ( $result ) {
$auto_ipd_ids = $this->doRetroactiveAutoblock();
return [ 'id' => $this->mId, 'autoIds' => $auto_ipd_ids ];
}
return $result;
}
/**
* Get an array suitable for passing to $dbw->insert() or $dbw->update()
* @param IDatabase $dbw
* @return array
*/
protected function getDatabaseArray( IDatabase $dbw ) {
$expiry = $dbw->encodeExpiry( $this->getExpiry() );
if ( $this->forcedTargetID ) {
$uid = $this->forcedTargetID;
} else {
$uid = $this->target instanceof User ? $this->target->getId() : 0;
}
$a = [
'ipb_address' => (string)$this->target,
'ipb_user' => $uid,
'ipb_timestamp' => $dbw->timestamp( $this->getTimestamp() ),
'ipb_auto' => $this->mAuto,
'ipb_anon_only' => !$this->isHardblock(),
'ipb_create_account' => $this->isCreateAccountBlocked(),
'ipb_enable_autoblock' => $this->isAutoblocking(),
'ipb_expiry' => $expiry,
'ipb_range_start' => $this->getRangeStart(),
'ipb_range_end' => $this->getRangeEnd(),
'ipb_deleted' => intval( $this->getHideName() ), // typecast required for SQLite
'ipb_block_email' => $this->isEmailBlocked(),
'ipb_allow_usertalk' => $this->isUsertalkEditAllowed(),
'ipb_parent_block_id' => $this->mParentBlockId,
'ipb_sitewide' => $this->isSitewide(),
] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->getReasonComment() )
+ ActorMigration::newMigration()->getInsertValues( $dbw, 'ipb_by', $this->getBlocker() );
return $a;
}
/**
* @param IDatabase $dbw
* @return array
*/
protected function getAutoblockUpdateArray( IDatabase $dbw ) {
return [
'ipb_create_account' => $this->isCreateAccountBlocked(),
'ipb_deleted' => (int)$this->getHideName(), // typecast required for SQLite
'ipb_allow_usertalk' => $this->isUsertalkEditAllowed(),
'ipb_sitewide' => $this->isSitewide(),
] + CommentStore::getStore()->insert( $dbw, 'ipb_reason', $this->getReasonComment() )
+ ActorMigration::newMigration()->getInsertValues( $dbw, 'ipb_by', $this->getBlocker() );
}
/**
* Retroactively autoblocks the last IP used by the user (if it is a user)
* blocked by this block.
*
* @return array IDs of retroactive autoblocks made
*/
protected function doRetroactiveAutoblock() {
$blockIds = [];
# If autoblock is enabled, autoblock the LAST IP(s) used
if ( $this->isAutoblocking() && $this->getType() == self::TYPE_USER ) {
wfDebug( "Doing retroactive autoblocks for " . $this->getTarget() );
$continue = Hooks::runner()->onPerformRetroactiveAutoblock( $this, $blockIds );
if ( $continue ) {
self::defaultRetroactiveAutoblock( $this, $blockIds );
}
}
return $blockIds;
}
/**
* Retroactively autoblocks the last IP used by the user (if it is a user)
* blocked by this block. This will use the recentchanges table.
*
* @param DatabaseBlock $block
* @param array &$blockIds
*/
protected static function defaultRetroactiveAutoblock( DatabaseBlock $block, array &$blockIds ) {
global $wgPutIPinRC;
// No IPs are in recentchanges table, so nothing to select
if ( !$wgPutIPinRC ) {
return;
}
// Autoblocks only apply to TYPE_USER
if ( $block->getType() !== self::TYPE_USER ) {
return;
}
$target = $block->getTarget(); // TYPE_USER => always a User object
$dbr = wfGetDB( DB_REPLICA );
$rcQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $target, false );
$options = [ 'ORDER BY' => 'rc_timestamp DESC' ];
// Just the last IP used.
$options['LIMIT'] = 1;
$res = $dbr->select(
[ 'recentchanges' ] + $rcQuery['tables'],
[ 'rc_ip' ],
$rcQuery['conds'],
__METHOD__,
$options,
$rcQuery['joins']
);
if ( !$res->numRows() ) {
# No results, don't autoblock anything
wfDebug( "No IP found to retroactively autoblock" );
} else {
foreach ( $res as $row ) {
if ( $row->rc_ip ) {
$id = $block->doAutoblock( $row->rc_ip );
if ( $id ) {
$blockIds[] = $id;
}
}
}
}
return MediaWikiServices::getInstance()
->getDatabaseBlockStore()
->updateBlock( $this );
}
/**
@ -1014,10 +759,11 @@ class DatabaseBlock extends AbstractBlock {
/**
* Set the block ID
*
* @internal Only for use in DatabaseBlockStore; private until 1.36
* @param int $blockId
* @return self
*/
private function setId( $blockId ) {
public function setId( $blockId ) {
$this->mId = (int)$blockId;
if ( is_array( $this->restrictions ) ) {
@ -1109,27 +855,7 @@ class DatabaseBlock extends AbstractBlock {
* Purge expired blocks from the ipblocks table
*/
public static function purgeExpired() {
if ( wfReadOnly() ) {
return;
}
DeferredUpdates::addUpdate( new AutoCommitUpdate(
wfGetDB( DB_MASTER ),
__METHOD__,
function ( IDatabase $dbw, $fname ) {
$ids = $dbw->selectFieldValues( 'ipblocks',
'ipb_id',
[ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
$fname
);
if ( $ids ) {
$blockRestrictionStore = MediaWikiServices::getInstance()->getBlockRestrictionStore();
$blockRestrictionStore->deleteByBlockId( $ids );
$dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], $fname );
}
}
) );
MediaWikiServices::getInstance()->getDatabaseBlockStore()->purgeExpiredBlocks();
}
/**
@ -1470,6 +1196,16 @@ class DatabaseBlock extends AbstractBlock {
return $this->restrictions;
}
/**
* Get restrictions without loading from database if not yet loaded
*
* @internal
* @return ?Restriction[]
*/
public function getRawRestrictions() : ?array {
return $this->restrictions;
}
/**
* Set Restrictions.
*
@ -1612,6 +1348,16 @@ class DatabaseBlock extends AbstractBlock {
return $this->blocker;
}
/**
* Get the forcedTargetID if set
*
* @internal
* @return ?int
*/
public function getForcedTargetID() : ?int {
return $this->forcedTargetID;
}
/**
* Set the user who implemented (or will implement) this block
*

View file

@ -0,0 +1,497 @@
<?php
/**
* Class for DatabaseBlock objects to interact with the database
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Block;
use ActorMigration;
use AutoCommitUpdate;
use CommentStore;
use DeferredUpdates;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MWException;
use Psr\Log\LoggerInterface;
use ReadOnlyMode;
use User;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* @since 1.36
*
* @author DannyS712
*/
class DatabaseBlockStore {
/** @var ServiceOptions */
private $options;
public const CONSTRUCTOR_OPTIONS = [
'PutIPinRC',
'BlockDisablesLogin',
];
/** @var LoggerInterface */
private $logger;
/** @var ActorMigration */
private $actorMigration;
/** @var BlockRestrictionStore */
private $blockRestrictionStore;
/** @var CommentStore */
private $commentStore;
/** @var HookRunner */
private $hookRunner;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var ReadOnlyMode */
private $readOnlyMode;
/**
* @param ServiceOptions $options
* @param LoggerInterface $logger
* @param ActorMigration $actorMigration
* @param BlockRestrictionStore $blockRestrictionStore
* @param CommentStore $commentStore
* @param HookContainer $hookContainer
* @param ILoadBalancer $loadBalancer
* @param ReadOnlyMode $readOnlyMode
*/
public function __construct(
ServiceOptions $options,
LoggerInterface $logger,
ActorMigration $actorMigration,
BlockRestrictionStore $blockRestrictionStore,
CommentStore $commentStore,
HookContainer $hookContainer,
ILoadBalancer $loadBalancer,
ReadOnlyMode $readOnlyMode
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->logger = $logger;
$this->actorMigration = $actorMigration;
$this->blockRestrictionStore = $blockRestrictionStore;
$this->commentStore = $commentStore;
$this->hookRunner = new HookRunner( $hookContainer );
$this->loadBalancer = $loadBalancer;
$this->readOnlyMode = $readOnlyMode;
}
/**
* Delete expired blocks from the ipblocks table
*
* @internal only public for use in DatabaseBlock
*/
public function purgeExpiredBlocks() {
if ( $this->readOnlyMode->isReadOnly() ) {
return;
}
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$blockRestrictionStore = $this->blockRestrictionStore;
DeferredUpdates::addUpdate( new AutoCommitUpdate(
$dbw,
__METHOD__,
function ( IDatabase $dbw, $fname ) use ( $blockRestrictionStore ) {
$ids = $dbw->selectFieldValues(
'ipblocks',
'ipb_id',
[ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
$fname
);
if ( $ids ) {
$blockRestrictionStore->deleteByBlockId( $ids );
$dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], $fname );
}
}
) );
}
/**
* Insert a block into the block table. Will fail if there is a conflicting
* block (same name and options) already in the database.
*
* @param DatabaseBlock $block
* @param IDatabase|null $database Database to use if not the same as the one in the load balancer
* @return bool|array False on failure, assoc array on success:
* ('id' => block ID, 'autoIds' => array of autoblock IDs)
* @throws MWException
*/
public function insertBlock(
DatabaseBlock $block,
IDatabase $database = null
) {
if ( !$block->getBlocker() || $block->getBlocker()->getName() === '' ) {
throw new MWException( 'Cannot insert a block without a blocker set' );
}
$this->logger->debug( 'Inserting block; timestamp ' . $block->getTimestamp() );
// TODO T258866 - consider passing the database
$this->purgeExpiredBlocks();
$dbw = $database ?: $this->loadBalancer->getConnectionRef( DB_MASTER );
$row = $this->getArrayForDatabaseBlock( $block, $dbw );
$dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
if ( $affected ) {
$block->setId( $dbw->insertId() );
if ( $block->getRawRestrictions() ) {
$this->blockRestrictionStore->insert( $block->getRawRestrictions() );
}
}
// Don't collide with expired blocks.
// Do this after trying to insert to avoid locking.
if ( !$affected ) {
// T96428: The ipb_address index uses a prefix on a field, so
// use a standard SELECT + DELETE to avoid annoying gap locks.
$ids = $dbw->selectFieldValues(
'ipblocks',
'ipb_id',
[
'ipb_address' => $row['ipb_address'],
'ipb_user' => $row['ipb_user'],
'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() )
],
__METHOD__
);
if ( $ids ) {
$dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], __METHOD__ );
$this->blockRestrictionStore->deleteByBlockId( $ids );
$dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
$block->setId( $dbw->insertId() );
if ( $block->getRawRestrictions() ) {
$this->blockRestrictionStore->insert( $block->getRawRestrictions() );
}
}
}
if ( $affected ) {
$autoBlockIds = $this->doRetroactiveAutoblock( $block );
if ( $this->options->get( 'BlockDisablesLogin' ) ) {
$target = $block->getTarget();
if ( $target instanceof User ) {
// Change user login token to force them to be logged out.
$target->setToken();
$target->saveSettings();
}
}
return [ 'id' => $block->getId(), 'autoIds' => $autoBlockIds ];
}
return false;
}
/**
* Update a block in the DB with new parameters.
* The ID field needs to be loaded first.
*
* @param DatabaseBlock $block
* @return bool|array False on failure, array on success:
* ('id' => block ID, 'autoIds' => array of autoblock IDs)
*/
public function updateBlock( DatabaseBlock $block ) {
$this->logger->debug( 'Updating block; timestamp ' . $block->getTimestamp() );
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$row = $this->getArrayForDatabaseBlock( $block, $dbw );
$dbw->startAtomic( __METHOD__ );
$result = $dbw->update(
'ipblocks',
$row,
[ 'ipb_id' => $block->getId() ],
__METHOD__
);
// Only update the restrictions if they have been modified.
$restrictions = $block->getRawRestrictions();
if ( $restrictions !== null ) {
// An empty array should remove all of the restrictions.
if ( empty( $restrictions ) ) {
$success = $this->blockRestrictionStore->deleteByBlockId( $block->getId() );
} else {
$success = $this->blockRestrictionStore->update( $restrictions );
}
// Update the result. The first false is the result, otherwise, true.
$result = $result && $success;
}
if ( $block->isAutoblocking() ) {
// update corresponding autoblock(s) (T50813)
$dbw->update(
'ipblocks',
$this->getArrayForAutoblockUpdate( $block ),
[ 'ipb_parent_block_id' => $block->getId() ],
__METHOD__
);
// Only update the restrictions if they have been modified.
if ( $restrictions !== null ) {
$this->blockRestrictionStore->updateByParentBlockId(
$block->getId(),
$restrictions
);
}
} else {
// autoblock no longer required, delete corresponding autoblock(s)
$this->blockRestrictionStore->deleteByParentBlockId( $block->getId() );
$dbw->delete(
'ipblocks',
[ 'ipb_parent_block_id' => $block->getId() ],
__METHOD__
);
}
$dbw->endAtomic( __METHOD__ );
if ( $result ) {
$autoBlockIds = $this->doRetroactiveAutoblock( $block );
return [ 'id' => $block->getId(), 'autoIds' => $autoBlockIds ];
}
return false;
}
/**
* Delete a DatabaseBlock from the database
*
* @param DatabaseBlock $block
* @return bool whether it was deleted
* @throws MWException
*/
public function deleteBlock( DatabaseBlock $block ) : bool {
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
$blockId = $block->getId();
if ( !$blockId ) {
throw new MWException(
__METHOD__ . " requires that a block id be set\n"
);
}
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$this->blockRestrictionStore->deleteByParentBlockId( $blockId );
$dbw->delete(
'ipblocks',
[ 'ipb_parent_block_id' => $blockId ],
__METHOD__
);
$this->blockRestrictionStore->deleteByBlockId( $blockId );
$dbw->delete(
'ipblocks',
[ 'ipb_id' => $blockId ],
__METHOD__
);
return $dbw->affectedRows() > 0;
}
/**
* Get an array suitable for passing to $dbw->insert() or $dbw->update()
*
* @param DatabaseBlock $block
* @param IDatabase $dbw
* @return array
*/
private function getArrayForDatabaseBlock(
DatabaseBlock $block,
IDatabase $dbw
) : array {
$expiry = $dbw->encodeExpiry( $block->getExpiry() );
$target = $block->getTarget();
$forcedTargetId = $block->getForcedTargetId();
if ( $forcedTargetId ) {
$userId = $forcedTargetId;
} elseif ( $target instanceof User ) {
$userId = $target->getId();
} else {
$userId = 0;
}
$blockArray = [
'ipb_address' => (string)$target,
'ipb_user' => $userId,
'ipb_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
'ipb_auto' => $block->getType() === AbstractBlock::TYPE_AUTO,
'ipb_anon_only' => !$block->isHardblock(),
'ipb_create_account' => $block->isCreateAccountBlocked(),
'ipb_enable_autoblock' => $block->isAutoblocking(),
'ipb_expiry' => $expiry,
'ipb_range_start' => $block->getRangeStart(),
'ipb_range_end' => $block->getRangeEnd(),
'ipb_deleted' => intval( $block->getHideName() ), // typecast required for SQLite
'ipb_block_email' => $block->isEmailBlocked(),
'ipb_allow_usertalk' => $block->isUsertalkEditAllowed(),
'ipb_parent_block_id' => $block->getParentBlockId(),
'ipb_sitewide' => $block->isSitewide(),
];
$commentArray = $this->commentStore->insert(
$dbw,
'ipb_reason',
$block->getReasonComment()
);
$actorArray = $this->actorMigration->getInsertValues(
$dbw,
'ipb_by',
$block->getBlocker()
);
$combinedArray = $blockArray + $commentArray + $actorArray;
return $combinedArray;
}
/**
* Get an array suitable for autoblock updates
*
* @param DatabaseBlock $block
* @return array
*/
private function getArrayForAutoblockUpdate( DatabaseBlock $block ) : array {
$blockArray = [
'ipb_create_account' => $block->isCreateAccountBlocked(),
'ipb_deleted' => (int)$block->getHideName(), // typecast required for SQLite
'ipb_allow_usertalk' => $block->isUsertalkEditAllowed(),
'ipb_sitewide' => $block->isSitewide(),
];
$dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
$commentArray = $this->commentStore->insert(
$dbw,
'ipb_reason',
$block->getReasonComment()
);
$actorArray = $this->actorMigration->getInsertValues(
$dbw,
'ipb_by',
$block->getBlocker()
);
$combinedArray = $blockArray + $commentArray + $actorArray;
return $combinedArray;
}
/**
* Handles retroactively autoblocking the last IP used by the user (if it is a user)
* blocked by an auto block.
*
* @param DatabaseBlock $block
* @return array IDs of retroactive autoblocks made
*/
private function doRetroactiveAutoblock( DatabaseBlock $block ) : array {
$autoBlockIds = [];
// If autoblock is enabled, autoblock the LAST IP(s) used
if ( $block->isAutoblocking() && $block->getType() == AbstractBlock::TYPE_USER ) {
$this->logger->debug(
'Doing retroactive autoblocks for ' . $block->getTarget()
);
$hookAutoBlocked = [];
$continue = $this->hookRunner->onPerformRetroactiveAutoblock(
$block,
$hookAutoBlocked
);
if ( $continue ) {
$coreAutoBlocked = $this->performRetroactiveAutoblock( $block );
$autoBlockIds = array_merge( $hookAutoBlocked, $coreAutoBlocked );
} else {
$autoBlockIds = $hookAutoBlocked;
}
}
return $autoBlockIds;
}
/**
* Actually retroactively autoblocks the last IP used by the user (if it is a user)
* blocked by this block. This will use the recentchanges table.
*
* @param DatabaseBlock $block
* @return array
*/
private function performRetroactiveAutoblock( DatabaseBlock $block ) : array {
if ( !$this->options->get( 'PutIPinRC' ) ) {
// No IPs in the recent changes table to autoblock
return [];
}
list( $target, $type ) = $block->getTargetAndType();
if ( $type !== AbstractBlock::TYPE_USER ) {
// Autoblocks only apply to users
return [];
}
$dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
$rcQuery = $this->actorMigration->getWhere( $dbr, 'rc_user', $target, false );
$options = [
'ORDER BY' => 'rc_timestamp DESC',
'LIMIT' => 1,
];
$res = $dbr->select(
[ 'recentchanges' ] + $rcQuery['tables'],
[ 'rc_ip' ],
$rcQuery['conds'],
__METHOD__,
$options,
$rcQuery['joins']
);
if ( !$res->numRows() ) {
$this->logger->debug( 'No IP found to retroactively autoblock' );
return [];
}
$blockIds = [];
foreach ( $res as $row ) {
if ( $row->rc_ip ) {
$id = $block->doAutoblock( $row->rc_ip );
if ( $id ) {
$blockIds[] = $id;
}
}
}
return $blockIds;
}
}

View file

@ -0,0 +1,518 @@
<?php
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MediaWikiServices;
use Psr\Log\NullLogger;
/**
* Integration tests for DatabaseBlockStore.
*
* @author DannyS712
* @group Blocking
* @group Database
* @covers \MediaWiki\Block\DatabaseBlockStore
*/
class DatabaseBlockStoreTest extends MediaWikiIntegrationTestCase {
/** @var User */
private $sysop;
/** @var integer */
private $expiredBlockId = 11111;
/** @var integer */
private $unexpiredBlockId = 22222;
/** @var integer */
private $autoblockId = 33333;
/**
* @param array $options
* - config: Override the ServiceOptions config
* - constructorArgs: Override the constructor arguments
* @return DatabaseBlockStore
*/
private function getStore( array $options = [] ) : DatabaseBlockStore {
$overrideConfig = $options['config'] ?? [];
$overrideConstructorArgs = $options['constructorArgs'] ?? [];
$defaultConfig = [
'PutIPinRC' => true,
'BlockDisablesLogin' => false,
];
$config = array_merge( $defaultConfig, $overrideConfig );
// This ensures continuation after hooks
$hookContainer = $this->createMock( HookContainer::class );
$hookContainer->method( 'run' )
->willReturn( true );
// Most tests need read only to be false
$readOnlyMode = $this->createMock( ReadOnlyMode::class );
$readOnlyMode->method( 'isReadOnly' )
->willReturn( false );
$services = MediaWikiServices::getInstance();
$defaultConstructorArgs = [
'serviceOptions' => new ServiceOptions(
DatabaseBlockStore::CONSTRUCTOR_OPTIONS,
$config
),
'logger' => new NullLogger(),
'actorMigration' => $services->getActorMigration(),
'blockRestrictionStore' => $services->getBlockRestrictionStore(),
'commentStore' => $services->getCommentStore(),
'hookContainer' => $hookContainer,
'loadBalancer' => $services->getDBLoadBalancer(),
'readOnlyMode' => $readOnlyMode,
];
$constructorArgs = array_merge( $defaultConstructorArgs, $overrideConstructorArgs );
return new DatabaseBlockStore( ...array_values( $constructorArgs ) );
}
/**
* @param array $options
* - target: The intended target, an unblocked user by default
* - autoblock: Whether this block is autoblocking
* @return DatabaseBlock
*/
private function getBlock( array $options = [] ) : DatabaseBlock {
$target = $options['target'] ?? $this->getTestUser()->getUser();
$autoblock = $options['autoblock'] ?? false;
return new DatabaseBlock( [
'by' => $this->sysop->getId(),
'address' => $target,
'enableAutoblock' => $autoblock,
] );
}
/**
* Check that an autoblock corresponds to a parent block. The following are not
* required to be equal, so are not tested:
* - target
* - type
* - expiry
* - autoblocking
*
* @param DatabaseBlock $block
* @param DatabaseBlock $autoblock
*/
private function assertAutoblockEqualsBlock(
DatabaseBlock $block,
DatabaseBlock $autoblock
) {
$this->assertSame( $autoblock->getParentBlockId(), $block->getId() );
$this->assertSame( $autoblock->isHardblock(), $block->isHardblock() );
$this->assertSame( $autoblock->isCreateAccountBlocked(), $block->isCreateAccountBlocked() );
$this->assertSame( $autoblock->getHideName(), $block->getHideName() );
$this->assertSame( $autoblock->isEmailBlocked(), $block->isEmailBlocked() );
$this->assertSame( $autoblock->isUsertalkEditAllowed(), $block->isUsertalkEditAllowed() );
$this->assertSame( $autoblock->isSitewide(), $block->isSitewide() );
$restrictionStore = MediaWikiServices::getInstance()->getBlockRestrictionStore();
$this->assertTrue(
$restrictionStore->equals(
$autoblock->getRestrictions(),
$block->getRestrictions()
)
);
}
/**
* @dataProvider provideInsertBlockSuccess
*/
public function testInsertBlockSuccess( $options ) {
$block = $this->getBlock( $options['block'] ?? [] );
$block->setRestrictions( [
new NamespaceRestriction( 0, NS_MAIN ),
] );
$store = $this->getStore( $options['store'] ?? [] );
$result = $store->insertBlock( $block );
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'id', $result );
$this->assertArrayHasKey( 'autoIds', $result );
$this->assertSame( 0, count( $result['autoIds'] ) );
$retrievedBlock = DatabaseBlock::newFromId( $result['id'] );
$this->assertTrue( $block->equals( $retrievedBlock ) );
}
public function provideInsertBlockSuccess() {
return [
'No conflicting block, not autoblocking' => [
'block' => [
'autoblock' => false,
],
],
'No conflicting block, autoblocking but IP not in recent changes' => [
[
'block' => [
'autoblock' => true,
],
'store' => [
'constructorArgs' => [
'PutIPinRC' => false,
],
],
],
],
'No conflicting block, autoblocking but no recent edits' => [
'block' => [
'autoblock' => true,
],
],
'Conflicting block, expired' => [
'block' => [
// Blocked with expired block in addDBData
'target' => '1.1.1.1',
],
],
];
}
public function testInsertBlockConflict() {
$block = $this->getBlock( [ 'target' => $this->sysop ] );
$store = $this->getStore();
$result = $store->insertBlock( $block );
$this->assertFalse( $result );
$this->assertNull( $block->getId() );
}
/**
* @dataProvider provideInsertBlockLogout
*/
public function testInsertBlockLogout( $options, $expectTokenEqual ) {
$block = $this->getBlock();
$targetToken = $block->getTarget()->getToken();
$store = $this->getStore( $options );
$result = $store->insertBlock( $block );
$this->assertSame(
$expectTokenEqual,
$targetToken === $block->getTarget()->getToken()
);
}
public function provideInsertBlockLogout() {
return [
'Blocked user can log in' => [
[
'config' => [
'BlockDisablesLogin' => false,
],
],
true,
],
'Blocked user cannot log in' => [
[
'config' => [
'BlockDisablesLogin' => true,
],
],
false,
],
];
}
public function testInsertBlockAutoblock() {
// This is quicker than adding a recent change for an unblocked user.
// See addDBDataOnce documentation for more details.
$target = $this->sysop;
$this->db->delete(
'ipblocks',
[ 'ipb_address' => $target->getName() ]
);
$block = $this->getBlock( [
'autoblock' => true,
'target' => $target,
] );
$store = $this->getStore();
$result = $store->insertBlock( $block );
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'autoIds', $result );
$this->assertCount( 1, $result['autoIds'] );
$retrievedBlock = DatabaseBlock::newFromId( $result['autoIds'][0] );
$this->assertSame( $block->getId(), $retrievedBlock->getParentBlockId() );
$this->assertAutoblockEqualsBlock( $block, $retrievedBlock );
}
public function testInsertBlockError() {
$block = $this->createMock( DatabaseBlock::class );
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'insert' );
$store = $this->getStore();
$store->insertBlock( $block );
}
public function testUpdateBlock() {
$existingBlock = DatabaseBlock::newFromTarget( $this->sysop );
$existingBlock->isUsertalkEditAllowed( true );
$store = $this->getStore();
$result = $store->updateBlock( $existingBlock );
$updatedBlock = DatabaseBlock::newFromId( $result['id'] );
$autoblock = DatabaseBlock::newFromId( $result['autoIds'][0] );
$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
$this->assertAutoblockEqualsBlock( $existingBlock, $autoblock );
}
public function testUpdateBlockAddOrRemoveAutoblock() {
// Existing block is autoblocking to begin with
$existingBlock = DatabaseBlock::newFromTarget( $this->sysop );
$existingBlock->isAutoblocking( false );
$store = $this->getStore();
$result = $store->updateBlock( $existingBlock );
$updatedBlock = DatabaseBlock::newFromId( $result['id'] );
$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
$this->assertCount( 0, $result['autoIds'] );
// Test adding an autoblock in the same test run, since we need the
// target to be the sysop (see addDBDataOnce documentation), and the
// sysop is blocked with an autoblock between test runs.
$existingBlock->isAutoblocking( true );
$result = $store->updateBlock( $existingBlock );
$updatedBlock = DatabaseBlock::newFromId( $result['id'] );
$autoblock = DatabaseBlock::newFromId( $result['autoIds'][0] );
$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
$this->assertAutoblockEqualsBlock( $existingBlock, $autoblock );
}
/**
* @dataProvider provideUpdateBlockRestrictions
*/
public function testUpdateBlockRestrictions( $expectedCount ) {
$existingBlock = DatabaseBlock::newFromTarget( $this->sysop );
$restrictions = [];
for ( $ns = 0; $ns < $expectedCount; $ns++ ) {
$restrictions[] = new NamespaceRestriction( $existingBlock->getId(), $ns );
}
$existingBlock->setRestrictions( $restrictions );
$store = $this->getStore();
$result = $store->updateBlock( $existingBlock );
$retrievedBlock = DatabaseBlock::newFromId( $result['id'] );
$this->assertCount(
$expectedCount,
$retrievedBlock->getRestrictions()
);
}
public function provideUpdateBlockRestrictions() {
return [
'Restrictions deleted if removed' => [ 0 ],
'Restrictions changed if updated' => [ 2 ],
];
}
public function testDeleteBlockSuccess() {
$target = $this->sysop;
$block = DatabaseBlock::newFromTarget( $target );
$store = $this->getStore();
$this->assertTrue( $store->deleteBlock( $block ) );
$this->assertNull( DatabaseBlock::newFromTarget( $target ) );
}
public function testDeleteBlockFailureReadOnly() {
$target = $this->sysop;
$block = DatabaseBlock::newFromTarget( $target );
$readOnlyMode = $this->createMock( ReadOnlyMode::class );
$readOnlyMode->method( 'isReadOnly' )
->willReturn( true );
$store = $this->getStore( [
'constructorArgs' => [
'readOnlyMode' => $readOnlyMode
],
] );
$this->assertFalse( $store->deleteBlock( $block ) );
$this->assertTrue( (bool)DatabaseBlock::newFromTarget( $target ) );
}
public function testDeleteBlockFailureNoBlockId() {
$block = $this->createMock( DatabaseBlock::class );
$block->method( 'getId' )
->willReturn( null );
$this->expectException( MWException::class );
$this->expectExceptionMessage( 'delete' );
$store = $this->getStore();
$store->deleteBlock( $block );
}
/**
* Check whether expired blocks and restrictions were removed from the database.
*
* @param int $blockId
* @param bool $expected Whether to expect to find any rows
*/
private function assertPurgeWorked( int $blockId, bool $expected ) : void {
$blockRows = (bool)$this->db->select(
'ipblocks',
'ipb_id',
[ 'ipb_id' => $blockId ]
)->numRows();
$blockRestrictionsRows = (bool)$this->db->select(
'ipblocks_restrictions',
'ir_ipb_id',
[ 'ir_ipb_id' => $blockId ]
)->numRows();
$this->assertSame( $expected, $blockRows );
$this->assertSame( $expected, $blockRestrictionsRows );
}
public function testPurgeExpiredBlocksSuccess() {
$store = $this->getStore();
$store->purgeExpiredBlocks();
$this->assertPurgeWorked( $this->expiredBlockId, false );
$this->assertPurgeWorked( $this->unexpiredBlockId, true );
}
public function testPurgeExpiredBlocksFailureReadOnly() {
$readOnlyMode = $this->createMock( ReadOnlyMode::class );
$readOnlyMode->method( 'isReadOnly' )
->willReturn( true );
$store = $this->getStore( [
'constructorArgs' => [
'readOnlyMode' => $readOnlyMode,
],
] );
$store->purgeExpiredBlocks();
$this->assertPurgeWorked( $this->expiredBlockId, true );
}
/**
* In order to autoblock a user, they must have a recent change.
*
* Make a recent change for the test sysop. This user persists between test runs,
* so will always have this recent change.
*
* Regular test users don't persist between test runs, because the TestUserRegistry
* is cleared between runs. If we tested autoblocking on a regular test user, we
* would need to make a recent change for each test, which is slow.
*
* Instead we always test autoblocks on the test sysop.
*/
public function addDBDataOnce() {
$this->editPage(
'UTPage', // Added in addCoreDBData
'an edit',
'a summary',
NS_MAIN,
$this->getTestSysop()->getUser()
);
}
/**
* Three blocks are added:
* - an expired block with restrictions, against an IP
* - a current block with restrictions, against a user with recent changes
* - a current autoblock from the current block above
*/
public function addDBData() {
$this->sysop = $this->getTestSysop()->getUser();
// Get a comment ID. One was added in addCoreDBData.
$commentId = $this->db->select(
'comment',
'comment_id'
)->fetchObject()->comment_id;
$commonBlockData = [
'ipb_user' => 0,
'ipb_by_actor' => $this->sysop->getActorId(),
'ipb_reason_id' => $commentId,
'ipb_timestamp' => $this->db->timestamp( '20000101000000' ),
'ipb_auto' => 0,
'ipb_anon_only' => 0,
'ipb_create_account' => 0,
'ipb_enable_autoblock' => 0,
'ipb_expiry' => $this->db->getInfinity(),
'ipb_range_start' => '',
'ipb_range_end' => '',
'ipb_deleted' => 0,
'ipb_block_email' => 0,
'ipb_allow_usertalk' => 0,
'ipb_parent_block_id' => 0,
'ipb_sitewide' => 0,
];
$blockData = [
[
'ipb_id' => $this->expiredBlockId,
'ipb_address' => '1.1.1.1',
'ipb_expiry' => $this->db->timestamp( '20010101000000' ),
],
[
'ipb_id' => $this->unexpiredBlockId,
'ipb_address' => $this->sysop,
'ipb_user' => $this->sysop->getId(),
'ipb_enable_autoblock' => 1,
],
[
'ipb_id' => $this->autoblockId,
'ipb_address' => '2.2.2.2',
'ipb_parent_block_id' => $this->unexpiredBlockId,
],
];
$restrictionData = [
[
'ir_ipb_id' => $this->expiredBlockId,
'ir_type' => 1,
'ir_value' => 1,
],
[
'ir_ipb_id' => $this->unexpiredBlockId,
'ir_type' => 2,
'ir_value' => 2,
],
[
'ir_ipb_id' => $this->autoblockId,
'ir_type' => 2,
'ir_value' => 2,
],
];
foreach ( $blockData as $row ) {
$this->db->insert( 'ipblocks', $row + $commonBlockData );
}
foreach ( $restrictionData as $row ) {
$this->db->insert( 'ipblocks_restrictions', $row );
}
$this->tablesUsed[] = 'ipblocks';
$this->tablesUsed[] = 'ipblocks_restrictions';
}
}