Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / api / ApiUpload.php
blob4576d5afbaaaa3be96cc9c8360ff4a1a0165549a
1 <?php
2 /**
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
20 * @file
23 /**
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;
31 use ChangeTags;
32 use Exception;
33 use JobQueueGroup;
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;
45 use StatusValue;
46 use UploadBase;
47 use UploadFromChunks;
48 use UploadFromFile;
49 use UploadFromStash;
50 use UploadFromUrl;
51 use UploadFromUrlJob;
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;
64 /**
65 * @ingroup API
67 class ApiUpload extends ApiBase {
69 use ApiWatchlistTrait;
71 /** @var UploadBase|UploadFromChunks|null */
72 protected $mUpload = null;
74 /** @var array */
75 protected $mParams;
77 private JobQueueGroup $jobQueueGroup;
79 private LoggerInterface $log;
81 public function __construct(
82 ApiMain $mainModule,
83 string $moduleName,
84 JobQueueGroup $jobQueueGroup,
85 WatchlistManager $watchlistManager,
86 UserOptionsLookup $userOptionsLookup
87 ) {
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
124 try {
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();
142 } else {
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:
171 try {
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(
194 $apiMain,
195 'upload',
196 $services->getJobQueueGroup(),
197 $services->getWatchlistManager(),
198 $services->getUserOptionsLookup()
201 return $apiUpload;
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.
224 if ( $stashFile ) {
225 $imParam = ApiQueryStashImageInfo::getPropertyNames();
226 $info = ApiQueryStashImageInfo::getInfo(
227 $stashFile,
228 array_fill_keys( $imParam, true ),
229 $result
231 } else {
232 $localFile = $upload->getLocalFile();
233 $imParam = ApiQueryImageInfo::getPropertyNames();
234 $info = ApiQueryImageInfo::getInfo(
235 $localFile,
236 array_fill_keys( $imParam, true ),
237 $result
241 return $info;
245 * Get an upload result based on upload context
246 * @return array
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
269 * @return array
271 private function getStashResult( $warnings ) {
272 $result = [];
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 );
281 return $result;
285 * Get Warnings Result
286 * @param array $warnings Array of Api upload warnings
287 * @return array
289 private function getWarningsResult( $warnings ) {
290 $result = [];
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 );
297 return $result;
301 * @since 1.35
302 * @see $wgMinUploadChunkSize
303 * @param Config $config Site configuration for MinUploadChunkSize
304 * @return int
306 public static function getMinUploadChunkSize( Config $config ) {
307 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
309 // Leave some room for other POST parameters
310 $postMax = (
311 wfShorthandToInteger(
312 ini_get( 'post_max_size' ),
313 PHP_INT_MAX
314 ) ?: PHP_INT_MAX
315 ) - 1024;
317 // Ensure the minimum chunk size is less than PHP upload limits
318 // or the maximum upload size.
319 return min(
320 $configured,
321 UploadBase::getMaxUploadSize( 'file' ),
322 UploadBase::getMaxPhpUploadSize(),
323 $postMax
328 * Get the result of a chunk upload.
329 * @param array $warnings Array of Api upload warnings
330 * @return array
332 private function getChunkResult( $warnings ) {
333 $result = [];
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' );
365 } else {
366 $filekey = $this->mParams['filekey'];
368 // Don't allow further uploads to an already-completed session
369 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
370 if ( !$progress ) {
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() ) {
389 $extradata = [
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 );
404 } else {
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(
422 $this->getUser(),
423 $filekey,
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()
436 ] ) );
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';
448 } else {
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(
462 $this->getUser(),
463 $filekey,
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();
481 if ( $warnings ) {
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';
493 } else {
494 UploadBase::setSessionStatus(
495 $this->getUser(),
496 $filekey,
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;
510 return $result;
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'];
527 try {
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' ) ]
546 ) );
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'] ?? '-'
579 // Bad status
580 if ( $failureMode !== 'optional' ) {
581 $this->dieStatus( $status );
582 } else {
583 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
584 return null;
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
596 * @return never
598 private function dieRecoverableError( $errors, $parameter = null ) {
599 $this->performStash( 'optional', $data );
601 if ( $parameter ) {
602 $data['invalidparameter'] = $parameter;
605 $sv = StatusValue::newGood();
606 foreach ( $errors as $error ) {
607 $msg = ApiMessage::create( $error );
608 $msg->setApiData( $msg->getApiData() + $data );
609 $sv->fatal( $msg );
611 $this->dieStatus( $sv );
615 * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
616 * IApiMessage.
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
622 * @return never
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 );
631 $sv->fatal( $msg );
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;
639 * otherwise true
641 * @return bool
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 );
654 if ( !$progress ) {
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'] );
671 if ( $warnings ) {
672 $progress['warnings'] = $warnings;
675 unset( $progress['status'] ); // remove Status object
676 $imageinfo = null;
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).
685 if ( $imageinfo ) {
686 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
689 return false;
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'] ) {
698 // Chunk upload
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' );
705 // handle new chunk
706 $this->mUpload->continueChunks(
707 $this->mParams['filename'],
708 $this->mParams['filekey'],
709 $this->getMain()->getUpload( 'chunk' )
711 } else {
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'] );
766 return true;
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 ) );
786 // Check blocks
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 ) {
809 return;
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 ) {
817 return;
819 } else {
820 wfDebug( __METHOD__ . " about to verify" );
822 $verification = $this->mUpload->verifyUpload();
824 if ( $verification['status'] === UploadBase::OK ) {
825 return;
826 } else {
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
845 * @return never
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'] ]
857 ) ], 'filename'
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:
878 $extradata = [
879 'filetype' => $verification['finalExt'],
880 'allowed' => array_values( array_unique(
881 $this->getConfig()->get( MainConfigNames::FileExtensions ) ) )
883 $extensions =
884 array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
885 $msg = [
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' );
899 } else {
900 $msg[1] = $verification['finalExt'];
901 $msg[4] = 1;
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() ];
911 } else {
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
923 default:
924 $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
925 [ 'details' => [ 'code' => $verification['status'] ] ] );
930 * Check warnings.
931 * Returns a suitable array for inclusion into API results if there were warnings
932 * Returns the empty array if there were no warnings
934 * @return array
936 protected function getApiWarnings() {
937 $warnings = UploadBase::makeWarningsSerializable(
938 $this->mUpload->checkWarnings( $this->getUser() )
941 return $this->transformWarnings( $warnings );
944 protected function transformWarnings( $warnings ) {
945 if ( $warnings ) {
946 // Add indices
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'] ) ) {
972 $dupes = [];
973 foreach ( $warnings['duplicate-version'] as $dupe ) {
974 $dupes[] = [
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'] );
989 return $warnings;
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';
1011 break;
1012 case UploadStashBadPathException::class:
1013 $wrap = 'apierror-stashpathinvalid';
1014 break;
1015 case UploadStashFileException::class:
1016 $wrap = 'apierror-stashfilestorage';
1017 break;
1018 case UploadStashZeroLengthFileException::class:
1019 $wrap = 'apierror-stashzerolength';
1020 break;
1021 case UploadStashNotLoggedInException::class:
1022 return StatusValue::newFatal( ApiMessage::create(
1023 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
1024 ) );
1025 case UploadStashWrongOwnerException::class:
1026 $wrap = 'apierror-stashwrongowner';
1027 break;
1028 case UploadStashNoSuchKeyException::class:
1029 $wrap = 'apierror-stashnosuchfilekey';
1030 break;
1031 default:
1032 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
1033 break;
1035 return StatusValue::newFatal(
1036 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1041 * Perform the actual upload. Returns a suitable result array on success;
1042 * dies on failure.
1044 * @param array $warnings Array of Api upload warnings
1045 * @return array
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() ) {
1066 $watch = (
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'] ) {
1075 $watch = true;
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
1086 $result = [];
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'],
1097 'watch' => $watch,
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'],
1111 'watch' => $watch,
1112 'watchlistexpiry' => $watchlistExpiry,
1113 'session' => $this->getContext()->exportSession(),
1114 'ignorewarnings' => $this->mParams['ignorewarnings']
1117 } else {
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
1122 return [];
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(
1132 $this->getUser(),
1133 $cacheKey,
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';
1146 } else {
1147 /** @var Status $status */
1148 $status = $this->mUpload->performUpload(
1149 $this->mParams['comment'],
1150 $this->mParams['text'],
1151 $watch,
1152 $this->getUser(),
1153 $this->mParams['tags'] ?? [],
1154 $watchlistExpiry
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;
1176 return $result;
1179 public function mustBePosted() {
1180 return true;
1183 public function isWriteMode() {
1184 return true;
1187 public function getAllowedParams() {
1188 $params = [
1189 'filename' => [
1190 ParamValidator::PARAM_TYPE => 'string',
1192 'comment' => [
1193 ParamValidator::PARAM_DEFAULT => ''
1195 'tags' => [
1196 ParamValidator::PARAM_TYPE => 'tags',
1197 ParamValidator::PARAM_ISMULTI => true,
1199 'text' => [
1200 ParamValidator::PARAM_TYPE => 'text',
1202 'watch' => [
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( [
1211 'watch',
1212 'preferences',
1213 'nochange',
1214 ] );
1216 $params += [
1217 'ignorewarnings' => false,
1218 'file' => [
1219 ParamValidator::PARAM_TYPE => 'upload',
1221 'url' => null,
1222 'filekey' => null,
1223 'sessionkey' => [
1224 ParamValidator::PARAM_DEPRECATED => true,
1226 'stash' => false,
1228 'filesize' => [
1229 ParamValidator::PARAM_TYPE => 'integer',
1230 IntegerDef::PARAM_MIN => 0,
1231 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(),
1233 'offset' => [
1234 ParamValidator::PARAM_TYPE => 'integer',
1235 IntegerDef::PARAM_MIN => 0,
1237 'chunk' => [
1238 ParamValidator::PARAM_TYPE => 'upload',
1241 'async' => false,
1242 'checkstatus' => false,
1245 return $params;
1248 public function needsToken() {
1249 return 'csrf';
1252 protected function getExamplesMessages() {
1253 return [
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' );