Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 209
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 / 209
0.00% covered (danger)
0.00%
0 / 14
1806
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 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 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
21use MediaWiki\Logger\LoggerFactory;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Status\Status;
24use MediaWiki\Title\Title;
25use Psr\Log\LoggerInterface;
26use Wikimedia\Rdbms\IDatabase;
27use Wikimedia\Rdbms\RawSQLValue;
28use Wikimedia\ScopedCallback;
29
30/**
31 * Helper class for file movement
32 *
33 * @ingroup FileAbstraction
34 */
35class LocalFileMoveBatch {
36    /** @var LocalFile */
37    protected $file;
38
39    /** @var Title */
40    protected $target;
41
42    /** @var string[] */
43    protected $cur;
44
45    /** @var string[][] */
46    protected $olds;
47
48    /** @var int */
49    protected $oldCount;
50
51    /** @var IDatabase */
52    protected $db;
53
54    /** @var string */
55    protected $oldHash;
56
57    /** @var string */
58    protected $newHash;
59
60    /** @var string */
61    protected $oldName;
62
63    /** @var string */
64    protected $newName;
65
66    /** @var string */
67    protected $oldRel;
68
69    /** @var string */
70    protected $newRel;
71
72    /** @var LoggerInterface */
73    private $logger;
74
75    /** @var bool */
76    private $haveSourceLock = false;
77
78    /** @var bool */
79    private $haveTargetLock = false;
80
81    /** @var LocalFile|null */
82    private $targetFile;
83
84    /**
85     * @param LocalFile $file
86     * @param Title $target
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        // Update current image
380        $dbw->newUpdateQueryBuilder()
381            ->update( 'image' )
382            ->set( [ 'img_name' => $this->newName ] )
383            ->where( [ 'img_name' => $this->oldName ] )
384            ->caller( __METHOD__ )->execute();
385
386        // Update old images
387        $dbw->newUpdateQueryBuilder()
388            ->update( 'oldimage' )
389            ->set( [
390                'oi_name' => $this->newName,
391                'oi_archive_name' => new RawSQLValue( $dbw->strreplace(
392                    'oi_archive_name',
393                    $dbw->addQuotes( $this->oldName ),
394                    $dbw->addQuotes( $this->newName )
395                ) ),
396            ] )
397            ->where( [ 'oi_name' => $this->oldName ] )
398            ->caller( __METHOD__ )->execute();
399    }
400
401    /**
402     * Generate triplets for FileRepo::storeBatch().
403     * @return array[]
404     */
405    protected function getMoveTriplets() {
406        $moves = array_merge( [ $this->cur ], $this->olds );
407        $triplets = []; // The format is: (srcUrl, destZone, destUrl)
408
409        foreach ( $moves as $move ) {
410            // $move: (oldRelativePath, newRelativePath)
411            $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
412            $triplets[] = [ $srcUrl, 'public', $move[1] ];
413
414            $this->logger->debug(
415                'Generated move triplet for {fileName}: {srcUrl} :: public :: {move1}',
416                [
417                    'fileName' => $this->file->getName(),
418                    'srcUrl' => $srcUrl,
419                    'move1' => $move[1],
420                ]
421            );
422        }
423
424        return $triplets;
425    }
426
427    /**
428     * Removes non-existent files from move batch.
429     * @param array[] $triplets
430     * @return Status
431     */
432    protected function removeNonexistentFiles( $triplets ) {
433        $files = [];
434
435        foreach ( $triplets as $file ) {
436            $files[$file[0]] = $file[0];
437        }
438
439        $result = $this->file->repo->fileExistsBatch( $files );
440        if ( in_array( null, $result, true ) ) {
441            return Status::newFatal( 'backend-fail-internal',
442                $this->file->repo->getBackend()->getName() );
443        }
444
445        $filteredTriplets = [];
446        foreach ( $triplets as $file ) {
447            if ( $result[$file[0]] ) {
448                $filteredTriplets[] = $file;
449            } else {
450                $this->logger->debug(
451                    'File {file} does not exist',
452                    [ 'file' => $file[0] ]
453                );
454            }
455        }
456
457        return Status::newGood( $filteredTriplets );
458    }
459
460    /**
461     * Cleanup a partially moved array of triplets by deleting the target
462     * files. Called if something went wrong half way.
463     * @param array[] $triplets
464     */
465    protected function cleanupTarget( $triplets ) {
466        // Create dest pairs from the triplets
467        $pairs = [];
468        foreach ( $triplets as $triplet ) {
469            // $triplet: (old source virtual URL, dst zone, dest rel)
470            $pairs[] = [ $triplet[1], $triplet[2] ];
471        }
472
473        $this->file->repo->cleanupBatch( $pairs );
474    }
475
476    /**
477     * Cleanup a fully moved array of triplets by deleting the source files.
478     * Called at the end of the move process if everything else went ok.
479     * @param array[] $triplets
480     */
481    protected function cleanupSource( $triplets ) {
482        // Create source file names from the triplets
483        $files = [];
484        foreach ( $triplets as $triplet ) {
485            $files[] = $triplet[0];
486        }
487
488        $this->file->repo->cleanupBatch( $files );
489    }
490}