MediaWiki master
LocalFileRestoreBatch.php
Go to the documentation of this file.
1<?php
22
32use Wikimedia\ScopedCallback;
33
42 private $file;
43
45 private $cleanupBatch;
46
48 private $ids;
49
51 private $all;
52
54 private $unsuppress;
55
60 public function __construct( LocalFile $file, $unsuppress = false ) {
61 $this->file = $file;
62 $this->cleanupBatch = [];
63 $this->ids = [];
64 $this->unsuppress = $unsuppress;
65 }
66
71 public function addId( $fa_id ) {
72 $this->ids[] = $fa_id;
73 }
74
79 public function addIds( $ids ) {
80 $this->ids = array_merge( $this->ids, $ids );
81 }
82
86 public function addAll() {
87 $this->all = true;
88 }
89
98 public function execute() {
100 global $wgLang;
101
102 $repo = $this->file->getRepo();
103 if ( !$this->all && !$this->ids ) {
104 // Do nothing
105 return $repo->newGood();
106 }
107
108 $status = $this->file->acquireFileLock();
109 if ( !$status->isOK() ) {
110 return $status;
111 }
112
113 $dbw = $this->file->repo->getPrimaryDB();
114
115 $ownTrx = !$dbw->trxLevel();
116 $funcName = __METHOD__;
117 $dbw->startAtomic( __METHOD__ );
118
119 $unlockScope = new ScopedCallback( function () use ( $dbw, $funcName ) {
120 $dbw->endAtomic( $funcName );
121 $this->file->releaseFileLock();
122 } );
123
124 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
125
126 $status = $this->file->repo->newGood();
127
128 $queryBuilder = $dbw->newSelectQueryBuilder()
129 ->select( '1' )
130 ->from( 'image' )
131 ->where( [ 'img_name' => $this->file->getName() ] );
132 // The acquireFileLock() should already prevent changes, but this still may need
133 // to bypass any transaction snapshot. However, if we started the
134 // trx (which we probably did) then snapshot is post-lock and up-to-date.
135 if ( !$ownTrx ) {
136 $queryBuilder->lockInShareMode();
137 }
138 $exists = (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
139
140 // Fetch all or selected archived revisions for the file,
141 // sorted from the most recent to the oldest.
142 $arQueryBuilder = FileSelectQueryBuilder::newForArchivedFile( $dbw );
143 $arQueryBuilder->where( [ 'fa_name' => $this->file->getName() ] )
144 ->orderBy( 'fa_timestamp', SelectQueryBuilder::SORT_DESC );
145
146 if ( !$this->all ) {
147 $arQueryBuilder->andWhere( [ 'fa_id' => $this->ids ] );
148 }
149
150 $result = $arQueryBuilder->caller( __METHOD__ )->fetchResultSet();
151
152 $idsPresent = [];
153 $storeBatch = [];
154 $insertBatch = [];
155 $insertCurrent = false;
156 $insertFileRevisions = [];
157 $deleteIds = [];
158 $first = true;
159 $archiveNames = [];
160
161 foreach ( $result as $row ) {
162 $idsPresent[] = $row->fa_id;
163
164 if ( $row->fa_name != $this->file->getName() ) {
165 $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
166 $status->failCount++;
167 continue;
168 }
169
170 if ( $row->fa_storage_key == '' ) {
171 // Revision was missing pre-deletion
172 $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
173 $status->failCount++;
174 continue;
175 }
176
177 $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
178 $row->fa_storage_key;
179 $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
180
181 if ( isset( $row->fa_sha1 ) ) {
182 $sha1 = $row->fa_sha1;
183 } else {
184 // old row, populate from key
185 $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
186 }
187
188 # Fix leading zero
189 if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
190 $sha1 = substr( $sha1, 1 );
191 }
192
193 if ( $row->fa_major_mime === null || $row->fa_major_mime == 'unknown'
194 || $row->fa_minor_mime === null || $row->fa_minor_mime == 'unknown'
195 || $row->fa_media_type === null || $row->fa_media_type == 'UNKNOWN'
196 || $row->fa_metadata === null
197 ) {
198 // Refresh our metadata
199 // Required for a new current revision; nice for older ones too. :)
200 $this->file->loadFromFile( $deletedUrl );
201 $mime = $this->file->getMimeType();
202 [ $majorMime, $minorMime ] = File::splitMime( $mime );
203 $mediaInfo = [
204 'minor_mime' => $minorMime,
205 'major_mime' => $majorMime,
206 'media_type' => $this->file->getMediaType(),
207 'metadata' => $this->file->getMetadataForDb( $dbw )
208 ];
209 } else {
210 $mediaInfo = [
211 'minor_mime' => $row->fa_minor_mime,
212 'major_mime' => $row->fa_major_mime,
213 'media_type' => $row->fa_media_type,
214 'metadata' => $row->fa_metadata
215 ];
216 }
217 $this->file->setProps( [
218 'media_type' => $mediaInfo['media_type'],
219 'major_mime' => $mediaInfo['major_mime'],
220 'minor_mime' => $mediaInfo['minor_mime'],
221 ] );
222 $comment = $commentStore->getComment( 'fa_description', $row );
223
224 $commentFieldsNew = $commentStore->insert( $dbw, 'fr_description', $comment );
225 $fileRevisionRow = [
226 'fr_size' => $row->fa_size,
227 'fr_width' => $row->fa_width,
228 'fr_height' => $row->fa_height,
229 'fr_metadata' => $mediaInfo['metadata'],
230 'fr_bits' => $row->fa_bits,
231 'fr_actor' => $row->fa_actor,
232 'fr_timestamp' => $row->fa_timestamp,
233 'fr_sha1' => $sha1
234 ] + $commentFieldsNew;
235
236 if ( $first && !$exists ) {
237 // This revision will be published as the new current version
238 $destRel = $this->file->getRel();
239 $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
240 $insertCurrent = [
241 'img_name' => $row->fa_name,
242 'img_size' => $row->fa_size,
243 'img_width' => $row->fa_width,
244 'img_height' => $row->fa_height,
245 'img_metadata' => $mediaInfo['metadata'],
246 'img_bits' => $row->fa_bits,
247 'img_media_type' => $mediaInfo['media_type'],
248 'img_major_mime' => $mediaInfo['major_mime'],
249 'img_minor_mime' => $mediaInfo['minor_mime'],
250 'img_actor' => $row->fa_actor,
251 'img_timestamp' => $row->fa_timestamp,
252 'img_sha1' => $sha1
253 ] + $commentFields;
254
255 // The live (current) version cannot be hidden!
256 if ( !$this->unsuppress && $row->fa_deleted ) {
257 $status->fatal( 'undeleterevdel' );
258 return $status;
259 }
260 $fileRevisionRow['fr_archive_name'] = '';
261 $fileRevisionRow['fr_deleted'] = 0;
262 } else {
263 $archiveName = $row->fa_archive_name;
264
265 if ( $archiveName === null ) {
266 // This was originally a current version; we
267 // have to devise a new archive name for it.
268 // Format is <timestamp of archiving>!<name>
269 $timestamp = (int)wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );
270
271 do {
272 $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
273 $timestamp++;
274 } while ( isset( $archiveNames[$archiveName] ) );
275 }
276
277 $archiveNames[$archiveName] = true;
278 $destRel = $this->file->getArchiveRel( $archiveName );
279 $insertBatch[] = [
280 'oi_name' => $row->fa_name,
281 'oi_archive_name' => $archiveName,
282 'oi_size' => $row->fa_size,
283 'oi_width' => $row->fa_width,
284 'oi_height' => $row->fa_height,
285 'oi_bits' => $row->fa_bits,
286 'oi_actor' => $row->fa_actor,
287 'oi_timestamp' => $row->fa_timestamp,
288 'oi_metadata' => $mediaInfo['metadata'],
289 'oi_media_type' => $mediaInfo['media_type'],
290 'oi_major_mime' => $mediaInfo['major_mime'],
291 'oi_minor_mime' => $mediaInfo['minor_mime'],
292 'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
293 'oi_sha1' => $sha1
294 ] + $commentStore->insert( $dbw, 'oi_description', $comment );
295
296 $fileRevisionRow['fr_archive_name'] = $archiveName;
297 $fileRevisionRow['fr_deleted'] = $this->unsuppress ? 0 : $row->fa_deleted;
298 }
299 $insertFileRevisions[] = $fileRevisionRow;
300
301 $deleteIds[] = $row->fa_id;
302
303 if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
304 // private files can stay where they are
305 $status->successCount++;
306 } else {
307 $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
308 $this->cleanupBatch[] = $row->fa_storage_key;
309 }
310
311 $first = false;
312 }
313
314 unset( $result );
315
316 // Add a warning to the status object for missing IDs
317 $missingIds = array_diff( $this->ids, $idsPresent );
318
319 foreach ( $missingIds as $id ) {
320 $status->error( 'undelete-missing-filearchive', $id );
321 }
322
323 if ( !$repo->hasSha1Storage() ) {
324 // Remove missing files from batch, so we don't get errors when undeleting them
325 $checkStatus = $this->removeNonexistentFiles( $storeBatch );
326 if ( !$checkStatus->isGood() ) {
327 $status->merge( $checkStatus );
328 return $status;
329 }
330 $storeBatch = $checkStatus->value;
331
332 // Run the store batch
333 // Use the OVERWRITE_SAME flag to smooth over a common error
334 $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
335 $status->merge( $storeStatus );
336
337 if ( !$status->isGood() ) {
338 // Even if some files could be copied, fail entirely as that is the
339 // easiest thing to do without data loss
340 $this->cleanupFailedBatch( $storeStatus, $storeBatch );
341 $status->setOK( false );
342 return $status;
343 }
344 }
345
346 // Run the DB updates
347 // Because we have locked the image row, key conflicts should be rare.
348 // If they do occur, we can roll back the transaction at this time with
349 // no data loss, but leaving unregistered files scattered throughout the
350 // public zone.
351 // This is not ideal, which is why it's important to lock the image row.
352 $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
354 );
355 if ( $insertCurrent ) {
356 $dbw->newInsertQueryBuilder()
357 ->insertInto( 'image' )
358 ->row( $insertCurrent )
359 ->caller( __METHOD__ )->execute();
360 if ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
361 $dbw->newUpdateQueryBuilder()
362 ->update( 'file' )
363 ->set( [ 'file_deleted' => 0 ] )
364 ->where( [ 'file_id' => $this->file->acquireFileIdFromName() ] )
365 ->caller( __METHOD__ )->execute();
366 }
367 }
368
369 if ( $insertBatch ) {
370 $dbw->newInsertQueryBuilder()
371 ->insertInto( 'oldimage' )
372 ->rows( $insertBatch )
373 ->caller( __METHOD__ )->execute();
374 }
375
376 if ( $deleteIds ) {
377 $dbw->newDeleteQueryBuilder()
378 ->deleteFrom( 'filearchive' )
379 ->where( [ 'fa_id' => $deleteIds ] )
380 ->caller( __METHOD__ )->execute();
381 }
382
383 if ( $insertFileRevisions && ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
384 // reverse the order to make the newest have the highest id
385 $insertFileRevisions = array_reverse( $insertFileRevisions );
386
387 foreach ( $insertFileRevisions as &$row ) {
388 $row['fr_file'] = $this->file->getFileIdFromName();
389 }
390 $dbw->newInsertQueryBuilder()
391 ->insertInto( 'filerevision' )
392 ->rows( $insertFileRevisions )
393 ->caller( __METHOD__ )->execute();
394 $latestId = $dbw->newSelectQueryBuilder()
395 ->select( 'fr_id' )
396 ->from( 'filerevision' )
397 ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] )
398 ->orderBy( 'fr_timestamp', 'DESC' )
399 ->fetchField();
400 $dbw->newUpdateQueryBuilder()
401 ->update( 'file' )
402 ->set( [ 'file_latest' => $latestId ] )
403 ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
404 ->caller( __METHOD__ )->execute();
405
406 }
407
408 // If store batch is empty (all files are missing), deletion is to be considered successful
409 if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
410 if ( !$exists ) {
411 wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current" );
412
413 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
414
415 $this->file->purgeEverything();
416 } else {
417 wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions" );
418 $this->file->purgeDescription();
419 }
420 }
421
422 ScopedCallback::consume( $unlockScope );
423
424 return $status;
425 }
426
432 protected function removeNonexistentFiles( $triplets ) {
433 $files = $filteredTriplets = [];
434 foreach ( $triplets as $file ) {
435 $files[$file[0]] = $file[0];
436 }
437
438 $result = $this->file->repo->fileExistsBatch( $files );
439 if ( in_array( null, $result, true ) ) {
440 return Status::newFatal( 'backend-fail-internal',
441 $this->file->repo->getBackend()->getName() );
442 }
443
444 foreach ( $triplets as $file ) {
445 if ( $result[$file[0]] ) {
446 $filteredTriplets[] = $file;
447 }
448 }
449
450 return Status::newGood( $filteredTriplets );
451 }
452
458 protected function removeNonexistentFromCleanup( $batch ) {
459 $files = $newBatch = [];
460 $repo = $this->file->repo;
461
462 foreach ( $batch as $file ) {
463 $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
464 rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
465 }
466
467 $result = $repo->fileExistsBatch( $files );
468
469 foreach ( $batch as $file ) {
470 if ( $result[$file] ) {
471 $newBatch[] = $file;
472 }
473 }
474
475 return $newBatch;
476 }
477
483 public function cleanup() {
484 if ( !$this->cleanupBatch ) {
485 return $this->file->repo->newGood();
486 }
487
488 $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );
489
490 $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );
491
492 return $status;
493 }
494
502 protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
503 $cleanupBatch = [];
504
505 foreach ( $storeStatus->success as $i => $success ) {
506 // Check if this item of the batch was successfully copied
507 if ( $success ) {
508 // Item was successfully copied and needs to be removed again
509 // Extract ($dstZone, $dstRel) from the batch
510 $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
511 }
512 }
513 $this->file->repo->cleanupBatch( $cleanupBatch );
514 }
515}
516
518class_alias( LocalFileRestoreBatch::class, 'LocalFileRestoreBatch' );
const SCHEMA_COMPAT_WRITE_NEW
Definition Defines.php:307
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(!defined( 'MW_NO_SESSION') &&MW_ENTRY_POINT !=='cli' $wgLang
Definition Setup.php:558
Defer callable updates to run later in the PHP process.
Class for handling updates to the site_stats table.
Base class for file repositories.
Definition FileRepo.php:68
static newForArchivedFile(IReadableDatabase $db, array $options=[])
static splitMime(?string $mime)
Split an internet media type into its two components; if not a two-part name, set the minor type to '...
Definition File.php:334
cleanup()
Delete unused files in the deleted zone.
__construct(LocalFile $file, $unsuppress=false)
removeNonexistentFiles( $triplets)
Removes non-existent files from a store batch.
removeNonexistentFromCleanup( $batch)
Removes non-existent files from a cleanup batch.
cleanupFailedBatch( $storeStatus, $storeBatch)
Cleanup a failed batch.
addIds( $ids)
Add a whole lot of files by ID.
execute()
Run the transaction, except the cleanup batch.
Local file in the wiki's own database.
Definition LocalFile.php:93
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:57
static getHashFromKey( $key)
Gets the SHA1 hash from a storage key.
Base class for language-specific code.
Definition Language.php:82
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.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Build SELECT queries with a fluent interface.