3 * Copyright © 2008 - 2010 Bryan Tong Minh <Bryan.TongMinh@Gmail.com>
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
24 * @todo: create a UploadCommandFactory and UploadComand classes to share logic with Special:Upload
25 * @todo: split the different cases of upload in subclasses or submethods.
28 namespace MediaWiki\Api
;
30 use AssembleUploadChunksJob
;
34 use MediaWiki\Config\Config
;
35 use MediaWiki\Logger\LoggerFactory
;
36 use MediaWiki\MainConfigNames
;
37 use MediaWiki\MediaWikiServices
;
38 use MediaWiki\Message\Message
;
39 use MediaWiki\Status\Status
;
40 use MediaWiki\User\Options\UserOptionsLookup
;
41 use MediaWiki\User\User
;
42 use MediaWiki\Watchlist\WatchlistManager
;
43 use Psr\Log\LoggerInterface
;
44 use PublishStashedFileJob
;
52 use UploadStashBadPathException
;
53 use UploadStashException
;
54 use UploadStashFileException
;
55 use UploadStashFileNotFoundException
;
56 use UploadStashNoSuchKeyException
;
57 use UploadStashNotLoggedInException
;
58 use UploadStashWrongOwnerException
;
59 use UploadStashZeroLengthFileException
;
60 use Wikimedia\Message\MessageSpecifier
;
61 use Wikimedia\ParamValidator\ParamValidator
;
62 use Wikimedia\ParamValidator\TypeDef\IntegerDef
;
67 class ApiUpload
extends ApiBase
{
69 use ApiWatchlistTrait
;
71 /** @var UploadBase|UploadFromChunks|null */
72 protected $mUpload = null;
77 private JobQueueGroup
$jobQueueGroup;
79 private LoggerInterface
$log;
81 public function __construct(
84 JobQueueGroup
$jobQueueGroup,
85 WatchlistManager
$watchlistManager,
86 UserOptionsLookup
$userOptionsLookup
88 parent
::__construct( $mainModule, $moduleName );
89 $this->jobQueueGroup
= $jobQueueGroup;
91 // Variables needed in ApiWatchlistTrait trait
92 $this->watchlistExpiryEnabled
= $this->getConfig()->get( MainConfigNames
::WatchlistExpiry
);
93 $this->watchlistMaxDuration
=
94 $this->getConfig()->get( MainConfigNames
::WatchlistExpiryMaxDuration
);
95 $this->watchlistManager
= $watchlistManager;
96 $this->userOptionsLookup
= $userOptionsLookup;
97 $this->log
= LoggerFactory
::getInstance( 'upload' );
100 public function execute() {
101 // Check whether upload is enabled
102 if ( !UploadBase
::isEnabled() ) {
103 $this->dieWithError( 'uploaddisabled' );
106 $user = $this->getUser();
108 // Parameter handling
109 $this->mParams
= $this->extractRequestParams();
110 // Check if async mode is actually supported (jobs done in cli mode)
111 $this->mParams
['async'] = ( $this->mParams
['async'] &&
112 $this->getConfig()->get( MainConfigNames
::EnableAsyncUploads
) );
114 // Copy the session key to the file key, for backward compatibility.
115 if ( !$this->mParams
['filekey'] && $this->mParams
['sessionkey'] ) {
116 $this->mParams
['filekey'] = $this->mParams
['sessionkey'];
119 if ( !$this->mParams
['checkstatus'] ) {
120 $this->useTransactionalTimeLimit();
123 // Select an upload module
125 if ( !$this->selectUploadModule() ) {
126 return; // not a true upload, but a status request or similar
127 } elseif ( !$this->mUpload
) {
128 $this->dieDebug( __METHOD__
, 'No upload module set' );
130 } catch ( UploadStashException
$e ) { // XXX: don't spam exception log
131 $this->dieStatus( $this->handleStashException( $e ) );
134 // First check permission to upload
135 $this->checkPermissions( $user );
137 // Fetch the file (usually a no-op)
138 // Skip for async upload from URL, where we just want to run checks.
139 /** @var Status $status */
140 if ( $this->mParams
['async'] && $this->mParams
['url'] ) {
141 $status = $this->mUpload
->canFetchFile();
143 $status = $this->mUpload
->fetchFile();
146 if ( !$status->isGood() ) {
147 $this->log
->info( "Unable to fetch file {filename} for {user} because {status}",
149 'user' => $this->getUser()->getName(),
150 'status' => (string)$status,
151 'filename' => $this->mParams
['filename'] ??
'-',
154 $this->dieStatus( $status );
157 // Check the uploaded file
158 $this->verifyUpload();
160 // Check if the user has the rights to modify or overwrite the requested title
161 // (This check is irrelevant if stashing is already requested, since the errors
162 // can always be fixed by changing the title)
163 if ( !$this->mParams
['stash'] ) {
164 $permErrors = $this->mUpload
->verifyTitlePermissions( $user );
165 if ( $permErrors !== true ) {
166 $this->dieRecoverableError( $permErrors, 'filename' );
170 // Get the result based on the current upload context:
172 $result = $this->getContextResult();
173 } catch ( UploadStashException
$e ) { // XXX: don't spam exception log
174 $this->dieStatus( $this->handleStashException( $e ) );
176 $this->getResult()->addValue( null, $this->getModuleName(), $result );
178 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
179 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
180 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
181 if ( $result['result'] === 'Success' ) {
182 $imageinfo = $this->getUploadImageInfo( $this->mUpload
);
183 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
186 // Cleanup any temporary mess
187 $this->mUpload
->cleanupTempFile();
190 public static function getDummyInstance(): self
{
191 $services = MediaWikiServices
::getInstance();
192 $apiMain = new ApiMain(); // dummy object (XXX)
193 $apiUpload = new ApiUpload(
196 $services->getJobQueueGroup(),
197 $services->getWatchlistManager(),
198 $services->getUserOptionsLookup()
205 * Gets image info about the file just uploaded.
207 * Also has the effect of setting metadata to be an 'indexed tag name' in
208 * returned API result if 'metadata' was requested. Oddly, we have to pass
209 * the "result" object down just so it can do that with the appropriate
210 * format, presumably.
212 * @internal For use in upload jobs and a deprecated method on UploadBase.
213 * @todo Extract the logic actually needed by the jobs, and separate it
214 * from the structure used in API responses.
216 * @return array Image info
218 public function getUploadImageInfo( UploadBase
$upload ): array {
219 $result = $this->getResult();
220 $stashFile = $upload->getStashFile();
222 // Calling a different API module depending on whether the file was stashed is less than optimal.
223 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
225 $imParam = ApiQueryStashImageInfo
::getPropertyNames();
226 $info = ApiQueryStashImageInfo
::getInfo(
228 array_fill_keys( $imParam, true ),
232 $localFile = $upload->getLocalFile();
233 $imParam = ApiQueryImageInfo
::getPropertyNames();
234 $info = ApiQueryImageInfo
::getInfo(
236 array_fill_keys( $imParam, true ),
245 * Get an upload result based on upload context
248 private function getContextResult() {
249 $warnings = $this->getApiWarnings();
250 if ( $warnings && !$this->mParams
['ignorewarnings'] ) {
251 // Get warnings formatted in result array format
252 return $this->getWarningsResult( $warnings );
253 } elseif ( $this->mParams
['chunk'] ) {
254 // Add chunk, and get result
255 return $this->getChunkResult( $warnings );
256 } elseif ( $this->mParams
['stash'] ) {
257 // Stash the file and get stash result
258 return $this->getStashResult( $warnings );
261 // This is the most common case -- a normal upload with no warnings
262 // performUpload will return a formatted properly for the API with status
263 return $this->performUpload( $warnings );
267 * Get Stash Result, throws an exception if the file could not be stashed.
268 * @param array $warnings Array of Api upload warnings
271 private function getStashResult( $warnings ) {
273 $result['result'] = 'Success';
274 if ( $warnings && count( $warnings ) > 0 ) {
275 $result['warnings'] = $warnings;
277 // Some uploads can request they be stashed, so as not to publish them immediately.
278 // In this case, a failure to stash ought to be fatal
279 $this->performStash( 'critical', $result );
285 * Get Warnings Result
286 * @param array $warnings Array of Api upload warnings
289 private function getWarningsResult( $warnings ) {
291 $result['result'] = 'Warning';
292 $result['warnings'] = $warnings;
293 // in case the warnings can be fixed with some further user action, let's stash this upload
294 // and return a key they can use to restart it
295 $this->performStash( 'optional', $result );
302 * @see $wgMinUploadChunkSize
303 * @param Config $config Site configuration for MinUploadChunkSize
306 public static function getMinUploadChunkSize( Config
$config ) {
307 $configured = $config->get( MainConfigNames
::MinUploadChunkSize
);
309 // Leave some room for other POST parameters
311 wfShorthandToInteger(
312 ini_get( 'post_max_size' ),
317 // Ensure the minimum chunk size is less than PHP upload limits
318 // or the maximum upload size.
321 UploadBase
::getMaxUploadSize( 'file' ),
322 UploadBase
::getMaxPhpUploadSize(),
328 * Get the result of a chunk upload.
329 * @param array $warnings Array of Api upload warnings
332 private function getChunkResult( $warnings ) {
335 if ( $warnings && count( $warnings ) > 0 ) {
336 $result['warnings'] = $warnings;
339 $chunkUpload = $this->getMain()->getUpload( 'chunk' );
340 $chunkPath = $chunkUpload->getTempName();
341 $chunkSize = $chunkUpload->getSize();
342 $totalSoFar = $this->mParams
['offset'] +
$chunkSize;
343 $minChunkSize = self
::getMinUploadChunkSize( $this->getConfig() );
345 // Double check sizing
346 if ( $totalSoFar > $this->mParams
['filesize'] ) {
347 $this->dieWithError( 'apierror-invalid-chunk' );
350 // Enforce minimum chunk size
351 if ( $totalSoFar != $this->mParams
['filesize'] && $chunkSize < $minChunkSize ) {
352 $this->dieWithError( [ 'apierror-chunk-too-small', Message
::numParam( $minChunkSize ) ] );
355 if ( $this->mParams
['offset'] == 0 ) {
356 $this->log
->debug( "Started first chunk of chunked upload of {filename} for {user}",
358 'user' => $this->getUser()->getName(),
359 'filename' => $this->mParams
['filename'] ??
'-',
360 'filesize' => $this->mParams
['filesize'],
361 'chunkSize' => $chunkSize
364 $filekey = $this->performStash( 'critical' );
366 $filekey = $this->mParams
['filekey'];
368 // Don't allow further uploads to an already-completed session
369 $progress = UploadBase
::getSessionStatus( $this->getUser(), $filekey );
371 // Probably can't get here, but check anyway just in case
372 $this->log
->info( "Stash failed due to no session for {user}",
374 'user' => $this->getUser()->getName(),
375 'filename' => $this->mParams
['filename'] ??
'-',
376 'filekey' => $this->mParams
['filekey'] ??
'-',
377 'filesize' => $this->mParams
['filesize'],
378 'chunkSize' => $chunkSize
381 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
382 } elseif ( $progress['result'] !== 'Continue' ||
$progress['stage'] !== 'uploading' ) {
383 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
386 $status = $this->mUpload
->addChunk(
387 $chunkPath, $chunkSize, $this->mParams
['offset'] );
388 if ( !$status->isGood() ) {
390 'offset' => $this->mUpload
->getOffset(),
392 $this->log
->info( "Chunked upload stash failure {status} for {user}",
394 'status' => (string)$status,
395 'user' => $this->getUser()->getName(),
396 'filename' => $this->mParams
['filename'] ??
'-',
397 'filekey' => $this->mParams
['filekey'] ??
'-',
398 'filesize' => $this->mParams
['filesize'],
399 'chunkSize' => $chunkSize,
400 'offset' => $this->mUpload
->getOffset()
403 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
405 $this->log
->debug( "Got chunk for {filename} with offset {offset} for {user}",
407 'user' => $this->getUser()->getName(),
408 'filename' => $this->mParams
['filename'] ??
'-',
409 'filekey' => $this->mParams
['filekey'] ??
'-',
410 'filesize' => $this->mParams
['filesize'],
411 'chunkSize' => $chunkSize,
412 'offset' => $this->mUpload
->getOffset()
418 // Check we added the last chunk:
419 if ( $totalSoFar == $this->mParams
['filesize'] ) {
420 if ( $this->mParams
['async'] ) {
421 UploadBase
::setSessionStatus(
424 [ 'result' => 'Poll',
425 'stage' => 'queued', 'status' => Status
::newGood() ]
427 // It is important that this be lazyPush, as we do not want to insert
428 // into job queue until after the current transaction has completed since
429 // this depends on values in uploadstash table that were updated during
430 // the current transaction. (T350917)
431 $this->jobQueueGroup
->lazyPush( new AssembleUploadChunksJob( [
432 'filename' => $this->mParams
['filename'],
433 'filekey' => $filekey,
434 'filesize' => $this->mParams
['filesize'],
435 'session' => $this->getContext()->exportSession()
437 $this->log
->info( "Received final chunk of {filename} for {user}, queuing assemble job",
439 'user' => $this->getUser()->getName(),
440 'filename' => $this->mParams
['filename'] ??
'-',
441 'filekey' => $this->mParams
['filekey'] ??
'-',
442 'filesize' => $this->mParams
['filesize'],
443 'chunkSize' => $chunkSize,
446 $result['result'] = 'Poll';
447 $result['stage'] = 'queued';
449 $this->log
->info( "Received final chunk of {filename} for {user}, assembling immediately",
451 'user' => $this->getUser()->getName(),
452 'filename' => $this->mParams
['filename'] ??
'-',
453 'filekey' => $this->mParams
['filekey'] ??
'-',
454 'filesize' => $this->mParams
['filesize'],
455 'chunkSize' => $chunkSize,
459 $status = $this->mUpload
->concatenateChunks();
460 if ( !$status->isGood() ) {
461 UploadBase
::setSessionStatus(
464 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
466 $this->log
->info( "Non jobqueue assembly of {filename} failed because {status}",
468 'user' => $this->getUser()->getName(),
469 'filename' => $this->mParams
['filename'] ??
'-',
470 'filekey' => $this->mParams
['filekey'] ??
'-',
471 'filesize' => $this->mParams
['filesize'],
472 'chunkSize' => $chunkSize,
473 'status' => (string)$status
476 $this->dieStatusWithCode( $status, 'stashfailed' );
479 // We can only get warnings like 'duplicate' after concatenating the chunks
480 $warnings = $this->getApiWarnings();
482 $result['warnings'] = $warnings;
485 // The fully concatenated file has a new filekey. So remove
486 // the old filekey and fetch the new one.
487 UploadBase
::setSessionStatus( $this->getUser(), $filekey, false );
488 $this->mUpload
->stash
->removeFile( $filekey );
489 $filekey = $this->mUpload
->getStashFile()->getFileKey();
491 $result['result'] = 'Success';
494 UploadBase
::setSessionStatus(
498 'result' => 'Continue',
499 'stage' => 'uploading',
500 'offset' => $totalSoFar,
501 'status' => Status
::newGood(),
504 $result['result'] = 'Continue';
505 $result['offset'] = $totalSoFar;
508 $result['filekey'] = $filekey;
514 * Stash the file and add the file key, or error information if it fails, to the data.
516 * @param string $failureMode What to do on failure to stash:
517 * - When 'critical', use dieStatus() to produce an error response and throw an exception.
518 * Use this when stashing the file was the primary purpose of the API request.
519 * - When 'optional', only add a 'stashfailed' key to the data and return null.
520 * Use this when some error happened for a non-stash upload and we're stashing the file
521 * only to save the client the trouble of re-uploading it.
522 * @param array|null &$data API result to which to add the information
523 * @return string|null File key
525 private function performStash( $failureMode, &$data = null ) {
526 $isPartial = (bool)$this->mParams
['chunk'];
528 $status = $this->mUpload
->tryStashFile( $this->getUser(), $isPartial );
530 if ( $status->isGood() && !$status->getValue() ) {
531 // Not actually a 'good' status...
532 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
534 } catch ( Exception
$e ) {
535 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
536 $this->log
->info( $debugMessage,
538 'user' => $this->getUser()->getName(),
539 'filename' => $this->mParams
['filename'] ??
'-',
540 'filekey' => $this->mParams
['filekey'] ??
'-'
544 $status = Status
::newFatal( $this->getErrorFormatter()->getMessageFromException(
545 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
549 if ( $status->isGood() ) {
550 $stashFile = $status->getValue();
551 $data['filekey'] = $stashFile->getFileKey();
552 // Backwards compatibility
553 $data['sessionkey'] = $data['filekey'];
554 return $data['filekey'];
557 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
558 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
559 // Statuses for it. Just extract the exception details and parse them ourselves.
560 [ $exceptionType, $message ] = $status->getMessage()->getParams();
561 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
562 $this->log
->info( $debugMessage,
564 'user' => $this->getUser()->getName(),
565 'filename' => $this->mParams
['filename'] ??
'-',
566 'filekey' => $this->mParams
['filekey'] ??
'-'
571 $this->log
->info( "Stash upload failure {status}",
573 'status' => (string)$status,
574 'user' => $this->getUser()->getName(),
575 'filename' => $this->mParams
['filename'] ??
'-',
576 'filekey' => $this->mParams
['filekey'] ??
'-'
580 if ( $failureMode !== 'optional' ) {
581 $this->dieStatus( $status );
583 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
589 * Throw an error that the user can recover from by providing a better
590 * value for $parameter
592 * @param array $errors Array of Message objects, message keys, key+param
593 * arrays, or StatusValue::getErrors()-style arrays
594 * @param string|null $parameter Parameter that needs revising
595 * @throws ApiUsageException
598 private function dieRecoverableError( $errors, $parameter = null ) {
599 $this->performStash( 'optional', $data );
602 $data['invalidparameter'] = $parameter;
605 $sv = StatusValue
::newGood();
606 foreach ( $errors as $error ) {
607 $msg = ApiMessage
::create( $error );
608 $msg->setApiData( $msg->getApiData() +
$data );
611 $this->dieStatus( $sv );
615 * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
618 * @param Status $status
619 * @param string $overrideCode Error code to use if there isn't one from IApiMessage
620 * @param array|null $moreExtraData
621 * @throws ApiUsageException
624 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
625 $sv = StatusValue
::newGood();
626 foreach ( $status->getMessages() as $error ) {
627 $msg = ApiMessage
::create( $error, $overrideCode );
628 if ( $moreExtraData ) {
629 $msg->setApiData( $msg->getApiData() +
$moreExtraData );
633 $this->dieStatus( $sv );
637 * Select an upload module and set it to mUpload. Dies on failure. If the
638 * request was a status request and not a true upload, returns false;
643 protected function selectUploadModule() {
644 // chunk or one and only one of the following parameters is needed
645 if ( !$this->mParams
['chunk'] ) {
646 $this->requireOnlyOneParameter( $this->mParams
,
647 'filekey', 'file', 'url' );
650 // Status report for "upload to stash"/"upload from stash"/"upload by url"
651 if ( $this->mParams
['checkstatus'] && ( $this->mParams
['filekey'] ||
$this->mParams
['url'] ) ) {
652 $statusKey = $this->mParams
['filekey'] ?
: UploadFromUrl
::getCacheKey( $this->mParams
);
653 $progress = UploadBase
::getSessionStatus( $this->getUser(), $statusKey );
655 $this->log
->info( "Cannot check upload status due to missing upload session for {user}",
657 'user' => $this->getUser()->getName(),
658 'filename' => $this->mParams
['filename'] ??
'-',
659 'filekey' => $this->mParams
['filekey'] ??
'-'
662 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
663 } elseif ( !$progress['status']->isGood() ) {
664 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
666 if ( isset( $progress['status']->value
['verification'] ) ) {
667 $this->checkVerification( $progress['status']->value
['verification'] );
669 if ( isset( $progress['status']->value
['warnings'] ) ) {
670 $warnings = $this->transformWarnings( $progress['status']->value
['warnings'] );
672 $progress['warnings'] = $warnings;
675 unset( $progress['status'] ); // remove Status object
677 if ( isset( $progress['imageinfo'] ) ) {
678 $imageinfo = $progress['imageinfo'];
679 unset( $progress['imageinfo'] );
682 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
683 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
684 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
686 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
692 // The following modules all require the filename parameter to be set
693 if ( $this->mParams
['filename'] === null ) {
694 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
697 if ( $this->mParams
['chunk'] ) {
699 $this->mUpload
= new UploadFromChunks( $this->getUser() );
700 if ( isset( $this->mParams
['filekey'] ) ) {
701 if ( $this->mParams
['offset'] === 0 ) {
702 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
706 $this->mUpload
->continueChunks(
707 $this->mParams
['filename'],
708 $this->mParams
['filekey'],
709 $this->getMain()->getUpload( 'chunk' )
712 if ( $this->mParams
['offset'] !== 0 ) {
713 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
716 // handle first chunk
717 $this->mUpload
->initialize(
718 $this->mParams
['filename'],
719 $this->getMain()->getUpload( 'chunk' )
722 } elseif ( isset( $this->mParams
['filekey'] ) ) {
723 // Upload stashed in a previous request
724 if ( !UploadFromStash
::isValidKey( $this->mParams
['filekey'] ) ) {
725 $this->dieWithError( 'apierror-invalid-file-key' );
728 $this->mUpload
= new UploadFromStash( $this->getUser() );
729 // This will not download the temp file in initialize() in async mode.
730 // We still have enough information to call checkWarnings() and such.
731 $this->mUpload
->initialize(
732 $this->mParams
['filekey'], $this->mParams
['filename'], !$this->mParams
['async']
734 } elseif ( isset( $this->mParams
['file'] ) ) {
735 // Can't async upload directly from a POSTed file, we'd have to
736 // stash the file and then queue the publish job. The user should
737 // just submit the two API queries to perform those two steps.
738 if ( $this->mParams
['async'] ) {
739 $this->dieWithError( 'apierror-cannot-async-upload-file' );
742 $this->mUpload
= new UploadFromFile();
743 $this->mUpload
->initialize(
744 $this->mParams
['filename'],
745 $this->getMain()->getUpload( 'file' )
747 } elseif ( isset( $this->mParams
['url'] ) ) {
748 // Make sure upload by URL is enabled:
749 if ( !UploadFromUrl
::isEnabled() ) {
750 $this->dieWithError( 'copyuploaddisabled' );
753 if ( !UploadFromUrl
::isAllowedHost( $this->mParams
['url'] ) ) {
754 $this->dieWithError( 'apierror-copyuploadbaddomain' );
757 if ( !UploadFromUrl
::isAllowedUrl( $this->mParams
['url'] ) ) {
758 $this->dieWithError( 'apierror-copyuploadbadurl' );
761 $this->mUpload
= new UploadFromUrl
;
762 $this->mUpload
->initialize( $this->mParams
['filename'],
763 $this->mParams
['url'] );
770 * Checks that the user has permissions to perform this upload.
771 * Dies with usage message on inadequate permissions.
772 * @param User $user The user to check.
774 protected function checkPermissions( $user ) {
775 // Check whether the user has the appropriate permissions to upload anyway
776 $permission = $this->mUpload
->isAllowed( $user );
778 if ( $permission !== true ) {
779 if ( !$user->isNamed() ) {
780 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
783 $this->dieStatus( User
::newFatalPermissionDeniedStatus( $permission ) );
787 if ( $user->isBlockedFromUpload() ) {
788 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
789 $this->dieBlocked( $user->getBlock() );
794 * Performs file verification, dies on error.
796 protected function verifyUpload() {
797 if ( $this->mParams
['chunk'] ) {
798 $maxSize = UploadBase
::getMaxUploadSize();
799 if ( $this->mParams
['filesize'] > $maxSize ) {
800 $this->dieWithError( 'file-too-large' );
802 if ( !$this->mUpload
->getTitle() ) {
803 $this->dieWithError( 'illegal-filename' );
805 // file will be assembled after having uploaded the last chunk,
806 // so we can only validate the name at this point
807 $verification = $this->mUpload
->validateName();
808 if ( $verification === true ) {
811 } elseif ( $this->mParams
['async'] && ( $this->mParams
['filekey'] ||
$this->mParams
['url'] ) ) {
812 // file will be assembled/downloaded in a background process, so we
813 // can only validate the name at this point
814 // file verification will happen in background process
815 $verification = $this->mUpload
->validateName();
816 if ( $verification === true ) {
820 wfDebug( __METHOD__
. " about to verify" );
822 $verification = $this->mUpload
->verifyUpload();
824 if ( $verification['status'] === UploadBase
::OK
) {
827 $this->log
->info( "File verification of {filename} failed for {user} because {result}",
829 'user' => $this->getUser()->getName(),
830 'resultCode' => $verification['status'],
831 'result' => $this->mUpload
->getVerificationErrorCode( $verification['status'] ),
832 'filename' => $this->mParams
['filename'] ??
'-',
833 'details' => $verification['details'] ??
''
839 $this->checkVerification( $verification );
843 * Performs file verification, dies on error.
844 * @param array $verification
847 protected function checkVerification( array $verification ) {
848 switch ( $verification['status'] ) {
849 // Recoverable errors
850 case UploadBase
::MIN_LENGTH_PARTNAME
:
851 $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
852 // dieRecoverableError prevents continuation
853 case UploadBase
::ILLEGAL_FILENAME
:
854 $this->dieRecoverableError(
855 [ ApiMessage
::create(
856 'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
859 // dieRecoverableError prevents continuation
860 case UploadBase
::FILENAME_TOO_LONG
:
861 $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
862 // dieRecoverableError prevents continuation
863 case UploadBase
::FILETYPE_MISSING
:
864 $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
865 // dieRecoverableError prevents continuation
866 case UploadBase
::WINDOWS_NONASCII_FILENAME
:
867 $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
869 // Unrecoverable errors
870 case UploadBase
::EMPTY_FILE
:
871 $this->dieWithError( 'empty-file' );
872 // dieWithError prevents continuation
873 case UploadBase
::FILE_TOO_LARGE
:
874 $this->dieWithError( 'file-too-large' );
875 // dieWithError prevents continuation
877 case UploadBase
::FILETYPE_BADTYPE
:
879 'filetype' => $verification['finalExt'],
880 'allowed' => array_values( array_unique(
881 $this->getConfig()->get( MainConfigNames
::FileExtensions
) ) )
884 array_unique( $this->getConfig()->get( MainConfigNames
::FileExtensions
) );
886 'filetype-banned-type',
887 null, // filled in below
888 Message
::listParam( $extensions, 'comma' ),
889 count( $extensions ),
890 null, // filled in below
892 ApiResult
::setIndexedTagName( $extradata['allowed'], 'ext' );
894 if ( isset( $verification['blacklistedExt'] ) ) {
895 $msg[1] = Message
::listParam( $verification['blacklistedExt'], 'comma' );
896 $msg[4] = count( $verification['blacklistedExt'] );
897 $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
898 ApiResult
::setIndexedTagName( $extradata['blacklisted'], 'ext' );
900 $msg[1] = $verification['finalExt'];
904 $this->dieWithError( $msg, 'filetype-banned', $extradata );
905 // dieWithError prevents continuation
907 case UploadBase
::VERIFICATION_ERROR
:
908 $msg = ApiMessage
::create( $verification['details'], 'verification-error' );
909 if ( $verification['details'][0] instanceof MessageSpecifier
) {
910 $details = [ $msg->getKey(), ...$msg->getParams() ];
912 $details = $verification['details'];
914 ApiResult
::setIndexedTagName( $details, 'detail' );
915 $msg->setApiData( $msg->getApiData() +
[ 'details' => $details ] );
916 $this->dieWithError( $msg );
917 // dieWithError prevents continuation
919 case UploadBase
::HOOK_ABORTED
:
920 $msg = $verification['error'] === '' ?
'hookaborted' : $verification['error'];
921 $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] );
922 // dieWithError prevents continuation
924 $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
925 [ 'details' => [ 'code' => $verification['status'] ] ] );
931 * Returns a suitable array for inclusion into API results if there were warnings
932 * Returns the empty array if there were no warnings
936 protected function getApiWarnings() {
937 $warnings = UploadBase
::makeWarningsSerializable(
938 $this->mUpload
->checkWarnings( $this->getUser() )
941 return $this->transformWarnings( $warnings );
944 protected function transformWarnings( $warnings ) {
947 ApiResult
::setIndexedTagName( $warnings, 'warning' );
949 if ( isset( $warnings['duplicate'] ) ) {
950 $dupes = array_column( $warnings['duplicate'], 'fileName' );
951 ApiResult
::setIndexedTagName( $dupes, 'duplicate' );
952 $warnings['duplicate'] = $dupes;
955 if ( isset( $warnings['exists'] ) ) {
956 $warning = $warnings['exists'];
957 unset( $warnings['exists'] );
958 $localFile = $warning['normalizedFile'] ??
$warning['file'];
959 $warnings[$warning['warning']] = $localFile['fileName'];
962 if ( isset( $warnings['no-change'] ) ) {
963 $file = $warnings['no-change'];
964 unset( $warnings['no-change'] );
966 $warnings['nochange'] = [
967 'timestamp' => wfTimestamp( TS_ISO_8601
, $file['timestamp'] )
971 if ( isset( $warnings['duplicate-version'] ) ) {
973 foreach ( $warnings['duplicate-version'] as $dupe ) {
975 'timestamp' => wfTimestamp( TS_ISO_8601
, $dupe['timestamp'] )
978 unset( $warnings['duplicate-version'] );
980 ApiResult
::setIndexedTagName( $dupes, 'ver' );
981 $warnings['duplicateversions'] = $dupes;
983 // We haven't downloaded the file, so this will result in an empty file warning
984 if ( $this->mParams
['async'] && $this->mParams
['url'] ) {
985 unset( $warnings['empty-file'] );
993 * Handles a stash exception, giving a useful error to the user.
994 * @todo Internationalize the exceptions then get rid of this
995 * @param Exception $e
996 * @return StatusValue
998 protected function handleStashException( $e ) {
999 $this->log
->info( "Upload stashing of {filename} failed for {user} because {error}",
1001 'user' => $this->getUser()->getName(),
1002 'error' => get_class( $e ),
1003 'filename' => $this->mParams
['filename'] ??
'-',
1004 'filekey' => $this->mParams
['filekey'] ??
'-'
1008 switch ( get_class( $e ) ) {
1009 case UploadStashFileNotFoundException
::class:
1010 $wrap = 'apierror-stashedfilenotfound';
1012 case UploadStashBadPathException
::class:
1013 $wrap = 'apierror-stashpathinvalid';
1015 case UploadStashFileException
::class:
1016 $wrap = 'apierror-stashfilestorage';
1018 case UploadStashZeroLengthFileException
::class:
1019 $wrap = 'apierror-stashzerolength';
1021 case UploadStashNotLoggedInException
::class:
1022 return StatusValue
::newFatal( ApiMessage
::create(
1023 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
1025 case UploadStashWrongOwnerException
::class:
1026 $wrap = 'apierror-stashwrongowner';
1028 case UploadStashNoSuchKeyException
::class:
1029 $wrap = 'apierror-stashnosuchfilekey';
1032 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
1035 return StatusValue
::newFatal(
1036 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1041 * Perform the actual upload. Returns a suitable result array on success;
1044 * @param array $warnings Array of Api upload warnings
1047 protected function performUpload( $warnings ) {
1048 // Use comment as initial page text by default
1049 $this->mParams
['text'] ??
= $this->mParams
['comment'];
1051 /** @var LocalFile $file */
1052 $file = $this->mUpload
->getLocalFile();
1053 $user = $this->getUser();
1054 $title = $file->getTitle();
1056 // for preferences mode, we want to watch if 'watchdefault' is set,
1057 // or if the *file* doesn't exist, and either 'watchuploads' or
1058 // 'watchcreations' is set. But getWatchlistValue()'s automatic
1059 // handling checks if the *title* exists or not, so we need to check
1060 // all three preferences manually.
1061 $watch = $this->getWatchlistValue(
1062 $this->mParams
['watchlist'], $title, $user, 'watchdefault'
1065 if ( !$watch && $this->mParams
['watchlist'] == 'preferences' && !$file->exists() ) {
1067 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
1068 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
1071 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams
);
1073 // Deprecated parameters
1074 if ( $this->mParams
['watch'] ) {
1078 if ( $this->mParams
['tags'] ) {
1079 $status = ChangeTags
::canAddTagsAccompanyingChange( $this->mParams
['tags'], $this->getAuthority() );
1080 if ( !$status->isOK() ) {
1081 $this->dieStatus( $status );
1085 // No errors, no warnings: do the upload
1087 if ( $this->mParams
['async'] ) {
1088 // Only stash uploads and copy uploads support async
1089 if ( $this->mParams
['filekey'] ) {
1090 $job = new PublishStashedFileJob(
1092 'filename' => $this->mParams
['filename'],
1093 'filekey' => $this->mParams
['filekey'],
1094 'comment' => $this->mParams
['comment'],
1095 'tags' => $this->mParams
['tags'] ??
[],
1096 'text' => $this->mParams
['text'],
1098 'watchlistexpiry' => $watchlistExpiry,
1099 'session' => $this->getContext()->exportSession(),
1100 'ignorewarnings' => $this->mParams
['ignorewarnings']
1103 } elseif ( $this->mParams
['url'] ) {
1104 $job = new UploadFromUrlJob(
1106 'filename' => $this->mParams
['filename'],
1107 'url' => $this->mParams
['url'],
1108 'comment' => $this->mParams
['comment'],
1109 'tags' => $this->mParams
['tags'] ??
[],
1110 'text' => $this->mParams
['text'],
1112 'watchlistexpiry' => $watchlistExpiry,
1113 'session' => $this->getContext()->exportSession(),
1114 'ignorewarnings' => $this->mParams
['ignorewarnings']
1118 $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1119 // We will never reach this, but it's here to help phan figure out
1120 // $job is never null
1121 // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1124 $cacheKey = $job->getCacheKey();
1125 // Check if an upload is already in progress.
1126 // the result can be Poll / Failure / Success
1127 $progress = UploadBase
::getSessionStatus( $this->getUser(), $cacheKey );
1128 if ( $progress && $progress['result'] === 'Poll' ) {
1129 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1131 UploadBase
::setSessionStatus(
1134 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status
::newGood() ]
1137 $this->jobQueueGroup
->push( $job );
1138 $this->log
->info( "Sending publish job of {filename} for {user}",
1140 'user' => $this->getUser()->getName(),
1141 'filename' => $this->mParams
['filename'] ??
'-'
1144 $result['result'] = 'Poll';
1145 $result['stage'] = 'queued';
1147 /** @var Status $status */
1148 $status = $this->mUpload
->performUpload(
1149 $this->mParams
['comment'],
1150 $this->mParams
['text'],
1153 $this->mParams
['tags'] ??
[],
1157 if ( !$status->isGood() ) {
1158 $this->log
->info( "Non-async API upload publish failed for {user} because {status}",
1160 'user' => $this->getUser()->getName(),
1161 'filename' => $this->mParams
['filename'] ??
'-',
1162 'filekey' => $this->mParams
['filekey'] ??
'-',
1163 'status' => (string)$status
1166 $this->dieRecoverableError( $status->getMessages() );
1168 $result['result'] = 'Success';
1171 $result['filename'] = $file->getName();
1172 if ( $warnings && count( $warnings ) > 0 ) {
1173 $result['warnings'] = $warnings;
1179 public function mustBePosted() {
1183 public function isWriteMode() {
1187 public function getAllowedParams() {
1190 ParamValidator
::PARAM_TYPE
=> 'string',
1193 ParamValidator
::PARAM_DEFAULT
=> ''
1196 ParamValidator
::PARAM_TYPE
=> 'tags',
1197 ParamValidator
::PARAM_ISMULTI
=> true,
1200 ParamValidator
::PARAM_TYPE
=> 'text',
1203 ParamValidator
::PARAM_DEFAULT
=> false,
1204 ParamValidator
::PARAM_DEPRECATED
=> true,
1208 // Params appear in the docs in the order they are defined,
1209 // which is why this is here and not at the bottom.
1210 $params +
= $this->getWatchlistParams( [
1217 'ignorewarnings' => false,
1219 ParamValidator
::PARAM_TYPE
=> 'upload',
1224 ParamValidator
::PARAM_DEPRECATED
=> true,
1229 ParamValidator
::PARAM_TYPE
=> 'integer',
1230 IntegerDef
::PARAM_MIN
=> 0,
1231 IntegerDef
::PARAM_MAX
=> UploadBase
::getMaxUploadSize(),
1234 ParamValidator
::PARAM_TYPE
=> 'integer',
1235 IntegerDef
::PARAM_MIN
=> 0,
1238 ParamValidator
::PARAM_TYPE
=> 'upload',
1242 'checkstatus' => false,
1248 public function needsToken() {
1252 protected function getExamplesMessages() {
1254 'action=upload&filename=Wiki.png' .
1255 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1256 => 'apihelp-upload-example-url',
1257 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1258 => 'apihelp-upload-example-filekey',
1262 public function getHelpUrls() {
1263 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1267 /** @deprecated class alias since 1.43 */
1268 class_alias( ApiUpload
::class, 'ApiUpload' );