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