Merge "Type hint against LinkTarget in WatchedItemStore"
[lhc/web/wiklou.git] / includes / filerepo / file / LocalFileDeleteBatch.php
1 <?php
2 /**
3 * Local file in the wiki's own database.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup FileAbstraction
22 */
23
24 use MediaWiki\MediaWikiServices;
25 use MediaWiki\Storage\RevisionRecord;
26
27 /**
28 * Helper class for file deletion
29 * @ingroup FileAbstraction
30 */
31 class LocalFileDeleteBatch {
32 /** @var LocalFile */
33 private $file;
34
35 /** @var string */
36 private $reason;
37
38 /** @var array */
39 private $srcRels = [];
40
41 /** @var array */
42 private $archiveUrls = [];
43
44 /** @var array Items to be processed in the deletion batch */
45 private $deletionBatch;
46
47 /** @var bool Whether to suppress all suppressable fields when deleting */
48 private $suppress;
49
50 /** @var Status */
51 private $status;
52
53 /** @var User */
54 private $user;
55
56 /**
57 * @param File $file
58 * @param string $reason
59 * @param bool $suppress
60 * @param User|null $user
61 */
62 function __construct( File $file, $reason = '', $suppress = false, $user = null ) {
63 $this->file = $file;
64 $this->reason = $reason;
65 $this->suppress = $suppress;
66 global $wgUser;
67 $this->user = $user ?: $wgUser;
68 $this->status = $file->repo->newGood();
69 }
70
71 public function addCurrent() {
72 $this->srcRels['.'] = $this->file->getRel();
73 }
74
75 /**
76 * @param string $oldName
77 */
78 public function addOld( $oldName ) {
79 $this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
80 $this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
81 }
82
83 /**
84 * Add the old versions of the image to the batch
85 * @return string[] List of archive names from old versions
86 */
87 public function addOlds() {
88 $archiveNames = [];
89
90 $dbw = $this->file->repo->getMasterDB();
91 $result = $dbw->select( 'oldimage',
92 [ 'oi_archive_name' ],
93 [ 'oi_name' => $this->file->getName() ],
94 __METHOD__
95 );
96
97 foreach ( $result as $row ) {
98 $this->addOld( $row->oi_archive_name );
99 $archiveNames[] = $row->oi_archive_name;
100 }
101
102 return $archiveNames;
103 }
104
105 /**
106 * @return array
107 */
108 protected function getOldRels() {
109 if ( !isset( $this->srcRels['.'] ) ) {
110 $oldRels =& $this->srcRels;
111 $deleteCurrent = false;
112 } else {
113 $oldRels = $this->srcRels;
114 unset( $oldRels['.'] );
115 $deleteCurrent = true;
116 }
117
118 return [ $oldRels, $deleteCurrent ];
119 }
120
121 /**
122 * @return array
123 */
124 protected function getHashes() {
125 $hashes = [];
126 list( $oldRels, $deleteCurrent ) = $this->getOldRels();
127
128 if ( $deleteCurrent ) {
129 $hashes['.'] = $this->file->getSha1();
130 }
131
132 if ( count( $oldRels ) ) {
133 $dbw = $this->file->repo->getMasterDB();
134 $res = $dbw->select(
135 'oldimage',
136 [ 'oi_archive_name', 'oi_sha1' ],
137 [ 'oi_archive_name' => array_keys( $oldRels ),
138 'oi_name' => $this->file->getName() ], // performance
139 __METHOD__
140 );
141
142 foreach ( $res as $row ) {
143 if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
144 // Get the hash from the file
145 $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
146 $props = $this->file->repo->getFileProps( $oldUrl );
147
148 if ( $props['fileExists'] ) {
149 // Upgrade the oldimage row
150 $dbw->update( 'oldimage',
151 [ 'oi_sha1' => $props['sha1'] ],
152 [ 'oi_name' => $this->file->getName(), 'oi_archive_name' => $row->oi_archive_name ],
153 __METHOD__ );
154 $hashes[$row->oi_archive_name] = $props['sha1'];
155 } else {
156 $hashes[$row->oi_archive_name] = false;
157 }
158 } else {
159 $hashes[$row->oi_archive_name] = $row->oi_sha1;
160 }
161 }
162 }
163
164 $missing = array_diff_key( $this->srcRels, $hashes );
165
166 foreach ( $missing as $name => $rel ) {
167 $this->status->error( 'filedelete-old-unregistered', $name );
168 }
169
170 foreach ( $hashes as $name => $hash ) {
171 if ( !$hash ) {
172 $this->status->error( 'filedelete-missing', $this->srcRels[$name] );
173 unset( $hashes[$name] );
174 }
175 }
176
177 return $hashes;
178 }
179
180 protected function doDBInserts() {
181 global $wgActorTableSchemaMigrationStage;
182
183 $now = time();
184 $dbw = $this->file->repo->getMasterDB();
185
186 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
187 $actorMigration = ActorMigration::newMigration();
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 list( $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 ];
230 $joins = [];
231
232 $fields += array_map(
233 [ $dbw, 'addQuotes' ],
234 $commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
235 );
236
237 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
238 $fields['fa_user'] = 'img_user';
239 $fields['fa_user_text'] = 'img_user_text';
240 }
241 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
242 $fields['fa_actor'] = 'img_actor';
243 }
244
245 if (
246 ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) === SCHEMA_COMPAT_WRITE_BOTH
247 ) {
248 // Upgrade any rows that are still old-style. Otherwise an upgrade
249 // might be missed if a deletion happens while the migration script
250 // is running.
251 $res = $dbw->select(
252 [ 'image' ],
253 [ 'img_name', 'img_user', 'img_user_text' ],
254 [ 'img_name' => $this->file->getName(), 'img_actor' => 0 ],
255 __METHOD__
256 );
257 foreach ( $res as $row ) {
258 $actorId = User::newFromAnyId( $row->img_user, $row->img_user_text, null )->getActorId( $dbw );
259 $dbw->update(
260 'image',
261 [ 'img_actor' => $actorId ],
262 [ 'img_name' => $row->img_name, 'img_actor' => 0 ],
263 __METHOD__
264 );
265 }
266 }
267
268 $dbw->insertSelect( 'filearchive', $tables, $fields,
269 [ 'img_name' => $this->file->getName() ], __METHOD__, [], [], $joins );
270 }
271
272 if ( count( $oldRels ) ) {
273 $fileQuery = OldLocalFile::getQueryInfo();
274 $res = $dbw->select(
275 $fileQuery['tables'],
276 $fileQuery['fields'],
277 [
278 'oi_name' => $this->file->getName(),
279 'oi_archive_name' => array_keys( $oldRels )
280 ],
281 __METHOD__,
282 [ 'FOR UPDATE' ],
283 $fileQuery['joins']
284 );
285 $rowsInsert = [];
286 if ( $res->numRows() ) {
287 $reason = $commentStore->createComment( $dbw, $this->reason );
288 foreach ( $res as $row ) {
289 $comment = $commentStore->getComment( 'oi_description', $row );
290 $user = User::newFromAnyId( $row->oi_user, $row->oi_user_text, $row->oi_actor );
291 $rowsInsert[] = [
292 // Deletion-specific fields
293 'fa_storage_group' => 'deleted',
294 'fa_storage_key' => ( $row->oi_sha1 === '' )
295 ? ''
296 : "{$row->oi_sha1}{$dotExt}",
297 'fa_deleted_user' => $this->user->getId(),
298 'fa_deleted_timestamp' => $dbw->timestamp( $now ),
299 // Counterpart fields
300 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
301 'fa_name' => $row->oi_name,
302 'fa_archive_name' => $row->oi_archive_name,
303 'fa_size' => $row->oi_size,
304 'fa_width' => $row->oi_width,
305 'fa_height' => $row->oi_height,
306 'fa_metadata' => $row->oi_metadata,
307 'fa_bits' => $row->oi_bits,
308 'fa_media_type' => $row->oi_media_type,
309 'fa_major_mime' => $row->oi_major_mime,
310 'fa_minor_mime' => $row->oi_minor_mime,
311 'fa_timestamp' => $row->oi_timestamp,
312 'fa_sha1' => $row->oi_sha1
313 ] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
314 + $commentStore->insert( $dbw, 'fa_description', $comment )
315 + $actorMigration->getInsertValues( $dbw, 'fa_user', $user );
316 }
317 }
318
319 $dbw->insert( 'filearchive', $rowsInsert, __METHOD__ );
320 }
321 }
322
323 function doDBDeletes() {
324 $dbw = $this->file->repo->getMasterDB();
325 list( $oldRels, $deleteCurrent ) = $this->getOldRels();
326
327 if ( count( $oldRels ) ) {
328 $dbw->delete( 'oldimage',
329 [
330 'oi_name' => $this->file->getName(),
331 'oi_archive_name' => array_keys( $oldRels )
332 ], __METHOD__ );
333 }
334
335 if ( $deleteCurrent ) {
336 $dbw->delete( 'image', [ 'img_name' => $this->file->getName() ], __METHOD__ );
337 }
338 }
339
340 /**
341 * Run the transaction
342 * @return Status
343 */
344 public function execute() {
345 $repo = $this->file->getRepo();
346 $this->file->lock();
347
348 // Prepare deletion batch
349 $hashes = $this->getHashes();
350 $this->deletionBatch = [];
351 $ext = $this->file->getExtension();
352 $dotExt = $ext === '' ? '' : ".$ext";
353
354 foreach ( $this->srcRels as $name => $srcRel ) {
355 // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
356 if ( isset( $hashes[$name] ) ) {
357 $hash = $hashes[$name];
358 $key = $hash . $dotExt;
359 $dstRel = $repo->getDeletedHashPath( $key ) . $key;
360 $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
361 }
362 }
363
364 if ( !$repo->hasSha1Storage() ) {
365 // Removes non-existent file from the batch, so we don't get errors.
366 // This also handles files in the 'deleted' zone deleted via revision deletion.
367 $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
368 if ( !$checkStatus->isGood() ) {
369 $this->status->merge( $checkStatus );
370 return $this->status;
371 }
372 $this->deletionBatch = $checkStatus->value;
373
374 // Execute the file deletion batch
375 $status = $this->file->repo->deleteBatch( $this->deletionBatch );
376 if ( !$status->isGood() ) {
377 $this->status->merge( $status );
378 }
379 }
380
381 if ( !$this->status->isOK() ) {
382 // Critical file deletion error; abort
383 $this->file->unlock();
384
385 return $this->status;
386 }
387
388 // Copy the image/oldimage rows to filearchive
389 $this->doDBInserts();
390 // Delete image/oldimage rows
391 $this->doDBDeletes();
392
393 // Commit and return
394 $this->file->unlock();
395
396 return $this->status;
397 }
398
399 /**
400 * Removes non-existent files from a deletion batch.
401 * @param array $batch
402 * @return Status
403 */
404 protected function removeNonexistentFiles( $batch ) {
405 $files = $newBatch = [];
406
407 foreach ( $batch as $batchItem ) {
408 list( $src, ) = $batchItem;
409 $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
410 }
411
412 $result = $this->file->repo->fileExistsBatch( $files );
413 if ( in_array( null, $result, true ) ) {
414 return Status::newFatal( 'backend-fail-internal',
415 $this->file->repo->getBackend()->getName() );
416 }
417
418 foreach ( $batch as $batchItem ) {
419 if ( $result[$batchItem[0]] ) {
420 $newBatch[] = $batchItem;
421 }
422 }
423
424 return Status::newGood( $newBatch );
425 }
426 }