Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 236
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalFileMoveBatch
0.00% covered (danger)
0.00%
0 / 235
0.00% covered (danger)
0.00%
0 / 14
2070
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 addCurrent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addOlds
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
30
 acquireSourceLock
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 acquireTargetLock
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 releaseLocks
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getTargetFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
72
 verifyDBUpdates
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 doDBUpdates
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
20
 getMoveTriplets
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 removeNonexistentFiles
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 cleanupTarget
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 cleanupSource
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
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\FileRepo\FileRepo;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Status\Status;
28use MediaWiki\Title\Title;
29use Psr\Log\LoggerInterface;
30use Wikimedia\Rdbms\IDatabase;
31use Wikimedia\Rdbms\RawSQLValue;
32use Wikimedia\ScopedCallback;
33
34/**
35 * Helper class for file movement
36 *
37 * @ingroup FileAbstraction
38 */
39class LocalFileMoveBatch {
40    /** @var LocalFile */
41    protected $file;
42
43    /** @var Title */
44    protected $target;
45
46    /** @var string[] */
47    protected $cur;
48
49    /** @var string[][] */
50    protected $olds;
51
52    /** @var int */
53    protected $oldCount;
54
55    /** @var IDatabase */
56    protected $db;
57
58    /** @var string */
59    protected $oldHash;
60
61    /** @var string */
62    protected $newHash;
63
64    /** @var string */
65    protected $oldName;
66
67    /** @var string */
68    protected $newName;
69
70    /** @var string */
71    protected $oldRel;
72
73    /** @var string */
74    protected $newRel;
75
76    /** @var LoggerInterface */
77    private $logger;
78
79    /** @var bool */
80    private $haveSourceLock = false;
81
82    /** @var bool */
83    private $haveTargetLock = false;
84
85    /** @var LocalFile|null */
86    private $targetFile;
87
88    public function __construct( LocalFile $file, Title $target ) {
89        $this->file = $file;
90        $this->target = $target;
91        $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
92        $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
93        $this->oldName = $this->file->getName();
94        $this->newName = $this->file->repo->getNameFromTitle( $this->target );
95        $this->oldRel = $this->oldHash . $this->oldName;
96        $this->newRel = $this->newHash . $this->newName;
97        $this->db = $file->getRepo()->getPrimaryDB();
98
99        $this->logger = LoggerFactory::getInstance( 'imagemove' );
100    }
101
102    /**
103     * Add the current image to the batch
104     *
105     * @return Status
106     */
107    public function addCurrent() {
108        $status = $this->acquireSourceLock();
109        if ( $status->isOK() ) {
110            $this->cur = [ $this->oldRel, $this->newRel ];
111        }
112        return $status;
113    }
114
115    /**
116     * Add the old versions of the image to the batch
117     * @return string[] List of archive names from old versions
118     */
119    public function addOlds() {
120        $archiveBase = 'archive';
121        $this->olds = [];
122        $this->oldCount = 0;
123        $archiveNames = [];
124
125        $result = $this->db->newSelectQueryBuilder()
126            ->select( [ 'oi_archive_name', 'oi_deleted' ] )
127            ->forUpdate() // ignore snapshot
128            ->from( 'oldimage' )
129            ->where( [ 'oi_name' => $this->oldName ] )
130            ->caller( __METHOD__ )->fetchResultSet();
131
132        foreach ( $result as $row ) {
133            $archiveNames[] = $row->oi_archive_name;
134            $oldName = $row->oi_archive_name;
135            $bits = explode( '!', $oldName, 2 );
136
137            if ( count( $bits ) != 2 ) {
138                $this->logger->debug(
139                    'Old file name missing !: {oldName}',
140                    [ 'oldName' => $oldName ]
141                );
142                continue;
143            }
144
145            [ $timestamp, $filename ] = $bits;
146
147            if ( $this->oldName != $filename ) {
148                $this->logger->debug(
149                    'Old file name does not match: {oldName}',
150                    [ 'oldName' => $oldName ]
151                );
152                continue;
153            }
154
155            $this->oldCount++;
156
157            // Do we want to add those to oldCount?
158            if ( $row->oi_deleted & File::DELETED_FILE ) {
159                continue;
160            }
161
162            $this->olds[] = [
163                "{$archiveBase}/{$this->oldHash}{$oldName}",
164                "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
165            ];
166        }
167
168        return $archiveNames;
169    }
170
171    /**
172     * Acquire the source file lock, if it has not been acquired already
173     *
174     * @return Status
175     */
176    protected function acquireSourceLock() {
177        if ( $this->haveSourceLock ) {
178            return Status::newGood();
179        }
180        $status = $this->file->acquireFileLock();
181        if ( $status->isOK() ) {
182            $this->haveSourceLock = true;
183        }
184        return $status;
185    }
186
187    /**
188     * Acquire the target file lock, if it has not been acquired already
189     *
190     * @return Status
191     */
192    protected function acquireTargetLock() {
193        if ( $this->haveTargetLock ) {
194            return Status::newGood();
195        }
196        $status = $this->getTargetFile()->acquireFileLock();
197        if ( $status->isOK() ) {
198            $this->haveTargetLock = true;
199        }
200        return $status;
201    }
202
203    /**
204     * Release both file locks
205     */
206    protected function releaseLocks() {
207        if ( $this->haveSourceLock ) {
208            $this->file->releaseFileLock();
209            $this->haveSourceLock = false;
210        }
211        if ( $this->haveTargetLock ) {
212            $this->getTargetFile()->releaseFileLock();
213            $this->haveTargetLock = false;
214        }
215    }
216
217    /**
218     * Get the target file
219     *
220     * @return LocalFile
221     */
222    protected function getTargetFile() {
223        if ( $this->targetFile === null ) {
224            $this->targetFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
225                ->newFile( $this->target );
226        }
227        return $this->targetFile;
228    }
229
230    /**
231     * Perform the move.
232     * @return Status
233     */
234    public function execute() {
235        $repo = $this->file->repo;
236        $status = $repo->newGood();
237
238        $status->merge( $this->acquireSourceLock() );
239        if ( !$status->isOK() ) {
240            return $status;
241        }
242        $status->merge( $this->acquireTargetLock() );
243        if ( !$status->isOK() ) {
244            $this->releaseLocks();
245            return $status;
246        }
247        $unlockScope = new ScopedCallback( function () {
248            $this->releaseLocks();
249        } );
250
251        $triplets = $this->getMoveTriplets();
252        $checkStatus = $this->removeNonexistentFiles( $triplets );
253        if ( !$checkStatus->isGood() ) {
254            $status->merge( $checkStatus ); // couldn't talk to file backend
255            return $status;
256        }
257        $triplets = $checkStatus->value;
258
259        // Verify the file versions metadata in the DB.
260        $statusDb = $this->verifyDBUpdates();
261        if ( !$statusDb->isGood() ) {
262            $statusDb->setOK( false );
263
264            return $statusDb;
265        }
266
267        if ( !$repo->hasSha1Storage() ) {
268            // Copy the files into their new location.
269            // If a prior process fataled copying or cleaning up files we tolerate any
270            // of the existing files if they are identical to the ones being stored.
271            $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
272
273            $this->logger->debug(
274                'Moved files for {fileName}: {successCount} successes, {failCount} failures',
275                [
276                    'fileName' => $this->file->getName(),
277                    'successCount' => $statusMove->successCount,
278                    'failCount' => $statusMove->failCount,
279                ]
280            );
281
282            if ( !$statusMove->isGood() ) {
283                // Delete any files copied over (while the destination is still locked)
284                $this->cleanupTarget( $triplets );
285
286                $this->logger->debug(
287                    'Error in moving files: {error}',
288                    [ 'error' => $statusMove->getWikiText( false, false, 'en' ) ]
289                );
290
291                $statusMove->setOK( false );
292
293                return $statusMove;
294            }
295            $status->merge( $statusMove );
296        }
297
298        // Rename the file versions metadata in the DB.
299        $this->doDBUpdates();
300
301        $this->logger->debug(
302            'Renamed {fileName} in database: {successCount} successes, {failCount} failures',
303            [
304                'fileName' => $this->file->getName(),
305                'successCount' => $statusDb->successCount,
306                'failCount' => $statusDb->failCount,
307            ]
308        );
309
310        // Everything went ok, remove the source files
311        $this->cleanupSource( $triplets );
312
313        // Defer lock release until the transaction is committed.
314        if ( $this->db->trxLevel() ) {
315            ScopedCallback::cancel( $unlockScope );
316            $this->db->onTransactionResolution( function () {
317                $this->releaseLocks();
318            }, __METHOD__ );
319        } else {
320            ScopedCallback::consume( $unlockScope );
321        }
322
323        $status->merge( $statusDb );
324
325        return $status;
326    }
327
328    /**
329     * Verify the database updates and return a new Status indicating how
330     * many rows would be updated.
331     *
332     * @return Status
333     */
334    protected function verifyDBUpdates() {
335        $repo = $this->file->repo;
336        $status = $repo->newGood();
337        $dbw = $this->db;
338
339        // Lock the image row
340        $hasCurrent = $dbw->newSelectQueryBuilder()
341            ->from( 'image' )
342            ->where( [ 'img_name' => $this->oldName ] )
343            ->forUpdate()
344            ->caller( __METHOD__ )
345            ->fetchRowCount();
346
347        // Lock the oldimage rows
348        $oldRowCount = $dbw->newSelectQueryBuilder()
349            ->from( 'oldimage' )
350            ->where( [ 'oi_name' => $this->oldName ] )
351            ->forUpdate()
352            ->caller( __METHOD__ )
353            ->fetchRowCount();
354
355        if ( $hasCurrent ) {
356            $status->successCount++;
357        } else {
358            $status->failCount++;
359        }
360        $status->successCount += $oldRowCount;
361        // T36934: oldCount is based on files that actually exist.
362        // There may be more DB rows than such files, in which case $affected
363        // can be greater than $total. We use max() to avoid negatives here.
364        $status->failCount += max( 0, $this->oldCount - $oldRowCount );
365        if ( $status->failCount ) {
366            $status->error( 'imageinvalidfilename' );
367        }
368
369        return $status;
370    }
371
372    /**
373     * Do the database updates and return a new Status indicating how
374     * many rows where updated.
375     */
376    protected function doDBUpdates() {
377        $dbw = $this->db;
378
379        $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
380            MainConfigNames::FileSchemaMigrationStage
381        );
382        if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
383            $deleted = $dbw->newSelectQueryBuilder()
384                ->select( 'file_id' )
385                ->from( 'file' )
386                ->where( [ 'file_name' => $this->newName ] )
387                ->andWhere( [ 'file_deleted' => 1 ] )
388                ->caller( __METHOD__ )->fetchField();
389            if ( $deleted ) {
390                // Overwriting an existing file that was deleted.
391                // Once the file deletion storage refactor starts,
392                // this should change to update deleted revisions too.
393                $dbw->newDeleteQueryBuilder()
394                    ->deleteFrom( 'file' )
395                    ->where( [ 'file_name' => $this->newName ] )
396                    ->andWhere( [ 'file_deleted' => 1 ] )
397                    ->caller( __METHOD__ )->execute();
398                // Paranoia
399                $dbw->newUpdateQueryBuilder()
400                    ->update( 'filerevision' )
401                    ->set( [ 'fr_file' => $this->file->getFileIdFromName() ] )
402                    ->where( [ 'fr_file' => $deleted ] )
403                    ->caller( __METHOD__ )->execute();
404            }
405            $dbw->newUpdateQueryBuilder()
406                ->update( 'file' )
407                ->set( [ 'file_name' => $this->newName ] )
408                ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
409                ->caller( __METHOD__ )->execute();
410        }
411        // Update current image
412        $dbw->newUpdateQueryBuilder()
413            ->update( 'image' )
414            ->set( [ 'img_name' => $this->newName ] )
415            ->where( [ 'img_name' => $this->oldName ] )
416            ->caller( __METHOD__ )->execute();
417
418        // Update old images
419        $dbw->newUpdateQueryBuilder()
420            ->update( 'oldimage' )
421            ->set( [
422                'oi_name' => $this->newName,
423                'oi_archive_name' => new RawSQLValue( $dbw->strreplace(
424                    'oi_archive_name',
425                    $dbw->addQuotes( $this->oldName ),
426                    $dbw->addQuotes( $this->newName )
427                ) ),
428            ] )
429            ->where( [ 'oi_name' => $this->oldName ] )
430            ->caller( __METHOD__ )->execute();
431    }
432
433    /**
434     * Generate triplets for FileRepo::storeBatch().
435     * @return array[]
436     */
437    protected function getMoveTriplets() {
438        $moves = array_merge( [ $this->cur ], $this->olds );
439        $triplets = []; // The format is: (srcUrl, destZone, destUrl)
440
441        foreach ( $moves as $move ) {
442            // $move: (oldRelativePath, newRelativePath)
443            $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
444            $triplets[] = [ $srcUrl, 'public', $move[1] ];
445
446            $this->logger->debug(
447                'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
448                [
449                    'fileName' => $this->file->getName(),
450                    'srcUrl' => $srcUrl,
451                    'move1' => $move[1],
452                ]
453            );
454        }
455
456        return $triplets;
457    }
458
459    /**
460     * Removes non-existent files from move batch.
461     * @param array[] $triplets
462     * @return Status
463     */
464    protected function removeNonexistentFiles( $triplets ) {
465        $files = [];
466
467        foreach ( $triplets as $file ) {
468            $files[$file[0]] = $file[0];
469        }
470
471        $result = $this->file->repo->fileExistsBatch( $files );
472        if ( in_array( null, $result, true ) ) {
473            return Status::newFatal( 'backend-fail-internal',
474                $this->file->repo->getBackend()->getName() );
475        }
476
477        $filteredTriplets = [];
478        foreach ( $triplets as $file ) {
479            if ( $result[$file[0]] ) {
480                $filteredTriplets[] = $file;
481            } else {
482                $this->logger->debug(
483                    'File {file} does not exist',
484                    [ 'file' => $file[0] ]
485                );
486            }
487        }
488
489        return Status::newGood( $filteredTriplets );
490    }
491
492    /**
493     * Cleanup a partially moved array of triplets by deleting the target
494     * files. Called if something went wrong half way.
495     * @param array[] $triplets
496     */
497    protected function cleanupTarget( $triplets ) {
498        // Create dest pairs from the triplets
499        $pairs = [];
500        foreach ( $triplets as $triplet ) {
501            // $triplet: (old source virtual URL, dst zone, dest rel)
502            $pairs[] = [ $triplet[1], $triplet[2] ];
503        }
504
505        $this->file->repo->cleanupBatch( $pairs );
506    }
507
508    /**
509     * Cleanup a fully moved array of triplets by deleting the source files.
510     * Called at the end of the move process if everything else went ok.
511     * @param array[] $triplets
512     */
513    protected function cleanupSource( $triplets ) {
514        // Create source file names from the triplets
515        $files = [];
516        foreach ( $triplets as $triplet ) {
517            $files[] = $triplet[0];
518        }
519
520        $this->file->repo->cleanupBatch( $files );
521    }
522}
523
524/** @deprecated class alias since 1.44 */
525class_alias( LocalFileMoveBatch::class, 'LocalFileMoveBatch' );