Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 254 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
LocalFileRestoreBatch | |
0.00% |
0 / 253 |
|
0.00% |
0 / 9 |
3782 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
addId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addIds | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
addAll | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 215 |
|
0.00% |
0 / 1 |
1892 | |||
removeNonexistentFiles | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
removeNonexistentFromCleanup | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
cleanup | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
cleanupFailedBatch | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\FileRepo\File; |
22 | |
23 | use MediaWiki\Deferred\DeferredUpdates; |
24 | use MediaWiki\Deferred\SiteStatsUpdate; |
25 | use MediaWiki\FileRepo\FileRepo; |
26 | use MediaWiki\FileRepo\LocalRepo; |
27 | use MediaWiki\Language\Language; |
28 | use MediaWiki\MainConfigNames; |
29 | use MediaWiki\MediaWikiServices; |
30 | use MediaWiki\Status\Status; |
31 | use Wikimedia\Rdbms\SelectQueryBuilder; |
32 | use Wikimedia\ScopedCallback; |
33 | |
34 | /** |
35 | * Helper class for file undeletion |
36 | * |
37 | * @internal |
38 | * @ingroup FileAbstraction |
39 | */ |
40 | class LocalFileRestoreBatch { |
41 | /** @var LocalFile */ |
42 | private $file; |
43 | |
44 | /** @var string[] List of file IDs to restore */ |
45 | private $cleanupBatch; |
46 | |
47 | /** @var int[] List of file IDs to restore */ |
48 | private $ids; |
49 | |
50 | /** @var bool Add all revisions of the file */ |
51 | private $all; |
52 | |
53 | /** @var bool Whether to remove all settings for suppressed fields */ |
54 | private $unsuppress; |
55 | |
56 | /** |
57 | * @param LocalFile $file |
58 | * @param bool $unsuppress |
59 | */ |
60 | public function __construct( LocalFile $file, $unsuppress = false ) { |
61 | $this->file = $file; |
62 | $this->cleanupBatch = []; |
63 | $this->ids = []; |
64 | $this->unsuppress = $unsuppress; |
65 | } |
66 | |
67 | /** |
68 | * Add a file by ID |
69 | * @param int $fa_id |
70 | */ |
71 | public function addId( $fa_id ) { |
72 | $this->ids[] = $fa_id; |
73 | } |
74 | |
75 | /** |
76 | * Add a whole lot of files by ID |
77 | * @param int[] $ids |
78 | */ |
79 | public function addIds( $ids ) { |
80 | $this->ids = array_merge( $this->ids, $ids ); |
81 | } |
82 | |
83 | /** |
84 | * Add all revisions of the file |
85 | */ |
86 | public function addAll() { |
87 | $this->all = true; |
88 | } |
89 | |
90 | /** |
91 | * Run the transaction, except the cleanup batch. |
92 | * The cleanup batch should be run in a separate transaction, because it locks different |
93 | * rows and there's no need to keep the image row locked while it's acquiring those locks |
94 | * The caller may have its own transaction open. |
95 | * So we save the batch and let the caller call cleanup() |
96 | * @return Status |
97 | */ |
98 | public function execute() { |
99 | /** @var Language $wgLang */ |
100 | global $wgLang; |
101 | |
102 | $repo = $this->file->getRepo(); |
103 | if ( !$this->all && !$this->ids ) { |
104 | // Do nothing |
105 | return $repo->newGood(); |
106 | } |
107 | |
108 | $status = $this->file->acquireFileLock(); |
109 | if ( !$status->isOK() ) { |
110 | return $status; |
111 | } |
112 | |
113 | $dbw = $this->file->repo->getPrimaryDB(); |
114 | |
115 | $ownTrx = !$dbw->trxLevel(); |
116 | $funcName = __METHOD__; |
117 | $dbw->startAtomic( __METHOD__ ); |
118 | |
119 | $unlockScope = new ScopedCallback( function () use ( $dbw, $funcName ) { |
120 | $dbw->endAtomic( $funcName ); |
121 | $this->file->releaseFileLock(); |
122 | } ); |
123 | |
124 | $commentStore = MediaWikiServices::getInstance()->getCommentStore(); |
125 | |
126 | $status = $this->file->repo->newGood(); |
127 | |
128 | $queryBuilder = $dbw->newSelectQueryBuilder() |
129 | ->select( '1' ) |
130 | ->from( 'image' ) |
131 | ->where( [ 'img_name' => $this->file->getName() ] ); |
132 | // The acquireFileLock() should already prevent changes, but this still may need |
133 | // to bypass any transaction snapshot. However, if we started the |
134 | // trx (which we probably did) then snapshot is post-lock and up-to-date. |
135 | if ( !$ownTrx ) { |
136 | $queryBuilder->lockInShareMode(); |
137 | } |
138 | $exists = (bool)$queryBuilder->caller( __METHOD__ )->fetchField(); |
139 | |
140 | // Fetch all or selected archived revisions for the file, |
141 | // sorted from the most recent to the oldest. |
142 | $arQueryBuilder = FileSelectQueryBuilder::newForArchivedFile( $dbw ); |
143 | $arQueryBuilder->where( [ 'fa_name' => $this->file->getName() ] ) |
144 | ->orderBy( 'fa_timestamp', SelectQueryBuilder::SORT_DESC ); |
145 | |
146 | if ( !$this->all ) { |
147 | $arQueryBuilder->andWhere( [ 'fa_id' => $this->ids ] ); |
148 | } |
149 | |
150 | $result = $arQueryBuilder->caller( __METHOD__ )->fetchResultSet(); |
151 | |
152 | $idsPresent = []; |
153 | $storeBatch = []; |
154 | $insertBatch = []; |
155 | $insertCurrent = false; |
156 | $insertFileRevisions = []; |
157 | $deleteIds = []; |
158 | $first = true; |
159 | $archiveNames = []; |
160 | |
161 | foreach ( $result as $row ) { |
162 | $idsPresent[] = $row->fa_id; |
163 | |
164 | if ( $row->fa_name != $this->file->getName() ) { |
165 | $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) ); |
166 | $status->failCount++; |
167 | continue; |
168 | } |
169 | |
170 | if ( $row->fa_storage_key == '' ) { |
171 | // Revision was missing pre-deletion |
172 | $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) ); |
173 | $status->failCount++; |
174 | continue; |
175 | } |
176 | |
177 | $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) . |
178 | $row->fa_storage_key; |
179 | $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel; |
180 | |
181 | if ( isset( $row->fa_sha1 ) ) { |
182 | $sha1 = $row->fa_sha1; |
183 | } else { |
184 | // old row, populate from key |
185 | $sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key ); |
186 | } |
187 | |
188 | # Fix leading zero |
189 | if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) { |
190 | $sha1 = substr( $sha1, 1 ); |
191 | } |
192 | |
193 | if ( $row->fa_major_mime === null || $row->fa_major_mime == 'unknown' |
194 | || $row->fa_minor_mime === null || $row->fa_minor_mime == 'unknown' |
195 | || $row->fa_media_type === null || $row->fa_media_type == 'UNKNOWN' |
196 | || $row->fa_metadata === null |
197 | ) { |
198 | // Refresh our metadata |
199 | // Required for a new current revision; nice for older ones too. :) |
200 | $this->file->loadFromFile( $deletedUrl ); |
201 | $mime = $this->file->getMimeType(); |
202 | [ $majorMime, $minorMime ] = File::splitMime( $mime ); |
203 | $mediaInfo = [ |
204 | 'minor_mime' => $minorMime, |
205 | 'major_mime' => $majorMime, |
206 | 'media_type' => $this->file->getMediaType(), |
207 | 'metadata' => $this->file->getMetadataForDb( $dbw ) |
208 | ]; |
209 | } else { |
210 | $mediaInfo = [ |
211 | 'minor_mime' => $row->fa_minor_mime, |
212 | 'major_mime' => $row->fa_major_mime, |
213 | 'media_type' => $row->fa_media_type, |
214 | 'metadata' => $row->fa_metadata |
215 | ]; |
216 | } |
217 | $this->file->setProps( [ |
218 | 'media_type' => $mediaInfo['media_type'], |
219 | 'major_mime' => $mediaInfo['major_mime'], |
220 | 'minor_mime' => $mediaInfo['minor_mime'], |
221 | ] ); |
222 | $comment = $commentStore->getComment( 'fa_description', $row ); |
223 | |
224 | $commentFieldsNew = $commentStore->insert( $dbw, 'fr_description', $comment ); |
225 | $fileRevisionRow = [ |
226 | 'fr_size' => $row->fa_size, |
227 | 'fr_width' => $row->fa_width, |
228 | 'fr_height' => $row->fa_height, |
229 | 'fr_metadata' => $mediaInfo['metadata'], |
230 | 'fr_bits' => $row->fa_bits, |
231 | 'fr_actor' => $row->fa_actor, |
232 | 'fr_timestamp' => $row->fa_timestamp, |
233 | 'fr_sha1' => $sha1 |
234 | ] + $commentFieldsNew; |
235 | |
236 | if ( $first && !$exists ) { |
237 | // This revision will be published as the new current version |
238 | $destRel = $this->file->getRel(); |
239 | $commentFields = $commentStore->insert( $dbw, 'img_description', $comment ); |
240 | $insertCurrent = [ |
241 | 'img_name' => $row->fa_name, |
242 | 'img_size' => $row->fa_size, |
243 | 'img_width' => $row->fa_width, |
244 | 'img_height' => $row->fa_height, |
245 | 'img_metadata' => $mediaInfo['metadata'], |
246 | 'img_bits' => $row->fa_bits, |
247 | 'img_media_type' => $mediaInfo['media_type'], |
248 | 'img_major_mime' => $mediaInfo['major_mime'], |
249 | 'img_minor_mime' => $mediaInfo['minor_mime'], |
250 | 'img_actor' => $row->fa_actor, |
251 | 'img_timestamp' => $row->fa_timestamp, |
252 | 'img_sha1' => $sha1 |
253 | ] + $commentFields; |
254 | |
255 | // The live (current) version cannot be hidden! |
256 | if ( !$this->unsuppress && $row->fa_deleted ) { |
257 | $status->fatal( 'undeleterevdel' ); |
258 | return $status; |
259 | } |
260 | $fileRevisionRow['fr_archive_name'] = ''; |
261 | $fileRevisionRow['fr_deleted'] = 0; |
262 | } else { |
263 | $archiveName = $row->fa_archive_name; |
264 | |
265 | if ( $archiveName === null ) { |
266 | // This was originally a current version; we |
267 | // have to devise a new archive name for it. |
268 | // Format is <timestamp of archiving>!<name> |
269 | $timestamp = (int)wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp ); |
270 | |
271 | do { |
272 | $archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name; |
273 | $timestamp++; |
274 | } while ( isset( $archiveNames[$archiveName] ) ); |
275 | } |
276 | |
277 | $archiveNames[$archiveName] = true; |
278 | $destRel = $this->file->getArchiveRel( $archiveName ); |
279 | $insertBatch[] = [ |
280 | 'oi_name' => $row->fa_name, |
281 | 'oi_archive_name' => $archiveName, |
282 | 'oi_size' => $row->fa_size, |
283 | 'oi_width' => $row->fa_width, |
284 | 'oi_height' => $row->fa_height, |
285 | 'oi_bits' => $row->fa_bits, |
286 | 'oi_actor' => $row->fa_actor, |
287 | 'oi_timestamp' => $row->fa_timestamp, |
288 | 'oi_metadata' => $mediaInfo['metadata'], |
289 | 'oi_media_type' => $mediaInfo['media_type'], |
290 | 'oi_major_mime' => $mediaInfo['major_mime'], |
291 | 'oi_minor_mime' => $mediaInfo['minor_mime'], |
292 | 'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted, |
293 | 'oi_sha1' => $sha1 |
294 | ] + $commentStore->insert( $dbw, 'oi_description', $comment ); |
295 | |
296 | $fileRevisionRow['fr_archive_name'] = $archiveName; |
297 | $fileRevisionRow['fr_deleted'] = $this->unsuppress ? 0 : $row->fa_deleted; |
298 | } |
299 | $insertFileRevisions[] = $fileRevisionRow; |
300 | |
301 | $deleteIds[] = $row->fa_id; |
302 | |
303 | if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) { |
304 | // private files can stay where they are |
305 | $status->successCount++; |
306 | } else { |
307 | $storeBatch[] = [ $deletedUrl, 'public', $destRel ]; |
308 | $this->cleanupBatch[] = $row->fa_storage_key; |
309 | } |
310 | |
311 | $first = false; |
312 | } |
313 | |
314 | unset( $result ); |
315 | |
316 | // Add a warning to the status object for missing IDs |
317 | $missingIds = array_diff( $this->ids, $idsPresent ); |
318 | |
319 | foreach ( $missingIds as $id ) { |
320 | $status->error( 'undelete-missing-filearchive', $id ); |
321 | } |
322 | |
323 | if ( !$repo->hasSha1Storage() ) { |
324 | // Remove missing files from batch, so we don't get errors when undeleting them |
325 | $checkStatus = $this->removeNonexistentFiles( $storeBatch ); |
326 | if ( !$checkStatus->isGood() ) { |
327 | $status->merge( $checkStatus ); |
328 | return $status; |
329 | } |
330 | $storeBatch = $checkStatus->value; |
331 | |
332 | // Run the store batch |
333 | // Use the OVERWRITE_SAME flag to smooth over a common error |
334 | $storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME ); |
335 | $status->merge( $storeStatus ); |
336 | |
337 | if ( !$status->isGood() ) { |
338 | // Even if some files could be copied, fail entirely as that is the |
339 | // easiest thing to do without data loss |
340 | $this->cleanupFailedBatch( $storeStatus, $storeBatch ); |
341 | $status->setOK( false ); |
342 | return $status; |
343 | } |
344 | } |
345 | |
346 | // Run the DB updates |
347 | // Because we have locked the image row, key conflicts should be rare. |
348 | // If they do occur, we can roll back the transaction at this time with |
349 | // no data loss, but leaving unregistered files scattered throughout the |
350 | // public zone. |
351 | // This is not ideal, which is why it's important to lock the image row. |
352 | $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get( |
353 | MainConfigNames::FileSchemaMigrationStage |
354 | ); |
355 | if ( $insertCurrent ) { |
356 | $dbw->newInsertQueryBuilder() |
357 | ->insertInto( 'image' ) |
358 | ->row( $insertCurrent ) |
359 | ->caller( __METHOD__ )->execute(); |
360 | if ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) { |
361 | $dbw->newUpdateQueryBuilder() |
362 | ->update( 'file' ) |
363 | ->set( [ 'file_deleted' => 0 ] ) |
364 | ->where( [ 'file_id' => $this->file->acquireFileIdFromName() ] ) |
365 | ->caller( __METHOD__ )->execute(); |
366 | } |
367 | } |
368 | |
369 | if ( $insertBatch ) { |
370 | $dbw->newInsertQueryBuilder() |
371 | ->insertInto( 'oldimage' ) |
372 | ->rows( $insertBatch ) |
373 | ->caller( __METHOD__ )->execute(); |
374 | } |
375 | |
376 | if ( $deleteIds ) { |
377 | $dbw->newDeleteQueryBuilder() |
378 | ->deleteFrom( 'filearchive' ) |
379 | ->where( [ 'fa_id' => $deleteIds ] ) |
380 | ->caller( __METHOD__ )->execute(); |
381 | } |
382 | |
383 | if ( $insertFileRevisions && ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) { |
384 | // reverse the order to make the newest have the highest id |
385 | $insertFileRevisions = array_reverse( $insertFileRevisions ); |
386 | |
387 | foreach ( $insertFileRevisions as &$row ) { |
388 | $row['fr_file'] = $this->file->getFileIdFromName(); |
389 | } |
390 | $dbw->newInsertQueryBuilder() |
391 | ->insertInto( 'filerevision' ) |
392 | ->rows( $insertFileRevisions ) |
393 | ->caller( __METHOD__ )->execute(); |
394 | $latestId = $dbw->newSelectQueryBuilder() |
395 | ->select( 'fr_id' ) |
396 | ->from( 'filerevision' ) |
397 | ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] ) |
398 | ->orderBy( 'fr_timestamp', 'DESC' ) |
399 | ->caller( __METHOD__ )->fetchField(); |
400 | $dbw->newUpdateQueryBuilder() |
401 | ->update( 'file' ) |
402 | ->set( [ 'file_latest' => $latestId ] ) |
403 | ->where( [ 'file_id' => $this->file->getFileIdFromName() ] ) |
404 | ->caller( __METHOD__ )->execute(); |
405 | |
406 | } |
407 | |
408 | // If store batch is empty (all files are missing), deletion is to be considered successful |
409 | if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) { |
410 | if ( !$exists ) { |
411 | wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current" ); |
412 | |
413 | DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) ); |
414 | |
415 | $this->file->purgeEverything(); |
416 | } else { |
417 | wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions" ); |
418 | $this->file->purgeDescription(); |
419 | } |
420 | } |
421 | |
422 | ScopedCallback::consume( $unlockScope ); |
423 | |
424 | return $status; |
425 | } |
426 | |
427 | /** |
428 | * Removes non-existent files from a store batch. |
429 | * @param array[] $triplets |
430 | * @return Status |
431 | */ |
432 | protected function removeNonexistentFiles( $triplets ) { |
433 | $files = $filteredTriplets = []; |
434 | foreach ( $triplets as $file ) { |
435 | $files[$file[0]] = $file[0]; |
436 | } |
437 | |
438 | $result = $this->file->repo->fileExistsBatch( $files ); |
439 | if ( in_array( null, $result, true ) ) { |
440 | return Status::newFatal( 'backend-fail-internal', |
441 | $this->file->repo->getBackend()->getName() ); |
442 | } |
443 | |
444 | foreach ( $triplets as $file ) { |
445 | if ( $result[$file[0]] ) { |
446 | $filteredTriplets[] = $file; |
447 | } |
448 | } |
449 | |
450 | return Status::newGood( $filteredTriplets ); |
451 | } |
452 | |
453 | /** |
454 | * Removes non-existent files from a cleanup batch. |
455 | * @param string[] $batch |
456 | * @return string[] |
457 | */ |
458 | protected function removeNonexistentFromCleanup( $batch ) { |
459 | $files = $newBatch = []; |
460 | $repo = $this->file->repo; |
461 | |
462 | foreach ( $batch as $file ) { |
463 | $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' . |
464 | rawurlencode( $repo->getDeletedHashPath( $file ) . $file ); |
465 | } |
466 | |
467 | $result = $repo->fileExistsBatch( $files ); |
468 | |
469 | foreach ( $batch as $file ) { |
470 | if ( $result[$file] ) { |
471 | $newBatch[] = $file; |
472 | } |
473 | } |
474 | |
475 | return $newBatch; |
476 | } |
477 | |
478 | /** |
479 | * Delete unused files in the deleted zone. |
480 | * This should be called from outside the transaction in which execute() was called. |
481 | * @return Status |
482 | */ |
483 | public function cleanup() { |
484 | if ( !$this->cleanupBatch ) { |
485 | return $this->file->repo->newGood(); |
486 | } |
487 | |
488 | $this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch ); |
489 | |
490 | $status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch ); |
491 | |
492 | return $status; |
493 | } |
494 | |
495 | /** |
496 | * Cleanup a failed batch. The batch was only partially successful, so |
497 | * rollback by removing all items that were successfully copied. |
498 | * |
499 | * @param Status $storeStatus |
500 | * @param array[] $storeBatch |
501 | */ |
502 | protected function cleanupFailedBatch( $storeStatus, $storeBatch ) { |
503 | $cleanupBatch = []; |
504 | |
505 | foreach ( $storeStatus->success as $i => $success ) { |
506 | // Check if this item of the batch was successfully copied |
507 | if ( $success ) { |
508 | // Item was successfully copied and needs to be removed again |
509 | // Extract ($dstZone, $dstRel) from the batch |
510 | $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ]; |
511 | } |
512 | } |
513 | $this->file->repo->cleanupBatch( $cleanupBatch ); |
514 | } |
515 | } |
516 | |
517 | /** @deprecated class alias since 1.44 */ |
518 | class_alias( LocalFileRestoreBatch::class, 'LocalFileRestoreBatch' ); |