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 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
85 );
86 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
87 $result = $dbw->newSelectQueryBuilder()
88 ->select( [ 'oi_archive_name' ] )
89 ->from( 'oldimage' )
90 ->where( [ 'oi_name' => $this->file->getName() ] )
91 ->caller( __METHOD__ )->fetchResultSet();
92 } else {
93 $result = $dbw->newSelectQueryBuilder()
94 ->select( [ 'oi_archive_name' => 'fr_archive_name' ] )
95 ->from( 'filerevision' )
96 ->join( 'file', null, 'fr_file = file_id' )
97 ->where( [ 'file_name' => $this->file->getName(), 'file_deleted' => 0, 'file_latest != fr_id' ] )
98 ->caller( __METHOD__ )->fetchResultSet();
99 }
100
101 foreach ( $result as $row ) {
102 $this->addOld( $row->oi_archive_name );
103 $archiveNames[] = $row->oi_archive_name;
104 }
105
106 return $archiveNames;
107 }
108
112 protected function getOldRels() {
113 if ( !isset( $this->srcRels['.'] ) ) {
114 $oldRels =& $this->srcRels;
115 $deleteCurrent = false;
116 } else {
117 $oldRels = $this->srcRels;
118 unset( $oldRels['.'] );
119 $deleteCurrent = true;
120 }
121
122 return [ $oldRels, $deleteCurrent ];
123 }
124
129 protected function getHashes( StatusValue $status ): array {
130 $hashes = [];
131 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
132
133 if ( $deleteCurrent ) {
134 $hashes['.'] = $this->file->getSha1();
135 }
136 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
138 );
139
140 if ( count( $oldRels ) ) {
141 $dbw = $this->file->repo->getPrimaryDB();
142 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
143 $res = $dbw->newSelectQueryBuilder()
144 ->select( [ 'oi_archive_name', 'oi_sha1' ] )
145 ->from( 'oldimage' )
146 ->where( [
147 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
148 'oi_name' => $this->file->getName() // performance
149 ] )
150 ->caller( __METHOD__ )->fetchResultSet();
151 } else {
152 $res = $dbw->newSelectQueryBuilder()
153 ->select( [
154 'oi_archive_name' => 'fr_archive_name',
155 'oi_sha1' => 'fr_sha1'
156 ] )
157 ->from( 'filerevision' )
158 ->join( 'file', null, 'fr_file = file_id' )
159 ->where( [
160 'fr_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
161 'file_name' => $this->file->getName(), // performance
162 'file_deleted' => 0,
163 'file_latest != fr_id',
164 ] )
165 ->caller( __METHOD__ )->fetchResultSet();
166 }
167
168 foreach ( $res as $row ) {
169 if ( $row->oi_archive_name === '' ) {
170 // File lost, the check simulates OldLocalFile::exists
171 $hashes[$row->oi_archive_name] = false;
172 continue;
173 }
174 if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
175 // Get the hash from the file
176 $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
177 $props = $this->file->repo->getFileProps( $oldUrl );
178
179 if ( $props['fileExists'] ) {
180 // Upgrade the oldimage row
181
182 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
183 $dbw->newUpdateQueryBuilder()
184 ->update( 'oldimage' )
185 ->set( [ 'oi_sha1' => $props['sha1'] ] )
186 ->where( [
187 'oi_name' => $this->file->getName(),
188 'oi_archive_name' => $row->oi_archive_name,
189 ] )
190 ->caller( __METHOD__ )->execute();
191 }
192 if ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
193 $dbw->newUpdateQueryBuilder()
194 ->update( 'filerevision' )
195 ->set( [ 'fr_sha1' => $props['sha1'] ] )
196 ->where( [
197 'fr_file' => $this->file->getFileIdFromName(),
198 'fr_archive_name' => $row->oi_archive_name,
199 ] )
200 ->caller( __METHOD__ )->execute();
201 }
202 $hashes[$row->oi_archive_name] = $props['sha1'];
203 } else {
204 $hashes[$row->oi_archive_name] = false;
205 }
206 } else {
207 $hashes[$row->oi_archive_name] = $row->oi_sha1;
208 }
209 }
210 }
211
212 $missing = array_diff_key( $this->srcRels, $hashes );
213
214 foreach ( $missing as $name => $rel ) {
215 $status->error( 'filedelete-old-unregistered', $name );
216 }
217
218 foreach ( $hashes as $name => $hash ) {
219 if ( !$hash ) {
220 $status->error( 'filedelete-missing', $this->srcRels[$name] );
221 unset( $hashes[$name] );
222 }
223 }
224
225 return $hashes;
226 }
227
228 protected function doDBInserts() {
229 $now = time();
230 $dbw = $this->file->repo->getPrimaryDB();
231
232 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
233
234 $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
235 $encUserId = $dbw->addQuotes( $this->user->getId() );
236 $encGroup = $dbw->addQuotes( 'deleted' );
237 $ext = $this->file->getExtension();
238 $dotExt = $ext === '' ? '' : ".$ext";
239 $encExt = $dbw->addQuotes( $dotExt );
240 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
241
242 // Bitfields to further suppress the content
243 if ( $this->suppress ) {
244 $bitfield = RevisionRecord::SUPPRESSED_ALL;
245 } else {
246 $bitfield = 'oi_deleted';
247 }
248
249 if ( $deleteCurrent ) {
250 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
252 );
253 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
254 $tables = [ 'image' ];
255 $sourceFields = [
256 'name' => 'img_name',
257 'size' => 'img_size',
258 'width' => 'img_width',
259 'height' => 'img_height',
260 'metadata' => 'img_metadata',
261 'bits' => 'img_bits',
262 'media_type' => 'img_media_type',
263 'major_mime' => 'img_major_mime',
264 'minor_mime' => 'img_minor_mime',
265 'description_id' => 'img_description_id',
266 'timestamp' => 'img_timestamp',
267 'sha1' => 'img_sha1',
268 'actor' => 'img_actor',
269 ];
270 $conds = [ 'img_name' => $this->file->getName() ];
271 $joins = [];
272 } else {
273 $tables = [ 'file', 'filerevision', 'filetypes' ];
274 $sourceFields = [
275 'name' => 'file_name',
276 'size' => 'fr_size',
277 'width' => 'fr_width',
278 'height' => 'fr_height',
279 'metadata' => 'fr_metadata',
280 'bits' => 'fr_bits',
281 'media_type' => 'ft_media_type',
282 'major_mime' => 'ft_major_mime',
283 'minor_mime' => 'ft_minor_mime',
284 'description_id' => 'fr_description_id',
285 'timestamp' => 'fr_timestamp',
286 'sha1' => 'fr_sha1',
287 'actor' => 'fr_actor',
288 ];
289 $conds = [ 'file_name' => $this->file->getName(), 'file_deleted' => 0 ];
290 $joins = [
291 'filerevision' => [ 'JOIN', 'file_latest = fr_id' ],
292 'filetypes' => [ 'JOIN', 'file_type = ft_id' ],
293 ];
294 }
295
296 $fields = [
297 'fa_storage_group' => $encGroup,
298 'fa_storage_key' => $dbw->conditional(
299 [ $sourceFields['sha1'] => '' ],
300 $dbw->addQuotes( '' ),
301 $dbw->buildConcat( [ $sourceFields['sha1'], $encExt ] )
302 ),
303 'fa_deleted_user' => $encUserId,
304 'fa_deleted_timestamp' => $encTimestamp,
305 'fa_deleted' => $this->suppress ? $bitfield : 0,
306 'fa_archive_name' => 'NULL',
307 ];
308 foreach ( $sourceFields as $field => $sourceField ) {
309 $fields["fa_$field"] = $sourceField;
310 }
311
312 $fields += array_map(
313 $dbw->addQuotes( ... ),
314 $commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
315 );
316
317 $dbw->insertSelect( 'filearchive', $tables, $fields,
318 $conds, __METHOD__, [ 'IGNORE' ], [], $joins );
319 }
320
321 if ( count( $oldRels ) ) {
322 $queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
323 $queryBuilder
324 ->forUpdate()
325 ->where( [ 'oi_name' => $this->file->getName() ] )
326 ->andWhere( [ 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
327 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
328 if ( $res->numRows() ) {
329 $reason = $commentStore->createComment( $dbw, $this->reason );
330 $rowsInsert = [];
331 foreach ( $res as $row ) {
332 $comment = $commentStore->getComment( 'oi_description', $row );
333 $rowsInsert[] = [
334 // Deletion-specific fields
335 'fa_storage_group' => 'deleted',
336 'fa_storage_key' => ( $row->oi_sha1 === '' )
337 ? ''
338 : "{$row->oi_sha1}{$dotExt}",
339 'fa_deleted_user' => $this->user->getId(),
340 'fa_deleted_timestamp' => $dbw->timestamp( $now ),
341 // Counterpart fields
342 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
343 'fa_name' => $row->oi_name,
344 'fa_archive_name' => $row->oi_archive_name,
345 'fa_size' => $row->oi_size,
346 'fa_width' => $row->oi_width,
347 'fa_height' => $row->oi_height,
348 'fa_metadata' => $row->oi_metadata,
349 'fa_bits' => $row->oi_bits,
350 'fa_media_type' => $row->oi_media_type,
351 'fa_major_mime' => $row->oi_major_mime,
352 'fa_minor_mime' => $row->oi_minor_mime,
353 'fa_actor' => $row->oi_actor,
354 'fa_timestamp' => $row->oi_timestamp,
355 'fa_sha1' => $row->oi_sha1
356 ] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
357 + $commentStore->insert( $dbw, 'fa_description', $comment );
358 }
359 $dbw->newInsertQueryBuilder()
360 ->insertInto( 'filearchive' )
361 ->ignore()
362 ->rows( $rowsInsert )
363 ->caller( __METHOD__ )->execute();
364 }
365 }
366 }
367
368 private function doDBDeletes() {
369 $dbw = $this->file->repo->getPrimaryDB();
370 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
372 );
373
374 [ $oldRels, $deleteCurrent ] = $this->getOldRels();
375
376 if ( count( $oldRels ) ) {
377 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
378 $dbw->newDeleteQueryBuilder()
379 ->deleteFrom( 'oldimage' )
380 ->where( [
381 'oi_name' => $this->file->getName(),
382 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) )
383 ] )
384 ->caller( __METHOD__ )->execute();
385 }
386 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
387 $delete = $dbw->newDeleteQueryBuilder()
388 ->deleteFrom( 'filerevision' )
389 ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] );
390 if ( !$deleteCurrent ) {
391 // It's not full page deletion.
392 $delete->andWhere( [ 'fr_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
393 }
394 $delete->caller( __METHOD__ )->execute();
395
396 }
397 }
398
399 if ( $deleteCurrent ) {
400 if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
401 $dbw->newDeleteQueryBuilder()
402 ->deleteFrom( 'image' )
403 ->where( [ 'img_name' => $this->file->getName() ] )
404 ->caller( __METHOD__ )->execute();
405 }
406 if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
407 $dbw->newUpdateQueryBuilder()
408 ->update( 'file' )
409 ->set( [
410 'file_deleted' => $this->suppress ? 3 : 1,
411 'file_latest' => 0
412 ] )
413 ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
414 ->caller( __METHOD__ )->execute();
415 if ( !count( $oldRels ) ) {
416 // Only the current version is uploaded and then deleted
417 // TODO: After migration is done and old code is removed,
418 // this should be refactored to become much simpler
419 $dbw->newDeleteQueryBuilder()
420 ->deleteFrom( 'filerevision' )
421 ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] )
422 ->caller( __METHOD__ )->execute();
423 }
424 }
425 }
426 }
427
432 public function execute() {
433 $repo = $this->file->getRepo();
434 $lockStatus = $this->file->acquireFileLock();
435 if ( !$lockStatus->isOK() ) {
436 return $lockStatus;
437 }
438 $unlockScope = new ScopedCallback( function () {
439 $this->file->releaseFileLock();
440 } );
441
442 $status = $this->file->repo->newGood();
443 // Prepare deletion batch
444 $hashes = $this->getHashes( $status );
445 $this->deletionBatch = [];
446 $ext = $this->file->getExtension();
447 $dotExt = $ext === '' ? '' : ".$ext";
448
449 foreach ( $this->srcRels as $name => $srcRel ) {
450 // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
451 if ( isset( $hashes[$name] ) ) {
452 $hash = $hashes[$name];
453 $key = $hash . $dotExt;
454 $dstRel = $repo->getDeletedHashPath( $key ) . $key;
455 $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
456 }
457 }
458
459 if ( !$repo->hasSha1Storage() ) {
460 // Removes non-existent file from the batch, so we don't get errors.
461 // This also handles files in the 'deleted' zone deleted via revision deletion.
462 $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
463 if ( !$checkStatus->isGood() ) {
464 $status->merge( $checkStatus );
465 return $status;
466 }
467 $this->deletionBatch = $checkStatus->value;
468
469 // Execute the file deletion batch
470 $status = $this->file->repo->deleteBatch( $this->deletionBatch );
471 if ( !$status->isGood() ) {
472 $status->merge( $status );
473 }
474 }
475
476 if ( !$status->isOK() ) {
477 // Critical file deletion error; abort
478 return $status;
479 }
480
481 $dbw = $this->file->repo->getPrimaryDB();
482
483 $dbw->startAtomic( __METHOD__ );
484
485 // Copy the image/oldimage rows to filearchive
486 $this->doDBInserts();
487 // Delete image/oldimage rows
488 $this->doDBDeletes();
489
490 // This is typically a no-op since we are wrapped by another atomic
491 // section in FileDeleteForm and also the implicit transaction.
492 $dbw->endAtomic( __METHOD__ );
493
494 // Commit and return
495 ScopedCallback::consume( $unlockScope );
496
497 return $status;
498 }
499
505 protected function removeNonexistentFiles( $batch ) {
506 $files = [];
507
508 foreach ( $batch as [ $src, /* dest */ ] ) {
509 $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
510 }
511
512 $result = $this->file->repo->fileExistsBatch( $files );
513 if ( in_array( null, $result, true ) ) {
514 return Status::newFatal( 'backend-fail-internal',
515 $this->file->repo->getBackend()->getName() );
516 }
517
518 $newBatch = [];
519 foreach ( $batch as $batchItem ) {
520 if ( $result[$batchItem[0]] ) {
521 $newBatch[] = $batchItem;
522 }
523 }
524
525 return Status::newGood( $newBatch );
526 }
527}
528
530class_alias( LocalFileDeleteBatch::class, 'LocalFileDeleteBatch' );
const SCHEMA_COMPAT_WRITE_OLD
Definition Defines.php:293
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:80
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.