Merge "Include change tags in revision import structure"
[lhc/web/wiklou.git] / includes / filerepo / file / LocalFileMoveBatch.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 Wikimedia\Rdbms\IDatabase;
26
27 /**
28 * Helper class for file movement
29 * @ingroup FileAbstraction
30 */
31 class LocalFileMoveBatch {
32 /** @var LocalFile */
33 protected $file;
34
35 /** @var Title */
36 protected $target;
37
38 protected $cur;
39
40 protected $olds;
41
42 protected $oldCount;
43
44 protected $archive;
45
46 /** @var IDatabase */
47 protected $db;
48
49 /**
50 * @param File $file
51 * @param Title $target
52 */
53 function __construct( File $file, Title $target ) {
54 $this->file = $file;
55 $this->target = $target;
56 $this->oldHash = $this->file->repo->getHashPath( $this->file->getName() );
57 $this->newHash = $this->file->repo->getHashPath( $this->target->getDBkey() );
58 $this->oldName = $this->file->getName();
59 $this->newName = $this->file->repo->getNameFromTitle( $this->target );
60 $this->oldRel = $this->oldHash . $this->oldName;
61 $this->newRel = $this->newHash . $this->newName;
62 $this->db = $file->getRepo()->getMasterDB();
63 }
64
65 /**
66 * Add the current image to the batch
67 */
68 public function addCurrent() {
69 $this->cur = [ $this->oldRel, $this->newRel ];
70 }
71
72 /**
73 * Add the old versions of the image to the batch
74 * @return string[] List of archive names from old versions
75 */
76 public function addOlds() {
77 $archiveBase = 'archive';
78 $this->olds = [];
79 $this->oldCount = 0;
80 $archiveNames = [];
81
82 $result = $this->db->select( 'oldimage',
83 [ 'oi_archive_name', 'oi_deleted' ],
84 [ 'oi_name' => $this->oldName ],
85 __METHOD__,
86 [ 'LOCK IN SHARE MODE' ] // ignore snapshot
87 );
88
89 foreach ( $result as $row ) {
90 $archiveNames[] = $row->oi_archive_name;
91 $oldName = $row->oi_archive_name;
92 $bits = explode( '!', $oldName, 2 );
93
94 if ( count( $bits ) != 2 ) {
95 wfDebug( "Old file name missing !: '$oldName' \n" );
96 continue;
97 }
98
99 list( $timestamp, $filename ) = $bits;
100
101 if ( $this->oldName != $filename ) {
102 wfDebug( "Old file name doesn't match: '$oldName' \n" );
103 continue;
104 }
105
106 $this->oldCount++;
107
108 // Do we want to add those to oldCount?
109 if ( $row->oi_deleted & File::DELETED_FILE ) {
110 continue;
111 }
112
113 $this->olds[] = [
114 "{$archiveBase}/{$this->oldHash}{$oldName}",
115 "{$archiveBase}/{$this->newHash}{$timestamp}!{$this->newName}"
116 ];
117 }
118
119 return $archiveNames;
120 }
121
122 /**
123 * Perform the move.
124 * @return Status
125 */
126 public function execute() {
127 $repo = $this->file->repo;
128 $status = $repo->newGood();
129 /** @var LocalFile $destFile */
130 $destFile = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
131 ->newFile( $this->target );
132 '@phan-var LocalFile $destFile';
133
134 $this->file->lock();
135 $destFile->lock(); // quickly fail if destination is not available
136
137 $triplets = $this->getMoveTriplets();
138 $checkStatus = $this->removeNonexistentFiles( $triplets );
139 if ( !$checkStatus->isGood() ) {
140 $destFile->unlock();
141 $this->file->unlock();
142 $status->merge( $checkStatus ); // couldn't talk to file backend
143 return $status;
144 }
145 $triplets = $checkStatus->value;
146
147 // Verify the file versions metadata in the DB.
148 $statusDb = $this->verifyDBUpdates();
149 if ( !$statusDb->isGood() ) {
150 $destFile->unlock();
151 $this->file->unlock();
152 $statusDb->setOK( false );
153
154 return $statusDb;
155 }
156
157 if ( !$repo->hasSha1Storage() ) {
158 // Copy the files into their new location.
159 // If a prior process fataled copying or cleaning up files we tolerate any
160 // of the existing files if they are identical to the ones being stored.
161 $statusMove = $repo->storeBatch( $triplets, FileRepo::OVERWRITE_SAME );
162 wfDebugLog( 'imagemove', "Moved files for {$this->file->getName()}: " .
163 "{$statusMove->successCount} successes, {$statusMove->failCount} failures" );
164 if ( !$statusMove->isGood() ) {
165 // Delete any files copied over (while the destination is still locked)
166 $this->cleanupTarget( $triplets );
167 $destFile->unlock();
168 $this->file->unlock();
169 wfDebugLog( 'imagemove', "Error in moving files: "
170 . $statusMove->getWikiText( false, false, 'en' ) );
171 $statusMove->setOK( false );
172
173 return $statusMove;
174 }
175 $status->merge( $statusMove );
176 }
177
178 // Rename the file versions metadata in the DB.
179 $this->doDBUpdates();
180
181 wfDebugLog( 'imagemove', "Renamed {$this->file->getName()} in database: " .
182 "{$statusDb->successCount} successes, {$statusDb->failCount} failures" );
183
184 $destFile->unlock();
185 $this->file->unlock();
186
187 // Everything went ok, remove the source files
188 $this->cleanupSource( $triplets );
189
190 $status->merge( $statusDb );
191
192 return $status;
193 }
194
195 /**
196 * Verify the database updates and return a new Status indicating how
197 * many rows would be updated.
198 *
199 * @return Status
200 */
201 protected function verifyDBUpdates() {
202 $repo = $this->file->repo;
203 $status = $repo->newGood();
204 $dbw = $this->db;
205
206 $hasCurrent = $dbw->lockForUpdate(
207 'image',
208 [ 'img_name' => $this->oldName ],
209 __METHOD__
210 );
211 $oldRowCount = $dbw->lockForUpdate(
212 'oldimage',
213 [ 'oi_name' => $this->oldName ],
214 __METHOD__
215 );
216
217 if ( $hasCurrent ) {
218 $status->successCount++;
219 } else {
220 $status->failCount++;
221 }
222 $status->successCount += $oldRowCount;
223 // T36934: oldCount is based on files that actually exist.
224 // There may be more DB rows than such files, in which case $affected
225 // can be greater than $total. We use max() to avoid negatives here.
226 $status->failCount += max( 0, $this->oldCount - $oldRowCount );
227 if ( $status->failCount ) {
228 $status->error( 'imageinvalidfilename' );
229 }
230
231 return $status;
232 }
233
234 /**
235 * Do the database updates and return a new Status indicating how
236 * many rows where updated.
237 */
238 protected function doDBUpdates() {
239 $dbw = $this->db;
240
241 // Update current image
242 $dbw->update(
243 'image',
244 [ 'img_name' => $this->newName ],
245 [ 'img_name' => $this->oldName ],
246 __METHOD__
247 );
248
249 // Update old images
250 $dbw->update(
251 'oldimage',
252 [
253 'oi_name' => $this->newName,
254 'oi_archive_name = ' . $dbw->strreplace( 'oi_archive_name',
255 $dbw->addQuotes( $this->oldName ), $dbw->addQuotes( $this->newName ) ),
256 ],
257 [ 'oi_name' => $this->oldName ],
258 __METHOD__
259 );
260 }
261
262 /**
263 * Generate triplets for FileRepo::storeBatch().
264 * @return array[]
265 */
266 protected function getMoveTriplets() {
267 $moves = array_merge( [ $this->cur ], $this->olds );
268 $triplets = []; // The format is: (srcUrl, destZone, destUrl)
269
270 foreach ( $moves as $move ) {
271 // $move: (oldRelativePath, newRelativePath)
272 $srcUrl = $this->file->repo->getVirtualUrl() . '/public/' . rawurlencode( $move[0] );
273 $triplets[] = [ $srcUrl, 'public', $move[1] ];
274 wfDebugLog(
275 'imagemove',
276 "Generated move triplet for {$this->file->getName()}: {$srcUrl} :: public :: {$move[1]}"
277 );
278 }
279
280 return $triplets;
281 }
282
283 /**
284 * Removes non-existent files from move batch.
285 * @param array $triplets
286 * @return Status
287 */
288 protected function removeNonexistentFiles( $triplets ) {
289 $files = [];
290
291 foreach ( $triplets as $file ) {
292 $files[$file[0]] = $file[0];
293 }
294
295 $result = $this->file->repo->fileExistsBatch( $files );
296 if ( in_array( null, $result, true ) ) {
297 return Status::newFatal( 'backend-fail-internal',
298 $this->file->repo->getBackend()->getName() );
299 }
300
301 $filteredTriplets = [];
302 foreach ( $triplets as $file ) {
303 if ( $result[$file[0]] ) {
304 $filteredTriplets[] = $file;
305 } else {
306 wfDebugLog( 'imagemove', "File {$file[0]} does not exist" );
307 }
308 }
309
310 return Status::newGood( $filteredTriplets );
311 }
312
313 /**
314 * Cleanup a partially moved array of triplets by deleting the target
315 * files. Called if something went wrong half way.
316 * @param array[] $triplets
317 */
318 protected function cleanupTarget( $triplets ) {
319 // Create dest pairs from the triplets
320 $pairs = [];
321 foreach ( $triplets as $triplet ) {
322 // $triplet: (old source virtual URL, dst zone, dest rel)
323 $pairs[] = [ $triplet[1], $triplet[2] ];
324 }
325
326 $this->file->repo->cleanupBatch( $pairs );
327 }
328
329 /**
330 * Cleanup a fully moved array of triplets by deleting the source files.
331 * Called at the end of the move process if everything else went ok.
332 * @param array[] $triplets
333 */
334 protected function cleanupSource( $triplets ) {
335 // Create source file names from the triplets
336 $files = [];
337 foreach ( $triplets as $triplet ) {
338 $files[] = $triplet[0];
339 }
340
341 $this->file->repo->cleanupBatch( $files );
342 }
343 }