1<?php 2/** 3 * This program is free software; you can redistribute it and/or modify 4 * it under the terms of the GNU General Public License as published by 5 * the Free Software Foundation; either version 2 of the License, or 6 * (at your option) any later version. 7 * 8 * This program is distributed in the hope that it will be useful, 9 * but WITHOUT ANY WARRANTY; without even the implied warranty of 10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 * GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along 14 * with this program; if not, write to the Free Software Foundation, Inc., 15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 * http://www.gnu.org/copyleft/gpl.html 17 * 18 * @file 19 * @ingroup RevisionDelete 20 */ 21 22use MediaWiki\Page\PageIdentity; 23use MediaWiki\Revision\RevisionRecord; 24use Wikimedia\Rdbms\LBFactory; 25 26/** 27 * Abstract base class for a list of deletable items. The list class 28 * needs to be able to make a query from a set of identifiers to pull 29 * relevant rows, to return RevDelItem subclasses wrapping them, and 30 * to wrap bulk update operations. 31 * 32 * @property RevDelItem $current 33 * @method RevDelItem next() 34 * @method RevDelItem reset() 35 * @method RevDelItem current() 36 */ 37abstract class RevDelList extends RevisionListBase { 38 39 /** @var LBFactory */ 40 private $lbFactory; 41 42 /** 43 * @param IContextSource $context 44 * @param PageIdentity $page 45 * @param array $ids 46 * @param LBFactory $lbFactory 47 */ 48 public function __construct( 49 IContextSource $context, 50 PageIdentity $page, 51 array $ids, 52 LBFactory $lbFactory 53 ) { 54 parent::__construct( $context, $page ); 55 56 // ids is a protected variable in RevisionListBase 57 $this->ids = $ids; 58 $this->lbFactory = $lbFactory; 59 } 60 61 /** 62 * Get the DB field name associated with the ID list. 63 * This used to populate the log_search table for finding log entries. 64 * Override this function. 65 * @return string|null 66 */ 67 public static function getRelationType() { 68 return null; 69 } 70 71 /** 72 * Get the user right required for this list type 73 * Override this function. 74 * @since 1.22 75 * @return string|null 76 */ 77 public static function getRestriction() { 78 return null; 79 } 80 81 /** 82 * Get the revision deletion constant for this list type 83 * Override this function. 84 * @since 1.22 85 * @return int|null 86 */ 87 public static function getRevdelConstant() { 88 return null; 89 } 90 91 /** 92 * Suggest a target for the revision deletion 93 * Optionally override this function. 94 * @since 1.22 95 * @param Title|null $target User-supplied target 96 * @param array $ids 97 * @return Title|null 98 */ 99 public static function suggestTarget( $target, array $ids ) { 100 return $target; 101 } 102 103 /** 104 * Indicate whether any item in this list is suppressed 105 * @since 1.25 106 * @return bool 107 */ 108 public function areAnySuppressed() { 109 $bit = $this->getSuppressBit(); 110 111 /** @var RevDelItem $item */ 112 foreach ( $this as $item ) { 113 if ( $item->getBits() & $bit ) { 114 return true; 115 } 116 } 117 118 return false; 119 } 120 121 /** 122 * Set the visibility for the revisions in this list. Logging and 123 * transactions are done here. 124 * 125 * @param array $params Associative array of parameters. Members are: 126 * value: ExtractBitParams() bitfield array 127 * comment: The log comment 128 * perItemStatus: Set if you want per-item status reports 129 * tags: The array of change tags to apply to the log entry 130 * @return Status 131 * @since 1.23 Added 'perItemStatus' param 132 */ 133 public function setVisibility( array $params ) { 134 $status = Status::newGood(); 135 136 $bitPars = $params['value']; 137 $comment = $params['comment']; 138 $perItemStatus = $params['perItemStatus'] ?? false; 139 140 // CAS-style checks are done on the _deleted fields so the select 141 // does not need to use FOR UPDATE nor be in the atomic section 142 $dbw = $this->lbFactory->getMainLB()->getConnectionRef( DB_PRIMARY ); 143 $this->res = $this->doQuery( $dbw ); 144 145 $status->merge( $this->acquireItemLocks() ); 146 if ( !$status->isGood() ) { 147 return $status; 148 } 149 150 $dbw->startAtomic( __METHOD__ ); 151 $dbw->onTransactionResolution( 152 function () { 153 // Release locks on commit or error 154 $this->releaseItemLocks(); 155 }, 156 __METHOD__ 157 ); 158 159 $missing = array_fill_keys( $this->ids, true ); 160 $this->clearFileOps(); 161 $idsForLog = []; 162 $authorActors = []; 163 164 if ( $perItemStatus ) { 165 $status->itemStatuses = []; 166 } 167 168 // For multi-item deletions, set the old/new bitfields in log_params such that "hid X" 169 // shows in logs if field X was hidden from ANY item and likewise for "unhid Y". Note the 170 // form does not let the same field get hidden and unhidden in different items at once. 171 $virtualOldBits = 0; 172 $virtualNewBits = 0; 173 $logType = 'delete'; 174 175 // Will be filled with id => [old, new bits] information and 176 // passed to doPostCommitUpdates(). 177 $visibilityChangeMap = []; 178 179 /** @var RevDelItem $item */ 180 foreach ( $this as $item ) { 181 unset( $missing[$item->getId()] ); 182 183 if ( $perItemStatus ) { 184 $itemStatus = Status::newGood(); 185 $status->itemStatuses[$item->getId()] = $itemStatus; 186 } else { 187 $itemStatus = $status; 188 } 189 190 $oldBits = $item->getBits(); 191 // Build the actual new rev_deleted bitfield 192 $newBits = RevisionDeleter::extractBitfield( $bitPars, $oldBits ); 193 194 if ( $oldBits == $newBits ) { 195 $itemStatus->warning( 196 'revdelete-no-change', $item->formatDate(), $item->formatTime() ); 197 $status->failCount++; 198 continue; 199 } elseif ( $oldBits == 0 && $newBits != 0 ) { 200 $opType = 'hide'; 201 } elseif ( $oldBits != 0 && $newBits == 0 ) { 202 $opType = 'show'; 203 } else { 204 $opType = 'modify'; 205 } 206 207 if ( $item->isHideCurrentOp( $newBits ) ) { 208 // Cannot hide current version text 209 $itemStatus->error( 210 'revdelete-hide-current', $item->formatDate(), $item->formatTime() ); 211 $status->failCount++; 212 continue; 213 } elseif ( !$item->canView() ) { 214 // Cannot access this revision 215 $msg = ( $opType == 'show' ) ? 216 'revdelete-show-no-access' : 'revdelete-modify-no-access'; 217 $itemStatus->error( $msg, $item->formatDate(), $item->formatTime() ); 218 $status->failCount++; 219 continue; 220 // Cannot just "hide from Sysops" without hiding any fields 221 } elseif ( $newBits == RevisionRecord::DELETED_RESTRICTED ) { 222 $itemStatus->warning( 223 'revdelete-only-restricted', $item->formatDate(), $item->formatTime() ); 224 $status->failCount++; 225 continue; 226 } 227 228 // Update the revision 229 $ok = $item->setBits( $newBits ); 230 231 if ( $ok ) { 232 $idsForLog[] = $item->getId(); 233 // If any item field was suppressed or unsuppressed 234 if ( ( $oldBits | $newBits ) & $this->getSuppressBit() ) { 235 $logType = 'suppress'; 236 } 237 // Track which fields where (un)hidden for each item 238 $addedBits = ( $oldBits ^ $newBits ) & $newBits; 239 $removedBits = ( $oldBits ^ $newBits ) & $oldBits; 240 $virtualNewBits |= $addedBits; 241 $virtualOldBits |= $removedBits; 242 243 $status->successCount++; 244 $authorActors[] = $item->getAuthorActor(); 245 246 // Save the old and new bits in $visibilityChangeMap for 247 // later use. 248 $visibilityChangeMap[$item->getId()] = [ 249 'oldBits' => $oldBits, 250 'newBits' => $newBits, 251 ]; 252 } else { 253 $itemStatus->error( 254 'revdelete-concurrent-change', $item->formatDate(), $item->formatTime() ); 255 $status->failCount++; 256 } 257 } 258 259 // Handle missing revisions 260 foreach ( $missing as $id => $unused ) { 261 if ( $perItemStatus ) { 262 $status->itemStatuses[$id] = Status::newFatal( 'revdelete-modify-missing', $id ); 263 } else { 264 $status->error( 'revdelete-modify-missing', $id ); 265 } 266 $status->failCount++; 267 } 268 269 if ( $status->successCount == 0 ) { 270 $dbw->endAtomic( __METHOD__ ); 271 return $status; 272 } 273 274 // Save success count 275 $successCount = $status->successCount; 276 277 // Move files, if there are any 278 $status->merge( $this->doPreCommitUpdates() ); 279 if ( !$status->isOK() ) { 280 // Fatal error, such as no configured archive directory or I/O failures 281 $this->lbFactory->rollbackPrimaryChanges( __METHOD__ ); 282 return $status; 283 } 284 285 // Log it 286 $authorFields = []; 287 $authorFields['authorActors'] = $authorActors; 288 $this->updateLog( 289 $logType, 290 [ 291 'title' => $this->title, 292 'count' => $successCount, 293 'newBits' => $virtualNewBits, 294 'oldBits' => $virtualOldBits, 295 'comment' => $comment, 296 'ids' => $idsForLog, 297 'tags' => $params['tags'] ?? [], 298 ] + $authorFields 299 ); 300 301 // Clear caches after commit 302 DeferredUpdates::addCallableUpdate( 303 function () use ( $visibilityChangeMap ) { 304 $this->doPostCommitUpdates( $visibilityChangeMap ); 305 }, 306 DeferredUpdates::PRESEND, 307 $dbw 308 ); 309 310 $dbw->endAtomic( __METHOD__ ); 311 312 return $status; 313 } 314 315 final protected function acquireItemLocks() { 316 $status = Status::newGood(); 317 /** @var RevDelItem $item */ 318 foreach ( $this as $item ) { 319 $status->merge( $item->lock() ); 320 } 321 322 return $status; 323 } 324 325 final protected function releaseItemLocks() { 326 $status = Status::newGood(); 327 /** @var RevDelItem $item */ 328 foreach ( $this as $item ) { 329 $status->merge( $item->unlock() ); 330 } 331 332 return $status; 333 } 334 335 /** 336 * Reload the list data from the primary DB. This can be done after setVisibility() 337 * to allow $item->getHTML() to show the new data. 338 * @since 1.37 339 */ 340 public function reloadFromPrimary() { 341 $dbw = $this->lbFactory->getMainLB()->getConnectionRef( DB_PRIMARY ); 342 $this->res = $this->doQuery( $dbw ); 343 } 344 345 /** 346 * @deprecated since 1.37; please use reloadFromPrimary() instead. 347 */ 348 public function reloadFromMaster() { 349 wfDeprecated( __METHOD__, '1.37' ); 350 $this->reloadFromPrimary(); 351 } 352 353 /** 354 * Record a log entry on the action 355 * @param string $logType One of (delete,suppress) 356 * @param array $params Associative array of parameters: 357 * newBits: The new value of the *_deleted bitfield 358 * oldBits: The old value of the *_deleted bitfield. 359 * title: The target title 360 * ids: The ID list 361 * comment: The log comment 362 * authorActors: The array of the actor IDs of the offenders 363 * tags: The array of change tags to apply to the log entry 364 * @throws MWException 365 */ 366 private function updateLog( $logType, $params ) { 367 // Get the URL param's corresponding DB field 368 $field = RevisionDeleter::getRelationType( $this->getType() ); 369 if ( !$field ) { 370 throw new MWException( "Bad log URL param type!" ); 371 } 372 // Add params for affected page and ids 373 $logParams = $this->getLogParams( $params ); 374 // Actually add the deletion log entry 375 $logEntry = new ManualLogEntry( $logType, $this->getLogAction() ); 376 $logEntry->setTarget( $params['title'] ); 377 $logEntry->setComment( $params['comment'] ); 378 $logEntry->setParameters( $logParams ); 379 $logEntry->setPerformer( $this->getUser() ); 380 // Allow for easy searching of deletion log items for revision/log items 381 $relations = [ 382 $field => $params['ids'], 383 ]; 384 if ( isset( $params['authorActors'] ) ) { 385 $relations += [ 386 'target_author_actor' => $params['authorActors'], 387 ]; 388 } 389 $logEntry->setRelations( $relations ); 390 // Apply change tags to the log entry 391 $logEntry->addTags( $params['tags'] ); 392 $logId = $logEntry->insert(); 393 $logEntry->publish( $logId ); 394 } 395 396 /** 397 * Get the log action for this list type 398 * @return string 399 */ 400 public function getLogAction() { 401 return 'revision'; 402 } 403 404 /** 405 * Get log parameter array. 406 * @param array $params Associative array of log parameters, same as updateLog() 407 * @return array 408 */ 409 public function getLogParams( $params ) { 410 return [ 411 '4::type' => $this->getType(), 412 '5::ids' => $params['ids'], 413 '6::ofield' => $params['oldBits'], 414 '7::nfield' => $params['newBits'], 415 ]; 416 } 417 418 /** 419 * Clear any data structures needed for doPreCommitUpdates() and doPostCommitUpdates() 420 * STUB 421 */ 422 public function clearFileOps() { 423 } 424 425 /** 426 * A hook for setVisibility(): do batch updates pre-commit. 427 * STUB 428 * @return Status 429 */ 430 public function doPreCommitUpdates() { 431 return Status::newGood(); 432 } 433 434 /** 435 * A hook for setVisibility(): do any necessary updates post-commit. 436 * STUB 437 * @param array $visibilityChangeMap [id => ['oldBits' => $oldBits, 'newBits' => $newBits], ... ] 438 * @return Status 439 */ 440 public function doPostCommitUpdates( array $visibilityChangeMap ) { 441 return Status::newGood(); 442 } 443 444 /** 445 * Get the integer value of the flag used for suppression 446 */ 447 abstract public function getSuppressBit(); 448} 449