423 lines
12 KiB
PHP
423 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* 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
|
|
*/
|
|
|
|
use MediaWiki\FileRepo\File\FileSelectQueryBuilder;
|
|
use MediaWiki\MediaWikiServices;
|
|
use MediaWiki\Revision\RevisionRecord;
|
|
use MediaWiki\Status\Status;
|
|
use MediaWiki\User\UserIdentity;
|
|
use Wikimedia\ScopedCallback;
|
|
|
|
/**
|
|
* Helper class for file deletion
|
|
*
|
|
* @internal
|
|
* @ingroup FileAbstraction
|
|
*/
|
|
class LocalFileDeleteBatch {
|
|
/** @var LocalFile */
|
|
private $file;
|
|
|
|
/** @var string */
|
|
private $reason;
|
|
|
|
/** @var array */
|
|
private $srcRels = [];
|
|
|
|
/** @var array */
|
|
private $archiveUrls = [];
|
|
|
|
/** @var array[] Items to be processed in the deletion batch */
|
|
private $deletionBatch;
|
|
|
|
/** @var bool Whether to suppress all suppressable fields when deleting */
|
|
private $suppress;
|
|
|
|
/** @var UserIdentity */
|
|
private $user;
|
|
|
|
/**
|
|
* @param File $file
|
|
* @param UserIdentity $user
|
|
* @param string $reason
|
|
* @param bool $suppress
|
|
*/
|
|
public function __construct(
|
|
File $file,
|
|
UserIdentity $user,
|
|
$reason = '',
|
|
$suppress = false
|
|
) {
|
|
$this->file = $file;
|
|
$this->user = $user;
|
|
$this->reason = $reason;
|
|
$this->suppress = $suppress;
|
|
}
|
|
|
|
public function addCurrent() {
|
|
$this->srcRels['.'] = $this->file->getRel();
|
|
}
|
|
|
|
/**
|
|
* @param string $oldName
|
|
*/
|
|
public function addOld( $oldName ) {
|
|
$this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
|
|
$this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
|
|
}
|
|
|
|
/**
|
|
* Add the old versions of the image to the batch
|
|
* @return string[] List of archive names from old versions
|
|
*/
|
|
public function addOlds() {
|
|
$archiveNames = [];
|
|
|
|
$dbw = $this->file->repo->getPrimaryDB();
|
|
$result = $dbw->newSelectQueryBuilder()
|
|
->select( [ 'oi_archive_name' ] )
|
|
->from( 'oldimage' )
|
|
->where( [ 'oi_name' => $this->file->getName() ] )
|
|
->caller( __METHOD__ )->fetchResultSet();
|
|
|
|
foreach ( $result as $row ) {
|
|
$this->addOld( $row->oi_archive_name );
|
|
$archiveNames[] = $row->oi_archive_name;
|
|
}
|
|
|
|
return $archiveNames;
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
protected function getOldRels() {
|
|
if ( !isset( $this->srcRels['.'] ) ) {
|
|
$oldRels =& $this->srcRels;
|
|
$deleteCurrent = false;
|
|
} else {
|
|
$oldRels = $this->srcRels;
|
|
unset( $oldRels['.'] );
|
|
$deleteCurrent = true;
|
|
}
|
|
|
|
return [ $oldRels, $deleteCurrent ];
|
|
}
|
|
|
|
/**
|
|
* @param StatusValue $status To add error messages to
|
|
* @return array
|
|
*/
|
|
protected function getHashes( StatusValue $status ): array {
|
|
$hashes = [];
|
|
[ $oldRels, $deleteCurrent ] = $this->getOldRels();
|
|
|
|
if ( $deleteCurrent ) {
|
|
$hashes['.'] = $this->file->getSha1();
|
|
}
|
|
|
|
if ( count( $oldRels ) ) {
|
|
$dbw = $this->file->repo->getPrimaryDB();
|
|
$res = $dbw->newSelectQueryBuilder()
|
|
->select( [ 'oi_archive_name', 'oi_sha1' ] )
|
|
->from( 'oldimage' )
|
|
->where( [
|
|
'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
|
|
'oi_name' => $this->file->getName() // performance
|
|
] )
|
|
->caller( __METHOD__ )->fetchResultSet();
|
|
|
|
foreach ( $res as $row ) {
|
|
if ( $row->oi_archive_name === '' ) {
|
|
// File lost, the check simulates OldLocalFile::exists
|
|
$hashes[$row->oi_archive_name] = false;
|
|
continue;
|
|
}
|
|
if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
|
|
// Get the hash from the file
|
|
$oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
|
|
$props = $this->file->repo->getFileProps( $oldUrl );
|
|
|
|
if ( $props['fileExists'] ) {
|
|
// Upgrade the oldimage row
|
|
$dbw->newUpdateQueryBuilder()
|
|
->update( 'oldimage' )
|
|
->set( [ 'oi_sha1' => $props['sha1'] ] )
|
|
->where( [
|
|
'oi_name' => $this->file->getName(),
|
|
'oi_archive_name' => $row->oi_archive_name,
|
|
] )
|
|
->caller( __METHOD__ )->execute();
|
|
$hashes[$row->oi_archive_name] = $props['sha1'];
|
|
} else {
|
|
$hashes[$row->oi_archive_name] = false;
|
|
}
|
|
} else {
|
|
$hashes[$row->oi_archive_name] = $row->oi_sha1;
|
|
}
|
|
}
|
|
}
|
|
|
|
$missing = array_diff_key( $this->srcRels, $hashes );
|
|
|
|
foreach ( $missing as $name => $rel ) {
|
|
$status->error( 'filedelete-old-unregistered', $name );
|
|
}
|
|
|
|
foreach ( $hashes as $name => $hash ) {
|
|
if ( !$hash ) {
|
|
$status->error( 'filedelete-missing', $this->srcRels[$name] );
|
|
unset( $hashes[$name] );
|
|
}
|
|
}
|
|
|
|
return $hashes;
|
|
}
|
|
|
|
protected function doDBInserts() {
|
|
$now = time();
|
|
$dbw = $this->file->repo->getPrimaryDB();
|
|
|
|
$commentStore = MediaWikiServices::getInstance()->getCommentStore();
|
|
|
|
$encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
|
|
$encUserId = $dbw->addQuotes( $this->user->getId() );
|
|
$encGroup = $dbw->addQuotes( 'deleted' );
|
|
$ext = $this->file->getExtension();
|
|
$dotExt = $ext === '' ? '' : ".$ext";
|
|
$encExt = $dbw->addQuotes( $dotExt );
|
|
[ $oldRels, $deleteCurrent ] = $this->getOldRels();
|
|
|
|
// Bitfields to further suppress the content
|
|
if ( $this->suppress ) {
|
|
$bitfield = RevisionRecord::SUPPRESSED_ALL;
|
|
} else {
|
|
$bitfield = 'oi_deleted';
|
|
}
|
|
|
|
if ( $deleteCurrent ) {
|
|
$tables = [ 'image' ];
|
|
$fields = [
|
|
'fa_storage_group' => $encGroup,
|
|
'fa_storage_key' => $dbw->conditional(
|
|
[ 'img_sha1' => '' ],
|
|
$dbw->addQuotes( '' ),
|
|
$dbw->buildConcat( [ "img_sha1", $encExt ] )
|
|
),
|
|
'fa_deleted_user' => $encUserId,
|
|
'fa_deleted_timestamp' => $encTimestamp,
|
|
'fa_deleted' => $this->suppress ? $bitfield : 0,
|
|
'fa_name' => 'img_name',
|
|
'fa_archive_name' => 'NULL',
|
|
'fa_size' => 'img_size',
|
|
'fa_width' => 'img_width',
|
|
'fa_height' => 'img_height',
|
|
'fa_metadata' => 'img_metadata',
|
|
'fa_bits' => 'img_bits',
|
|
'fa_media_type' => 'img_media_type',
|
|
'fa_major_mime' => 'img_major_mime',
|
|
'fa_minor_mime' => 'img_minor_mime',
|
|
'fa_description_id' => 'img_description_id',
|
|
'fa_timestamp' => 'img_timestamp',
|
|
'fa_sha1' => 'img_sha1',
|
|
'fa_actor' => 'img_actor',
|
|
];
|
|
$joins = [];
|
|
|
|
$fields += array_map(
|
|
[ $dbw, 'addQuotes' ],
|
|
$commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
|
|
);
|
|
|
|
$dbw->insertSelect( 'filearchive', $tables, $fields,
|
|
[ 'img_name' => $this->file->getName() ], __METHOD__, [ 'IGNORE' ], [], $joins );
|
|
}
|
|
|
|
if ( count( $oldRels ) ) {
|
|
$queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
|
|
$queryBuilder
|
|
->forUpdate()
|
|
->where( [ 'oi_name' => $this->file->getName() ] )
|
|
->andWhere( [ 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
|
|
$res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
|
|
$rowsInsert = [];
|
|
if ( $res->numRows() ) {
|
|
$reason = $commentStore->createComment( $dbw, $this->reason );
|
|
foreach ( $res as $row ) {
|
|
$comment = $commentStore->getComment( 'oi_description', $row );
|
|
$rowsInsert[] = [
|
|
// Deletion-specific fields
|
|
'fa_storage_group' => 'deleted',
|
|
'fa_storage_key' => ( $row->oi_sha1 === '' )
|
|
? ''
|
|
: "{$row->oi_sha1}{$dotExt}",
|
|
'fa_deleted_user' => $this->user->getId(),
|
|
'fa_deleted_timestamp' => $dbw->timestamp( $now ),
|
|
// Counterpart fields
|
|
'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
|
|
'fa_name' => $row->oi_name,
|
|
'fa_archive_name' => $row->oi_archive_name,
|
|
'fa_size' => $row->oi_size,
|
|
'fa_width' => $row->oi_width,
|
|
'fa_height' => $row->oi_height,
|
|
'fa_metadata' => $row->oi_metadata,
|
|
'fa_bits' => $row->oi_bits,
|
|
'fa_media_type' => $row->oi_media_type,
|
|
'fa_major_mime' => $row->oi_major_mime,
|
|
'fa_minor_mime' => $row->oi_minor_mime,
|
|
'fa_actor' => $row->oi_actor,
|
|
'fa_timestamp' => $row->oi_timestamp,
|
|
'fa_sha1' => $row->oi_sha1
|
|
] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
|
|
+ $commentStore->insert( $dbw, 'fa_description', $comment );
|
|
}
|
|
}
|
|
|
|
$dbw->newInsertQueryBuilder()
|
|
->insertInto( 'filearchive' )
|
|
->ignore()
|
|
->rows( $rowsInsert )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
}
|
|
|
|
private function doDBDeletes() {
|
|
$dbw = $this->file->repo->getPrimaryDB();
|
|
[ $oldRels, $deleteCurrent ] = $this->getOldRels();
|
|
|
|
if ( count( $oldRels ) ) {
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'oldimage' )
|
|
->where( [
|
|
'oi_name' => $this->file->getName(),
|
|
'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) )
|
|
] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
|
|
if ( $deleteCurrent ) {
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'image' )
|
|
->where( [ 'img_name' => $this->file->getName() ] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the transaction
|
|
* @return Status
|
|
*/
|
|
public function execute() {
|
|
$repo = $this->file->getRepo();
|
|
$lockStatus = $this->file->acquireFileLock();
|
|
if ( !$lockStatus->isOK() ) {
|
|
return $lockStatus;
|
|
}
|
|
$unlockScope = new ScopedCallback( function () {
|
|
$this->file->releaseFileLock();
|
|
} );
|
|
|
|
$status = $this->file->repo->newGood();
|
|
// Prepare deletion batch
|
|
$hashes = $this->getHashes( $status );
|
|
$this->deletionBatch = [];
|
|
$ext = $this->file->getExtension();
|
|
$dotExt = $ext === '' ? '' : ".$ext";
|
|
|
|
foreach ( $this->srcRels as $name => $srcRel ) {
|
|
// Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
|
|
if ( isset( $hashes[$name] ) ) {
|
|
$hash = $hashes[$name];
|
|
$key = $hash . $dotExt;
|
|
$dstRel = $repo->getDeletedHashPath( $key ) . $key;
|
|
$this->deletionBatch[$name] = [ $srcRel, $dstRel ];
|
|
}
|
|
}
|
|
|
|
if ( !$repo->hasSha1Storage() ) {
|
|
// Removes non-existent file from the batch, so we don't get errors.
|
|
// This also handles files in the 'deleted' zone deleted via revision deletion.
|
|
$checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
|
|
if ( !$checkStatus->isGood() ) {
|
|
$status->merge( $checkStatus );
|
|
return $status;
|
|
}
|
|
$this->deletionBatch = $checkStatus->value;
|
|
|
|
// Execute the file deletion batch
|
|
$status = $this->file->repo->deleteBatch( $this->deletionBatch );
|
|
if ( !$status->isGood() ) {
|
|
$status->merge( $status );
|
|
}
|
|
}
|
|
|
|
if ( !$status->isOK() ) {
|
|
// Critical file deletion error; abort
|
|
return $status;
|
|
}
|
|
|
|
$dbw = $this->file->repo->getPrimaryDB();
|
|
|
|
$dbw->startAtomic( __METHOD__ );
|
|
|
|
// Copy the image/oldimage rows to filearchive
|
|
$this->doDBInserts();
|
|
// Delete image/oldimage rows
|
|
$this->doDBDeletes();
|
|
|
|
// This is typically a no-op since we are wrapped by another atomic
|
|
// section in FileDeleteForm and also the implicit transaction.
|
|
$dbw->endAtomic( __METHOD__ );
|
|
|
|
// Commit and return
|
|
ScopedCallback::consume( $unlockScope );
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Removes non-existent files from a deletion batch.
|
|
* @param array[] $batch
|
|
* @return Status A good status with existing files in $batch as value, or a fatal status in case of I/O errors.
|
|
*/
|
|
protected function removeNonexistentFiles( $batch ) {
|
|
$files = [];
|
|
|
|
foreach ( $batch as [ $src, /* dest */ ] ) {
|
|
$files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
|
|
}
|
|
|
|
$result = $this->file->repo->fileExistsBatch( $files );
|
|
if ( in_array( null, $result, true ) ) {
|
|
return Status::newFatal( 'backend-fail-internal',
|
|
$this->file->repo->getBackend()->getName() );
|
|
}
|
|
|
|
$newBatch = [];
|
|
foreach ( $batch as $batchItem ) {
|
|
if ( $result[$batchItem[0]] ) {
|
|
$newBatch[] = $batchItem;
|
|
}
|
|
}
|
|
|
|
return Status::newGood( $newBatch );
|
|
}
|
|
}
|