MediaWiki master
LocalFileDeleteBatch.php
Go to the documentation of this file.
1<?php
8
14use StatusValue;
15use Wikimedia\ScopedCallback;
16
25 private $file;
26
28 private $reason;
29
31 private $srcRels = [];
32
34 private $archiveUrls = [];
35
37 private $deletionBatch;
38
40 private $suppress;
41
43 private $user;
44
51 public function __construct(
52 LocalFile $file,
53 UserIdentity $user,
54 $reason = '',
55 $suppress = false
56 ) {
57 $this->file = $file;
58 $this->user = $user;
59 $this->reason = $reason;
60 $this->suppress = $suppress;
61 }
62
63 public function addCurrent() {
64 $this->srcRels['.'] = $this->file->getRel();
65 }
66
70 public function addOld( $oldName ) {
71 $this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
72 $this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
73 }
74
79 public function addOlds() {
80 $archiveNames = [];
81
82 $dbw = $this->file->repo->getPrimaryDB();
83 $result = $dbw->newSelectQueryBuilder()
84 ->select( [ 'oi_archive_name' ] )
85 ->from( 'oldimage' )
86 ->where( [ 'oi_name' => $this->file->getName() ] )
87 ->caller( __METHOD__ )->fetchResultSet();
88
89 foreach ( $result as $row ) {
90 $this->addOld( $row->oi_archive_name );
91 $archiveNames[] = $row->oi_archive_name;
92 }
93
94 return $archiveNames;
95 }
96
100 protected function getOldRels() {
101 if ( !isset( $this->srcRels['.'] ) ) {
102 $oldRels =& $this->srcRels;
103 $deleteCurrent = false;
104 } else {
105 $oldRels = $this->srcRels;
106 unset( $oldRels['.'] );
107 $deleteCurrent = true;
108 }
109
110 return [ $oldRels, $deleteCurrent ];
111 }
112
117 protected function getHashes( StatusValue $status ): array {
118 $hashes = [];
119 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
120
121 if ( $deleteCurrent ) {
122 $hashes['.'] = $this->file->getSha1();
123 }
124
125 if ( count( $oldRels ) ) {
126 $dbw = $this->file->repo->getPrimaryDB();
127 $res = $dbw->newSelectQueryBuilder()
128 ->select( [ 'oi_archive_name', 'oi_sha1' ] )
129 ->from( 'oldimage' )
130 ->where( [
131 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
132 'oi_name' => $this->file->getName() // performance
133 ] )
134 ->caller( __METHOD__ )->fetchResultSet();
135
136 foreach ( $res as $row ) {
137 if ( $row->oi_archive_name === '' ) {
138 // File lost, the check simulates OldLocalFile::exists
139 $hashes[$row->oi_archive_name] = false;
140 continue;
141 }
142 if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
143 // Get the hash from the file
144 $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
145 $props = $this->file->repo->getFileProps( $oldUrl );
146
147 if ( $props['fileExists'] ) {
148 // Upgrade the oldimage row
149 $dbw->newUpdateQueryBuilder()
150 ->update( 'oldimage' )
151 ->set( [ 'oi_sha1' => $props['sha1'] ] )
152 ->where( [
153 'oi_name' => $this->file->getName(),
154 'oi_archive_name' => $row->oi_archive_name,
155 ] )
156 ->caller( __METHOD__ )->execute();
157 $hashes[$row->oi_archive_name] = $props['sha1'];
158 } else {
159 $hashes[$row->oi_archive_name] = false;
160 }
161 } else {
162 $hashes[$row->oi_archive_name] = $row->oi_sha1;
163 }
164 }
165 }
166
167 $missing = array_diff_key( $this->srcRels, $hashes );
168
169 foreach ( $missing as $name => $rel ) {
170 $status->error( 'filedelete-old-unregistered', $name );
171 }
172
173 foreach ( $hashes as $name => $hash ) {
174 if ( !$hash ) {
175 $status->error( 'filedelete-missing', $this->srcRels[$name] );
176 unset( $hashes[$name] );
177 }
178 }
179
180 return $hashes;
181 }
182
183 protected function doDBInserts() {
184 $now = time();
185 $dbw = $this->file->repo->getPrimaryDB();
186
187 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
188
189 $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
190 $encUserId = $dbw->addQuotes( $this->user->getId() );
191 $encGroup = $dbw->addQuotes( 'deleted' );
192 $ext = $this->file->getExtension();
193 $dotExt = $ext === '' ? '' : ".$ext";
194 $encExt = $dbw->addQuotes( $dotExt );
195 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
196
197 // Bitfields to further suppress the content
198 if ( $this->suppress ) {
199 $bitfield = RevisionRecord::SUPPRESSED_ALL;
200 } else {
201 $bitfield = 'oi_deleted';
202 }
203
204 if ( $deleteCurrent ) {
205 $tables = [ 'image' ];
206 $fields = [
207 'fa_storage_group' => $encGroup,
208 'fa_storage_key' => $dbw->conditional(
209 [ 'img_sha1' => '' ],
210 $dbw->addQuotes( '' ),
211 $dbw->buildConcat( [ "img_sha1", $encExt ] )
212 ),
213 'fa_deleted_user' => $encUserId,
214 'fa_deleted_timestamp' => $encTimestamp,
215 'fa_deleted' => $this->suppress ? $bitfield : 0,
216 'fa_name' => 'img_name',
217 'fa_archive_name' => 'NULL',
218 'fa_size' => 'img_size',
219 'fa_width' => 'img_width',
220 'fa_height' => 'img_height',
221 'fa_metadata' => 'img_metadata',
222 'fa_bits' => 'img_bits',
223 'fa_media_type' => 'img_media_type',
224 'fa_major_mime' => 'img_major_mime',
225 'fa_minor_mime' => 'img_minor_mime',
226 'fa_description_id' => 'img_description_id',
227 'fa_timestamp' => 'img_timestamp',
228 'fa_sha1' => 'img_sha1',
229 'fa_actor' => 'img_actor',
230 ];
231 $joins = [];
232
233 $fields += array_map(
234 $dbw->addQuotes( ... ),
235 $commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
236 );
237
238 $dbw->insertSelect( 'filearchive', $tables, $fields,
239 [ 'img_name' => $this->file->getName() ], __METHOD__, [ 'IGNORE' ], [], $joins );
240 }
241
242 if ( count( $oldRels ) ) {
243 $queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
244 $queryBuilder
245 ->forUpdate()
246 ->where( [ 'oi_name' => $this->file->getName() ] )
247 ->andWhere( [ 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
248 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
249 if ( $res->numRows() ) {
250 $reason = $commentStore->createComment( $dbw, $this->reason );
251 $rowsInsert = [];
252 foreach ( $res as $row ) {
253 $comment = $commentStore->getComment( 'oi_description', $row );
254 $rowsInsert[] = [
255 // Deletion-specific fields
256 'fa_storage_group' => 'deleted',
257 'fa_storage_key' => ( $row->oi_sha1 === '' )
258 ? ''
259 : "{$row->oi_sha1}{$dotExt}",
260 'fa_deleted_user' => $this->user->getId(),
261 'fa_deleted_timestamp' => $dbw->timestamp( $now ),
262 // Counterpart fields
263 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
264 'fa_name' => $row->oi_name,
265 'fa_archive_name' => $row->oi_archive_name,
266 'fa_size' => $row->oi_size,
267 'fa_width' => $row->oi_width,
268 'fa_height' => $row->oi_height,
269 'fa_metadata' => $row->oi_metadata,
270 'fa_bits' => $row->oi_bits,
271 'fa_media_type' => $row->oi_media_type,
272 'fa_major_mime' => $row->oi_major_mime,
273 'fa_minor_mime' => $row->oi_minor_mime,
274 'fa_actor' => $row->oi_actor,
275 'fa_timestamp' => $row->oi_timestamp,
276 'fa_sha1' => $row->oi_sha1
277 ] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
278 + $commentStore->insert( $dbw, 'fa_description', $comment );
279 }
280 $dbw->newInsertQueryBuilder()
281 ->insertInto( 'filearchive' )
282 ->ignore()
283 ->rows( $rowsInsert )
284 ->caller( __METHOD__ )->execute();
285 }
286 }
287 }
288
289 private function doDBDeletes() {
290 $dbw = $this->file->repo->getPrimaryDB();
291 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
293 );
294
295 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
296
297 if ( count( $oldRels ) ) {
298 $dbw->newDeleteQueryBuilder()
299 ->deleteFrom( 'oldimage' )
300 ->where( [
301 'oi_name' => $this->file->getName(),
302 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) )
303 ] )
304 ->caller( __METHOD__ )->execute();
305 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
306 $delete = $dbw->newDeleteQueryBuilder()
307 ->deleteFrom( 'filerevision' )
308 ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] );
309 if ( !$deleteCurrent ) {
310 // It's not full page deletion.
311 $delete->andWhere( [ 'fr_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
312 }
313 $delete->caller( __METHOD__ )->execute();
314
315 }
316 }
317
318 if ( $deleteCurrent ) {
319 $dbw->newDeleteQueryBuilder()
320 ->deleteFrom( 'image' )
321 ->where( [ 'img_name' => $this->file->getName() ] )
322 ->caller( __METHOD__ )->execute();
323 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
324 $dbw->newUpdateQueryBuilder()
325 ->update( 'file' )
326 ->set( [
327 'file_deleted' => $this->suppress ? 3 : 1,
328 'file_latest' => 0
329 ] )
330 ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
331 ->caller( __METHOD__ )->execute();
332 if ( !count( $oldRels ) ) {
333 // Only the current version is uploaded and then deleted
334 // TODO: After migration is done and old code is removed,
335 // this should be refactored to become much simpler
336 $dbw->newDeleteQueryBuilder()
337 ->deleteFrom( 'filerevision' )
338 ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] )
339 ->caller( __METHOD__ )->execute();
340 }
341 }
342 }
343 }
344
349 public function execute() {
350 $repo = $this->file->getRepo();
351 $lockStatus = $this->file->acquireFileLock();
352 if ( !$lockStatus->isOK() ) {
353 return $lockStatus;
354 }
355 $unlockScope = new ScopedCallback( function () {
356 $this->file->releaseFileLock();
357 } );
358
359 $status = $this->file->repo->newGood();
360 // Prepare deletion batch
361 $hashes = $this->getHashes( $status );
362 $this->deletionBatch = [];
363 $ext = $this->file->getExtension();
364 $dotExt = $ext === '' ? '' : ".$ext";
365
366 foreach ( $this->srcRels as $name => $srcRel ) {
367 // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
368 if ( isset( $hashes[$name] ) ) {
369 $hash = $hashes[$name];
370 $key = $hash . $dotExt;
371 $dstRel = $repo->getDeletedHashPath( $key ) . $key;
372 $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
373 }
374 }
375
376 if ( !$repo->hasSha1Storage() ) {
377 // Removes non-existent file from the batch, so we don't get errors.
378 // This also handles files in the 'deleted' zone deleted via revision deletion.
379 $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
380 if ( !$checkStatus->isGood() ) {
381 $status->merge( $checkStatus );
382 return $status;
383 }
384 $this->deletionBatch = $checkStatus->value;
385
386 // Execute the file deletion batch
387 $status = $this->file->repo->deleteBatch( $this->deletionBatch );
388 if ( !$status->isGood() ) {
389 $status->merge( $status );
390 }
391 }
392
393 if ( !$status->isOK() ) {
394 // Critical file deletion error; abort
395 return $status;
396 }
397
398 $dbw = $this->file->repo->getPrimaryDB();
399
400 $dbw->startAtomic( __METHOD__ );
401
402 // Copy the image/oldimage rows to filearchive
403 $this->doDBInserts();
404 // Delete image/oldimage rows
405 $this->doDBDeletes();
406
407 // This is typically a no-op since we are wrapped by another atomic
408 // section in FileDeleteForm and also the implicit transaction.
409 $dbw->endAtomic( __METHOD__ );
410
411 // Commit and return
412 ScopedCallback::consume( $unlockScope );
413
414 return $status;
415 }
416
422 protected function removeNonexistentFiles( $batch ) {
423 $files = [];
424
425 foreach ( $batch as [ $src, /* dest */ ] ) {
426 $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
427 }
428
429 $result = $this->file->repo->fileExistsBatch( $files );
430 if ( in_array( null, $result, true ) ) {
431 return Status::newFatal( 'backend-fail-internal',
432 $this->file->repo->getBackend()->getName() );
433 }
434
435 $newBatch = [];
436 foreach ( $batch as $batchItem ) {
437 if ( $result[$batchItem[0]] ) {
438 $newBatch[] = $batchItem;
439 }
440 }
441
442 return Status::newGood( $newBatch );
443 }
444}
445
447class_alias( LocalFileDeleteBatch::class, 'LocalFileDeleteBatch' );
const SCHEMA_COMPAT_WRITE_NEW
Definition Defines.php:297
static newForOldFile(IReadableDatabase $db, array $options=[])
addOlds()
Add the old versions of the image to the batch.
__construct(LocalFile $file, UserIdentity $user, $reason='', $suppress=false)
removeNonexistentFiles( $batch)
Removes non-existent files from a deletion batch.
Local file in the wiki's own database.
Definition LocalFile.php:81
A class containing constants representing the names of configuration variables.
const FileSchemaMigrationStage
Name constant for the FileSchemaMigrationStage setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Page revision base class.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Generic operation result class Has warning/error list, boolean status and arbitrary value.
error( $message,... $parameters)
Add an error, do not set fatal flag This can be used for non-fatal errors.
static newGood( $value=null)
Factory function for good results.
Interface for objects representing user identity.