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