3 * Local file in the wiki's own database.
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.
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.
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
21 * @ingroup FileAbstraction
24 use MediaWiki\MediaWikiServices
;
27 * Helper class for file undeletion
28 * @ingroup FileAbstraction
30 class LocalFileRestoreBatch
{
34 /** @var string[] List of file IDs to restore */
35 private $cleanupBatch;
37 /** @var string[] List of file IDs to restore */
40 /** @var bool Add all revisions of the file */
43 /** @var bool Whether to remove all settings for suppressed fields */
44 private $unsuppress = false;
48 * @param bool $unsuppress
50 function __construct( File
$file, $unsuppress = false ) {
52 $this->cleanupBatch
= [];
54 $this->unsuppress
= $unsuppress;
61 public function addId( $fa_id ) {
62 $this->ids
[] = $fa_id;
66 * Add a whole lot of files by ID
69 public function addIds( $ids ) {
70 $this->ids
= array_merge( $this->ids
, $ids );
74 * Add all revisions of the file
76 public function addAll() {
81 * Run the transaction, except the cleanup batch.
82 * The cleanup batch should be run in a separate transaction, because it locks different
83 * rows and there's no need to keep the image row locked while it's acquiring those locks
84 * The caller may have its own transaction open.
85 * So we save the batch and let the caller call cleanup()
88 public function execute() {
92 $repo = $this->file
->getRepo();
93 if ( !$this->all
&& !$this->ids
) {
95 return $repo->newGood();
98 $lockOwnsTrx = $this->file
->lock();
100 $dbw = $this->file
->repo
->getMasterDB();
102 $commentStore = MediaWikiServices
::getInstance()->getCommentStore();
103 $actorMigration = ActorMigration
::newMigration();
105 $status = $this->file
->repo
->newGood();
107 $exists = (bool)$dbw->selectField( 'image', '1',
108 [ 'img_name' => $this->file
->getName() ],
110 // The lock() should already prevents changes, but this still may need
111 // to bypass any transaction snapshot. However, if lock() started the
112 // trx (which it probably did) then snapshot is post-lock and up-to-date.
113 $lockOwnsTrx ?
[] : [ 'LOCK IN SHARE MODE' ]
116 // Fetch all or selected archived revisions for the file,
117 // sorted from the most recent to the oldest.
118 $conditions = [ 'fa_name' => $this->file
->getName() ];
121 $conditions['fa_id'] = $this->ids
;
124 $arFileQuery = ArchivedFile
::getQueryInfo();
125 $result = $dbw->select(
126 $arFileQuery['tables'],
127 $arFileQuery['fields'],
130 [ 'ORDER BY' => 'fa_timestamp DESC' ],
131 $arFileQuery['joins']
137 $insertCurrent = false;
142 foreach ( $result as $row ) {
143 $idsPresent[] = $row->fa_id
;
145 if ( $row->fa_name
!= $this->file
->getName() ) {
146 $status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp
) );
147 $status->failCount++
;
151 if ( $row->fa_storage_key
== '' ) {
152 // Revision was missing pre-deletion
153 $status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp
) );
154 $status->failCount++
;
158 $deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key
) .
159 $row->fa_storage_key
;
160 $deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;
162 if ( isset( $row->fa_sha1
) ) {
163 $sha1 = $row->fa_sha1
;
165 // old row, populate from key
166 $sha1 = LocalRepo
::getHashFromKey( $row->fa_storage_key
);
170 if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
171 $sha1 = substr( $sha1, 1 );
174 if ( is_null( $row->fa_major_mime
) ||
$row->fa_major_mime
== 'unknown'
175 ||
is_null( $row->fa_minor_mime
) ||
$row->fa_minor_mime
== 'unknown'
176 ||
is_null( $row->fa_media_type
) ||
$row->fa_media_type
== 'UNKNOWN'
177 ||
is_null( $row->fa_metadata
)
179 // Refresh our metadata
180 // Required for a new current revision; nice for older ones too. :)
181 $props = RepoGroup
::singleton()->getFileProps( $deletedUrl );
184 'minor_mime' => $row->fa_minor_mime
,
185 'major_mime' => $row->fa_major_mime
,
186 'media_type' => $row->fa_media_type
,
187 'metadata' => $row->fa_metadata
191 $comment = $commentStore->getComment( 'fa_description', $row );
192 $user = User
::newFromAnyId( $row->fa_user
, $row->fa_user_text
, $row->fa_actor
);
193 if ( $first && !$exists ) {
194 // This revision will be published as the new current version
195 $destRel = $this->file
->getRel();
196 $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
197 $actorFields = $actorMigration->getInsertValues( $dbw, 'img_user', $user );
199 'img_name' => $row->fa_name
,
200 'img_size' => $row->fa_size
,
201 'img_width' => $row->fa_width
,
202 'img_height' => $row->fa_height
,
203 'img_metadata' => $props['metadata'],
204 'img_bits' => $row->fa_bits
,
205 'img_media_type' => $props['media_type'],
206 'img_major_mime' => $props['major_mime'],
207 'img_minor_mime' => $props['minor_mime'],
208 'img_timestamp' => $row->fa_timestamp
,
210 ] +
$commentFields +
$actorFields;
212 // The live (current) version cannot be hidden!
213 if ( !$this->unsuppress
&& $row->fa_deleted
) {
214 $status->fatal( 'undeleterevdel' );
215 $this->file
->unlock();
219 $archiveName = $row->fa_archive_name
;
221 if ( $archiveName == '' ) {
222 // This was originally a current version; we
223 // have to devise a new archive name for it.
224 // Format is <timestamp of archiving>!<name>
225 $timestamp = wfTimestamp( TS_UNIX
, $row->fa_deleted_timestamp
);
228 $archiveName = wfTimestamp( TS_MW
, $timestamp ) . '!' . $row->fa_name
;
230 } while ( isset( $archiveNames[$archiveName] ) );
233 $archiveNames[$archiveName] = true;
234 $destRel = $this->file
->getArchiveRel( $archiveName );
236 'oi_name' => $row->fa_name
,
237 'oi_archive_name' => $archiveName,
238 'oi_size' => $row->fa_size
,
239 'oi_width' => $row->fa_width
,
240 'oi_height' => $row->fa_height
,
241 'oi_bits' => $row->fa_bits
,
242 'oi_timestamp' => $row->fa_timestamp
,
243 'oi_metadata' => $props['metadata'],
244 'oi_media_type' => $props['media_type'],
245 'oi_major_mime' => $props['major_mime'],
246 'oi_minor_mime' => $props['minor_mime'],
247 'oi_deleted' => $this->unsuppress ?
0 : $row->fa_deleted
,
249 ] +
$commentStore->insert( $dbw, 'oi_description', $comment )
250 +
$actorMigration->getInsertValues( $dbw, 'oi_user', $user );
253 $deleteIds[] = $row->fa_id
;
255 if ( !$this->unsuppress
&& $row->fa_deleted
& File
::DELETED_FILE
) {
256 // private files can stay where they are
257 $status->successCount++
;
259 $storeBatch[] = [ $deletedUrl, 'public', $destRel ];
260 $this->cleanupBatch
[] = $row->fa_storage_key
;
268 // Add a warning to the status object for missing IDs
269 $missingIds = array_diff( $this->ids
, $idsPresent );
271 foreach ( $missingIds as $id ) {
272 $status->error( 'undelete-missing-filearchive', $id );
275 if ( !$repo->hasSha1Storage() ) {
276 // Remove missing files from batch, so we don't get errors when undeleting them
277 $checkStatus = $this->removeNonexistentFiles( $storeBatch );
278 if ( !$checkStatus->isGood() ) {
279 $status->merge( $checkStatus );
282 $storeBatch = $checkStatus->value
;
284 // Run the store batch
285 // Use the OVERWRITE_SAME flag to smooth over a common error
286 $storeStatus = $this->file
->repo
->storeBatch( $storeBatch, FileRepo
::OVERWRITE_SAME
);
287 $status->merge( $storeStatus );
289 if ( !$status->isGood() ) {
290 // Even if some files could be copied, fail entirely as that is the
291 // easiest thing to do without data loss
292 $this->cleanupFailedBatch( $storeStatus, $storeBatch );
293 $status->setOK( false );
294 $this->file
->unlock();
300 // Run the DB updates
301 // Because we have locked the image row, key conflicts should be rare.
302 // If they do occur, we can roll back the transaction at this time with
303 // no data loss, but leaving unregistered files scattered throughout the
305 // This is not ideal, which is why it's important to lock the image row.
306 if ( $insertCurrent ) {
307 $dbw->insert( 'image', $insertCurrent, __METHOD__
);
310 if ( $insertBatch ) {
311 $dbw->insert( 'oldimage', $insertBatch, __METHOD__
);
315 $dbw->delete( 'filearchive',
316 [ 'fa_id' => $deleteIds ],
320 // If store batch is empty (all files are missing), deletion is to be considered successful
321 if ( $status->successCount
> 0 ||
!$storeBatch ||
$repo->hasSha1Storage() ) {
323 wfDebug( __METHOD__
. " restored {$status->successCount} items, creating a new current\n" );
325 DeferredUpdates
::addUpdate( SiteStatsUpdate
::factory( [ 'images' => 1 ] ) );
327 $this->file
->purgeEverything();
329 wfDebug( __METHOD__
. " restored {$status->successCount} as archived versions\n" );
330 $this->file
->purgeDescription();
334 $this->file
->unlock();
340 * Removes non-existent files from a store batch.
341 * @param array $triplets
344 protected function removeNonexistentFiles( $triplets ) {
345 $files = $filteredTriplets = [];
346 foreach ( $triplets as $file ) {
347 $files[$file[0]] = $file[0];
350 $result = $this->file
->repo
->fileExistsBatch( $files );
351 if ( in_array( null, $result, true ) ) {
352 return Status
::newFatal( 'backend-fail-internal',
353 $this->file
->repo
->getBackend()->getName() );
356 foreach ( $triplets as $file ) {
357 if ( $result[$file[0]] ) {
358 $filteredTriplets[] = $file;
362 return Status
::newGood( $filteredTriplets );
366 * Removes non-existent files from a cleanup batch.
367 * @param string[] $batch
370 protected function removeNonexistentFromCleanup( $batch ) {
371 $files = $newBatch = [];
372 $repo = $this->file
->repo
;
374 foreach ( $batch as $file ) {
375 $files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
376 rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
379 $result = $repo->fileExistsBatch( $files );
381 foreach ( $batch as $file ) {
382 if ( $result[$file] ) {
391 * Delete unused files in the deleted zone.
392 * This should be called from outside the transaction in which execute() was called.
395 public function cleanup() {
396 if ( !$this->cleanupBatch
) {
397 return $this->file
->repo
->newGood();
400 $this->cleanupBatch
= $this->removeNonexistentFromCleanup( $this->cleanupBatch
);
402 $status = $this->file
->repo
->cleanupDeletedBatch( $this->cleanupBatch
);
408 * Cleanup a failed batch. The batch was only partially successful, so
409 * rollback by removing all items that were successfully copied.
411 * @param Status $storeStatus
412 * @param array[] $storeBatch
414 protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
417 foreach ( $storeStatus->success
as $i => $success ) {
418 // Check if this item of the batch was successfully copied
420 // Item was successfully copied and needs to be removed again
421 // Extract ($dstZone, $dstRel) from the batch
422 $cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
425 $this->file
->repo
->cleanupBatch( $cleanupBatch );