Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 254
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalFileRestoreBatch
0.00% covered (danger)
0.00%
0 / 253
0.00% covered (danger)
0.00%
0 / 9
3782
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addIds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addAll
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 215
0.00% covered (danger)
0.00%
0 / 1
1892
 removeNonexistentFiles
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 removeNonexistentFromCleanup
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 cleanup
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 cleanupFailedBatch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
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
21namespace MediaWiki\FileRepo\File;
22
23use MediaWiki\Deferred\DeferredUpdates;
24use MediaWiki\Deferred\SiteStatsUpdate;
25use MediaWiki\FileRepo\FileRepo;
26use MediaWiki\FileRepo\LocalRepo;
27use MediaWiki\Language\Language;
28use MediaWiki\MainConfigNames;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\Status\Status;
31use Wikimedia\Rdbms\SelectQueryBuilder;
32use Wikimedia\ScopedCallback;
33
34/**
35 * Helper class for file undeletion
36 *
37 * @internal
38 * @ingroup FileAbstraction
39 */
40class 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 */
518class_alias( LocalFileRestoreBatch::class, 'LocalFileRestoreBatch' );