MediaWiki  master
LocalFileRestoreBatch.php
Go to the documentation of this file.
1 <?php
25 use Wikimedia\ScopedCallback;
26 
33  private $file;
34 
36  private $cleanupBatch;
37 
39  private $ids;
40 
42  private $all;
43 
45  private $unsuppress;
46 
51  public function __construct( LocalFile $file, $unsuppress = false ) {
52  $this->file = $file;
53  $this->cleanupBatch = [];
54  $this->ids = [];
55  $this->unsuppress = $unsuppress;
56  }
57 
62  public function addId( $fa_id ) {
63  $this->ids[] = $fa_id;
64  }
65 
70  public function addIds( $ids ) {
71  $this->ids = array_merge( $this->ids, $ids );
72  }
73 
77  public function addAll() {
78  $this->all = true;
79  }
80 
89  public function execute() {
91  global $wgLang;
92 
93  $repo = $this->file->getRepo();
94  if ( !$this->all && !$this->ids ) {
95  // Do nothing
96  return $repo->newGood();
97  }
98 
99  $status = $this->file->acquireFileLock();
100  if ( !$status->isOK() ) {
101  return $status;
102  }
103 
104  $dbw = $this->file->repo->getPrimaryDB();
105 
106  $ownTrx = !$dbw->trxLevel();
107  $funcName = __METHOD__;
108  $dbw->startAtomic( __METHOD__ );
109 
110  $unlockScope = new ScopedCallback( function () use ( $dbw, $funcName ) {
111  $dbw->endAtomic( $funcName );
112  $this->file->releaseFileLock();
113  } );
114 
115  $commentStore = MediaWikiServices::getInstance()->getCommentStore();
116 
117  $status = $this->file->repo->newGood();
118 
119  $exists = (bool)$dbw->selectField( 'image', '1',
120  [ 'img_name' => $this->file->getName() ],
121  __METHOD__,
122  // The acquireFileLock() should already prevent changes, but this still may need
123  // to bypass any transaction snapshot. However, if we started the
124  // trx (which we probably did) then snapshot is post-lock and up-to-date.
125  $ownTrx ? [] : [ 'LOCK IN SHARE MODE' ]
126  );
127 
128  // Fetch all or selected archived revisions for the file,
129  // sorted from the most recent to the oldest.
130  $conditions = [ 'fa_name' => $this->file->getName() ];
131 
132  if ( !$this->all ) {
133  $conditions['fa_id'] = $this->ids;
134  }
135 
136  $arFileQuery = ArchivedFile::getQueryInfo();
137  $result = $dbw->select(
138  $arFileQuery['tables'],
139  $arFileQuery['fields'],
140  $conditions,
141  __METHOD__,
142  [ 'ORDER BY' => 'fa_timestamp DESC' ],
143  $arFileQuery['joins']
144  );
145 
146  $idsPresent = [];
147  $storeBatch = [];
148  $insertBatch = [];
149  $insertCurrent = false;
150  $deleteIds = [];
151  $first = true;
152  $archiveNames = [];
153 
154  foreach ( $result as $row ) {
155  $idsPresent[] = $row->fa_id;
156 
157  if ( $row->fa_name != $this->file->getName() ) {
158  $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
159  $status->failCount++;
160  continue;
161  }
162 
163  if ( $row->fa_storage_key == '' ) {
164  // Revision was missing pre-deletion
165  $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
166  $status->failCount++;
167  continue;
168  }
169 
170  $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
171  $row->fa_storage_key;
172  $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
173 
174  if ( isset( $row->fa_sha1 ) ) {
175  $sha1 = $row->fa_sha1;
176  } else {
177  // old row, populate from key
178  $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
179  }
180 
181  # Fix leading zero
182  if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
183  $sha1 = substr( $sha1, 1 );
184  }
185 
186  if ( $row->fa_major_mime === null || $row->fa_major_mime == 'unknown'
187  || $row->fa_minor_mime === null || $row->fa_minor_mime == 'unknown'
188  || $row->fa_media_type === null || $row->fa_media_type == 'UNKNOWN'
189  || $row->fa_metadata === null
190  ) {
191  // Refresh our metadata
192  // Required for a new current revision; nice for older ones too. :)
193  $this->file->loadFromFile( $deletedUrl );
194  $mime = $this->file->getMimeType();
195  list( $majorMime, $minorMime ) = File::splitMime( $mime );
196  $mediaInfo = [
197  'minor_mime' => $minorMime,
198  'major_mime' => $majorMime,
199  'media_type' => $this->file->getMediaType(),
200  'metadata' => $this->file->getMetadataForDb( $dbw )
201  ];
202  } else {
203  $mediaInfo = [
204  'minor_mime' => $row->fa_minor_mime,
205  'major_mime' => $row->fa_major_mime,
206  'media_type' => $row->fa_media_type,
207  'metadata' => $row->fa_metadata
208  ];
209  }
210 
211  $comment = $commentStore->getComment( 'fa_description', $row );
212  if ( $first && !$exists ) {
213  // This revision will be published as the new current version
214  $destRel = $this->file->getRel();
215  $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
216  $insertCurrent = [
217  'img_name' => $row->fa_name,
218  'img_size' => $row->fa_size,
219  'img_width' => $row->fa_width,
220  'img_height' => $row->fa_height,
221  'img_metadata' => $mediaInfo['metadata'],
222  'img_bits' => $row->fa_bits,
223  'img_media_type' => $mediaInfo['media_type'],
224  'img_major_mime' => $mediaInfo['major_mime'],
225  'img_minor_mime' => $mediaInfo['minor_mime'],
226  'img_actor' => $row->fa_actor,
227  'img_timestamp' => $row->fa_timestamp,
228  'img_sha1' => $sha1
229  ] + $commentFields;
230 
231  // The live (current) version cannot be hidden!
232  if ( !$this->unsuppress && $row->fa_deleted ) {
233  $status->fatal( 'undeleterevdel' );
234  return $status;
235  }
236  } else {
237  $archiveName = $row->fa_archive_name;
238 
239  if ( $archiveName == '' ) {
240  // This was originally a current version; we
241  // have to devise a new archive name for it.
242  // Format is <timestamp of archiving>!<name>
243  $timestamp = (int)wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );
244 
245  do {
246  $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
247  $timestamp++;
248  } while ( isset( $archiveNames[$archiveName] ) );
249  }
250 
251  $archiveNames[$archiveName] = true;
252  $destRel = $this->file->getArchiveRel( $archiveName );
253  $insertBatch[] = [
254  'oi_name' => $row->fa_name,
255  'oi_archive_name' => $archiveName,
256  'oi_size' => $row->fa_size,
257  'oi_width' => $row->fa_width,
258  'oi_height' => $row->fa_height,
259  'oi_bits' => $row->fa_bits,
260  'oi_actor' => $row->fa_actor,
261  'oi_timestamp' => $row->fa_timestamp,
262  'oi_metadata' => $mediaInfo['metadata'],
263  'oi_media_type' => $mediaInfo['media_type'],
264  'oi_major_mime' => $mediaInfo['major_mime'],
265  'oi_minor_mime' => $mediaInfo['minor_mime'],
266  'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
267  'oi_sha1' => $sha1
268  ] + $commentStore->insert( $dbw, 'oi_description', $comment );
269  }
270 
271  $deleteIds[] = $row->fa_id;
272 
273  if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
274  // private files can stay where they are
275  $status->successCount++;
276  } else {
277  $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
278  $this->cleanupBatch[] = $row->fa_storage_key;
279  }
280 
281  $first = false;
282  }
283 
284  unset( $result );
285 
286  // Add a warning to the status object for missing IDs
287  $missingIds = array_diff( $this->ids, $idsPresent );
288 
289  foreach ( $missingIds as $id ) {
290  $status->error( 'undelete-missing-filearchive', $id );
291  }
292 
293  if ( !$repo->hasSha1Storage() ) {
294  // Remove missing files from batch, so we don't get errors when undeleting them
295  $checkStatus = $this->removeNonexistentFiles( $storeBatch );
296  if ( !$checkStatus->isGood() ) {
297  $status->merge( $checkStatus );
298  return $status;
299  }
300  $storeBatch = $checkStatus->value;
301 
302  // Run the store batch
303  // Use the OVERWRITE_SAME flag to smooth over a common error
304  $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
305  $status->merge( $storeStatus );
306 
307  if ( !$status->isGood() ) {
308  // Even if some files could be copied, fail entirely as that is the
309  // easiest thing to do without data loss
310  $this->cleanupFailedBatch( $storeStatus, $storeBatch );
311  $status->setOK( false );
312  return $status;
313  }
314  }
315 
316  // Run the DB updates
317  // Because we have locked the image row, key conflicts should be rare.
318  // If they do occur, we can roll back the transaction at this time with
319  // no data loss, but leaving unregistered files scattered throughout the
320  // public zone.
321  // This is not ideal, which is why it's important to lock the image row.
322  if ( $insertCurrent ) {
323  $dbw->insert( 'image', $insertCurrent, __METHOD__ );
324  }
325 
326  if ( $insertBatch ) {
327  $dbw->insert( 'oldimage', $insertBatch, __METHOD__ );
328  }
329 
330  if ( $deleteIds ) {
331  $dbw->delete( 'filearchive',
332  [ 'fa_id' => $deleteIds ],
333  __METHOD__ );
334  }
335 
336  // If store batch is empty (all files are missing), deletion is to be considered successful
337  if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
338  if ( !$exists ) {
339  wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current" );
340 
342 
343  $this->file->purgeEverything();
344  } else {
345  wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions" );
346  $this->file->purgeDescription();
347  }
348  }
349 
350  ScopedCallback::consume( $unlockScope );
351 
352  return $status;
353  }
354 
360  protected function removeNonexistentFiles( $triplets ) {
361  $files = $filteredTriplets = [];
362  foreach ( $triplets as $file ) {
363  $files[$file[0]] = $file[0];
364  }
365 
366  $result = $this->file->repo->fileExistsBatch( $files );
367  if ( in_array( null, $result, true ) ) {
368  return Status::newFatal( 'backend-fail-internal',
369  $this->file->repo->getBackend()->getName() );
370  }
371 
372  foreach ( $triplets as $file ) {
373  if ( $result[$file[0]] ) {
374  $filteredTriplets[] = $file;
375  }
376  }
377 
378  return Status::newGood( $filteredTriplets );
379  }
380 
386  protected function removeNonexistentFromCleanup( $batch ) {
387  $files = $newBatch = [];
388  $repo = $this->file->repo;
389 
390  foreach ( $batch as $file ) {
391  $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
392  rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
393  }
394 
395  $result = $repo->fileExistsBatch( $files );
396 
397  foreach ( $batch as $file ) {
398  if ( $result[$file] ) {
399  $newBatch[] = $file;
400  }
401  }
402 
403  return $newBatch;
404  }
405 
411  public function cleanup() {
412  if ( !$this->cleanupBatch ) {
413  return $this->file->repo->newGood();
414  }
415 
416  $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );
417 
418  $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );
419 
420  return $status;
421  }
422 
430  protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
431  $cleanupBatch = [];
432 
433  foreach ( $storeStatus->success as $i => $success ) {
434  // Check if this item of the batch was successfully copied
435  if ( $success ) {
436  // Item was successfully copied and needs to be removed again
437  // Extract ($dstZone, $dstRel) from the batch
438  $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
439  }
440  }
441  $this->file->repo->cleanupBatch( $cleanupBatch );
442  }
443 }
LocalFileRestoreBatch
Helper class for file undeletion.
Definition: LocalFileRestoreBatch.php:31
LocalFileRestoreBatch\$cleanupBatch
string[] $cleanupBatch
List of file IDs to restore.
Definition: LocalFileRestoreBatch.php:36
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
FileRepo\OVERWRITE_SAME
const OVERWRITE_SAME
Definition: FileRepo.php:48
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:204
LocalFileRestoreBatch\$ids
int[] $ids
List of file IDs to restore.
Definition: LocalFileRestoreBatch.php:39
LocalFileRestoreBatch\execute
execute()
Run the transaction, except the cleanup batch.
Definition: LocalFileRestoreBatch.php:89
LocalFileRestoreBatch\addId
addId( $fa_id)
Add a file by ID.
Definition: LocalFileRestoreBatch.php:62
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1649
LocalFileRestoreBatch\$all
bool $all
Add all revisions of the file.
Definition: LocalFileRestoreBatch.php:42
DeferredUpdates\addUpdate
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
Definition: DeferredUpdates.php:125
ArchivedFile\getQueryInfo
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new archivedfile object.
Definition: ArchivedFile.php:247
File\splitMime
static splitMime( $mime)
Split an internet media type into its two components; if not a two-part name, set the minor type to '...
Definition: File.php:306
LocalFileRestoreBatch\$file
LocalFile $file
Definition: LocalFileRestoreBatch.php:33
$success
$success
Definition: NoLocalSettings.php:42
$wgLang
$wgLang
Definition: Setup.php:861
LocalRepo\getHashFromKey
static getHashFromKey( $key)
Gets the SHA1 hash from a storage key.
Definition: LocalRepo.php:225
LocalFileRestoreBatch\cleanupFailedBatch
cleanupFailedBatch( $storeStatus, $storeBatch)
Cleanup a failed batch.
Definition: LocalFileRestoreBatch.php:430
SiteStatsUpdate\factory
static factory(array $deltas)
Definition: SiteStatsUpdate.php:71
LocalFileRestoreBatch\addAll
addAll()
Add all revisions of the file.
Definition: LocalFileRestoreBatch.php:77
LocalFileRestoreBatch\$unsuppress
bool $unsuppress
Whether to remove all settings for suppressed fields.
Definition: LocalFileRestoreBatch.php:45
wfDebug
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
Definition: GlobalFunctions.php:894
LocalFile
Class to represent a local file in the wiki's own database.
Definition: LocalFile.php:63
StatusValue\newGood
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
LocalFileRestoreBatch\__construct
__construct(LocalFile $file, $unsuppress=false)
Definition: LocalFileRestoreBatch.php:51
LocalFileRestoreBatch\addIds
addIds( $ids)
Add a whole lot of files by ID.
Definition: LocalFileRestoreBatch.php:70
LocalFileRestoreBatch\cleanup
cleanup()
Delete unused files in the deleted zone.
Definition: LocalFileRestoreBatch.php:411
LocalFileRestoreBatch\removeNonexistentFiles
removeNonexistentFiles( $triplets)
Removes non-existent files from a store batch.
Definition: LocalFileRestoreBatch.php:360
File\DELETED_FILE
const DELETED_FILE
Definition: File.php:71
$mime
$mime
Definition: router.php:60
Language
Internationalisation code See https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation for more...
Definition: Language.php:42
LocalFileRestoreBatch\removeNonexistentFromCleanup
removeNonexistentFromCleanup( $batch)
Removes non-existent files from a cleanup batch.
Definition: LocalFileRestoreBatch.php:386