Split LocalFile.php to have one class in one file
[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
26 /**
27 * Helper class for file deletion
28 * @ingroup FileAbstraction
29 */
30 class LocalFileDeleteBatch {
31 /** @var LocalFile */
32 private $file;
33
34 /** @var string */
35 private $reason;
36
37 /** @var array */
38 private $srcRels = [];
39
40 /** @var array */
41 private $archiveUrls = [];
42
43 /** @var array Items to be processed in the deletion batch */
44 private $deletionBatch;
45
46 /** @var bool Whether to suppress all suppressable fields when deleting */
47 private $suppress;
48
49 /** @var Status */
50 private $status;
51
52 /** @var User */
53 private $user;
54
55 /**
56 * @param File $file
57 * @param string $reason
58 * @param bool $suppress
59 * @param User|null $user
60 */
61 function __construct( File $file, $reason = '', $suppress = false, $user = null ) {
62 $this->file = $file;
63 $this->reason = $reason;
64 $this->suppress = $suppress;
65 if ( $user ) {
66 $this->user = $user;
67 } else {
68 global $wgUser;
69 $this->user = $wgUser;
70 }
71 $this->status = $file->repo->newGood();
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->getMasterDB();
94 $result = $dbw->select( 'oldimage',
95 [ 'oi_archive_name' ],
96 [ 'oi_name' => $this->file->getName() ],
97 __METHOD__
98 );
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 * @return array
126 */
127 protected function getHashes() {
128 $hashes = [];
129 list( $oldRels, $deleteCurrent ) = $this->getOldRels();
130
131 if ( $deleteCurrent ) {
132 $hashes['.'] = $this->file->getSha1();
133 }
134
135 if ( count( $oldRels ) ) {
136 $dbw = $this->file->repo->getMasterDB();
137 $res = $dbw->select(
138 'oldimage',
139 [ 'oi_archive_name', 'oi_sha1' ],
140 [ 'oi_archive_name' => array_keys( $oldRels ),
141 'oi_name' => $this->file->getName() ], // performance
142 __METHOD__
143 );
144
145 foreach ( $res as $row ) {
146 if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
147 // Get the hash from the file
148 $oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
149 $props = $this->file->repo->getFileProps( $oldUrl );
150
151 if ( $props['fileExists'] ) {
152 // Upgrade the oldimage row
153 $dbw->update( 'oldimage',
154 [ 'oi_sha1' => $props['sha1'] ],
155 [ 'oi_name' => $this->file->getName(), 'oi_archive_name' => $row->oi_archive_name ],
156 __METHOD__ );
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 $this->status->error( 'filedelete-old-unregistered', $name );
171 }
172
173 foreach ( $hashes as $name => $hash ) {
174 if ( !$hash ) {
175 $this->status->error( 'filedelete-missing', $this->srcRels[$name] );
176 unset( $hashes[$name] );
177 }
178 }
179
180 return $hashes;
181 }
182
183 protected function doDBInserts() {
184 global $wgActorTableSchemaMigrationStage;
185
186 $now = time();
187 $dbw = $this->file->repo->getMasterDB();
188
189 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
190 $actorMigration = ActorMigration::newMigration();
191
192 $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
193 $encUserId = $dbw->addQuotes( $this->user->getId() );
194 $encGroup = $dbw->addQuotes( 'deleted' );
195 $ext = $this->file->getExtension();
196 $dotExt = $ext === '' ? '' : ".$ext";
197 $encExt = $dbw->addQuotes( $dotExt );
198 list( $oldRels, $deleteCurrent ) = $this->getOldRels();
199
200 // Bitfields to further suppress the content
201 if ( $this->suppress ) {
202 $bitfield = Revision::SUPPRESSED_ALL;
203 } else {
204 $bitfield = 'oi_deleted';
205 }
206
207 if ( $deleteCurrent ) {
208 $tables = [ 'image' ];
209 $fields = [
210 'fa_storage_group' => $encGroup,
211 'fa_storage_key' => $dbw->conditional(
212 [ 'img_sha1' => '' ],
213 $dbw->addQuotes( '' ),
214 $dbw->buildConcat( [ "img_sha1", $encExt ] )
215 ),
216 'fa_deleted_user' => $encUserId,
217 'fa_deleted_timestamp' => $encTimestamp,
218 'fa_deleted' => $this->suppress ? $bitfield : 0,
219 'fa_name' => 'img_name',
220 'fa_archive_name' => 'NULL',
221 'fa_size' => 'img_size',
222 'fa_width' => 'img_width',
223 'fa_height' => 'img_height',
224 'fa_metadata' => 'img_metadata',
225 'fa_bits' => 'img_bits',
226 'fa_media_type' => 'img_media_type',
227 'fa_major_mime' => 'img_major_mime',
228 'fa_minor_mime' => 'img_minor_mime',
229 'fa_description_id' => 'img_description_id',
230 'fa_timestamp' => 'img_timestamp',
231 'fa_sha1' => 'img_sha1'
232 ];
233 $joins = [];
234
235 $fields += array_map(
236 [ $dbw, 'addQuotes' ],
237 $commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
238 );
239
240 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
241 $fields['fa_user'] = 'img_user';
242 $fields['fa_user_text'] = 'img_user_text';
243 }
244 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
245 $fields['fa_actor'] = 'img_actor';
246 }
247
248 if (
249 ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) === SCHEMA_COMPAT_WRITE_BOTH
250 ) {
251 // Upgrade any rows that are still old-style. Otherwise an upgrade
252 // might be missed if a deletion happens while the migration script
253 // is running.
254 $res = $dbw->select(
255 [ 'image' ],
256 [ 'img_name', 'img_user', 'img_user_text' ],
257 [ 'img_name' => $this->file->getName(), 'img_actor' => 0 ],
258 __METHOD__
259 );
260 foreach ( $res as $row ) {
261 $actorId = User::newFromAnyId( $row->img_user, $row->img_user_text, null )->getActorId( $dbw );
262 $dbw->update(
263 'image',
264 [ 'img_actor' => $actorId ],
265 [ 'img_name' => $row->img_name, 'img_actor' => 0 ],
266 __METHOD__
267 );
268 }
269 }
270
271 $dbw->insertSelect( 'filearchive', $tables, $fields,
272 [ 'img_name' => $this->file->getName() ], __METHOD__, [], [], $joins );
273 }
274
275 if ( count( $oldRels ) ) {
276 $fileQuery = OldLocalFile::getQueryInfo();
277 $res = $dbw->select(
278 $fileQuery['tables'],
279 $fileQuery['fields'],
280 [
281 'oi_name' => $this->file->getName(),
282 'oi_archive_name' => array_keys( $oldRels )
283 ],
284 __METHOD__,
285 [ 'FOR UPDATE' ],
286 $fileQuery['joins']
287 );
288 $rowsInsert = [];
289 if ( $res->numRows() ) {
290 $reason = $commentStore->createComment( $dbw, $this->reason );
291 foreach ( $res as $row ) {
292 $comment = $commentStore->getComment( 'oi_description', $row );
293 $user = User::newFromAnyId( $row->oi_user, $row->oi_user_text, $row->oi_actor );
294 $rowsInsert[] = [
295 // Deletion-specific fields
296 'fa_storage_group' => 'deleted',
297 'fa_storage_key' => ( $row->oi_sha1 === '' )
298 ? ''
299 : "{$row->oi_sha1}{$dotExt}",
300 'fa_deleted_user' => $this->user->getId(),
301 'fa_deleted_timestamp' => $dbw->timestamp( $now ),
302 // Counterpart fields
303 'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
304 'fa_name' => $row->oi_name,
305 'fa_archive_name' => $row->oi_archive_name,
306 'fa_size' => $row->oi_size,
307 'fa_width' => $row->oi_width,
308 'fa_height' => $row->oi_height,
309 'fa_metadata' => $row->oi_metadata,
310 'fa_bits' => $row->oi_bits,
311 'fa_media_type' => $row->oi_media_type,
312 'fa_major_mime' => $row->oi_major_mime,
313 'fa_minor_mime' => $row->oi_minor_mime,
314 'fa_timestamp' => $row->oi_timestamp,
315 'fa_sha1' => $row->oi_sha1
316 ] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
317 + $commentStore->insert( $dbw, 'fa_description', $comment )
318 + $actorMigration->getInsertValues( $dbw, 'fa_user', $user );
319 }
320 }
321
322 $dbw->insert( 'filearchive', $rowsInsert, __METHOD__ );
323 }
324 }
325
326 function doDBDeletes() {
327 $dbw = $this->file->repo->getMasterDB();
328 list( $oldRels, $deleteCurrent ) = $this->getOldRels();
329
330 if ( count( $oldRels ) ) {
331 $dbw->delete( 'oldimage',
332 [
333 'oi_name' => $this->file->getName(),
334 'oi_archive_name' => array_keys( $oldRels )
335 ], __METHOD__ );
336 }
337
338 if ( $deleteCurrent ) {
339 $dbw->delete( 'image', [ 'img_name' => $this->file->getName() ], __METHOD__ );
340 }
341 }
342
343 /**
344 * Run the transaction
345 * @return Status
346 */
347 public function execute() {
348 $repo = $this->file->getRepo();
349 $this->file->lock();
350
351 // Prepare deletion batch
352 $hashes = $this->getHashes();
353 $this->deletionBatch = [];
354 $ext = $this->file->getExtension();
355 $dotExt = $ext === '' ? '' : ".$ext";
356
357 foreach ( $this->srcRels as $name => $srcRel ) {
358 // Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
359 if ( isset( $hashes[$name] ) ) {
360 $hash = $hashes[$name];
361 $key = $hash . $dotExt;
362 $dstRel = $repo->getDeletedHashPath( $key ) . $key;
363 $this->deletionBatch[$name] = [ $srcRel, $dstRel ];
364 }
365 }
366
367 if ( !$repo->hasSha1Storage() ) {
368 // Removes non-existent file from the batch, so we don't get errors.
369 // This also handles files in the 'deleted' zone deleted via revision deletion.
370 $checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
371 if ( !$checkStatus->isGood() ) {
372 $this->status->merge( $checkStatus );
373 return $this->status;
374 }
375 $this->deletionBatch = $checkStatus->value;
376
377 // Execute the file deletion batch
378 $status = $this->file->repo->deleteBatch( $this->deletionBatch );
379 if ( !$status->isGood() ) {
380 $this->status->merge( $status );
381 }
382 }
383
384 if ( !$this->status->isOK() ) {
385 // Critical file deletion error; abort
386 $this->file->unlock();
387
388 return $this->status;
389 }
390
391 // Copy the image/oldimage rows to filearchive
392 $this->doDBInserts();
393 // Delete image/oldimage rows
394 $this->doDBDeletes();
395
396 // Commit and return
397 $this->file->unlock();
398
399 return $this->status;
400 }
401
402 /**
403 * Removes non-existent files from a deletion batch.
404 * @param array $batch
405 * @return Status
406 */
407 protected function removeNonexistentFiles( $batch ) {
408 $files = $newBatch = [];
409
410 foreach ( $batch as $batchItem ) {
411 list( $src, ) = $batchItem;
412 $files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
413 }
414
415 $result = $this->file->repo->fileExistsBatch( $files );
416 if ( in_array( null, $result, true ) ) {
417 return Status::newFatal( 'backend-fail-internal',
418 $this->file->repo->getBackend()->getName() );
419 }
420
421 foreach ( $batch as $batchItem ) {
422 if ( $result[$batchItem[0]] ) {
423 $newBatch[] = $batchItem;
424 }
425 }
426
427 return Status::newGood( $newBatch );
428 }
429 }