Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 281
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalFileDeleteBatch
0.00% covered (danger)
0.00%
0 / 280
0.00% covered (danger)
0.00%
0 / 10
3306
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
 addCurrent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addOld
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addOlds
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getOldRels
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getHashes
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
182
 doDBInserts
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 1
110
 doDBDeletes
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
156
 execute
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 removeNonexistentFiles
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\FileRepo\File;
8
9use MediaWiki\MainConfigNames;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\Status\Status;
13use MediaWiki\User\UserIdentity;
14use StatusValue;
15use Wikimedia\ScopedCallback;
16
17/**
18 * Helper class for file deletion
19 *
20 * @internal
21 * @ingroup FileAbstraction
22 */
23class LocalFileDeleteBatch {
24    /** @var LocalFile */
25    private $file;
26
27    /** @var string */
28    private $reason;
29
30    /** @var array */
31    private $srcRels = [];
32
33    /** @var array */
34    private $archiveUrls = [];
35
36    /** @var array[] Items to be processed in the deletion batch */
37    private $deletionBatch;
38
39    /** @var bool Whether to suppress all suppressable fields when deleting */
40    private $suppress;
41
42    /** @var UserIdentity */
43    private $user;
44
45    /**
46     * @param LocalFile $file
47     * @param UserIdentity $user
48     * @param string $reason
49     * @param bool $suppress
50     */
51    public function __construct(
52        LocalFile $file,
53        UserIdentity $user,
54        $reason = '',
55        $suppress = false
56    ) {
57        $this->file = $file;
58        $this->user = $user;
59        $this->reason = $reason;
60        $this->suppress = $suppress;
61    }
62
63    public function addCurrent() {
64        $this->srcRels['.'] = $this->file->getRel();
65    }
66
67    /**
68     * @param string $oldName
69     */
70    public function addOld( $oldName ) {
71        $this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
72        $this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
73    }
74
75    /**
76     * Add the old versions of the image to the batch
77     * @return string[] List of archive names from old versions
78     */
79    public function addOlds() {
80        $archiveNames = [];
81
82        $dbw = $this->file->repo->getPrimaryDB();
83        $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
84            MainConfigNames::FileSchemaMigrationStage
85        );
86        if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
87            $result = $dbw->newSelectQueryBuilder()
88                ->select( [ 'oi_archive_name' ] )
89                ->from( 'oldimage' )
90                ->where( [ 'oi_name' => $this->file->getName() ] )
91                ->caller( __METHOD__ )->fetchResultSet();
92        } else {
93            $result = $dbw->newSelectQueryBuilder()
94                ->select( [ 'oi_archive_name' => 'fr_archive_name' ] )
95                ->from( 'filerevision' )
96                ->join( 'file', null, 'fr_file = file_id' )
97                ->where( [ 'file_name' => $this->file->getName(), 'file_deleted' => 0, 'file_latest != fr_id' ] )
98                ->caller( __METHOD__ )->fetchResultSet();
99        }
100
101        foreach ( $result as $row ) {
102            $this->addOld( $row->oi_archive_name );
103            $archiveNames[] = $row->oi_archive_name;
104        }
105
106        return $archiveNames;
107    }
108
109    /**
110     * @return array
111     */
112    protected function getOldRels() {
113        if ( !isset( $this->srcRels['.'] ) ) {
114            $oldRels =& $this->srcRels;
115            $deleteCurrent = false;
116        } else {
117            $oldRels = $this->srcRels;
118            unset( $oldRels['.'] );
119            $deleteCurrent = true;
120        }
121
122        return [ $oldRels, $deleteCurrent ];
123    }
124
125    /**
126     * @param StatusValue $status To add error messages to
127     * @return array
128     */
129    protected function getHashes( StatusValue $status ): array {
130        $hashes = [];
131        [ $oldRels, $deleteCurrent ] = $this->getOldRels();
132
133        if ( $deleteCurrent ) {
134            $hashes['.'] = $this->file->getSha1();
135        }
136        $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
137            MainConfigNames::FileSchemaMigrationStage
138        );
139
140        if ( count( $oldRels ) ) {
141            $dbw = $this->file->repo->getPrimaryDB();
142            if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
143                $res = $dbw->newSelectQueryBuilder()
144                    ->select( [ 'oi_archive_name', 'oi_sha1' ] )
145                    ->from( 'oldimage' )
146                    ->where( [
147                        'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
148                        'oi_name' => $this->file->getName() // performance
149                    ] )
150                    ->caller( __METHOD__ )->fetchResultSet();
151            } else {
152                $res = $dbw->newSelectQueryBuilder()
153                    ->select( [
154                        'oi_archive_name' => 'fr_archive_name',
155                        'oi_sha1' => 'fr_sha1'
156                    ] )
157                    ->from( 'filerevision' )
158                    ->join( 'file', null, 'fr_file = file_id' )
159                    ->where( [
160                        'fr_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
161                        'file_name' => $this->file->getName(), // performance
162                        'file_deleted' => 0,
163                        'file_latest != fr_id',
164                    ] )
165                    ->caller( __METHOD__ )->fetchResultSet();
166            }
167
168            foreach ( $res as $row ) {
169                if ( $row->oi_archive_name === '' ) {
170                    // File lost, the check simulates OldLocalFile::exists
171                    $hashes[$row->oi_archive_name] = false;
172                    continue;
173                }
174                if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
175                    // Get the hash from the file
176                    $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
177                    $props = $this->file->repo->getFileProps( $oldUrl );
178
179                    if ( $props['fileExists'] ) {
180                        // Upgrade the oldimage row
181
182                        if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
183                            $dbw->newUpdateQueryBuilder()
184                                ->update( 'oldimage' )
185                                ->set( [ 'oi_sha1' => $props['sha1'] ] )
186                                ->where( [
187                                    'oi_name' => $this->file->getName(),
188                                    'oi_archive_name' => $row->oi_archive_name,
189                                ] )
190                                ->caller( __METHOD__ )->execute();
191                        }
192                        if ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
193                            $dbw->newUpdateQueryBuilder()
194                                ->update( 'filerevision' )
195                                ->set( [ 'fr_sha1' => $props['sha1'] ] )
196                                ->where( [
197                                    'fr_file' => $this->file->getFileIdFromName(),
198                                    'fr_archive_name' => $row->oi_archive_name,
199                                ] )
200                                ->caller( __METHOD__ )->execute();
201                        }
202                        $hashes[$row->oi_archive_name] = $props['sha1'];
203                    } else {
204                        $hashes[$row->oi_archive_name] = false;
205                    }
206                } else {
207                    $hashes[$row->oi_archive_name] = $row->oi_sha1;
208                }
209            }
210        }
211
212        $missing = array_diff_key( $this->srcRels, $hashes );
213
214        foreach ( $missing as $name => $rel ) {
215            $status->error( 'filedelete-old-unregistered', $name );
216        }
217
218        foreach ( $hashes as $name => $hash ) {
219            if ( !$hash ) {
220                $status->error( 'filedelete-missing', $this->srcRels[$name] );
221                unset( $hashes[$name] );
222            }
223        }
224
225        return $hashes;
226    }
227
228    protected function doDBInserts() {
229        $now = time();
230        $dbw = $this->file->repo->getPrimaryDB();
231
232        $commentStore = MediaWikiServices::getInstance()->getCommentStore();
233
234        $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
235        $encUserId = $dbw->addQuotes( $this->user->getId() );
236        $encGroup = $dbw->addQuotes( 'deleted' );
237        $ext = $this->file->getExtension();
238        $dotExt = $ext === '' ? '' : ".$ext";
239        $encExt = $dbw->addQuotes( $dotExt );
240        [ $oldRels, $deleteCurrent ] = $this->getOldRels();
241
242        // Bitfields to further suppress the content
243        if ( $this->suppress ) {
244            $bitfield = RevisionRecord::SUPPRESSED_ALL;
245        } else {
246            $bitfield = 'oi_deleted';
247        }
248
249        if ( $deleteCurrent ) {
250            $tables = [ 'image' ];
251            $fields = [
252                'fa_storage_group' => $encGroup,
253                'fa_storage_key' => $dbw->conditional(
254                    [ 'img_sha1' => '' ],
255                    $dbw->addQuotes( '' ),
256                    $dbw->buildConcat( [ "img_sha1", $encExt ] )
257                ),
258                'fa_deleted_user' => $encUserId,
259                'fa_deleted_timestamp' => $encTimestamp,
260                'fa_deleted' => $this->suppress ? $bitfield : 0,
261                'fa_name' => 'img_name',
262                'fa_archive_name' => 'NULL',
263                'fa_size' => 'img_size',
264                'fa_width' => 'img_width',
265                'fa_height' => 'img_height',
266                'fa_metadata' => 'img_metadata',
267                'fa_bits' => 'img_bits',
268                'fa_media_type' => 'img_media_type',
269                'fa_major_mime' => 'img_major_mime',
270                'fa_minor_mime' => 'img_minor_mime',
271                'fa_description_id' => 'img_description_id',
272                'fa_timestamp' => 'img_timestamp',
273                'fa_sha1' => 'img_sha1',
274                'fa_actor' => 'img_actor',
275            ];
276            $joins = [];
277
278            $fields += array_map(
279                $dbw->addQuotes( ... ),
280                $commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
281            );
282
283            $dbw->insertSelect( 'filearchive', $tables, $fields,
284                [ 'img_name' => $this->file->getName() ], __METHOD__, [ 'IGNORE' ], [], $joins );
285        }
286
287        if ( count( $oldRels ) ) {
288            $queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
289            $queryBuilder
290                ->forUpdate()
291                ->where( [ 'oi_name' => $this->file->getName() ] )
292                ->andWhere( [ 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
293            $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
294            if ( $res->numRows() ) {
295                $reason = $commentStore->createComment( $dbw, $this->reason );
296                $rowsInsert = [];
297                foreach ( $res as $row ) {
298                    $comment = $commentStore->getComment( 'oi_description', $row );
299                    $rowsInsert[] = [
300                        // Deletion-specific fields
301                        'fa_storage_group' => 'deleted',
302                        'fa_storage_key' => ( $row->oi_sha1 === '' )
303                        ? ''
304                        : "{$row->oi_sha1}{$dotExt}",
305                        'fa_deleted_user' => $this->user->getId(),
306                        'fa_deleted_timestamp' => $dbw->timestamp( $now ),
307                        // Counterpart fields
308                        'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
309                        'fa_name' => $row->oi_name,
310                        'fa_archive_name' => $row->oi_archive_name,
311                        'fa_size' => $row->oi_size,
312                        'fa_width' => $row->oi_width,
313                        'fa_height' => $row->oi_height,
314                        'fa_metadata' => $row->oi_metadata,
315                        'fa_bits' => $row->oi_bits,
316                        'fa_media_type' => $row->oi_media_type,
317                        'fa_major_mime' => $row->oi_major_mime,
318                        'fa_minor_mime' => $row->oi_minor_mime,
319                        'fa_actor' => $row->oi_actor,
320                        'fa_timestamp' => $row->oi_timestamp,
321                        'fa_sha1' => $row->oi_sha1
322                    ] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
323                    + $commentStore->insert( $dbw, 'fa_description', $comment );
324                }
325                $dbw->newInsertQueryBuilder()
326                    ->insertInto( 'filearchive' )
327                    ->ignore()
328                    ->rows( $rowsInsert )
329                    ->caller( __METHOD__ )->execute();
330            }
331        }
332    }
333
334    private function doDBDeletes() {
335        $dbw = $this->file->repo->getPrimaryDB();
336        $migrationStage = MediaWikiServices::getInstance()->getMainConfig()->get(
337            MainConfigNames::FileSchemaMigrationStage
338        );
339
340        [ $oldRels, $deleteCurrent ] = $this->getOldRels();
341
342        if ( count( $oldRels ) ) {
343            if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
344                $dbw->newDeleteQueryBuilder()
345                    ->deleteFrom( 'oldimage' )
346                    ->where( [
347                        'oi_name' => $this->file->getName(),
348                        'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) )
349                    ] )
350                    ->caller( __METHOD__ )->execute();
351            }
352            if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
353                $delete = $dbw->newDeleteQueryBuilder()
354                    ->deleteFrom( 'filerevision' )
355                    ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] );
356                if ( !$deleteCurrent ) {
357                    // It's not full page deletion.
358                    $delete->andWhere( [ 'fr_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
359                }
360                $delete->caller( __METHOD__ )->execute();
361
362            }
363        }
364
365        if ( $deleteCurrent ) {
366            if ( $migrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
367                $dbw->newDeleteQueryBuilder()
368                    ->deleteFrom( 'image' )
369                    ->where( [ 'img_name' => $this->file->getName() ] )
370                    ->caller( __METHOD__ )->execute();
371            }
372            if ( ( $migrationStage & SCHEMA_COMPAT_WRITE_NEW ) && $this->file->getFileIdFromName() ) {
373                $dbw->newUpdateQueryBuilder()
374                    ->update( 'file' )
375                    ->set( [
376                        'file_deleted' => $this->suppress ? 3 : 1,
377                        'file_latest' => 0
378                    ] )
379                    ->where( [ 'file_id' => $this->file->getFileIdFromName() ] )
380                    ->caller( __METHOD__ )->execute();
381                if ( !count( $oldRels ) ) {
382                    // Only the current version is uploaded and then deleted
383                    // TODO: After migration is done and old code is removed,
384                    // this should be refactored to become much simpler
385                    $dbw->newDeleteQueryBuilder()
386                        ->deleteFrom( 'filerevision' )
387                        ->where( [ 'fr_file' => $this->file->getFileIdFromName() ] )
388                        ->caller( __METHOD__ )->execute();
389                }
390            }
391        }
392    }
393
394    /**
395     * Run the transaction
396     * @return Status
397     */
398    public function execute() {
399        $repo = $this->file->getRepo();
400        $lockStatus = $this->file->acquireFileLock();
401        if ( !$lockStatus->isOK() ) {
402            return $lockStatus;
403        }
404        $unlockScope = new ScopedCallback( function () {
405            $this->file->releaseFileLock();
406        } );
407
408        $status = $this->file->repo->newGood();
409        // Prepare deletion batch
410        $hashes = $this->getHashes( $status );
411        $this->deletionBatch = [];
412        $ext = $this->file->getExtension();
413        $dotExt = $ext === '' ? '' : ".$ext";
414
415        foreach ( $this->srcRels as $name => $srcRel ) {
416            // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
417            if ( isset( $hashes[$name] ) ) {
418                $hash = $hashes[$name];
419                $key = $hash . $dotExt;
420                $dstRel = $repo->getDeletedHashPath( $key ) . $key;
421                $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
422            }
423        }
424
425        if ( !$repo->hasSha1Storage() ) {
426            // Removes non-existent file from the batch, so we don't get errors.
427            // This also handles files in the 'deleted' zone deleted via revision deletion.
428            $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
429            if ( !$checkStatus->isGood() ) {
430                $status->merge( $checkStatus );
431                return $status;
432            }
433            $this->deletionBatch = $checkStatus->value;
434
435            // Execute the file deletion batch
436            $status = $this->file->repo->deleteBatch( $this->deletionBatch );
437            if ( !$status->isGood() ) {
438                $status->merge( $status );
439            }
440        }
441
442        if ( !$status->isOK() ) {
443            // Critical file deletion error; abort
444            return $status;
445        }
446
447        $dbw = $this->file->repo->getPrimaryDB();
448
449        $dbw->startAtomic( __METHOD__ );
450
451        // Copy the image/oldimage rows to filearchive
452        $this->doDBInserts();
453        // Delete image/oldimage rows
454        $this->doDBDeletes();
455
456        // This is typically a no-op since we are wrapped by another atomic
457        // section in FileDeleteForm and also the implicit transaction.
458        $dbw->endAtomic( __METHOD__ );
459
460        // Commit and return
461        ScopedCallback::consume( $unlockScope );
462
463        return $status;
464    }
465
466    /**
467     * Removes non-existent files from a deletion batch.
468     * @param array[] $batch
469     * @return Status A good status with existing files in $batch as value, or a fatal status in case of I/O errors.
470     */
471    protected function removeNonexistentFiles( $batch ) {
472        $files = [];
473
474        foreach ( $batch as [ $src, /* dest */ ] ) {
475            $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
476        }
477
478        $result = $this->file->repo->fileExistsBatch( $files );
479        if ( in_array( null, $result, true ) ) {
480            return Status::newFatal( 'backend-fail-internal',
481                $this->file->repo->getBackend()->getName() );
482        }
483
484        $newBatch = [];
485        foreach ( $batch as $batchItem ) {
486            if ( $result[$batchItem[0]] ) {
487                $newBatch[] = $batchItem;
488            }
489        }
490
491        return Status::newGood( $newBatch );
492    }
493}
494
495/** @deprecated class alias since 1.44 */
496class_alias( LocalFileDeleteBatch::class, 'LocalFileDeleteBatch' );