MediaWiki REL1_39
LocalFileRestoreBatch.php
Go to the documentation of this file.
1<?php
22use 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 list( $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
340 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
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.
if(!defined( 'MW_NO_SESSION') &&! $wgCommandLineMode $wgLang
Definition Setup.php:497
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new archivedfile object.
const OVERWRITE_SAME
Definition FileRepo.php:50
Base class for language-specific code.
Definition Language.php:53
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:60
Service locator for MediaWiki core services.
$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