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