wiki.techinc.nl/includes/block/DatabaseBlockStore.php
Tim Starling dcbabb1a1d Fix autoblock update
When updating an autoblock due to a reblock, use the correct reason
message instead of the parent block reason.

If the reblock causes the parent block to expire before the autoblock,
adjust the autoblock expiry time downwards.

Adapt testUpdateBlock() to be a regression test for these two bugs.

Bug: T351173
Change-Id: I19843e4971106250cf9644ef68d68d6e33c6e3ab
2024-02-28 13:13:47 +11:00

2080 lines
64 KiB
PHP

<?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 InvalidArgumentException;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Config\ConfigException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\AutoCommitUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\MainConfigNames;
use MediaWiki\User\ActorStoreFactory;
use MediaWiki\User\TempUser\TempUserConfig;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use Psr\Log\LoggerInterface;
use RuntimeException;
use stdClass;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\IResultWrapper;
use Wikimedia\Rdbms\ReadOnlyMode;
use Wikimedia\Rdbms\SelectQueryBuilder;
use function array_key_exists;
/**
* @since 1.36
*
* @author DannyS712
*/
class DatabaseBlockStore {
/** The old schema */
public const SCHEMA_IPBLOCKS = 'ipblocks';
/** The new schema */
public const SCHEMA_BLOCK = 'block';
/** The schema currently selected by the read stage */
public const SCHEMA_CURRENT = 'current';
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::AutoblockExpiry,
MainConfigNames::BlockCIDRLimit,
MainConfigNames::BlockDisablesLogin,
MainConfigNames::BlockTargetMigrationStage,
MainConfigNames::PutIPinRC,
MainConfigNames::UpdateRowsPerQuery,
];
/** @var string|false */
private $wikiId;
/** @var ServiceOptions */
private $options;
/** @var LoggerInterface */
private $logger;
/** @var ActorStoreFactory */
private $actorStoreFactory;
/** @var BlockRestrictionStore */
private $blockRestrictionStore;
/** @var CommentStore */
private $commentStore;
/** @var HookRunner */
private $hookRunner;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var UserFactory */
private $userFactory;
/** @var TempUserConfig */
private $tempUserConfig;
/** @var BlockUtils */
private $blockUtils;
/** @var AutoblockExemptionList */
private $autoblockExemptionList;
/** @var int */
private $readStage;
/** @var int */
private $writeStage;
/**
* @param ServiceOptions $options
* @param LoggerInterface $logger
* @param ActorStoreFactory $actorStoreFactory
* @param BlockRestrictionStore $blockRestrictionStore
* @param CommentStore $commentStore
* @param HookContainer $hookContainer
* @param ILoadBalancer $loadBalancer
* @param ReadOnlyMode $readOnlyMode
* @param UserFactory $userFactory
* @param TempUserConfig $tempUserConfig
* @param BlockUtils $blockUtils
* @param AutoblockExemptionList $autoblockExemptionList
* @param string|false $wikiId
*/
public function __construct(
ServiceOptions $options,
LoggerInterface $logger,
ActorStoreFactory $actorStoreFactory,
BlockRestrictionStore $blockRestrictionStore,
CommentStore $commentStore,
HookContainer $hookContainer,
ILoadBalancer $loadBalancer,
ReadOnlyMode $readOnlyMode,
UserFactory $userFactory,
TempUserConfig $tempUserConfig,
BlockUtils $blockUtils,
AutoblockExemptionList $autoblockExemptionList,
$wikiId = DatabaseBlock::LOCAL
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->wikiId = $wikiId;
$this->options = $options;
$this->logger = $logger;
$this->actorStoreFactory = $actorStoreFactory;
$this->blockRestrictionStore = $blockRestrictionStore;
$this->commentStore = $commentStore;
$this->hookRunner = new HookRunner( $hookContainer );
$this->loadBalancer = $loadBalancer;
$this->readOnlyMode = $readOnlyMode;
$this->userFactory = $userFactory;
$this->tempUserConfig = $tempUserConfig;
$this->blockUtils = $blockUtils;
$this->autoblockExemptionList = $autoblockExemptionList;
$stage = $options->get( MainConfigNames::BlockTargetMigrationStage );
$this->readStage = $stage & SCHEMA_COMPAT_READ_MASK;
if ( !in_array( $this->readStage, [ SCHEMA_COMPAT_READ_OLD, SCHEMA_COMPAT_READ_NEW ], true ) ) {
throw new ConfigException(
'$wgBlockTargetMigrationStage has an unsupported read stage' );
}
$this->writeStage = $stage & SCHEMA_COMPAT_WRITE_MASK;
if ( !in_array(
$this->writeStage,
[ SCHEMA_COMPAT_WRITE_OLD, SCHEMA_COMPAT_WRITE_BOTH, SCHEMA_COMPAT_WRITE_NEW ]
) ) {
throw new ConfigException(
'$wgBlockTargetMigrationStage has an unsupported write stage' );
}
}
/**
* Get the read stage of the block_target migration
*
* @since 1.42
* @return int
*/
public function getReadStage() {
return $this->readStage;
}
/**
* Get the write stage of the block_target migration
*
* @since 1.42
* @return int
*/
public function getWriteStage() {
return $this->writeStage;
}
/***************************************************************************/
// region Database read methods
/** @name Database read methods */
/**
* Load a block from the block ID.
*
* @since 1.42
* @param int $id ID to search for
* @return DatabaseBlock|null
*/
public function newFromID( $id ) {
$dbr = $this->getReplicaDB();
if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
$blockQuery = $this->getQueryInfo( self::SCHEMA_IPBLOCKS );
$res = $dbr->selectRow(
$blockQuery['tables'],
$blockQuery['fields'],
[ 'ipb_id' => $id ],
__METHOD__,
[],
$blockQuery['joins']
);
} else {
$blockQuery = $this->getQueryInfo( self::SCHEMA_BLOCK );
$res = $dbr->selectRow(
$blockQuery['tables'],
$blockQuery['fields'],
[ 'bl_id' => $id ],
__METHOD__,
[],
$blockQuery['joins']
);
}
if ( $res ) {
return $this->newFromRow( $dbr, $res );
} else {
return null;
}
}
/**
* Return the tables, fields, and join conditions to be selected to create
* a new block object.
*
* Since 1.34, ipb_by and ipb_by_text have not been present in the
* database, but they continue to be available in query results as
* aliases.
*
* @since 1.42
* @internal Avoid this method and DatabaseBlock::getQueryInfo() in new
* external code, since they are not schema-independent. Use
* newListFromConds() and deleteBlocksMatchingConds().
*
* @param string $schema What schema to use for field aliases. May be either
* self::SCHEMA_IPBLOCKS or self::SCHEMA_BLOCK. In future this will
* default to SCHEMA_BLOCK, and later the parameter will be removed.
* @return array[] With three keys:
* - tables: (string[]) to include in the `$table` to `IDatabase->select()`
* or `SelectQueryBuilder::tables`
* - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
* or `SelectQueryBuilder::fields`
* - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
* or `SelectQueryBuilder::joinConds`
* @phan-return array{tables:string[],fields:string[],joins:array}
*/
public function getQueryInfo( $schema ) {
if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
$commentQuery = $this->commentStore->getJoin( 'ipb_reason' );
if ( $schema === self::SCHEMA_IPBLOCKS ) {
return [
'tables' => [
'ipblocks',
'ipblocks_actor' => 'actor'
] + $commentQuery['tables'],
'fields' => [
'ipb_id',
'ipb_address',
'ipb_timestamp',
'ipb_auto',
'ipb_anon_only',
'ipb_create_account',
'ipb_enable_autoblock',
'ipb_expiry',
'ipb_deleted',
'ipb_block_email',
'ipb_allow_usertalk',
'ipb_parent_block_id',
'ipb_sitewide',
'ipb_by_actor',
'ipb_by' => 'ipblocks_actor.actor_user',
'ipb_by_text' => 'ipblocks_actor.actor_name',
] + $commentQuery['fields'],
'joins' => [
'ipblocks_actor' => [ 'JOIN', 'actor_id=ipb_by_actor' ]
] + $commentQuery['joins'],
];
} elseif ( $schema === self::SCHEMA_BLOCK ) {
return [
'tables' => [
'ipblocks',
'ipblocks_actor' => 'actor'
] + $commentQuery['tables'],
'fields' => [
'bl_id' => 'ipb_id',
'bt_address' => 'ipb_address',
'bt_user' => 'ipb_user',
'bt_user_text' => 'ipb_address',
'bl_timestamp' => 'ipb_timestamp',
'bt_auto' => 'ipb_auto',
'bl_anon_only' => 'ipb_anon_only',
'bl_create_account' => 'ipb_create_account',
'bl_enable_autoblock' => 'ipb_enable_autoblock',
'bl_expiry' => 'ipb_expiry',
'bl_deleted' => 'ipb_deleted',
'bl_block_email' => 'ipb_block_email',
'bl_allow_usertalk' => 'ipb_allow_usertalk',
'bl_parent_block_id' => 'ipb_parent_block_id',
'bl_sitewide' => 'ipb_sitewide',
'bl_by_actor' => 'ipb_by_actor',
'bl_by_user' => 'ipblocks_actor.actor_user',
'bl_by_text' => 'ipblocks_actor.actor_name',
'bl_reason_text' => $commentQuery['fields']['ipb_reason_text'],
'bl_reason_data' => $commentQuery['fields']['ipb_reason_data'],
'bl_reason_cid' => $commentQuery['fields']['ipb_reason_cid'],
],
'joins' => [
'ipblocks_actor' => [ 'JOIN', 'actor_id=ipb_by_actor' ]
] + $commentQuery['joins'],
];
}
} else {
$commentQuery = $this->commentStore->getJoin( 'bl_reason' );
if ( $schema === self::SCHEMA_IPBLOCKS ) {
return [
'tables' => [
'block',
'block_by_actor' => 'actor',
] + $commentQuery['tables'],
'fields' => [
'ipb_id' => 'bl_id',
'ipb_address' => 'COALESCE(bt_address, bt_user_text)',
'ipb_timestamp' => 'bl_timestamp',
'ipb_auto' => 'bt_auto',
'ipb_anon_only' => 'bl_anon_only',
'ipb_create_account' => 'bl_create_account',
'ipb_enable_autoblock' => 'bl_enable_autoblock',
'ipb_expiry' => 'bl_expiry',
'ipb_deleted' => 'bl_deleted',
'ipb_block_email' => 'bl_block_email',
'ipb_allow_usertalk' => 'bl_allow_usertalk',
'ipb_parent_block_id' => 'bl_parent_block_id',
'ipb_sitewide' => 'bl_sitewide',
'ipb_by_actor' => 'bl_by_actor',
'ipb_by' => 'block_by_actor.actor_user',
'ipb_by_text' => 'block_by_actor.actor_name',
'ipb_reason_text' => $commentQuery['fields']['bl_reason_text'],
'ipb_reason_data' => $commentQuery['fields']['bl_reason_data'],
'ipb_reason_cid' => $commentQuery['fields']['bl_reason_cid'],
],
'joins' => [
'block_by_actor' => [ 'JOIN', 'actor_id=bl_by_actor' ],
] + $commentQuery['joins'],
];
} elseif ( $schema === self::SCHEMA_BLOCK ) {
return [
'tables' => [
'block',
'block_target',
'block_by_actor' => 'actor',
] + $commentQuery['tables'],
'fields' => [
'bl_id',
'bt_address',
'bt_user',
'bt_user_text',
'bl_timestamp',
'bt_auto',
'bl_anon_only',
'bl_create_account',
'bl_enable_autoblock',
'bl_expiry',
'bl_deleted',
'bl_block_email',
'bl_allow_usertalk',
'bl_parent_block_id',
'bl_sitewide',
'bl_by_actor',
'bl_by' => 'block_by_actor.actor_user',
'bl_by_text' => 'block_by_actor.actor_name',
] + $commentQuery['fields'],
'joins' => [
'block_target' => [ 'JOIN', 'bt_id=bl_target' ],
'block_by_actor' => [ 'JOIN', 'actor_id=bl_by_actor' ],
] + $commentQuery['joins'],
];
}
}
throw new InvalidArgumentException(
'$schema must be SCHEMA_IPBLOCKS or SCHEMA_BLOCK' );
}
/**
* Load blocks from the database which target the specific target exactly, or which cover the
* vague target.
*
* @param UserIdentity|string|null $specificTarget
* @param int|null $specificType
* @param bool $fromPrimary
* @param UserIdentity|string|null $vagueTarget Also search for blocks affecting this target.
* Doesn't make any sense to use TYPE_AUTO / TYPE_ID here. Leave blank to skip IP lookups.
* @return DatabaseBlock[] Any relevant blocks
*/
private function newLoad(
$specificTarget,
$specificType,
$fromPrimary,
$vagueTarget = null
) {
if ( $fromPrimary ) {
$db = $this->getPrimaryDB();
} else {
$db = $this->getReplicaDB();
}
$userIds = [];
$userNames = [];
$addresses = [];
$ranges = [];
if ( $specificType === Block::TYPE_USER ) {
if ( $specificTarget instanceof UserIdentity ) {
$userId = $specificTarget->getId( $this->wikiId );
if ( $userId ) {
$userIds[] = $specificTarget->getId( $this->wikiId );
} else {
// A nonexistent user can have no blocks.
// This case is hit in testing, possibly production too.
// Ignoring the user is optimal for production performance.
}
} else {
$userNames[] = (string)$specificTarget;
}
} elseif ( in_array( $specificType, [ Block::TYPE_IP, Block::TYPE_RANGE ], true ) ) {
$addresses[] = (string)$specificTarget;
}
// Be aware that the != '' check is explicit, since empty values will be
// passed by some callers (T31116)
if ( $vagueTarget != '' ) {
[ $target, $type ] = $this->blockUtils->parseBlockTarget( $vagueTarget );
switch ( $type ) {
case Block::TYPE_USER:
// Slightly weird, but who are we to argue?
/** @var UserIdentity $vagueUser */
$vagueUser = $target;
if ( $vagueUser->getId( $this->wikiId ) ) {
$userIds[] = $vagueUser->getId( $this->wikiId );
} else {
$userNames[] = $vagueUser->getName();
}
break;
case Block::TYPE_IP:
$ranges[] = [ IPUtils::toHex( $target ), null ];
break;
case Block::TYPE_RANGE:
$ranges[] = IPUtils::parseRange( $target );
break;
default:
$this->logger->debug( "Ignoring invalid vague target" );
}
}
if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
$userIdField = 'ipb_user';
$userNameField = 'ipb_address';
$addressField = 'ipb_address';
$schema = self::SCHEMA_IPBLOCKS;
} else {
$userIdField = 'bt_user';
$userNameField = 'bt_user_text';
$addressField = 'bt_address';
$schema = self::SCHEMA_BLOCK;
}
$orConds = [];
if ( $userIds ) {
// @phan-suppress-next-line PhanTypeMismatchArgument -- array_unique() result is non-empty
$orConds[] = $db->expr( $userIdField, '=', array_unique( $userIds ) );
}
if ( $userNames ) {
// @phan-suppress-next-line PhanTypeMismatchArgument
$orConds[] = $db->expr( $userNameField, '=', array_unique( $userNames ) );
}
if ( $addresses ) {
// @phan-suppress-next-line PhanTypeMismatchArgument
$orConds[] = $db->expr( $addressField, '=', array_unique( $addresses ) );
}
foreach ( $ranges as $range ) {
$orConds[] = $this->getRangeCond( $range[0], $range[1], $schema );
}
if ( !$orConds ) {
return [];
}
$blockQuery = $this->getQueryInfo( $schema );
$res = $db->select(
$blockQuery['tables'],
$blockQuery['fields'],
$db->makeList( $orConds, IDatabase::LIST_OR ),
__METHOD__,
[],
$blockQuery['joins']
);
$blocks = [];
$blockIds = [];
$autoBlocks = [];
foreach ( $res as $row ) {
$block = $this->newFromRow( $db, $row );
// Don't use expired blocks
if ( $block->isExpired() ) {
continue;
}
// Don't use anon only blocks on users
if (
$specificType == Block::TYPE_USER && $specificTarget &&
!$block->isHardblock() &&
!$this->tempUserConfig->isTempName( $specificTarget )
) {
continue;
}
// Check for duplicate autoblocks
if ( $block->getType() === Block::TYPE_AUTO ) {
$autoBlocks[] = $block;
} else {
$blocks[] = $block;
$blockIds[] = $block->getId( $this->wikiId );
}
}
// Only add autoblocks that aren't duplicates
foreach ( $autoBlocks as $block ) {
if ( !in_array( $block->getParentBlockId(), $blockIds ) ) {
$blocks[] = $block;
}
}
return $blocks;
}
/**
* Choose the most specific block from some combination of user, IP and IP range
* blocks. Decreasing order of specificity: user > IP > narrower IP range > wider IP
* range. A range that encompasses one IP address is ranked equally to a singe IP.
*
* @param DatabaseBlock[] $blocks These should not include autoblocks or ID blocks
* @return DatabaseBlock|null The block with the most specific target
*/
private function chooseMostSpecificBlock( array $blocks ) {
if ( count( $blocks ) === 1 ) {
return $blocks[0];
}
// This result could contain a block on the user, a block on the IP, and a russian-doll
// set of range blocks. We want to choose the most specific one, so keep a leader board.
$bestBlock = null;
// Lower will be better
$bestBlockScore = 100;
foreach ( $blocks as $block ) {
if ( $block->getType() == Block::TYPE_RANGE ) {
// This is the number of bits that are allowed to vary in the block, give
// or take some floating point errors
$target = $block->getTargetName();
$max = IPUtils::isIPv6( $target ) ? 128 : 32;
[ , $bits ] = IPUtils::parseCIDR( $target );
$size = $max - $bits;
// Rank a range block covering a single IP equally with a single-IP block
$score = Block::TYPE_RANGE - 1 + ( $size / $max );
} else {
$score = $block->getType();
}
if ( $score < $bestBlockScore ) {
$bestBlockScore = $score;
$bestBlock = $block;
}
}
return $bestBlock;
}
/**
* Get a set of SQL conditions which select range blocks encompassing a
* given range. If the given range is a single IP with start=end, it will
* also select single IP blocks with that IP.
*
* @since 1.42
* @param string $start Hexadecimal IP representation
* @param string|null $end Hexadecimal IP representation, or null to use $start = $end
* @param string $schema What schema to use for field aliases. Can be one of:
* - self::SCHEMA_IPBLOCKS for the old schema
* - self::SCHEMA_BLOCK for the new schema
* - self::SCHEMA_CURRENT for the schema configured by read mode in
* $wgBlockTargetMigrationStage.
* In future this will default to the new schema and later the parameter will be removed.
* @return string
*/
public function getRangeCond( $start, $end, $schema ) {
// Per T16634, we want to include relevant active range blocks; for
// range blocks, we want to include larger ranges which enclose the given
// range. We know that all blocks must be smaller than $wgBlockCIDRLimit,
// so we can improve performance by filtering on a LIKE clause
$chunk = $this->getIpFragment( $start );
$dbr = $this->getReplicaDB();
$like = $dbr->buildLike( $chunk, $dbr->anyString() );
$end ??= $start;
if ( $schema === self::SCHEMA_CURRENT ) {
$schema = $this->readStage === SCHEMA_COMPAT_READ_OLD
? self::SCHEMA_IPBLOCKS : self::SCHEMA_BLOCK;
}
if ( $schema === self::SCHEMA_IPBLOCKS ) {
return $dbr->makeList(
[
"ipb_range_start $like",
$dbr->expr( 'ipb_range_start', '<=', $start ),
$dbr->expr( 'ipb_range_end', '>=', $end ),
],
LIST_AND
);
} elseif ( $schema === self::SCHEMA_BLOCK ) {
return $dbr->makeList(
[
'bt_ip_hex' => $start,
$dbr->makeList(
[
"bt_range_start $like",
$dbr->expr( 'bt_range_start', '<=', $start ),
$dbr->expr( 'bt_range_end', '>=', $end ),
],
LIST_AND
)
],
LIST_OR
);
} else {
throw new InvalidArgumentException(
'$schema must be SCHEMA_IPBLOCKS or SCHEMA_BLOCK' );
}
}
/**
* Get the component of an IP address which is certain to be the same between an IP
* address and a range block containing that IP address.
*
* @param string $hex Hexadecimal IP representation
* @return string
*/
private function getIpFragment( $hex ) {
$blockCIDRLimit = $this->options->get( MainConfigNames::BlockCIDRLimit );
if ( str_starts_with( $hex, 'v6-' ) ) {
return 'v6-' . substr( substr( $hex, 3 ), 0, (int)floor( $blockCIDRLimit['IPv6'] / 4 ) );
} else {
return substr( $hex, 0, (int)floor( $blockCIDRLimit['IPv4'] / 4 ) );
}
}
/**
* Create a new DatabaseBlock object from a database row
*
* @since 1.42
* @param IReadableDatabase $db The database you got the row from
* @param stdClass $row Row from the ipblocks table
* @return DatabaseBlock
*/
public function newFromRow( IReadableDatabase $db, $row ) {
if ( isset( $row->ipb_id ) ) {
return new DatabaseBlock( [
'address' => $row->ipb_address,
'wiki' => $this->wikiId,
'timestamp' => $row->ipb_timestamp,
'auto' => (bool)$row->ipb_auto,
'hideName' => (bool)$row->ipb_deleted,
'id' => (int)$row->ipb_id,
// Blocks with no parent ID should have ipb_parent_block_id as null,
// don't save that as 0 though, see T282890
'parentBlockId' => $row->ipb_parent_block_id
? (int)$row->ipb_parent_block_id : null,
'by' => $this->actorStoreFactory
->getActorStore( $this->wikiId )
->newActorFromRowFields( $row->ipb_by, $row->ipb_by_text, $row->ipb_by_actor ),
'decodedExpiry' => $db->decodeExpiry( $row->ipb_expiry ),
'reason' => $this->commentStore
// Legacy because $row may have come from self::selectFields()
->getCommentLegacy( $db, 'ipb_reason', $row ),
'anonOnly' => $row->ipb_anon_only,
'enableAutoblock' => (bool)$row->ipb_enable_autoblock,
'sitewide' => (bool)$row->ipb_sitewide,
'createAccount' => (bool)$row->ipb_create_account,
'blockEmail' => (bool)$row->ipb_block_email,
'allowUsertalk' => (bool)$row->ipb_allow_usertalk
] );
} else {
$address = $row->bt_address
?? new UserIdentityValue( $row->bt_user, $row->bt_user_text, $this->wikiId );
return new DatabaseBlock( [
'address' => $address,
'wiki' => $this->wikiId,
'timestamp' => $row->bl_timestamp,
'auto' => (bool)$row->bt_auto,
'hideName' => (bool)$row->bl_deleted,
'id' => (int)$row->bl_id,
// Blocks with no parent ID should have ipb_parent_block_id as null,
// don't save that as 0 though, see T282890
'parentBlockId' => $row->bl_parent_block_id
? (int)$row->bl_parent_block_id : null,
'by' => $this->actorStoreFactory
->getActorStore( $this->wikiId )
->newActorFromRowFields( $row->bl_by, $row->bl_by_text, $row->bl_by_actor ),
'decodedExpiry' => $db->decodeExpiry( $row->bl_expiry ),
'reason' => $this->commentStore->getComment( 'bl_reason', $row ),
'anonOnly' => $row->bl_anon_only,
'enableAutoblock' => (bool)$row->bl_enable_autoblock,
'sitewide' => (bool)$row->bl_sitewide,
'createAccount' => (bool)$row->bl_create_account,
'blockEmail' => (bool)$row->bl_block_email,
'allowUsertalk' => (bool)$row->bl_allow_usertalk
] );
}
}
/**
* Given a target and the target's type, get an existing block object if possible.
*
* @since 1.42
* @param string|UserIdentity|int|null $specificTarget A block target, which may be one of
* several types:
* * A user to block, in which case $target will be a User
* * An IP to block, in which case $target will be a User generated by using
* User::newFromName( $ip, false ) to turn off name validation
* * An IP range, in which case $target will be a String "123.123.123.123/18" etc
* * The ID of an existing block, in the format "#12345" (since pure numbers are valid
* usernames
* Calling this with a user, IP address or range will not select autoblocks, and will
* only select a block where the targets match exactly (so looking for blocks on
* 1.2.3.4 will not select 1.2.0.0/16 or even 1.2.3.4/32)
* @param string|UserIdentity|int|null $vagueTarget As above, but we will search for *any*
* block which affects that target (so for an IP address, get ranges containing that IP;
* and also get any relevant autoblocks). Leave empty or blank to skip IP-based lookups.
* @param bool $fromPrimary Whether to use the DB_PRIMARY database
* @return DatabaseBlock|null (null if no relevant block could be found). The target and type
* of the returned block will refer to the actual block which was found, which might
* not be the same as the target you gave if you used $vagueTarget!
*/
public function newFromTarget(
$specificTarget,
$vagueTarget = null,
$fromPrimary = false
) {
$blocks = $this->newListFromTarget( $specificTarget, $vagueTarget, $fromPrimary );
return $this->chooseMostSpecificBlock( $blocks );
}
/**
* This is similar to DatabaseBlockStore::newFromTarget, but it returns all the relevant blocks.
*
* @since 1.42
* @param string|UserIdentity|int|null $specificTarget
* @param string|UserIdentity|int|null $vagueTarget
* @param bool $fromPrimary
* @return DatabaseBlock[] Any relevant blocks
*/
public function newListFromTarget(
$specificTarget,
$vagueTarget = null,
$fromPrimary = false
) {
[ $target, $type ] = $this->blockUtils->parseBlockTarget( $specificTarget );
if ( $type == Block::TYPE_ID || $type == Block::TYPE_AUTO ) {
$block = $this->newFromID( $target );
return $block ? [ $block ] : [];
} elseif ( $target === null && $vagueTarget == '' ) {
// We're not going to find anything useful here
// Be aware that the == '' check is explicit, since empty values will be
// passed by some callers (T31116)
return [];
} elseif ( in_array(
$type,
[ Block::TYPE_USER, Block::TYPE_IP, Block::TYPE_RANGE, null ] )
) {
return $this->newLoad( $target, $type, $fromPrimary, $vagueTarget );
}
return [];
}
/**
* Get all blocks that match any IP from an array of IP addresses
*
* @since 1.42
* @param string[] $addresses Validated list of IP addresses
* @param bool $applySoftBlocks Include soft blocks (anonymous-only blocks). These
* should only block anonymous and temporary users.
* @param bool $fromPrimary Whether to query the primary or replica DB
* @return DatabaseBlock[]
*/
public function newListFromIPs( array $addresses, $applySoftBlocks, $fromPrimary = false ) {
if ( $addresses === [] ) {
return [];
}
$conds = [];
foreach ( array_unique( $addresses ) as $ipaddr ) {
$conds[] = $this->getRangeCond( IPUtils::toHex( $ipaddr ), null, self::SCHEMA_CURRENT );
}
if ( $conds === [] ) {
return [];
}
if ( $fromPrimary ) {
$db = $this->getPrimaryDB();
} else {
$db = $this->getReplicaDB();
}
$conds = $db->makeList( $conds, LIST_OR );
if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
if ( !$applySoftBlocks ) {
$conds = [ $conds, 'ipb_anon_only' => 0 ];
}
$blockQuery = $this->getQueryInfo( self::SCHEMA_IPBLOCKS );
$rows = $db->newSelectQueryBuilder()
->queryInfo( $blockQuery )
->fields( [ 'ipb_range_start', 'ipb_range_end' ] )
->where( $conds )
->caller( __METHOD__ )
->fetchResultSet();
} else {
if ( !$applySoftBlocks ) {
$conds = [ $conds, 'bl_anon_only' => 0 ];
}
$blockQuery = $this->getQueryInfo( self::SCHEMA_BLOCK );
$rows = $db->newSelectQueryBuilder()
->queryInfo( $blockQuery )
->fields( [ 'bt_range_start', 'bt_range_end' ] )
->where( $conds )
->caller( __METHOD__ )
->fetchResultSet();
}
$blocks = [];
foreach ( $rows as $row ) {
$block = $this->newFromRow( $db, $row );
if ( !$block->isExpired() ) {
$blocks[] = $block;
}
}
return $blocks;
}
/**
* Construct an array of blocks from database conditions.
*
* @since 1.42
* @param array $conds For schema-independence this should be an associative
* array mapping field names to values. Field names from the new schema
* should be used.
* @param bool $fromPrimary
* @param bool $includeExpired
* @return DatabaseBlock[]
*/
public function newListFromConds( $conds, $fromPrimary = false, $includeExpired = false ) {
$db = $fromPrimary ? $this->getPrimaryDB() : $this->getReplicaDB();
if ( $this->readStage === SCHEMA_COMPAT_READ_OLD ) {
$conds = self::mapCondsToOldSchema( $conds );
if ( !$includeExpired ) {
$conds[] = $db->expr( 'ipb_expiry', '>=', $db->timestamp() );
}
$res = $db->newSelectQueryBuilder()
->queryInfo( $this->getQueryInfo( self::SCHEMA_IPBLOCKS ) )
->conds( $conds )
->caller( __METHOD__ )
->fetchResultSet();
} else {
$conds = self::mapActorAlias( $conds );
if ( !$includeExpired ) {
$conds[] = $db->expr( 'bl_expiry', '>=', $db->timestamp() );
}
$res = $db->newSelectQueryBuilder()
->queryInfo( $this->getQueryInfo( self::SCHEMA_BLOCK ) )
->conds( $conds )
->caller( __METHOD__ )
->fetchResultSet();
}
$blocks = [];
foreach ( $res as $row ) {
$blocks[] = $this->newFromRow( $db, $row );
}
return $blocks;
}
// endregion -- end of database read methods
/***************************************************************************/
// region Database write methods
/** @name Database write methods */
/**
* Delete expired blocks from the ipblocks table
*
* @internal only public for use in DatabaseBlock
*/
public function purgeExpiredBlocks() {
if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
return;
}
$dbw = $this->getPrimaryDB();
DeferredUpdates::addUpdate( new AutoCommitUpdate(
$dbw,
__METHOD__,
function ( IDatabase $dbw, $fname ) {
$limit = $this->options->get( MainConfigNames::UpdateRowsPerQuery );
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$ids = $dbw->newSelectQueryBuilder()
->select( 'ipb_id' )
->from( 'ipblocks' )
->where( $dbw->expr( 'ipb_expiry', '<', $dbw->timestamp() ) )
// Set a limit to avoid causing replication lag (T301742)
->limit( $limit )
->caller( $fname )->fetchFieldValues();
if ( $ids ) {
$ids = array_map( 'intval', $ids );
$this->blockRestrictionStore->deleteByBlockId( $ids );
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'ipblocks' )
->where( [ 'ipb_id' => $ids ] )
->caller( $fname )->execute();
}
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$res = $dbw->newSelectQueryBuilder()
->select( [ 'bl_id', 'bl_target' ] )
->from( 'block' )
->where( $dbw->expr( 'bl_expiry', '<', $dbw->timestamp() ) )
// Set a limit to avoid causing replication lag (T301742)
->limit( $limit )
->caller( $fname )->fetchResultSet();
$this->deleteBlockRows( $res );
}
}
) );
}
/**
* Delete all blocks matching the given conditions.
*
* @since 1.42
* @param array $conds An associative array mapping the field name to the
* matched value. Some limited schema abstractions are implemented, to
* allow new field names to be used with the old schema.
* @param int|null $limit The maximum number of blocks to delete
* @return int The number of blocks deleted
*/
public function deleteBlocksMatchingConds( array $conds, $limit = null ) {
$dbw = $this->getPrimaryDB();
$affected = 0;
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$oldConds = self::mapCondsToOldSchema( $conds );
$qb = $dbw->newSelectQueryBuilder()
->select( 'ipb_id' )
->from( 'ipblocks' )
->where( $oldConds )
->caller( __METHOD__ );
if ( self::hasActorAlias( $oldConds ) ) {
$qb->join( 'actor', 'ipblocks_actor', 'actor_id=ipb_by_actor' );
}
if ( $limit !== null ) {
$qb->limit( $limit );
}
$ids = $qb->fetchFieldValues();
if ( $ids ) {
$ids = array_map( 'intval', $ids );
$this->blockRestrictionStore->deleteByBlockId( $ids );
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'ipblocks' )
->where( [ 'ipb_id' => $ids ] )
->caller( __METHOD__ )->execute();
$affected = $dbw->affectedRows();
}
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$conds = self::mapActorAlias( $conds );
$qb = $dbw->newSelectQueryBuilder()
->select( [ 'bl_id', 'bl_target' ] )
->from( 'block' )
// Typical input conds need block_target
->join( 'block_target', null, 'bt_id=bl_target' )
->where( $conds )
->caller( __METHOD__ );
if ( self::hasActorAlias( $conds ) ) {
$qb->join( 'actor', 'ipblocks_actor', 'actor_id=bl_by_actor' );
}
if ( $limit !== null ) {
$qb->limit( $limit );
}
$res = $qb->fetchResultSet();
$affected = max( $affected, $this->deleteBlockRows( $res ) );
}
return $affected;
}
/**
* Convert the field names in the condition array from new/generic names
* old names.
*
* @param array $conds
* @return array
*/
private static function mapCondsToOldSchema( $conds ) {
return self::mapConds(
[
'bl_id' => 'ipb_id',
'bt_address' => 'ipb_address',
'bt_user' => 'ipb_user',
'bl_timestamp' => 'ipb_timestamp',
'bt_auto' => 'ipb_auto',
'bl_anon_only' => 'ipb_anon_only',
'bl_create_account' => 'ipb_create_account',
'bl_enable_autoblock' => 'ipb_enable_autoblock',
'bl_expiry' => 'ipb_expiry',
'bl_deleted' => 'ipb_deleted',
'bl_block_email' => 'ipb_block_email',
'bl_allow_usertalk' => 'ipb_allow_usertalk',
'bl_parent_block_id' => 'ipb_parent_block_id',
'bl_sitewide' => 'ipb_sitewide',
'bl_by_actor' => 'ipb_by_actor',
'bl_by' => 'ipblocks_actor.actor_user',
],
$conds
);
}
/**
* Helper for deleteBlocksMatchingConds()
*
* @param array $conds
* @return array
*/
private static function mapActorAlias( $conds ) {
return self::mapConds(
[
'bl_by' => 'ipblocks_actor.actor_user',
],
$conds
);
}
/**
* @param array $conds
* @return bool
*/
private static function hasActorAlias( $conds ) {
return array_key_exists( 'ipblocks_actor.actor_user', $conds )
|| array_key_exists( 'ipblocks_actor.actor_name', $conds );
}
/**
* Remap the keys in an array
*
* @param array $map
* @param array $conds
* @return array
*/
private static function mapConds( $map, $conds ) {
$newConds = [];
foreach ( $conds as $field => $value ) {
if ( isset( $map[$field] ) ) {
$newConds[$map[$field]] = $value;
} else {
$newConds[$field] = $value;
}
}
return $newConds;
}
/**
* Delete rows from the block table and update the block_target
* and ipblocks_restrictions tables accordingly.
*
* @param IResultWrapper $rows Rows containing bl_id and bl_target
* @return int Number of deleted block rows
*/
private function deleteBlockRows( $rows ) {
$ids = [];
$deltasByTarget = [];
foreach ( $rows as $row ) {
$ids[] = (int)$row->bl_id;
$target = (int)$row->bl_target;
if ( !isset( $deltasByTarget[$target] ) ) {
$deltasByTarget[$target] = 0;
}
$deltasByTarget[$target]++;
}
if ( !$ids ) {
return 0;
}
$dbw = $this->getPrimaryDB();
$dbw->startAtomic( __METHOD__ );
$maxTargetCount = max( $deltasByTarget );
for ( $delta = 1; $delta <= $maxTargetCount; $delta++ ) {
$targetsWithThisDelta = array_keys( $deltasByTarget, $delta, true );
$this->releaseTargets( $dbw, $targetsWithThisDelta, $delta );
}
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'block' )
->where( [ 'bl_id' => $ids ] )
->caller( __METHOD__ )->execute();
$numDeleted = $dbw->affectedRows();
$dbw->endAtomic( __METHOD__ );
$this->blockRestrictionStore->deleteByBlockId( $ids );
return $numDeleted;
}
/**
* Decrement the bt_count field of a set of block_target rows and delete
* the rows if the count falls to zero.
*
* @param IDatabase $dbw
* @param int[] $targetIds
* @param int $delta The amount to decrement by
*/
private function releaseTargets( IDatabase $dbw, $targetIds, $delta = 1 ) {
$dbw->newUpdateQueryBuilder()
->update( 'block_target' )
->set( "bt_count=bt_count-$delta" )
->where( [ 'bt_id' => $targetIds ] )
->caller( __METHOD__ )
->execute();
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'block_target' )
->where( [
'bt_count<1',
'bt_id' => $targetIds
] )
->caller( __METHOD__ )
->execute();
}
private function getReplicaDB(): IDatabase {
return $this->loadBalancer->getConnection( DB_REPLICA, [], $this->wikiId );
}
private function getPrimaryDB(): IDatabase {
return $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
}
/**
* 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 int|null $expectedTargetCount The expected number of existing blocks
* on the specified target. If this is zero but there is an existing
* block, the insertion will fail.
* @return bool|array False on failure, assoc array on success:
* ('id' => block ID, 'autoIds' => array of autoblock IDs)
*/
public function insertBlock(
DatabaseBlock $block,
$expectedTargetCount = 0
) {
$block->assertWiki( $this->wikiId );
$blocker = $block->getBlocker();
if ( !$blocker || $blocker->getName() === '' ) {
throw new InvalidArgumentException( 'Cannot insert a block without a blocker set' );
}
if ( $expectedTargetCount instanceof IDatabase ) {
throw new InvalidArgumentException(
'Old method signature: Passing a custom database connection to '
. 'DatabaseBlockStore::insertBlock is no longer supported'
);
}
$this->logger->debug( 'Inserting block; timestamp ' . $block->getTimestamp() );
// Purge expired blocks. This now just queues a deferred update, so it
// is possible for expired blocks to conflict with inserted blocks below.
$this->purgeExpiredBlocks();
$dbw = $this->getPrimaryDB();
$dbw->startAtomic( __METHOD__ );
$success = $this->attemptInsert( $block, $dbw, $expectedTargetCount );
// Don't collide with expired blocks.
// Do this after trying to insert to avoid locking.
if ( !$success ) {
if ( $this->purgeExpiredConflicts( $block, $dbw ) ) {
$success = $this->attemptInsert( $block, $dbw, $expectedTargetCount );
}
}
$dbw->endAtomic( __METHOD__ );
if ( $success ) {
$autoBlockIds = $this->doRetroactiveAutoblock( $block );
if ( $this->options->get( MainConfigNames::BlockDisablesLogin ) ) {
$targetUserIdentity = $block->getTargetUserIdentity();
if ( $targetUserIdentity ) {
$targetUser = $this->userFactory->newFromUserIdentity( $targetUserIdentity );
// TODO: respect the wiki the block belongs to here
// Change user login token to force them to be logged out.
$targetUser->setToken();
$targetUser->saveSettings();
}
}
return [ 'id' => $block->getId( $this->wikiId ), 'autoIds' => $autoBlockIds ];
}
return false;
}
/**
* Attempt to insert rows into ipblocks/block, block_target and
* ipblocks_restrictions. If there is a conflict, return false.
*
* @param DatabaseBlock $block
* @param IDatabase $dbw
* @param int|null $expectedTargetCount
* @return bool True if block successfully inserted
*/
private function attemptInsert(
DatabaseBlock $block,
IDatabase $dbw,
$expectedTargetCount
) {
$id = null;
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$row = $this->getArrayForBlockUpdate( $block, $dbw, self::SCHEMA_IPBLOCKS );
$dbw->newInsertQueryBuilder()
->insertInto( 'ipblocks' )
->ignore()
->row( $row )
->caller( __METHOD__ )->execute();
if ( !$dbw->affectedRows() ) {
return false;
}
$id = $dbw->insertId();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$targetId = $this->acquireTarget( $block, $dbw, $expectedTargetCount );
if ( !$targetId ) {
return false;
}
$row = $this->getArrayForBlockUpdate( $block, $dbw, self::SCHEMA_BLOCK );
if ( $id !== null ) {
$row['bl_id'] = $id;
}
$row['bl_target'] = $targetId;
$dbw->newInsertQueryBuilder()
->insertInto( 'block' )
->row( $row )
->caller( __METHOD__ )->execute();
if ( !$dbw->affectedRows() ) {
return false;
}
if ( $id === null ) {
$id = $dbw->insertId();
}
}
if ( !$id ) {
throw new RuntimeException( 'block insert ID is falsey' );
}
$block->setId( $id );
$restrictions = $block->getRawRestrictions();
if ( $restrictions ) {
$this->blockRestrictionStore->insert( $restrictions );
}
return true;
}
/**
* Purge expired blocks that have the same target as the specified block
*
* @param DatabaseBlock $block
* @param IDatabase $dbw
* @return bool True if a conflicting block was deleted
*/
private function purgeExpiredConflicts(
DatabaseBlock $block,
IDatabase $dbw
) {
$ipblocksIDs = [];
$blockDeletionDone = false;
// T96428: The ipb_address index uses a prefix on a field, so
// use a standard SELECT + DELETE to avoid annoying gap locks.
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$ipblocksIDs = $dbw->newSelectQueryBuilder()
->select( 'ipb_id' )
->from( 'ipblocks' )
->where( [ 'ipb_address' => $block->getTargetName() ] )
->andWhere( $dbw->expr( 'ipb_expiry', '<', $dbw->timestamp() ) )
->caller( __METHOD__ )->fetchFieldValues();
if ( $ipblocksIDs ) {
$ipblocksIDs = array_map( 'intval', $ipblocksIDs );
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'ipblocks' )
->where( [ 'ipb_id' => $ipblocksIDs ] )
->caller( __METHOD__ )->execute();
$this->blockRestrictionStore->deleteByBlockId( $ipblocksIDs );
}
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$targetConds = $this->getTargetConds( $block );
$res = $dbw->newSelectQueryBuilder()
->select( [ 'bl_id', 'bl_target' ] )
->from( 'block' )
->join( 'block_target', null, [ 'bt_id=bl_target' ] )
->where( $targetConds )
->andWhere( $dbw->expr( 'bl_expiry', '<', $dbw->timestamp() ) )
->caller( __METHOD__ )->fetchResultSet();
$blockDeletionDone = (bool)$this->deleteBlockRows( $res );
}
return $ipblocksIDs || $blockDeletionDone;
}
/**
* Get conditions matching the block's block_target row
*
* @param DatabaseBlock $block
* @return array
*/
private function getTargetConds( DatabaseBlock $block ) {
if ( $block->getType() === Block::TYPE_USER ) {
return [
'bt_user' => $block->getTargetUserIdentity()->getId( $this->wikiId )
];
} else {
return [ 'bt_address' => $block->getTargetName() ];
}
}
/**
* Insert a new block_target row, or update bt_count in the existing target
* row for a given block, and return the target ID.
*
* An atomic section should be active while calling this function.
*
* @param DatabaseBlock $block
* @param IDatabase $dbw
* @param int|null $expectedTargetCount If this is zero and a row already
* exists, abort the insert and return null. If this is greater than zero
* and the pre-increment bt_count value does not match, abort the update
* and return null. If this is null, do not perform any conflict checks.
* @return int|null
*/
private function acquireTarget(
DatabaseBlock $block,
IDatabase $dbw,
$expectedTargetCount
) {
$isUser = $block->getType() === Block::TYPE_USER;
$isRange = $block->getType() === Block::TYPE_RANGE;
$isAuto = $block->getType() === Block::TYPE_AUTO;
$isSingle = !$isUser && !$isRange;
$targetAddress = $isUser ? null : $block->getTargetName();
$targetUserName = $isUser ? $block->getTargetName() : null;
$targetUserId = $isUser
? $block->getTargetUserIdentity()->getId( $this->wikiId ) : null;
// Update bt_count field in existing target, if there is one
if ( $isUser ) {
$targetConds = [ 'bt_user' => $targetUserId ];
} else {
$targetConds = [
'bt_address' => $targetAddress,
'bt_auto' => $isAuto,
];
}
$condsWithCount = $targetConds;
if ( $expectedTargetCount !== null ) {
$condsWithCount['bt_count'] = $expectedTargetCount;
}
// This query locks the index gap when the target doesn't exist yet,
// so there is a risk of throttling adjacent block insertions,
// especially on small wikis which have larger gaps. If this proves to
// be a problem, we could have getPrimaryDB() return an autocommit
// connection.
$dbw->newUpdateQueryBuilder()
->update( 'block_target' )
->set( 'bt_count=bt_count+1' )
->where( $condsWithCount )
->caller( __METHOD__ )->execute();
$numUpdatedRows = $dbw->affectedRows();
// Now that the row is locked, find the target ID
$ids = $dbw->newSelectQueryBuilder()
->select( 'bt_id' )
->from( 'block_target' )
->where( $targetConds )
->caller( __METHOD__ )
->fetchFieldValues();
if ( count( $ids ) > 1 ) {
throw new RuntimeException( "Duplicate block_target rows detected: " .
implode( ',', $ids ) );
}
$id = $ids[0] ?? false;
if ( $id === false ) {
if ( $numUpdatedRows ) {
throw new RuntimeException(
'block_target row unexpectedly missing after we locked it' );
}
if ( $expectedTargetCount !== 0 && $expectedTargetCount !== null ) {
// Conflict (expectation failure)
return null;
}
// Insert new row
$targetRow = [
'bt_address' => $targetAddress,
'bt_user' => $targetUserId,
'bt_user_text' => $targetUserName,
'bt_auto' => $isAuto,
'bt_range_start' => $isRange ? $block->getRangeStart() : null,
'bt_range_end' => $isRange ? $block->getRangeEnd() : null,
'bt_ip_hex' => $isSingle || $isRange ? $block->getRangeStart() : null,
'bt_count' => 1
];
$dbw->newInsertQueryBuilder()
->insertInto( 'block_target' )
->row( $targetRow )
->caller( __METHOD__ )->execute();
$id = $dbw->insertId();
if ( !$id ) {
throw new RuntimeException(
'block_target insert ID is falsey despite unconditional insert' );
}
} elseif ( !$numUpdatedRows ) {
// ID found but count update failed -- must be a conflict due to bt_count mismatch
return null;
}
return (int)$id;
}
/**
* Update a block in the DB with new parameters.
* The ID field needs to be loaded first. The target must stay the same.
*
* @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() );
$block->assertWiki( $this->wikiId );
$blockId = $block->getId( $this->wikiId );
if ( !$blockId ) {
throw new InvalidArgumentException(
__METHOD__ . " requires that a block id be set\n"
);
}
$dbw = $this->getPrimaryDB();
$dbw->startAtomic( __METHOD__ );
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$row = $this->getArrayForBlockUpdate( $block, $dbw, self::SCHEMA_IPBLOCKS );
$dbw->newUpdateQueryBuilder()
->update( 'ipblocks' )
->set( $row )
->where( [ 'ipb_id' => $blockId ] )
->caller( __METHOD__ )->execute();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$row = $this->getArrayForBlockUpdate( $block, $dbw, self::SCHEMA_BLOCK );
$dbw->newUpdateQueryBuilder()
->update( 'block' )
->set( $row )
->where( [ 'bl_id' => $blockId ] )
->caller( __METHOD__ )->execute();
}
// Only update the restrictions if they have been modified.
$result = true;
$restrictions = $block->getRawRestrictions();
if ( $restrictions !== null ) {
// An empty array should remove all of the restrictions.
if ( $restrictions === [] ) {
$result = $this->blockRestrictionStore->deleteByBlockId( $blockId );
} else {
$result = $this->blockRestrictionStore->update( $restrictions );
}
}
if ( $block->isAutoblocking() ) {
// Update corresponding autoblock(s) (T50813)
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$dbw->newUpdateQueryBuilder()
->update( 'ipblocks' )
->set( $this->getArrayForAutoblockUpdate( $block, self::SCHEMA_IPBLOCKS ) )
->where( [ 'ipb_parent_block_id' => $blockId ] )
->caller( __METHOD__ )->execute();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->newUpdateQueryBuilder()
->update( 'block' )
->set( $this->getArrayForAutoblockUpdate( $block, self::SCHEMA_BLOCK ) )
->where( [ 'bl_parent_block_id' => $blockId ] )
->caller( __METHOD__ )->execute();
}
// Only update the restrictions if they have been modified.
if ( $restrictions !== null ) {
$this->blockRestrictionStore->updateByParentBlockId(
$blockId,
$restrictions
);
}
} else {
// Autoblock no longer required, delete corresponding autoblock(s)
$this->deleteBlocksMatchingConds( [ 'bl_parent_block_id' => $blockId ] );
}
$dbw->endAtomic( __METHOD__ );
if ( $result ) {
$autoBlockIds = $this->doRetroactiveAutoblock( $block );
return [ 'id' => $blockId, 'autoIds' => $autoBlockIds ];
}
return false;
}
/**
* Update the target in the specified object and in the database. The block
* ID must be set.
*
* This is an unusual operation, currently used only by the UserMerge
* extension.
*
* @since 1.42
* @param DatabaseBlock $block
* @param UserIdentity|string $newTarget
* @return bool True if the update was successful, false if there was no
* match for the block ID.
*/
public function updateTarget( DatabaseBlock $block, $newTarget ) {
$dbw = $this->getPrimaryDB();
$blockId = $block->getId( $this->wikiId );
if ( !$blockId ) {
throw new InvalidArgumentException(
__METHOD__ . " requires that a block id be set\n"
);
}
$oldTargetConds = $this->getTargetConds( $block );
$block->setTarget( $newTarget );
$affected = 0;
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
if ( $block->getTargetUserIdentity() ) {
$userId = $block->getTargetUserIdentity()->getId( $this->wikiId );
} else {
$userId = 0;
}
$dbw->newUpdateQueryBuilder()
->update( 'ipblocks' )
->set( [
'ipb_address' => $block->getTargetName(),
'ipb_user' => $userId,
] )
->where( [ 'ipb_id' => $blockId ] )
->caller( __METHOD__ )
->execute();
$affected = $dbw->affectedRows();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->startAtomic( __METHOD__ );
$targetId = $this->acquireTarget( $block, $dbw, null );
if ( !$targetId ) {
// This is an exotic and unlikely error -- perhaps an exception should be thrown
$dbw->endAtomic( __METHOD__ );
return false;
}
$oldTargetId = $dbw->newSelectQueryBuilder()
->select( 'bt_id' )
->from( 'block_target' )
->where( $oldTargetConds )
->caller( __METHOD__ )->fetchField();
$this->releaseTargets( $dbw, [ $oldTargetId ] );
$dbw->newUpdateQueryBuilder()
->update( 'block' )
->set( [ 'bl_target' => $targetId ] )
->where( [ 'bl_id' => $blockId ] )
->caller( __METHOD__ )
->execute();
$affected = max( $affected, $dbw->affectedRows() );
$dbw->endAtomic( __METHOD__ );
}
return (bool)$affected;
}
/**
* Delete a DatabaseBlock from the database
*
* @param DatabaseBlock $block
* @return bool whether it was deleted
*/
public function deleteBlock( DatabaseBlock $block ): bool {
if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
return false;
}
$block->assertWiki( $this->wikiId );
$blockId = $block->getId( $this->wikiId );
if ( !$blockId ) {
throw new InvalidArgumentException(
__METHOD__ . " requires that a block id be set\n"
);
}
$dbw = $this->getPrimaryDB();
$dbw->startAtomic( __METHOD__ );
$affected = 0;
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$ids = $dbw->newSelectQueryBuilder()
->select( 'ipb_id' )
->from( 'ipblocks' )
->where( [ 'ipb_parent_block_id' => $blockId ] )
->caller( __METHOD__ )->fetchFieldValues();
$ids = array_map( 'intval', $ids );
$ids[] = $blockId;
$this->blockRestrictionStore->deleteByBlockId( $ids );
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'ipblocks' )
->where( [ 'ipb_id' => $ids ] )
->caller( __METHOD__ )->execute();
$affected = $dbw->affectedRows();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$res = $dbw->newSelectQueryBuilder()
->select( [ 'bl_id', 'bl_target' ] )
->from( 'block' )
->where(
$dbw->makeList( [
'bl_parent_block_id' => $blockId,
'bl_id' => $blockId,
], IDatabase::LIST_OR )
)
->caller( __METHOD__ )->fetchResultSet();
$this->deleteBlockRows( $res );
$affected = max( $affected, $res->numRows() );
}
$dbw->endAtomic( __METHOD__ );
return $affected > 0;
}
/**
* Get an array suitable for passing to $dbw->insert() or $dbw->update()
*
* @param DatabaseBlock $block
* @param IDatabase $dbw Database to use if not the same as the one in the load balancer.
* Must connect to the wiki identified by $block->getBlocker->getWikiId().
* @param string $schema self:SCHEMA_IPBLOCKS or self::SCHEMA_BLOCK
* @return array
*/
private function getArrayForBlockUpdate(
DatabaseBlock $block,
IDatabase $dbw,
$schema
): array {
$expiry = $dbw->encodeExpiry( $block->getExpiry() );
if ( $block->getTargetUserIdentity() ) {
$userId = $block->getTargetUserIdentity()->getId( $this->wikiId );
} else {
$userId = 0;
}
$blocker = $block->getBlocker();
if ( !$blocker ) {
throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
}
// DatabaseBlockStore supports inserting cross-wiki blocks by passing
// non-local IDatabase and blocker.
$blockerActor = $this->actorStoreFactory
->getActorStore( $dbw->getDomainID() )
->acquireActorId( $blocker, $dbw );
if ( $schema === self::SCHEMA_IPBLOCKS ) {
$blockArray = [
'ipb_address' => $block->getTargetName(),
'ipb_user' => $userId,
'ipb_by_actor' => $blockerActor,
'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()
);
} else {
$blockArray = [
'bl_by_actor' => $blockerActor,
'bl_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
'bl_anon_only' => !$block->isHardblock(),
'bl_create_account' => $block->isCreateAccountBlocked(),
'bl_enable_autoblock' => $block->isAutoblocking(),
'bl_expiry' => $expiry,
'bl_deleted' => intval( $block->getHideName() ), // typecast required for SQLite
'bl_block_email' => $block->isEmailBlocked(),
'bl_allow_usertalk' => $block->isUsertalkEditAllowed(),
'bl_parent_block_id' => $block->getParentBlockId(),
'bl_sitewide' => $block->isSitewide(),
];
$commentArray = $this->commentStore->insert(
$dbw,
'bl_reason',
$block->getReasonComment()
);
}
$combinedArray = $blockArray + $commentArray;
return $combinedArray;
}
/**
* Get an array suitable for autoblock updates
*
* @param DatabaseBlock $block
* @param string $schema
* @return array
*/
private function getArrayForAutoblockUpdate( DatabaseBlock $block, $schema ): array {
$blocker = $block->getBlocker();
if ( !$blocker ) {
throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
}
$dbw = $this->getPrimaryDB();
$blockerActor = $this->actorStoreFactory
->getActorNormalization( $this->wikiId )
->acquireActorId( $blocker, $dbw );
if ( $schema === self::SCHEMA_IPBLOCKS ) {
$blockArray = [
'ipb_by_actor' => $blockerActor,
'ipb_create_account' => $block->isCreateAccountBlocked(),
'ipb_deleted' => (int)$block->getHideName(), // typecast required for SQLite
'ipb_allow_usertalk' => $block->isUsertalkEditAllowed(),
'ipb_sitewide' => $block->isSitewide(),
];
if ( $block->getExpiry() !== 'infinity' ) {
// Shorten the autoblock expiry if the parent block expiry is sooner.
// Don't lengthen -- that is only done when the IP address is actually
// used by the blocked user.
$blockArray[] = 'ipb_expiry=' . $dbw->conditional(
$dbw->expr( 'ipb_expiry', '>', $dbw->timestamp( $block->getExpiry() ) ),
$dbw->addQuotes( $dbw->timestamp( $block->getExpiry() ) ),
'ipb_expiry'
);
}
$commentArray = $this->commentStore->insert(
$dbw,
'ipb_reason',
$this->getAutoblockReason( $block )
);
} else {
$blockArray = [
'bl_by_actor' => $blockerActor,
'bl_create_account' => $block->isCreateAccountBlocked(),
'bl_deleted' => (int)$block->getHideName(), // typecast required for SQLite
'bl_allow_usertalk' => $block->isUsertalkEditAllowed(),
'bl_sitewide' => $block->isSitewide(),
];
// Shorten the autoblock expiry if the parent block expiry is sooner.
// Don't lengthen -- that is only done when the IP address is actually
// used by the blocked user.
if ( $block->getExpiry() !== 'infinity' ) {
$blockArray[] = 'bl_expiry=' . $dbw->conditional(
$dbw->expr( 'bl_expiry', '>', $dbw->timestamp( $block->getExpiry() ) ),
$dbw->addQuotes( $dbw->timestamp( $block->getExpiry() ) ),
'bl_expiry'
);
}
$commentArray = $this->commentStore->insert(
$dbw,
'bl_reason',
$this->getAutoblockReason( $block )
);
}
$combinedArray = $blockArray + $commentArray;
return $combinedArray;
}
/**
* Handle 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->getTargetName()
);
$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( MainConfigNames::PutIPinRC ) ) {
// No IPs in the recent changes table to autoblock
return [];
}
$type = $block->getType();
if ( $type !== AbstractBlock::TYPE_USER ) {
// Autoblocks only apply to users
return [];
}
$dbr = $this->getReplicaDB();
$targetUser = $block->getTargetUserIdentity();
$actor = $targetUser ? $this->actorStoreFactory
->getActorNormalization( $this->wikiId )
->findActorId( $targetUser, $dbr ) : null;
if ( !$actor ) {
$this->logger->debug( 'No actor found to retroactively autoblock' );
return [];
}
$rcIp = $dbr->newSelectQueryBuilder()
->select( 'rc_ip' )
->from( 'recentchanges' )
->where( [ 'rc_actor' => $actor ] )
->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
->caller( __METHOD__ )->fetchField();
if ( !$rcIp ) {
$this->logger->debug( 'No IP found to retroactively autoblock' );
return [];
}
$id = $this->doAutoblock( $block, $rcIp );
if ( !$id ) {
return [];
}
return [ $id ];
}
/**
* Autoblocks the given IP, referring to the specified block.
*
* @since 1.42
* @param DatabaseBlock $parentBlock
* @param string $autoblockIP The IP to autoblock.
* @return int|false ID if an autoblock was inserted, false if not.
*/
public function doAutoblock( DatabaseBlock $parentBlock, $autoblockIP ) {
// If autoblocks are disabled, go away.
if ( !$parentBlock->isAutoblocking() ) {
return false;
}
$parentBlock->assertWiki( $this->wikiId );
[ $target, $type ] = $this->blockUtils->parseBlockTarget( $autoblockIP );
if ( $type != Block::TYPE_IP ) {
$this->logger->debug( "Autoblock not supported for ip ranges." );
return false;
}
$target = (string)$target;
// Check if autoblock exempt.
if ( $this->autoblockExemptionList->isExempt( $target ) ) {
return false;
}
// Allow hooks to cancel the autoblock.
if ( !$this->hookRunner->onAbortAutoblock( $target, $parentBlock ) ) {
$this->logger->debug( "Autoblock aborted by hook." );
return false;
}
// It's okay to autoblock. Go ahead and insert/update the block...
// Do not add a *new* block if the IP is already blocked.
$blocks = $this->newLoad( $target, Block::TYPE_IP, false );
if ( $blocks ) {
foreach ( $blocks as $ipblock ) {
// Check if the block is an autoblock and would exceed the user block
// if renewed. If so, do nothing, otherwise prolong the block time...
if ( $ipblock->getType() === Block::TYPE_AUTO
&& $parentBlock->getExpiry() > $ipblock->getExpiry()
) {
// Reset block timestamp to now and its expiry to
// $wgAutoblockExpiry in the future
$this->updateTimestamp( $ipblock );
}
}
return false;
}
$blocker = $parentBlock->getBlocker();
if ( !$blocker ) {
throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
}
$timestamp = wfTimestampNow();
$expiry = $this->getAutoblockExpiry( $timestamp, $parentBlock->getExpiry() );
$autoblock = new DatabaseBlock( [
'wiki' => $this->wikiId,
'address' => UserIdentityValue::newAnonymous( $target, $this->wikiId ),
'by' => $blocker,
'reason' => $this->getAutoblockReason( $parentBlock ),
'decodedTimestamp' => $timestamp,
'auto' => true,
'createAccount' => $parentBlock->isCreateAccountBlocked(),
// Continue suppressing the name if needed
'hideName' => $parentBlock->getHideName(),
'allowUsertalk' => $parentBlock->isUsertalkEditAllowed(),
'parentBlockId' => $parentBlock->getId( $this->wikiId ),
'sitewide' => $parentBlock->isSitewide(),
'restrictions' => $parentBlock->getRestrictions(),
'decodedExpiry' => $expiry,
] );
$this->logger->debug( "Autoblocking {$parentBlock->getTargetName()}@" . $target );
$status = $this->insertBlock( $autoblock );
return $status
? $status['id']
: false;
}
private function getAutoblockReason( DatabaseBlock $parentBlock ) {
return wfMessage(
'autoblocker',
$parentBlock->getTargetName(),
$parentBlock->getReasonComment()->text
)->inContentLanguage()->plain();
}
/**
* Update the timestamp on autoblocks.
*
* @internal Public to support deprecated DatabaseBlock::updateTimestamp()
* @param DatabaseBlock $block
*/
public function updateTimestamp( DatabaseBlock $block ) {
$block->assertWiki( $this->wikiId );
if ( $block->getType() !== Block::TYPE_AUTO ) {
return;
}
$now = wfTimestamp();
$block->setTimestamp( $now );
// No need to reduce the autoblock expiry to the expiry of the parent
// block, since the caller already checked for that.
$block->setExpiry( $this->getAutoblockExpiry( $now ) );
$dbw = $this->getPrimaryDB();
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
$dbw->newUpdateQueryBuilder()
->update( 'ipblocks' )
->set(
[
'ipb_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
'ipb_expiry' => $dbw->timestamp( $block->getExpiry() ),
]
)
->where( [ 'ipb_id' => $block->getId( $this->wikiId ) ] )
->caller( __METHOD__ )->execute();
}
if ( $this->writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
$dbw->newUpdateQueryBuilder()
->update( 'block' )
->set(
[
'bl_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
'bl_expiry' => $dbw->timestamp( $block->getExpiry() ),
]
)
->where( [ 'bl_id' => $block->getId( $this->wikiId ) ] )
->caller( __METHOD__ )->execute();
}
}
/**
* Get the expiry timestamp for an autoblock created at the given time.
*
* If the parent block expiry is specified, the return value will be earlier
* than or equal to the parent block expiry.
*
* @internal Public to support deprecated DatabaseBlock method
* @param string|int $timestamp
* @param string|null $parentExpiry
* @return string
*/
public function getAutoblockExpiry( $timestamp, string $parentExpiry = null ) {
$maxDuration = $this->options->get( MainConfigNames::AutoblockExpiry );
$expiry = wfTimestamp( TS_MW, (int)wfTimestamp( TS_UNIX, $timestamp ) + $maxDuration );
if ( $parentExpiry !== null && $parentExpiry !== 'infinity' ) {
$expiry = min( $parentExpiry, $expiry );
}
return $expiry;
}
// endregion -- end of database write methods
}