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