MediaWiki 1.43.1
LocalFileRestoreBatch.php
Go to the documentation of this file.
1<?php
28use Wikimedia\ScopedCallback;
29
38 private $file;
39
41 private $cleanupBatch;
42
44 private $ids;
45
47 private $all;
48
50 private $unsuppress;
51
56 public function __construct( LocalFile $file, $unsuppress = false ) {
57 $this->file = $file;
58 $this->cleanupBatch = [];
59 $this->ids = [];
60 $this->unsuppress = $unsuppress;
61 }
62
67 public function addId( $fa_id ) {
68 $this->ids[] = $fa_id;
69 }
70
75 public function addIds( $ids ) {
76 $this->ids = array_merge( $this->ids, $ids );
77 }
78
82 public function addAll() {
83 $this->all = true;
84 }
85
94 public function execute() {
96 global $wgLang;
97
98 $repo = $this->file->getRepo();
99 if ( !$this->all && !$this->ids ) {
100 // Do nothing
101 return $repo->newGood();
102 }
103
104 $status = $this->file->acquireFileLock();
105 if ( !$status->isOK() ) {
106 return $status;
107 }
108
109 $dbw = $this->file->repo->getPrimaryDB();
110
111 $ownTrx = !$dbw->trxLevel();
112 $funcName = __METHOD__;
113 $dbw->startAtomic( __METHOD__ );
114
115 $unlockScope = new ScopedCallback( function () use ( $dbw, $funcName ) {
116 $dbw->endAtomic( $funcName );
117 $this->file->releaseFileLock();
118 } );
119
120 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
121
122 $status = $this->file->repo->newGood();
123
124 $queryBuilder = $dbw->newSelectQueryBuilder()
125 ->select( '1' )
126 ->from( 'image' )
127 ->where( [ 'img_name' => $this->file->getName() ] );
128 // The acquireFileLock() should already prevent changes, but this still may need
129 // to bypass any transaction snapshot. However, if we started the
130 // trx (which we probably did) then snapshot is post-lock and up-to-date.
131 if ( !$ownTrx ) {
132 $queryBuilder->lockInShareMode();
133 }
134 $exists = (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
135
136 // Fetch all or selected archived revisions for the file,
137 // sorted from the most recent to the oldest.
138 $arQueryBuilder = FileSelectQueryBuilder::newForArchivedFile( $dbw );
139 $arQueryBuilder->where( [ 'fa_name' => $this->file->getName() ] )
140 ->orderBy( 'fa_timestamp', SelectQueryBuilder::SORT_DESC );
141
142 if ( !$this->all ) {
143 $arQueryBuilder->andWhere( [ 'fa_id' => $this->ids ] );
144 }
145
146 $result = $arQueryBuilder->caller( __METHOD__ )->fetchResultSet();
147
148 $idsPresent = [];
149 $storeBatch = [];
150 $insertBatch = [];
151 $insertCurrent = false;
152 $deleteIds = [];
153 $first = true;
154 $archiveNames = [];
155
156 foreach ( $result as $row ) {
157 $idsPresent[] = $row->fa_id;
158
159 if ( $row->fa_name != $this->file->getName() ) {
160 $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
161 $status->failCount++;
162 continue;
163 }
164
165 if ( $row->fa_storage_key == '' ) {
166 // Revision was missing pre-deletion
167 $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
168 $status->failCount++;
169 continue;
170 }
171
172 $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
173 $row->fa_storage_key;
174 $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
175
176 if ( isset( $row->fa_sha1 ) ) {
177 $sha1 = $row->fa_sha1;
178 } else {
179 // old row, populate from key
180 $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
181 }
182
183 # Fix leading zero
184 if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
185 $sha1 = substr( $sha1, 1 );
186 }
187
188 if ( $row->fa_major_mime === null || $row->fa_major_mime == 'unknown'
189 || $row->fa_minor_mime === null || $row->fa_minor_mime == 'unknown'
190 || $row->fa_media_type === null || $row->fa_media_type == 'UNKNOWN'
191 || $row->fa_metadata === null
192 ) {
193 // Refresh our metadata
194 // Required for a new current revision; nice for older ones too. :)
195 $this->file->loadFromFile( $deletedUrl );
196 $mime = $this->file->getMimeType();
197 [ $majorMime, $minorMime ] = File::splitMime( $mime );
198 $mediaInfo = [
199 'minor_mime' => $minorMime,
200 'major_mime' => $majorMime,
201 'media_type' => $this->file->getMediaType(),
202 'metadata' => $this->file->getMetadataForDb( $dbw )
203 ];
204 } else {
205 $mediaInfo = [
206 'minor_mime' => $row->fa_minor_mime,
207 'major_mime' => $row->fa_major_mime,
208 'media_type' => $row->fa_media_type,
209 'metadata' => $row->fa_metadata
210 ];
211 }
212
213 $comment = $commentStore->getComment( 'fa_description', $row );
214 if ( $first && !$exists ) {
215 // This revision will be published as the new current version
216 $destRel = $this->file->getRel();
217 $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
218 $insertCurrent = [
219 'img_name' => $row->fa_name,
220 'img_size' => $row->fa_size,
221 'img_width' => $row->fa_width,
222 'img_height' => $row->fa_height,
223 'img_metadata' => $mediaInfo['metadata'],
224 'img_bits' => $row->fa_bits,
225 'img_media_type' => $mediaInfo['media_type'],
226 'img_major_mime' => $mediaInfo['major_mime'],
227 'img_minor_mime' => $mediaInfo['minor_mime'],
228 'img_actor' => $row->fa_actor,
229 'img_timestamp' => $row->fa_timestamp,
230 'img_sha1' => $sha1
231 ] + $commentFields;
232
233 // The live (current) version cannot be hidden!
234 if ( !$this->unsuppress && $row->fa_deleted ) {
235 $status->fatal( 'undeleterevdel' );
236 return $status;
237 }
238 } else {
239 $archiveName = $row->fa_archive_name;
240
241 if ( $archiveName === null ) {
242 // This was originally a current version; we
243 // have to devise a new archive name for it.
244 // Format is <timestamp of archiving>!<name>
245 $timestamp = (int)wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );
246
247 do {
248 $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
249 $timestamp++;
250 } while ( isset( $archiveNames[$archiveName] ) );
251 }
252
253 $archiveNames[$archiveName] = true;
254 $destRel = $this->file->getArchiveRel( $archiveName );
255 $insertBatch[] = [
256 'oi_name' => $row->fa_name,
257 'oi_archive_name' => $archiveName,
258 'oi_size' => $row->fa_size,
259 'oi_width' => $row->fa_width,
260 'oi_height' => $row->fa_height,
261 'oi_bits' => $row->fa_bits,
262 'oi_actor' => $row->fa_actor,
263 'oi_timestamp' => $row->fa_timestamp,
264 'oi_metadata' => $mediaInfo['metadata'],
265 'oi_media_type' => $mediaInfo['media_type'],
266 'oi_major_mime' => $mediaInfo['major_mime'],
267 'oi_minor_mime' => $mediaInfo['minor_mime'],
268 'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
269 'oi_sha1' => $sha1
270 ] + $commentStore->insert( $dbw, 'oi_description', $comment );
271 }
272
273 $deleteIds[] = $row->fa_id;
274
275 if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
276 // private files can stay where they are
277 $status->successCount++;
278 } else {
279 $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
280 $this->cleanupBatch[] = $row->fa_storage_key;
281 }
282
283 $first = false;
284 }
285
286 unset( $result );
287
288 // Add a warning to the status object for missing IDs
289 $missingIds = array_diff( $this->ids, $idsPresent );
290
291 foreach ( $missingIds as $id ) {
292 $status->error( 'undelete-missing-filearchive', $id );
293 }
294
295 if ( !$repo->hasSha1Storage() ) {
296 // Remove missing files from batch, so we don't get errors when undeleting them
297 $checkStatus = $this->removeNonexistentFiles( $storeBatch );
298 if ( !$checkStatus->isGood() ) {
299 $status->merge( $checkStatus );
300 return $status;
301 }
302 $storeBatch = $checkStatus->value;
303
304 // Run the store batch
305 // Use the OVERWRITE_SAME flag to smooth over a common error
306 $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
307 $status->merge( $storeStatus );
308
309 if ( !$status->isGood() ) {
310 // Even if some files could be copied, fail entirely as that is the
311 // easiest thing to do without data loss
312 $this->cleanupFailedBatch( $storeStatus, $storeBatch );
313 $status->setOK( false );
314 return $status;
315 }
316 }
317
318 // Run the DB updates
319 // Because we have locked the image row, key conflicts should be rare.
320 // If they do occur, we can roll back the transaction at this time with
321 // no data loss, but leaving unregistered files scattered throughout the
322 // public zone.
323 // This is not ideal, which is why it's important to lock the image row.
324 if ( $insertCurrent ) {
325 $dbw->newInsertQueryBuilder()
326 ->insertInto( 'image' )
327 ->row( $insertCurrent )
328 ->caller( __METHOD__ )->execute();
329 }
330
331 if ( $insertBatch ) {
332 $dbw->newInsertQueryBuilder()
333 ->insertInto( 'oldimage' )
334 ->rows( $insertBatch )
335 ->caller( __METHOD__ )->execute();
336 }
337
338 if ( $deleteIds ) {
339 $dbw->newDeleteQueryBuilder()
340 ->deleteFrom( 'filearchive' )
341 ->where( [ 'fa_id' => $deleteIds ] )
342 ->caller( __METHOD__ )->execute();
343 }
344
345 // If store batch is empty (all files are missing), deletion is to be considered successful
346 if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
347 if ( !$exists ) {
348 wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current" );
349
350 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
351
352 $this->file->purgeEverything();
353 } else {
354 wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions" );
355 $this->file->purgeDescription();
356 }
357 }
358
359 ScopedCallback::consume( $unlockScope );
360
361 return $status;
362 }
363
369 protected function removeNonexistentFiles( $triplets ) {
370 $files = $filteredTriplets = [];
371 foreach ( $triplets as $file ) {
372 $files[$file[0]] = $file[0];
373 }
374
375 $result = $this->file->repo->fileExistsBatch( $files );
376 if ( in_array( null, $result, true ) ) {
377 return Status::newFatal( 'backend-fail-internal',
378 $this->file->repo->getBackend()->getName() );
379 }
380
381 foreach ( $triplets as $file ) {
382 if ( $result[$file[0]] ) {
383 $filteredTriplets[] = $file;
384 }
385 }
386
387 return Status::newGood( $filteredTriplets );
388 }
389
395 protected function removeNonexistentFromCleanup( $batch ) {
396 $files = $newBatch = [];
397 $repo = $this->file->repo;
398
399 foreach ( $batch as $file ) {
400 $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
401 rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
402 }
403
404 $result = $repo->fileExistsBatch( $files );
405
406 foreach ( $batch as $file ) {
407 if ( $result[$file] ) {
408 $newBatch[] = $file;
409 }
410 }
411
412 return $newBatch;
413 }
414
420 public function cleanup() {
421 if ( !$this->cleanupBatch ) {
422 return $this->file->repo->newGood();
423 }
424
425 $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );
426
427 $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );
428
429 return $status;
430 }
431
439 protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
440 $cleanupBatch = [];
441
442 foreach ( $storeStatus->success as $i => $success ) {
443 // Check if this item of the batch was successfully copied
444 if ( $success ) {
445 // Item was successfully copied and needs to be removed again
446 // Extract ($dstZone, $dstRel) from the batch
447 $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
448 }
449 }
450 $this->file->repo->cleanupBatch( $cleanupBatch );
451 }
452}
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') &&MW_ENTRY_POINT !=='cli' $wgLang
Definition Setup.php:541
const OVERWRITE_SAME
Definition FileRepo.php:60
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:75
Defer callable updates to run later in the PHP process.
Class for handling updates to the site_stats table.
Base class for language-specific code.
Definition Language.php:79
Service locator for MediaWiki core services.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Build SELECT queries with a fluent interface.