Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 236 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
LocalFileMoveBatch | |
0.00% |
0 / 235 |
|
0.00% |
0 / 14 |
2070 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
addCurrent | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
addOlds | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
30 | |||
acquireSourceLock | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
acquireTargetLock | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
releaseLocks | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getTargetFile | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
execute | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
72 | |||
verifyDBUpdates | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
doDBUpdates | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
20 | |||
getMoveTriplets | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
removeNonexistentFiles | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
cleanupTarget | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
cleanupSource | |
0.00% |
0 / 4 |
|
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 | |
21 | namespace MediaWiki\FileRepo\File; |
22 | |
23 | use MediaWiki\FileRepo\FileRepo; |
24 | use MediaWiki\Logger\LoggerFactory; |
25 | use MediaWiki\MainConfigNames; |
26 | use MediaWiki\MediaWikiServices; |
27 | use MediaWiki\Status\Status; |
28 | use MediaWiki\Title\Title; |
29 | use Psr\Log\LoggerInterface; |
30 | use Wikimedia\Rdbms\IDatabase; |
31 | use Wikimedia\Rdbms\RawSQLValue; |
32 | use Wikimedia\ScopedCallback; |
33 | |
34 | /** |
35 | * Helper class for file movement |
36 | * |
37 | * @ingroup FileAbstraction |
38 | */ |
39 | class 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 */ |
525 | class_alias( LocalFileMoveBatch::class, 'LocalFileMoveBatch' ); |