wiki.techinc.nl/includes/block/DatabaseBlockStore.php

557 lines
16 KiB
PHP
Raw Normal View History

<?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 AutoCommitUpdate;
use CommentStore;
use DeferredUpdates;
use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\User\ActorStoreFactory;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MWException;
use Psr\Log\LoggerInterface;
use ReadOnlyMode;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* @since 1.36
*
* @author DannyS712
*/
class DatabaseBlockStore {
/** @var ServiceOptions */
private $options;
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
'PutIPinRC',
'BlockDisablesLogin',
];
/** @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;
/**
* @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
*/
public function __construct(
ServiceOptions $options,
LoggerInterface $logger,
ActorStoreFactory $actorStoreFactory,
BlockRestrictionStore $blockRestrictionStore,
CommentStore $commentStore,
HookContainer $hookContainer,
ILoadBalancer $loadBalancer,
ReadOnlyMode $readOnlyMode,
UserFactory $userFactory
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$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;
}
/**
* 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_PRIMARY );
$blockRestrictionStore = $this->blockRestrictionStore;
DeferredUpdates::addUpdate( new AutoCommitUpdate(
$dbw,
__METHOD__,
static 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 );
}
}
) );
}
/**
* Throws an exception if the given database connection does not match the
* given wiki ID.
*
* @param ?IDatabase $db
* @param string|false $expectedWiki
*/
private function checkDatabaseDomain( ?IDatabase $db, $expectedWiki ) {
if ( $db ) {
$dbDomain = $db->getDomainID();
$storeDomain = $this->loadBalancer->resolveDomainID( $expectedWiki );
if ( $dbDomain !== $storeDomain ) {
throw new InvalidArgumentException(
"DB connection domain '$dbDomain' does not match '$storeDomain'"
);
}
} else {
if ( $expectedWiki !== UserIdentity::LOCAL ) {
throw new InvalidArgumentException(
"Must provide a database connection for wiki '$expectedWiki'."
);
}
}
}
/**
* 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.
* Must connect to the wiki identified by $block->getBlocker->getWikiId().
* @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->checkDatabaseDomain( $database, $block->getBlocker()->getWikiId() );
$this->logger->debug( 'Inserting block; timestamp ' . $block->getTimestamp() );
// TODO T258866 - consider passing the database
$this->purgeExpiredBlocks();
$dbw = $database ?: $this->loadBalancer->getConnectionRef( DB_PRIMARY );
$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' ) ) {
$targetUserIdentity = $block->getTargetUserIdentity();
if ( $targetUserIdentity ) {
$targetUser = $this->userFactory->newFromUserIdentity( $targetUserIdentity );
// Change user login token to force them to be logged out.
$targetUser->setToken();
$targetUser->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() );
// We could allow cross-wiki updates here, just like we do in insertBlock().
Assert::parameter(
$block->getBlocker()->getWikiId() === UserIdentity::LOCAL,
'$block->getBlocker()',
'must belong to the local wiki.'
);
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
$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_PRIMARY );
$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|null $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().
* @return array
*/
private function getArrayForDatabaseBlock(
DatabaseBlock $block,
IDatabase $dbw
) : array {
$expiry = $dbw->encodeExpiry( $block->getExpiry() );
if ( $block->getTargetUserIdentity() ) {
$userId = $block->getTargetUserIdentity()->getId();
} else {
$userId = 0;
}
if ( !$block->getBlocker() ) {
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( $block->getBlocker(), $dbw );
$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()
);
$combinedArray = $blockArray + $commentArray;
return $combinedArray;
}
/**
* Get an array suitable for autoblock updates
*
* @param DatabaseBlock $block
* @return array
*/
private function getArrayForAutoblockUpdate( DatabaseBlock $block ) : array {
if ( !$block->getBlocker() ) {
throw new \RuntimeException( __METHOD__ . ': this block does not have a blocker' );
}
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
$blockerActor = $this->actorStoreFactory
->getActorNormalization()
->acquireActorId( $block->getBlocker(), $dbw );
$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(),
];
$commentArray = $this->commentStore->insert(
$dbw,
'ipb_reason',
$block->getReasonComment()
);
$combinedArray = $blockArray + $commentArray;
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->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( '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->loadBalancer->getConnectionRef( DB_REPLICA );
$options = [
'ORDER BY' => 'rc_timestamp DESC',
'LIMIT' => 1,
];
$targetUser = $block->getTargetUserIdentity();
$actor = $targetUser ? $this->actorStoreFactory->getActorNormalization()->findActorId(
$targetUser,
$dbr
) : null;
if ( !$actor ) {
$this->logger->debug( 'No actor found to retroactively autoblock' );
return [];
}
$res = $dbr->select(
[ 'recentchanges' ],
[ 'rc_ip' ],
[ 'rc_actor' => $actor ],
__METHOD__,
$options
);
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;
}
}