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