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