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