MediaWiki  master
DatabaseBlockStore.php
Go to the documentation of this file.
1 <?php
23 namespace MediaWiki\Block;
24 
26 use CommentStore;
27 use DeferredUpdates;
28 use InvalidArgumentException;
35 use MWException;
36 use Psr\Log\LoggerInterface;
37 use ReadOnlyMode;
38 use Wikimedia\Assert\Assert;
41 
48 
50  private $options;
51 
55  public const CONSTRUCTOR_OPTIONS = [
59  ];
60 
62  private $logger;
63 
65  private $actorStoreFactory;
66 
68  private $blockRestrictionStore;
69 
71  private $commentStore;
72 
74  private $hookRunner;
75 
77  private $loadBalancer;
78 
80  private $readOnlyMode;
81 
83  private $userFactory;
84 
96  public function __construct(
97  ServiceOptions $options,
98  LoggerInterface $logger,
99  ActorStoreFactory $actorStoreFactory,
100  BlockRestrictionStore $blockRestrictionStore,
101  CommentStore $commentStore,
102  HookContainer $hookContainer,
103  ILoadBalancer $loadBalancer,
104  ReadOnlyMode $readOnlyMode,
105  UserFactory $userFactory
106  ) {
107  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
108 
109  $this->options = $options;
110  $this->logger = $logger;
111  $this->actorStoreFactory = $actorStoreFactory;
112  $this->blockRestrictionStore = $blockRestrictionStore;
113  $this->commentStore = $commentStore;
114  $this->hookRunner = new HookRunner( $hookContainer );
115  $this->loadBalancer = $loadBalancer;
116  $this->readOnlyMode = $readOnlyMode;
117  $this->userFactory = $userFactory;
118  }
119 
125  public function purgeExpiredBlocks() {
126  if ( $this->readOnlyMode->isReadOnly() ) {
127  return;
128  }
129 
130  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
131  $store = $this->blockRestrictionStore;
132  $limit = $this->options->get( MainConfigNames::UpdateRowsPerQuery );
133 
135  $dbw,
136  __METHOD__,
137  static function ( IDatabase $dbw, $fname ) use ( $store, $limit ) {
138  $ids = $dbw->selectFieldValues(
139  'ipblocks',
140  'ipb_id',
141  [ 'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
142  $fname,
143  // Set a limit to avoid causing read-only mode (T301742)
144  [ 'LIMIT' => $limit ]
145  );
146  if ( $ids ) {
147  $store->deleteByBlockId( $ids );
148  $dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], $fname );
149  }
150  }
151  ) );
152  }
153 
161  private function checkDatabaseDomain( ?IDatabase $db, $expectedWiki ) {
162  if ( $db ) {
163  $dbDomain = $db->getDomainID();
164  $storeDomain = $this->loadBalancer->resolveDomainID( $expectedWiki );
165  if ( $dbDomain !== $storeDomain ) {
166  throw new InvalidArgumentException(
167  "DB connection domain '$dbDomain' does not match '$storeDomain'"
168  );
169  }
170  } else {
171  if ( $expectedWiki !== Block::LOCAL ) {
172  throw new InvalidArgumentException(
173  "Must provide a database connection for wiki '$expectedWiki'."
174  );
175  }
176  }
177  }
178 
190  public function insertBlock(
191  DatabaseBlock $block,
192  IDatabase $database = null
193  ) {
194  $blocker = $block->getBlocker();
195  if ( !$blocker || $blocker->getName() === '' ) {
196  throw new MWException( 'Cannot insert a block without a blocker set' );
197  }
198 
199  $this->checkDatabaseDomain( $database, $block->getWikiId() );
200 
201  $this->logger->debug( 'Inserting block; timestamp ' . $block->getTimestamp() );
202 
203  // TODO T258866 - consider passing the database
204  $this->purgeExpiredBlocks();
205 
206  $dbw = $database ?: $this->loadBalancer->getConnectionRef( DB_PRIMARY );
207  $row = $this->getArrayForDatabaseBlock( $block, $dbw );
208 
209  $dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
210  $affected = $dbw->affectedRows();
211 
212  if ( $affected ) {
213  $block->setId( $dbw->insertId() );
214  $restrictions = $block->getRawRestrictions();
215  if ( $restrictions ) {
216  $this->blockRestrictionStore->insert( $restrictions );
217  }
218  }
219 
220  // Don't collide with expired blocks.
221  // Do this after trying to insert to avoid locking.
222  if ( !$affected ) {
223  // T96428: The ipb_address index uses a prefix on a field, so
224  // use a standard SELECT + DELETE to avoid annoying gap locks.
225  $ids = $dbw->selectFieldValues(
226  'ipblocks',
227  'ipb_id',
228  [
229  'ipb_address' => $row['ipb_address'],
230  'ipb_user' => $row['ipb_user'],
231  'ipb_expiry < ' . $dbw->addQuotes( $dbw->timestamp() )
232  ],
233  __METHOD__
234  );
235  if ( $ids ) {
236  $dbw->delete( 'ipblocks', [ 'ipb_id' => $ids ], __METHOD__ );
237  $this->blockRestrictionStore->deleteByBlockId( $ids );
238  $dbw->insert( 'ipblocks', $row, __METHOD__, [ 'IGNORE' ] );
239  $affected = $dbw->affectedRows();
240  $block->setId( $dbw->insertId() );
241  $restrictions = $block->getRawRestrictions();
242  if ( $restrictions ) {
243  $this->blockRestrictionStore->insert( $restrictions );
244  }
245  }
246  }
247 
248  if ( $affected ) {
249  $autoBlockIds = $this->doRetroactiveAutoblock( $block );
250 
251  if ( $this->options->get( MainConfigNames::BlockDisablesLogin ) ) {
252  $targetUserIdentity = $block->getTargetUserIdentity();
253  if ( $targetUserIdentity ) {
254  $targetUser = $this->userFactory->newFromUserIdentity( $targetUserIdentity );
255  // Change user login token to force them to be logged out.
256  $targetUser->setToken();
257  $targetUser->saveSettings();
258  }
259  }
260 
261  return [ 'id' => $block->getId(), 'autoIds' => $autoBlockIds ];
262  }
263 
264  return false;
265  }
266 
275  public function updateBlock( DatabaseBlock $block ) {
276  $this->logger->debug( 'Updating block; timestamp ' . $block->getTimestamp() );
277 
278  // We could allow cross-wiki updates here, just like we do in insertBlock().
279  Assert::parameter(
280  $block->getWikiId() === Block::LOCAL,
281  '$block->getWikiId()',
282  'must belong to the local wiki.'
283  );
284  $blockId = $block->getId();
285  if ( !$blockId ) {
286  throw new MWException(
287  __METHOD__ . " requires that a block id be set\n"
288  );
289  }
290 
291  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
292  $row = $this->getArrayForDatabaseBlock( $block, $dbw );
293  $dbw->startAtomic( __METHOD__ );
294 
295  $result = $dbw->update(
296  'ipblocks',
297  $row,
298  [ 'ipb_id' => $blockId ],
299  __METHOD__
300  );
301 
302  // Only update the restrictions if they have been modified.
303  $restrictions = $block->getRawRestrictions();
304  if ( $restrictions !== null ) {
305  // An empty array should remove all of the restrictions.
306  if ( empty( $restrictions ) ) {
307  $success = $this->blockRestrictionStore->deleteByBlockId( $blockId );
308  } else {
309  $success = $this->blockRestrictionStore->update( $restrictions );
310  }
311  // Update the result. The first false is the result, otherwise, true.
312  $result = $result && $success;
313  }
314 
315  if ( $block->isAutoblocking() ) {
316  // update corresponding autoblock(s) (T50813)
317  $dbw->update(
318  'ipblocks',
319  $this->getArrayForAutoblockUpdate( $block ),
320  [ 'ipb_parent_block_id' => $blockId ],
321  __METHOD__
322  );
323 
324  // Only update the restrictions if they have been modified.
325  if ( $restrictions !== null ) {
326  $this->blockRestrictionStore->updateByParentBlockId(
327  $blockId,
328  $restrictions
329  );
330  }
331  } else {
332  // autoblock no longer required, delete corresponding autoblock(s)
333  $this->blockRestrictionStore->deleteByParentBlockId( $blockId );
334  $dbw->delete(
335  'ipblocks',
336  [ 'ipb_parent_block_id' => $blockId ],
337  __METHOD__
338  );
339  }
340 
341  $dbw->endAtomic( __METHOD__ );
342 
343  if ( $result ) {
344  $autoBlockIds = $this->doRetroactiveAutoblock( $block );
345  return [ 'id' => $blockId, 'autoIds' => $autoBlockIds ];
346  }
347 
348  return false;
349  }
350 
358  public function deleteBlock( DatabaseBlock $block ): bool {
359  if ( $this->readOnlyMode->isReadOnly() ) {
360  return false;
361  }
362 
363  $blockId = $block->getId();
364 
365  if ( !$blockId ) {
366  throw new MWException(
367  __METHOD__ . " requires that a block id be set\n"
368  );
369  }
370  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
371 
372  $this->blockRestrictionStore->deleteByParentBlockId( $blockId );
373  $dbw->delete(
374  'ipblocks',
375  [ 'ipb_parent_block_id' => $blockId ],
376  __METHOD__
377  );
378 
379  $this->blockRestrictionStore->deleteByBlockId( $blockId );
380  $dbw->delete(
381  'ipblocks',
382  [ 'ipb_id' => $blockId ],
383  __METHOD__
384  );
385 
386  return $dbw->affectedRows() > 0;
387  }
388 
397  private function getArrayForDatabaseBlock(
398  DatabaseBlock $block,
399  IDatabase $dbw
400  ): array {
401  $expiry = $dbw->encodeExpiry( $block->getExpiry() );
402 
403  if ( $block->getTargetUserIdentity() ) {
404  $userId = $block->getTargetUserIdentity()->getId( $block->getWikiId() );
405  } else {
406  $userId = 0;
407  }
408  $blocker = $block->getBlocker();
409  if ( !$blocker ) {
410  throw new \RuntimeException( __METHOD__ . ': this block does not have a blocker' );
411  }
412  // DatabaseBlockStore supports inserting cross-wiki blocks by passing non-local IDatabase and blocker.
413  $blockerActor = $this->actorStoreFactory
414  ->getActorStore( $dbw->getDomainID() )
415  ->acquireActorId( $blocker, $dbw );
416 
417  $blockArray = [
418  'ipb_address' => $block->getTargetName(),
419  'ipb_user' => $userId,
420  'ipb_by_actor' => $blockerActor,
421  'ipb_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
422  'ipb_auto' => $block->getType() === AbstractBlock::TYPE_AUTO,
423  'ipb_anon_only' => !$block->isHardblock(),
424  'ipb_create_account' => $block->isCreateAccountBlocked(),
425  'ipb_enable_autoblock' => $block->isAutoblocking(),
426  'ipb_expiry' => $expiry,
427  'ipb_range_start' => $block->getRangeStart(),
428  'ipb_range_end' => $block->getRangeEnd(),
429  'ipb_deleted' => intval( $block->getHideName() ), // typecast required for SQLite
430  'ipb_block_email' => $block->isEmailBlocked(),
431  'ipb_allow_usertalk' => $block->isUsertalkEditAllowed(),
432  'ipb_parent_block_id' => $block->getParentBlockId(),
433  'ipb_sitewide' => $block->isSitewide(),
434  ];
435  $commentArray = $this->commentStore->insert(
436  $dbw,
437  'ipb_reason',
438  $block->getReasonComment()
439  );
440 
441  $combinedArray = $blockArray + $commentArray;
442  return $combinedArray;
443  }
444 
451  private function getArrayForAutoblockUpdate( DatabaseBlock $block ): array {
452  $blocker = $block->getBlocker();
453  if ( !$blocker ) {
454  throw new \RuntimeException( __METHOD__ . ': this block does not have a blocker' );
455  }
456  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
457  $blockerActor = $this->actorStoreFactory
458  ->getActorNormalization()
459  ->acquireActorId( $blocker, $dbw );
460  $blockArray = [
461  'ipb_by_actor' => $blockerActor,
462  'ipb_create_account' => $block->isCreateAccountBlocked(),
463  'ipb_deleted' => (int)$block->getHideName(), // typecast required for SQLite
464  'ipb_allow_usertalk' => $block->isUsertalkEditAllowed(),
465  'ipb_sitewide' => $block->isSitewide(),
466  ];
467 
468  $commentArray = $this->commentStore->insert(
469  $dbw,
470  'ipb_reason',
471  $block->getReasonComment()
472  );
473 
474  $combinedArray = $blockArray + $commentArray;
475  return $combinedArray;
476  }
477 
485  private function doRetroactiveAutoblock( DatabaseBlock $block ): array {
486  $autoBlockIds = [];
487  // If autoblock is enabled, autoblock the LAST IP(s) used
488  if ( $block->isAutoblocking() && $block->getType() == AbstractBlock::TYPE_USER ) {
489  $this->logger->debug(
490  'Doing retroactive autoblocks for ' . $block->getTargetName()
491  );
492 
493  $hookAutoBlocked = [];
494  $continue = $this->hookRunner->onPerformRetroactiveAutoblock(
495  $block,
496  $hookAutoBlocked
497  );
498 
499  if ( $continue ) {
500  $coreAutoBlocked = $this->performRetroactiveAutoblock( $block );
501  $autoBlockIds = array_merge( $hookAutoBlocked, $coreAutoBlocked );
502  } else {
503  $autoBlockIds = $hookAutoBlocked;
504  }
505  }
506  return $autoBlockIds;
507  }
508 
516  private function performRetroactiveAutoblock( DatabaseBlock $block ): array {
517  if ( !$this->options->get( MainConfigNames::PutIPinRC ) ) {
518  // No IPs in the recent changes table to autoblock
519  return [];
520  }
521 
522  $type = $block->getType();
523  if ( $type !== AbstractBlock::TYPE_USER ) {
524  // Autoblocks only apply to users
525  return [];
526  }
527 
528  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
529 
530  $targetUser = $block->getTargetUserIdentity();
531  $actor = $targetUser ? $this->actorStoreFactory
532  ->getActorNormalization( $block->getWikiId() )
533  ->findActorId( $targetUser, $dbr ) : null;
534 
535  if ( !$actor ) {
536  $this->logger->debug( 'No actor found to retroactively autoblock' );
537  return [];
538  }
539 
540  $rcIp = $dbr->selectField(
541  [ 'recentchanges' ],
542  'rc_ip',
543  [ 'rc_actor' => $actor ],
544  __METHOD__,
545  [ 'ORDER BY' => 'rc_timestamp DESC' ]
546  );
547 
548  if ( !$rcIp ) {
549  $this->logger->debug( 'No IP found to retroactively autoblock' );
550  return [];
551  }
552 
553  $id = $block->doAutoblock( $rcIp );
554  if ( !$id ) {
555  return [];
556  }
557  return [ $id ];
558  }
559 
560 }
$success
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
Deferrable Update for closure/callback updates that should use auto-commit mode.
Handle database storage of comments such as edit summaries and log reasons.
Class for managing the deferral of updates within the scope of a PHP script invocation.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
MediaWiki exception.
Definition: MWException.php:29
getTimestamp()
Get the timestamp indicating when the block was created.
insertBlock(DatabaseBlock $block, IDatabase $database=null)
Insert a block into the block table.
updateBlock(DatabaseBlock $block)
Update a block in the DB with new parameters.
deleteBlock(DatabaseBlock $block)
Delete a DatabaseBlock from the database.
purgeExpiredBlocks()
Delete expired blocks from the ipblocks table.
__construct(ServiceOptions $options, LoggerInterface $logger, ActorStoreFactory $actorStoreFactory, BlockRestrictionStore $blockRestrictionStore, CommentStore $commentStore, HookContainer $hookContainer, ILoadBalancer $loadBalancer, ReadOnlyMode $readOnlyMode, UserFactory $userFactory)
A DatabaseBlock (unlike a SystemBlock) is stored in the database, may give rise to autoblocks and may...
setId( $blockId)
Set the block ID.
getBlocker()
Get the user who implemented this block.
getId( $wikiId=self::LOCAL)
Get the block ID.(since 1.38) ?int
getRawRestrictions()
Get restrictions without loading from database if not yet loaded.
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:560
A class containing constants representing the names of configuration variables.
const UpdateRowsPerQuery
Name constant for the UpdateRowsPerQuery setting, for use with Config::get()
const BlockDisablesLogin
Name constant for the BlockDisablesLogin setting, for use with Config::get()
const PutIPinRC
Name constant for the PutIPinRC setting, for use with Config::get()
Creates User objects.
Definition: UserFactory.php:38
A service class for fetching the wiki's current read-only mode.
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:40
getDomainID()
Return the currently selected domain ID.
delete( $table, $conds, $fname=__METHOD__)
Delete all rows in a table that match a condition.
selectFieldValues( $table, $var, $cond='', $fname=__METHOD__, $options=[], $join_conds=[])
A SELECT wrapper which returns a list of single field values from result rows.
Create and track the database connections and transactions for a given database cluster.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
const DB_REPLICA
Definition: defines.php:26
const DB_PRIMARY
Definition: defines.php:28