MediaWiki master
UploadFromChunks.php
Go to the documentation of this file.
1<?php
2
8use Psr\Log\LoggerInterface;
9
40 private $repo;
42 public $stash;
44 public $user;
45
46 protected $mOffset;
47 protected $mChunkIndex;
48 protected $mFileKey;
50
51 private LoggerInterface $logger;
52
62 public function __construct( User $user, $stash = false, $repo = false ) {
63 $this->user = $user;
64
65 if ( $repo ) {
66 $this->repo = $repo;
67 } else {
68 $this->repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
69 }
70
71 if ( $stash ) {
72 $this->stash = $stash;
73 } else {
74 wfDebug( __METHOD__ . " creating new UploadFromChunks instance for " . $user->getId() );
75 $this->stash = new UploadStash( $this->repo, $this->user );
76 }
77
78 $this->logger = LoggerFactory::getInstance( 'upload' );
79 }
80
84 public function tryStashFile( User $user, $isPartial = false ) {
85 try {
86 $this->verifyChunk();
88 return Status::newFatal( $e->msg );
89 }
90
91 return parent::tryStashFile( $user, $isPartial );
92 }
93
100 protected function doStashFile( User $user = null ) {
101 // Stash file is the called on creating a new chunk session:
102 $this->mChunkIndex = 0;
103 $this->mOffset = 0;
104
105 // Create a local stash target
106 $this->mStashFile = parent::doStashFile( $user );
107 // Update the initial file offset (based on file size)
108 $this->mOffset = $this->mStashFile->getSize();
109 $this->mFileKey = $this->mStashFile->getFileKey();
110
111 // Output a copy of this first to chunk 0 location:
112 $this->outputChunk( $this->mStashFile->getPath() );
113
114 // Update db table to reflect initial "chunk" state
115 $this->updateChunkStatus();
116
117 return $this->mStashFile;
118 }
119
127 public function continueChunks( $name, $key, $webRequestUpload ) {
128 $this->mFileKey = $key;
129 $this->mUpload = $webRequestUpload;
130 // Get the chunk status form the db:
131 $this->getChunkStatus();
132
133 $metadata = $this->stash->getMetadata( $key );
134 $this->initializePathInfo( $name,
135 $this->getRealPath( $metadata['us_path'] ),
136 $metadata['us_size'],
137 false
138 );
139 }
140
145 public function concatenateChunks() {
146 $oldFileKey = $this->mFileKey;
147 $chunkIndex = $this->getChunkIndex();
148 $this->logger->debug(
149 __METHOD__ . ' concatenate {totalChunks} chunks: {offset} inx: {curIndex}',
150 [
151 'offset' => $this->getOffset(),
152 'totalChunks' => $this->mChunkIndex,
153 'curIndex' => $chunkIndex,
154 'filekey' => $oldFileKey
155 ]
156 );
157
158 // Concatenate all the chunks to mVirtualTempPath
159 $fileList = [];
160 // The first chunk is stored at the mVirtualTempPath path so we start on "chunk 1"
161 for ( $i = 0; $i <= $chunkIndex; $i++ ) {
162 $fileList[] = $this->getVirtualChunkLocation( $i );
163 }
164
165 // Get the file extension from the last chunk
166 $ext = FileBackend::extensionFromPath( $this->mVirtualTempPath );
167 // Get a 0-byte temp file to perform the concatenation at
168 $tmpFile = MediaWikiServices::getInstance()->getTempFSFileFactory()
169 ->newTempFSFile( 'chunkedupload_', $ext );
170 $tmpPath = false; // fail in concatenate()
171 if ( $tmpFile ) {
172 // keep alive with $this
173 $tmpPath = $tmpFile->bind( $this )->getPath();
174 } else {
175 $this->logger->warning( "Error getting tmp file", [ 'filekey' => $oldFileKey ] );
176 }
177
178 // Concatenate the chunks at the temp file
179 $tStart = microtime( true );
180 $status = $this->repo->concatenate( $fileList, $tmpPath );
181 $tAmount = microtime( true ) - $tStart;
182 if ( !$status->isOK() ) {
183 // This is a backend error and not user-related, so log is safe
184 // Upload verification further on is not safe to log server side
185 $this->logFileBackendStatus(
186 $status,
187 '[{type}] Error on concatenate {chunks} stashed files ({details})',
188 [ 'chunks' => $chunkIndex, 'filekey' => $oldFileKey ]
189 );
190 return $status;
191 } else {
192 // Delete old chunks in deferred job. Put in deferred job because deleting
193 // lots of chunks can take a long time, sometimes to the point of causing
194 // a timeout, and we do not want that to tank the operation. Note that chunks
195 // are also automatically deleted after a set time by cleanupUploadStash.php
196 // Additionally, using AutoCommitUpdate ensures that we do not delete files
197 // if the main transaction is rolled back for some reason.
198 DeferredUpdates::addUpdate( new AutoCommitUpdate(
199 $this->repo->getPrimaryDB(),
200 __METHOD__,
201 function () use( $fileList, $oldFileKey ) {
202 $status = $this->repo->quickPurgeBatch( $fileList );
203 if ( !$status->isOK() ) {
204 $this->logger->warning(
205 "Could not delete chunks of {filekey} - {status}",
206 [
207 'status' => (string)$status,
208 'filekey' => $oldFileKey,
209 ]
210 );
211 }
212 }
213 ) );
214 }
215
216 wfDebugLog( 'fileconcatenate', "Combined $i chunks in $tAmount seconds." );
217
218 // File system path of the actual full temp file
219 $this->setTempFile( $tmpPath );
220
221 $ret = $this->verifyUpload();
222 if ( $ret['status'] !== UploadBase::OK ) {
223 $this->logger->info(
224 "Verification failed for chunked upload {filekey}",
225 [
226 'user' => $this->user->getName(),
227 'filekey' => $oldFileKey
228 ]
229 );
230 $status->fatal( $this->getVerificationErrorCode( $ret['status'] ) );
231
232 return $status;
233 }
234
235 // Update the mTempPath and mStashFile
236 // (for FileUpload or normal Stash to take over)
237 $tStart = microtime( true );
238 // This is a re-implementation of UploadBase::tryStashFile(), we can't call it because we
239 // override doStashFile() with completely different functionality in this class...
240 $error = $this->runUploadStashFileHook( $this->user );
241 if ( $error ) {
242 $status->fatal( ...$error );
243 $this->logger->info( "Aborting stash upload due to hook - {status}",
244 [
245 'status' => (string)$status,
246 'user' => $this->user->getName(),
247 'filekey' => $this->mFileKey
248 ]
249 );
250 return $status;
251 }
252 try {
253 $this->mStashFile = parent::doStashFile( $this->user );
254 } catch ( UploadStashException $e ) {
255 $this->logger->warning( "Could not stash file for {user} because {error} {msg}",
256 [
257 'user' => $this->user->getName(),
258 'error' => get_class( $e ),
259 'msg' => $e->getMessage(),
260 'filekey' => $this->mFileKey
261 ]
262 );
263 $status->fatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
264 return $status;
265 }
266
267 $tAmount = microtime( true ) - $tStart;
268 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable tmpFile is set when tmpPath is set here
269 $this->mStashFile->setLocalReference( $tmpFile ); // reuse (e.g. for getImageInfo())
270 $this->logger->info( "Stashed combined ({chunks} chunks) of {oldkey} under new name {filekey}",
271 [
272 'chunks' => $i,
273 'stashTime' => $tAmount,
274 'oldpath' => $this->mVirtualTempPath,
275 'filekey' => $this->mStashFile->getFileKey(),
276 'oldkey' => $oldFileKey,
277 'newpath' => $this->mStashFile->getPath(),
278 'user' => $this->user->getName()
279 ]
280 );
281 wfDebugLog( 'fileconcatenate', "Stashed combined file ($i chunks) in $tAmount seconds." );
282
283 return $status;
284 }
285
291 private function getVirtualChunkLocation( $index ) {
292 return $this->repo->getVirtualUrl( 'temp' ) .
293 '/' .
294 $this->repo->getHashPath(
295 $this->getChunkFileKey( $index )
296 ) .
297 $this->getChunkFileKey( $index );
298 }
299
308 public function addChunk( $chunkPath, $chunkSize, $offset ) {
309 // Get the offset before we add the chunk to the file system
310 $preAppendOffset = $this->getOffset();
311
312 if ( $preAppendOffset + $chunkSize > $this->getMaxUploadSize() ) {
313 $status = Status::newFatal( 'file-too-large' );
314 } else {
315 // Make sure the client is uploading the correct chunk with a matching offset.
316 if ( $preAppendOffset == $offset ) {
317 // Update local chunk index for the current chunk
318 $this->mChunkIndex++;
319 try {
320 # For some reason mTempPath is set to first part
321 $oldTemp = $this->mTempPath;
322 $this->mTempPath = $chunkPath;
323 $this->verifyChunk();
324 $this->mTempPath = $oldTemp;
325 } catch ( UploadChunkVerificationException $e ) {
326 $this->logger->info( "Error verifying upload chunk {msg}",
327 [
328 'user' => $this->user->getName(),
329 'msg' => $e->getMessage(),
330 'chunkIndex' => $this->mChunkIndex,
331 'filekey' => $this->mFileKey
332 ]
333 );
334
335 return Status::newFatal( $e->msg );
336 }
337 $status = $this->outputChunk( $chunkPath );
338 if ( $status->isGood() ) {
339 // Update local offset:
340 $this->mOffset = $preAppendOffset + $chunkSize;
341 // Update chunk table status db
342 $this->updateChunkStatus();
343 }
344 } else {
345 $status = Status::newFatal( 'invalid-chunk-offset' );
346 }
347 }
348
349 return $status;
350 }
351
355 private function updateChunkStatus() {
356 $this->logger->info( "update chunk status for {filekey} offset: {offset} inx: {inx}",
357 [
358 'offset' => $this->getOffset(),
359 'inx' => $this->getChunkIndex(),
360 'filekey' => $this->mFileKey,
361 'user' => $this->user->getName()
362 ]
363 );
364
365 $dbw = $this->repo->getPrimaryDB();
366 $dbw->newUpdateQueryBuilder()
367 ->update( 'uploadstash' )
368 ->set( [
369 'us_status' => 'chunks',
370 'us_chunk_inx' => $this->getChunkIndex(),
371 'us_size' => $this->getOffset()
372 ] )
373 ->where( [ 'us_key' => $this->mFileKey ] )
374 ->caller( __METHOD__ )->execute();
375 }
376
380 private function getChunkStatus() {
381 // get primary db to avoid race conditions.
382 // Otherwise, if chunk upload time < replag there will be spurious errors
383 $dbw = $this->repo->getPrimaryDB();
384 $row = $dbw->newSelectQueryBuilder()
385 ->select( [ 'us_chunk_inx', 'us_size', 'us_path' ] )
386 ->from( 'uploadstash' )
387 ->where( [ 'us_key' => $this->mFileKey ] )
388 ->caller( __METHOD__ )->fetchRow();
389 // Handle result:
390 if ( $row ) {
391 $this->mChunkIndex = $row->us_chunk_inx;
392 $this->mOffset = $row->us_size;
393 $this->mVirtualTempPath = $row->us_path;
394 }
395 }
396
401 private function getChunkIndex() {
402 if ( $this->mChunkIndex !== null ) {
403 return $this->mChunkIndex;
404 }
405
406 return 0;
407 }
408
413 public function getOffset() {
414 if ( $this->mOffset !== null ) {
415 return $this->mOffset;
416 }
417
418 return 0;
419 }
420
428 private function outputChunk( $chunkPath ) {
429 // Key is fileKey + chunk index
430 $fileKey = $this->getChunkFileKey();
431
432 // Store the chunk per its indexed fileKey:
433 $hashPath = $this->repo->getHashPath( $fileKey );
434 $storeStatus = $this->repo->quickImport( $chunkPath,
435 $this->repo->getZonePath( 'temp' ) . "/{$hashPath}{$fileKey}" );
436
437 // Check for error in stashing the chunk:
438 if ( !$storeStatus->isOK() ) {
439 $error = $this->logFileBackendStatus(
440 $storeStatus,
441 '[{type}] Error storing chunk in "{chunkPath}" for {fileKey} ({details})',
442 [ 'chunkPath' => $chunkPath, 'fileKey' => $fileKey ]
443 );
444 throw new UploadChunkFileException( "Error storing file in '{chunkPath}': " .
445 implode( '; ', $error ), [ 'chunkPath' => $chunkPath ] );
446 }
447
448 return $storeStatus;
449 }
450
451 private function getChunkFileKey( $index = null ) {
452 return $this->mFileKey . '.' . ( $index ?? $this->getChunkIndex() );
453 }
454
460 private function verifyChunk() {
461 // Rest mDesiredDestName here so we verify the name as if it were mFileKey
462 $oldDesiredDestName = $this->mDesiredDestName;
463 $this->mDesiredDestName = $this->mFileKey;
464 $this->mTitle = false;
465 $res = $this->verifyPartialFile();
466 $this->mDesiredDestName = $oldDesiredDestName;
467 $this->mTitle = false;
468 if ( is_array( $res ) ) {
469 throw new UploadChunkVerificationException( $res );
470 }
471 }
472
482 private function logFileBackendStatus( Status $status, string $logMessage, array $context = [] ): array {
483 $logger = $this->logger;
484 $errorToThrow = null;
485 $warningToThrow = null;
486
487 foreach ( $status->getErrors() as $errorItem ) {
488 // The message key stands for distinct error situation from the file backend,
489 // each error situation should be shown up in aggregated stats as own point, replace in message
490 $logMessageType = str_replace( '{type}', $errorItem['message'], $logMessage );
491
492 // The message arguments often contains the name of the failing datacenter or file names
493 // and should not show up in aggregated stats, add to context
494 $context['details'] = implode( '; ', $errorItem['params'] );
495 $context['user'] = $this->user->getName();
496
497 if ( $errorItem['type'] === 'error' ) {
498 // Use the first error of the list for the exception text
499 $errorToThrow ??= [ $errorItem['message'], ...$errorItem['params'] ];
500 $logger->error( $logMessageType, $context );
501 } else {
502 // When no error is found, fall back to the first warning
503 $warningToThrow ??= [ $errorItem['message'], ...$errorItem['params'] ];
504 $logger->warning( $logMessageType, $context );
505 }
506 }
507 return $errorToThrow ?? $warningToThrow ?? [ 'unknown', 'no error recorded' ];
508 }
509}
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.
static extensionFromPath( $path, $case='lowercase')
Get the final extension from a storage or FS path.
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:49
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:1561
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: