MediaWiki  master
LocalFileRestoreBatch.php
Go to the documentation of this file.
1 <?php
25 
32  private $file;
33 
35  private $cleanupBatch;
36 
38  private $ids;
39 
41  private $all;
42 
44  private $unsuppress = false;
45 
50  function __construct( File $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  $lockOwnsTrx = $this->file->lock();
99 
100  $dbw = $this->file->repo->getMasterDB();
101 
102  $commentStore = MediaWikiServices::getInstance()->getCommentStore();
103  $actorMigration = ActorMigration::newMigration();
104 
105  $status = $this->file->repo->newGood();
106 
107  $exists = (bool)$dbw->selectField( 'image', '1',
108  [ 'img_name' => $this->file->getName() ],
109  __METHOD__,
110  // The lock() should already prevents changes, but this still may need
111  // to bypass any transaction snapshot. However, if lock() started the
112  // trx (which it probably did) then snapshot is post-lock and up-to-date.
113  $lockOwnsTrx ? [] : [ 'LOCK IN SHARE MODE' ]
114  );
115 
116  // Fetch all or selected archived revisions for the file,
117  // sorted from the most recent to the oldest.
118  $conditions = [ 'fa_name' => $this->file->getName() ];
119 
120  if ( !$this->all ) {
121  $conditions['fa_id'] = $this->ids;
122  }
123 
124  $arFileQuery = ArchivedFile::getQueryInfo();
125  $result = $dbw->select(
126  $arFileQuery['tables'],
127  $arFileQuery['fields'],
128  $conditions,
129  __METHOD__,
130  [ 'ORDER BY' => 'fa_timestamp DESC' ],
131  $arFileQuery['joins']
132  );
133 
134  $idsPresent = [];
135  $storeBatch = [];
136  $insertBatch = [];
137  $insertCurrent = false;
138  $deleteIds = [];
139  $first = true;
140  $archiveNames = [];
141 
142  foreach ( $result as $row ) {
143  $idsPresent[] = $row->fa_id;
144 
145  if ( $row->fa_name != $this->file->getName() ) {
146  $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
147  $status->failCount++;
148  continue;
149  }
150 
151  if ( $row->fa_storage_key == '' ) {
152  // Revision was missing pre-deletion
153  $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
154  $status->failCount++;
155  continue;
156  }
157 
158  $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
159  $row->fa_storage_key;
160  $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
161 
162  if ( isset( $row->fa_sha1 ) ) {
163  $sha1 = $row->fa_sha1;
164  } else {
165  // old row, populate from key
166  $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
167  }
168 
169  # Fix leading zero
170  if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
171  $sha1 = substr( $sha1, 1 );
172  }
173 
174  if ( is_null( $row->fa_major_mime ) || $row->fa_major_mime == 'unknown'
175  || is_null( $row->fa_minor_mime ) || $row->fa_minor_mime == 'unknown'
176  || is_null( $row->fa_media_type ) || $row->fa_media_type == 'UNKNOWN'
177  || is_null( $row->fa_metadata )
178  ) {
179  // Refresh our metadata
180  // Required for a new current revision; nice for older ones too. :)
181  $props = RepoGroup::singleton()->getFileProps( $deletedUrl );
182  } else {
183  $props = [
184  'minor_mime' => $row->fa_minor_mime,
185  'major_mime' => $row->fa_major_mime,
186  'media_type' => $row->fa_media_type,
187  'metadata' => $row->fa_metadata
188  ];
189  }
190 
191  $comment = $commentStore->getComment( 'fa_description', $row );
192  $user = User::newFromAnyId( $row->fa_user, $row->fa_user_text, $row->fa_actor );
193  if ( $first && !$exists ) {
194  // This revision will be published as the new current version
195  $destRel = $this->file->getRel();
196  $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
197  $actorFields = $actorMigration->getInsertValues( $dbw, 'img_user', $user );
198  $insertCurrent = [
199  'img_name' => $row->fa_name,
200  'img_size' => $row->fa_size,
201  'img_width' => $row->fa_width,
202  'img_height' => $row->fa_height,
203  'img_metadata' => $props['metadata'],
204  'img_bits' => $row->fa_bits,
205  'img_media_type' => $props['media_type'],
206  'img_major_mime' => $props['major_mime'],
207  'img_minor_mime' => $props['minor_mime'],
208  'img_timestamp' => $row->fa_timestamp,
209  'img_sha1' => $sha1
210  ] + $commentFields + $actorFields;
211 
212  // The live (current) version cannot be hidden!
213  if ( !$this->unsuppress && $row->fa_deleted ) {
214  $status->fatal( 'undeleterevdel' );
215  $this->file->unlock();
216  return $status;
217  }
218  } else {
219  $archiveName = $row->fa_archive_name;
220 
221  if ( $archiveName == '' ) {
222  // This was originally a current version; we
223  // have to devise a new archive name for it.
224  // Format is <timestamp of archiving>!<name>
225  $timestamp = wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );
226 
227  do {
228  $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
229  $timestamp++;
230  } while ( isset( $archiveNames[$archiveName] ) );
231  }
232 
233  $archiveNames[$archiveName] = true;
234  $destRel = $this->file->getArchiveRel( $archiveName );
235  $insertBatch[] = [
236  'oi_name' => $row->fa_name,
237  'oi_archive_name' => $archiveName,
238  'oi_size' => $row->fa_size,
239  'oi_width' => $row->fa_width,
240  'oi_height' => $row->fa_height,
241  'oi_bits' => $row->fa_bits,
242  'oi_timestamp' => $row->fa_timestamp,
243  'oi_metadata' => $props['metadata'],
244  'oi_media_type' => $props['media_type'],
245  'oi_major_mime' => $props['major_mime'],
246  'oi_minor_mime' => $props['minor_mime'],
247  'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
248  'oi_sha1' => $sha1
249  ] + $commentStore->insert( $dbw, 'oi_description', $comment )
250  + $actorMigration->getInsertValues( $dbw, 'oi_user', $user );
251  }
252 
253  $deleteIds[] = $row->fa_id;
254 
255  if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
256  // private files can stay where they are
257  $status->successCount++;
258  } else {
259  $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
260  $this->cleanupBatch[] = $row->fa_storage_key;
261  }
262 
263  $first = false;
264  }
265 
266  unset( $result );
267 
268  // Add a warning to the status object for missing IDs
269  $missingIds = array_diff( $this->ids, $idsPresent );
270 
271  foreach ( $missingIds as $id ) {
272  $status->error( 'undelete-missing-filearchive', $id );
273  }
274 
275  if ( !$repo->hasSha1Storage() ) {
276  // Remove missing files from batch, so we don't get errors when undeleting them
277  $checkStatus = $this->removeNonexistentFiles( $storeBatch );
278  if ( !$checkStatus->isGood() ) {
279  $status->merge( $checkStatus );
280  return $status;
281  }
282  $storeBatch = $checkStatus->value;
283 
284  // Run the store batch
285  // Use the OVERWRITE_SAME flag to smooth over a common error
286  $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
287  $status->merge( $storeStatus );
288 
289  if ( !$status->isGood() ) {
290  // Even if some files could be copied, fail entirely as that is the
291  // easiest thing to do without data loss
292  $this->cleanupFailedBatch( $storeStatus, $storeBatch );
293  $status->setOK( false );
294  $this->file->unlock();
295 
296  return $status;
297  }
298  }
299 
300  // Run the DB updates
301  // Because we have locked the image row, key conflicts should be rare.
302  // If they do occur, we can roll back the transaction at this time with
303  // no data loss, but leaving unregistered files scattered throughout the
304  // public zone.
305  // This is not ideal, which is why it's important to lock the image row.
306  if ( $insertCurrent ) {
307  $dbw->insert( 'image', $insertCurrent, __METHOD__ );
308  }
309 
310  if ( $insertBatch ) {
311  $dbw->insert( 'oldimage', $insertBatch, __METHOD__ );
312  }
313 
314  if ( $deleteIds ) {
315  $dbw->delete( 'filearchive',
316  [ 'fa_id' => $deleteIds ],
317  __METHOD__ );
318  }
319 
320  // If store batch is empty (all files are missing), deletion is to be considered successful
321  if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
322  if ( !$exists ) {
323  wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current\n" );
324 
326 
327  $this->file->purgeEverything();
328  } else {
329  wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions\n" );
330  $this->file->purgeDescription();
331  }
332  }
333 
334  $this->file->unlock();
335 
336  return $status;
337  }
338 
344  protected function removeNonexistentFiles( $triplets ) {
345  $files = $filteredTriplets = [];
346  foreach ( $triplets as $file ) {
347  $files[$file[0]] = $file[0];
348  }
349 
350  $result = $this->file->repo->fileExistsBatch( $files );
351  if ( in_array( null, $result, true ) ) {
352  return Status::newFatal( 'backend-fail-internal',
353  $this->file->repo->getBackend()->getName() );
354  }
355 
356  foreach ( $triplets as $file ) {
357  if ( $result[$file[0]] ) {
358  $filteredTriplets[] = $file;
359  }
360  }
361 
362  return Status::newGood( $filteredTriplets );
363  }
364 
370  protected function removeNonexistentFromCleanup( $batch ) {
371  $files = $newBatch = [];
372  $repo = $this->file->repo;
373 
374  foreach ( $batch as $file ) {
375  $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
376  rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
377  }
378 
379  $result = $repo->fileExistsBatch( $files );
380 
381  foreach ( $batch as $file ) {
382  if ( $result[$file] ) {
383  $newBatch[] = $file;
384  }
385  }
386 
387  return $newBatch;
388  }
389 
395  public function cleanup() {
396  if ( !$this->cleanupBatch ) {
397  return $this->file->repo->newGood();
398  }
399 
400  $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );
401 
402  $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );
403 
404  return $status;
405  }
406 
414  protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
415  $cleanupBatch = [];
416 
417  foreach ( $storeStatus->success as $i => $success ) {
418  // Check if this item of the batch was successfully copied
419  if ( $success ) {
420  // Item was successfully copied and needs to be removed again
421  // Extract ($dstZone, $dstRel) from the batch
422  $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
423  }
424  }
425  $this->file->repo->cleanupBatch( $cleanupBatch );
426  }
427 }
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
$success
addAll()
Add all revisions of the file.
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new archivedfile object...
cleanup()
Delete unused files in the deleted zone.
static newFromAnyId( $userId, $userName, $actorId, $dbDomain=false)
Static factory method for creation from an ID, name, and/or actor ID.
Definition: User.php:596
Helper class for file undeletion.
removeNonexistentFromCleanup( $batch)
Removes non-existent files from a cleanup batch.
string [] $ids
List of file IDs to restore.
const DELETED_FILE
Definition: File.php:63
addIds( $ids)
Add a whole lot of files by ID.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
__construct(File $file, $unsuppress=false)
$wgLang
Definition: Setup.php:857
static newMigration()
Static constructor.
static factory(array $deltas)
static singleton()
Definition: RepoGroup.php:60
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
bool $unsuppress
Whether to remove all settings for suppressed fields.
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
bool $all
Add all revisions of the file.
static getHashFromKey( $key)
Gets the SHA1 hash from a storage key.
Definition: LocalRepo.php:183
removeNonexistentFiles( $triplets)
Removes non-existent files from a store batch.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the deferred list to be run later by execute()
const OVERWRITE_SAME
Definition: FileRepo.php:42
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition: File.php:61
string [] $cleanupBatch
List of file IDs to restore.
cleanupFailedBatch( $storeStatus, $storeBatch)
Cleanup a failed batch.
execute()
Run the transaction, except the cleanup batch.
addId( $fa_id)
Add a file by ID.