MediaWiki master
UploadFromChunks.php
Go to the documentation of this file.
1<?php
2
10use Psr\Log\LoggerInterface;
12
43 private $repo;
45 public $stash;
47 public $user;
48
49 protected $mOffset;
50 protected $mChunkIndex;
51 protected $mFileKey;
53
54 private LoggerInterface $logger;
55
65 public function __construct( User $user, $stash = false, $repo = false ) {
66 $this->user = $user;
67
68 if ( $repo ) {
69 $this->repo = $repo;
70 } else {
71 $this->repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
72 }
73
74 if ( $stash ) {
75 $this->stash = $stash;
76 } else {
77 wfDebug( __METHOD__ . " creating new UploadFromChunks instance for " . $user->getId() );
78 $this->stash = new UploadStash( $this->repo, $this->user );
79 }
80
81 $this->logger = LoggerFactory::getInstance( 'upload' );
82 }
83
87 public function tryStashFile( User $user, $isPartial = false ) {
88 try {
89 $this->verifyChunk();
91 return Status::newFatal( $e->msg );
92 }
93
94 return parent::tryStashFile( $user, $isPartial );
95 }
96
103 protected function doStashFile( User $user = null ) {
104 // Stash file is the called on creating a new chunk session:
105 $this->mChunkIndex = 0;
106 $this->mOffset = 0;
107
108 // Create a local stash target
109 $this->mStashFile = parent::doStashFile( $user );
110 // Update the initial file offset (based on file size)
111 $this->mOffset = $this->mStashFile->getSize();
112 $this->mFileKey = $this->mStashFile->getFileKey();
113
114 // Output a copy of this first to chunk 0 location:
115 $this->outputChunk( $this->mStashFile->getPath() );
116
117 // Update db table to reflect initial "chunk" state
118 $this->updateChunkStatus();
119
120 return $this->mStashFile;
121 }
122
130 public function continueChunks( $name, $key, $webRequestUpload ) {
131 $this->mFileKey = $key;
132 $this->mUpload = $webRequestUpload;
133 // Get the chunk status form the db:
134 $this->getChunkStatus();
135
136 $metadata = $this->stash->getMetadata( $key );
137 $this->initializePathInfo( $name,
138 $this->getRealPath( $metadata['us_path'] ),
139 $metadata['us_size'],
140 false
141 );
142 }
143
148 public function concatenateChunks() {
149 $oldFileKey = $this->mFileKey;
150 $chunkIndex = $this->getChunkIndex();
151 $this->logger->debug(
152 __METHOD__ . ' concatenate {totalChunks} chunks: {offset} inx: {curIndex}',
153 [
154 'offset' => $this->getOffset(),
155 'totalChunks' => $this->mChunkIndex,
156 'curIndex' => $chunkIndex,
157 'filekey' => $oldFileKey
158 ]
159 );
160
161 // Concatenate all the chunks to mVirtualTempPath
162 $fileList = [];
163 // The first chunk is stored at the mVirtualTempPath path so we start on "chunk 1"
164 for ( $i = 0; $i <= $chunkIndex; $i++ ) {
165 $fileList[] = $this->getVirtualChunkLocation( $i );
166 }
167
168 // Get the file extension from the last chunk
169 $ext = FileBackend::extensionFromPath( $this->mVirtualTempPath );
170 // Get a 0-byte temp file to perform the concatenation at
171 $tmpFile = MediaWikiServices::getInstance()->getTempFSFileFactory()
172 ->newTempFSFile( 'chunkedupload_', $ext );
173 $tmpPath = false; // fail in concatenate()
174 if ( $tmpFile ) {
175 // keep alive with $this
176 $tmpPath = $tmpFile->bind( $this )->getPath();
177 } else {
178 $this->logger->warning( "Error getting tmp file", [ 'filekey' => $oldFileKey ] );
179 }
180
181 // Concatenate the chunks at the temp file
182 $tStart = microtime( true );
183 $status = $this->repo->concatenate( $fileList, $tmpPath );
184 $tAmount = microtime( true ) - $tStart;
185 if ( !$status->isOK() ) {
186 // This is a backend error and not user-related, so log is safe
187 // Upload verification further on is not safe to log server side
188 $this->logFileBackendStatus(
189 $status,
190 '[{type}] Error on concatenate {chunks} stashed files ({details})',
191 [ 'chunks' => $chunkIndex, 'filekey' => $oldFileKey ]
192 );
193 return $status;
194 } else {
195 // Delete old chunks in deferred job. Put in deferred job because deleting
196 // lots of chunks can take a long time, sometimes to the point of causing
197 // a timeout, and we do not want that to tank the operation. Note that chunks
198 // are also automatically deleted after a set time by cleanupUploadStash.php
199 // Additionally, using AutoCommitUpdate ensures that we do not delete files
200 // if the main transaction is rolled back for some reason.
201 DeferredUpdates::addUpdate( new AutoCommitUpdate(
202 $this->repo->getPrimaryDB(),
203 __METHOD__,
204 function () use( $fileList, $oldFileKey ) {
205 $status = $this->repo->quickPurgeBatch( $fileList );
206 if ( !$status->isOK() ) {
207 $this->logger->warning(
208 "Could not delete chunks of {filekey} - {status}",
209 [
210 'status' => (string)$status,
211 'filekey' => $oldFileKey,
212 ]
213 );
214 }
215 }
216 ) );
217 }
218
219 wfDebugLog( 'fileconcatenate', "Combined $i chunks in $tAmount seconds." );
220
221 // File system path of the actual full temp file
222 $this->setTempFile( $tmpPath );
223
224 $ret = $this->verifyUpload();
225 if ( $ret['status'] !== UploadBase::OK ) {
226 $this->logger->info(
227 "Verification failed for chunked upload {filekey}",
228 [
229 'user' => $this->user->getName(),
230 'filekey' => $oldFileKey
231 ]
232 );
233 $status->fatal( $this->getVerificationErrorCode( $ret['status'] ) );
234
235 return $status;
236 }
237
238 // Update the mTempPath and mStashFile
239 // (for FileUpload or normal Stash to take over)
240 $tStart = microtime( true );
241 // This is a re-implementation of UploadBase::tryStashFile(), we can't call it because we
242 // override doStashFile() with completely different functionality in this class...
243 $error = $this->runUploadStashFileHook( $this->user );
244 if ( $error ) {
245 $status->fatal( ...$error );
246 $this->logger->info( "Aborting stash upload due to hook - {status}",
247 [
248 'status' => (string)$status,
249 'user' => $this->user->getName(),
250 'filekey' => $this->mFileKey
251 ]
252 );
253 return $status;
254 }
255 try {
256 $this->mStashFile = parent::doStashFile( $this->user );
257 } catch ( UploadStashException $e ) {
258 $this->logger->warning( "Could not stash file for {user} because {error} {msg}",
259 [
260 'user' => $this->user->getName(),
261 'error' => get_class( $e ),
262 'msg' => $e->getMessage(),
263 'filekey' => $this->mFileKey
264 ]
265 );
266 $status->fatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
267 return $status;
268 }
269
270 $tAmount = microtime( true ) - $tStart;
271 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable tmpFile is set when tmpPath is set here
272 $this->mStashFile->setLocalReference( $tmpFile ); // reuse (e.g. for getImageInfo())
273 $this->logger->info( "Stashed combined ({chunks} chunks) of {oldkey} under new name {filekey}",
274 [
275 'chunks' => $i,
276 'stashTime' => $tAmount,
277 'oldpath' => $this->mVirtualTempPath,
278 'filekey' => $this->mStashFile->getFileKey(),
279 'oldkey' => $oldFileKey,
280 'newpath' => $this->mStashFile->getPath(),
281 'user' => $this->user->getName()
282 ]
283 );
284 wfDebugLog( 'fileconcatenate', "Stashed combined file ($i chunks) in $tAmount seconds." );
285
286 return $status;
287 }
288
294 private function getVirtualChunkLocation( $index ) {
295 return $this->repo->getVirtualUrl( 'temp' ) .
296 '/' .
297 $this->repo->getHashPath(
298 $this->getChunkFileKey( $index )
299 ) .
300 $this->getChunkFileKey( $index );
301 }
302
311 public function addChunk( $chunkPath, $chunkSize, $offset ) {
312 // Get the offset before we add the chunk to the file system
313 $preAppendOffset = $this->getOffset();
314
315 if ( $preAppendOffset + $chunkSize > $this->getMaxUploadSize() ) {
316 $status = Status::newFatal( 'file-too-large' );
317 } else {
318 // Make sure the client is uploading the correct chunk with a matching offset.
319 if ( $preAppendOffset == $offset ) {
320 // Update local chunk index for the current chunk
321 $this->mChunkIndex++;
322 try {
323 # For some reason mTempPath is set to first part
324 $oldTemp = $this->mTempPath;
325 $this->mTempPath = $chunkPath;
326 $this->verifyChunk();
327 $this->mTempPath = $oldTemp;
328 } catch ( UploadChunkVerificationException $e ) {
329 $this->logger->info( "Error verifying upload chunk {msg}",
330 [
331 'user' => $this->user->getName(),
332 'msg' => $e->getMessage(),
333 'chunkIndex' => $this->mChunkIndex,
334 'filekey' => $this->mFileKey
335 ]
336 );
337
338 return Status::newFatal( $e->msg );
339 }
340 $status = $this->outputChunk( $chunkPath );
341 if ( $status->isGood() ) {
342 // Update local offset:
343 $this->mOffset = $preAppendOffset + $chunkSize;
344 // Update chunk table status db
345 $this->updateChunkStatus();
346 }
347 } else {
348 $status = Status::newFatal( 'invalid-chunk-offset' );
349 }
350 }
351
352 return $status;
353 }
354
358 private function updateChunkStatus() {
359 $this->logger->info( "update chunk status for {filekey} offset: {offset} inx: {inx}",
360 [
361 'offset' => $this->getOffset(),
362 'inx' => $this->getChunkIndex(),
363 'filekey' => $this->mFileKey,
364 'user' => $this->user->getName()
365 ]
366 );
367
368 $dbw = $this->repo->getPrimaryDB();
369 $dbw->newUpdateQueryBuilder()
370 ->update( 'uploadstash' )
371 ->set( [
372 'us_status' => 'chunks',
373 'us_chunk_inx' => $this->getChunkIndex(),
374 'us_size' => $this->getOffset()
375 ] )
376 ->where( [ 'us_key' => $this->mFileKey ] )
377 ->caller( __METHOD__ )->execute();
378 }
379
383 private function getChunkStatus() {
384 // get primary db to avoid race conditions.
385 // Otherwise, if chunk upload time < replag there will be spurious errors
386 $dbw = $this->repo->getPrimaryDB();
387 $row = $dbw->newSelectQueryBuilder()
388 ->select( [ 'us_chunk_inx', 'us_size', 'us_path' ] )
389 ->from( 'uploadstash' )
390 ->where( [ 'us_key' => $this->mFileKey ] )
391 ->caller( __METHOD__ )->fetchRow();
392 // Handle result:
393 if ( $row ) {
394 $this->mChunkIndex = $row->us_chunk_inx;
395 $this->mOffset = $row->us_size;
396 $this->mVirtualTempPath = $row->us_path;
397 }
398 }
399
404 private function getChunkIndex() {
405 if ( $this->mChunkIndex !== null ) {
406 return $this->mChunkIndex;
407 }
408
409 return 0;
410 }
411
416 public function getOffset() {
417 if ( $this->mOffset !== null ) {
418 return $this->mOffset;
419 }
420
421 return 0;
422 }
423
431 private function outputChunk( $chunkPath ) {
432 // Key is fileKey + chunk index
433 $fileKey = $this->getChunkFileKey();
434
435 // Store the chunk per its indexed fileKey:
436 $hashPath = $this->repo->getHashPath( $fileKey );
437 $storeStatus = $this->repo->quickImport( $chunkPath,
438 $this->repo->getZonePath( 'temp' ) . "/{$hashPath}{$fileKey}" );
439
440 // Check for error in stashing the chunk:
441 if ( !$storeStatus->isOK() ) {
442 $error = $this->logFileBackendStatus(
443 $storeStatus,
444 '[{type}] Error storing chunk in "{chunkPath}" for {fileKey} ({details})',
445 [ 'chunkPath' => $chunkPath, 'fileKey' => $fileKey ]
446 );
447 throw new UploadChunkFileException( "Error storing file in '{chunkPath}': " .
448 implode( '; ', $error ), [ 'chunkPath' => $chunkPath ] );
449 }
450
451 return $storeStatus;
452 }
453
454 private function getChunkFileKey( $index = null ) {
455 return $this->mFileKey . '.' . ( $index ?? $this->getChunkIndex() );
456 }
457
463 private function verifyChunk() {
464 // Rest mDesiredDestName here so we verify the name as if it were mFileKey
465 $oldDesiredDestName = $this->mDesiredDestName;
466 $this->mDesiredDestName = $this->mFileKey;
467 $this->mTitle = false;
468 $res = $this->verifyPartialFile();
469 $this->mDesiredDestName = $oldDesiredDestName;
470 $this->mTitle = false;
471 if ( is_array( $res ) ) {
472 throw new UploadChunkVerificationException( $res );
473 }
474 }
475
485 private function logFileBackendStatus( Status $status, string $logMessage, array $context = [] ): array {
486 $logger = $this->logger;
487 $errorToThrow = null;
488 $warningToThrow = null;
489
490 foreach ( $status->getErrors() as $errorItem ) {
491 // The message key stands for distinct error situation from the file backend,
492 // each error situation should be shown up in aggregated stats as own point, replace in message
493 $logMessageType = str_replace( '{type}', $errorItem['message'], $logMessage );
494
495 // The message arguments often contains the name of the failing datacenter or file names
496 // and should not show up in aggregated stats, add to context
497 $context['details'] = implode( '; ', $errorItem['params'] );
498 $context['user'] = $this->user->getName();
499
500 if ( $errorItem['type'] === 'error' ) {
501 // Use the first error of the list for the exception text
502 $errorToThrow ??= [ $errorItem['message'], ...$errorItem['params'] ];
503 $logger->error( $logMessageType, $context );
504 } else {
505 // When no error is found, fall back to the first warning
506 $warningToThrow ??= [ $errorItem['message'], ...$errorItem['params'] ];
507 $logger->warning( $logMessageType, $context );
508 }
509 }
510 return $errorToThrow ?? $warningToThrow ?? [ 'unknown', 'no error recorded' ];
511 }
512}
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not.
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:48
Deferrable Update for closure/callback updates that should use auto-commit mode.
Defer callable updates to run later in the PHP process.
Create PSR-3 logger objects.
Service locator for MediaWiki core services.
Object to access the $_FILES array.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
internal since 1.36
Definition User.php:93
getId( $wikiId=self::LOCAL)
Get the user's ID.
Definition User.php:1535
getErrors()
Get the list of errors.
isOK()
Returns whether the operation completed.
fatal( $message,... $parameters)
Add an error and set OK to false, indicating that the operation as a whole was fatal.
isGood()
Returns whether the operation completed and didn't have any error or warnings.
UploadStashFile null $mStashFile
getRealPath( $srcPath)
runUploadStashFileHook(User $user)
verifyPartialFile()
A verification routine suitable for partial files.
getVerificationErrorCode( $error)
string null $mDesiredDestName
setTempFile( $tempPath, $fileSize=null)
initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile=false)
static getMaxUploadSize( $forType=null)
Get MediaWiki's maximum uploaded file size for a given type of upload, based on $wgMaxUploadSize.
string null $mTempPath
Local file system path to the file to upload (or a local copy)
Implements uploading from chunks.
addChunk( $chunkPath, $chunkSize, $offset)
Add a chunk to the temporary directory.
doStashFile(User $user=null)
Calls the parent doStashFile and updates the uploadsession table to handle "chunks".
continueChunks( $name, $key, $webRequestUpload)
Continue chunk uploading.
tryStashFile(User $user, $isPartial=false)
Like stashFile(), but respects extensions' wishes to prevent the stashing.verifyUpload() must be call...
__construct(User $user, $stash=false, $repo=false)
@noinspection PhpMissingParentConstructorInspection
getOffset()
Get the offset at which the next uploaded chunk will be appended to.
concatenateChunks()
Append the final chunk and ready file for parent::performUpload()
Implements regular file uploads.
UploadStash is intended to accomplish a few things:
Base class for all file backend classes (including multi-write backends).