MediaWiki master
ApiUpload.php
Go to the documentation of this file.
1<?php
28namespace MediaWiki\Api;
29
31use ChangeTags;
32use Exception;
43use Psr\Log\LoggerInterface;
45use StatusValue;
46use UploadBase;
63
67class ApiUpload extends ApiBase {
68
70
72 protected $mUpload = null;
73
75 protected $mParams;
76
77 private JobQueueGroup $jobQueueGroup;
78
79 private LoggerInterface $log;
80
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;
90
91 // Variables needed in ApiWatchlistTrait trait
92 $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
93 $this->watchlistMaxDuration =
95 $this->watchlistManager = $watchlistManager;
96 $this->userOptionsLookup = $userOptionsLookup;
97 $this->log = LoggerFactory::getInstance( 'upload' );
98 }
99
100 public function execute() {
101 // Check whether upload is enabled
102 if ( !UploadBase::isEnabled() ) {
103 $this->dieWithError( 'uploaddisabled' );
104 }
105
106 $user = $this->getUser();
107
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'] &&
113
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'];
117 }
118
119 if ( !$this->mParams['checkstatus'] ) {
121 }
122
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 ( !isset( $this->mUpload ) ) {
128 $this->dieDebug( __METHOD__, 'No upload module set' );
129 }
130 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
131 $this->dieStatus( $this->handleStashException( $e ) );
132 }
133
134 // First check permission to upload
135 $this->checkPermissions( $user );
136
137 // Fetch the file (usually a no-op)
138 // Skip for async upload from URL, where we just want to run checks.
140 if ( $this->mParams['async'] && $this->mParams['url'] ) {
141 $status = $this->mUpload->canFetchFile();
142 } else {
143 $status = $this->mUpload->fetchFile();
144 }
145
146 if ( !$status->isGood() ) {
147 $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
148 [
149 'user' => $this->getUser()->getName(),
150 'status' => (string)$status,
151 'filename' => $this->mParams['filename'] ?? '-',
152 ]
153 );
154 $this->dieStatus( $status );
155 }
156
157 // Check the uploaded file
158 $this->verifyUpload();
159
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' );
167 }
168 }
169
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 ) );
175 }
176 $this->getResult()->addValue( null, $this->getModuleName(), $result );
177
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 );
184 }
185
186 // Cleanup any temporary mess
187 $this->mUpload->cleanupTempFile();
188 }
189
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()
199 );
200
201 return $apiUpload;
202 }
203
218 public function getUploadImageInfo( UploadBase $upload ): array {
219 $result = $this->getResult();
220 $stashFile = $upload->getStashFile();
221
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 ) {
227 $stashFile,
228 array_fill_keys( $imParam, true ),
229 $result
230 );
231 } else {
232 $localFile = $upload->getLocalFile();
235 $localFile,
236 array_fill_keys( $imParam, true ),
237 $result
238 );
239 }
240
241 return $info;
242 }
243
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 );
259 }
260
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 );
264 }
265
271 private function getStashResult( $warnings ) {
272 $result = [];
273 $result['result'] = 'Success';
274 if ( $warnings && count( $warnings ) > 0 ) {
275 $result['warnings'] = $warnings;
276 }
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 );
280
281 return $result;
282 }
283
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 );
296
297 return $result;
298 }
299
306 public static function getMinUploadChunkSize( Config $config ) {
307 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
308
309 // Leave some room for other POST parameters
310 $postMax = (
312 ini_get( 'post_max_size' ),
313 PHP_INT_MAX
314 ) ?: PHP_INT_MAX
315 ) - 1024;
316
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
324 );
325 }
326
332 private function getChunkResult( $warnings ) {
333 $result = [];
334
335 if ( $warnings && count( $warnings ) > 0 ) {
336 $result['warnings'] = $warnings;
337 }
338
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() );
344
345 // Double check sizing
346 if ( $totalSoFar > $this->mParams['filesize'] ) {
347 $this->dieWithError( 'apierror-invalid-chunk' );
348 }
349
350 // Enforce minimum chunk size
351 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
352 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
353 }
354
355 if ( $this->mParams['offset'] == 0 ) {
356 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
357 [
358 'user' => $this->getUser()->getName(),
359 'filename' => $this->mParams['filename'] ?? '-',
360 'filesize' => $this->mParams['filesize'],
361 'chunkSize' => $chunkSize
362 ]
363 );
364 $filekey = $this->performStash( 'critical' );
365 } else {
366 $filekey = $this->mParams['filekey'];
367
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}",
373 [
374 'user' => $this->getUser()->getName(),
375 'filename' => $this->mParams['filename'] ?? '-',
376 'filekey' => $this->mParams['filekey'] ?? '-',
377 'filesize' => $this->mParams['filesize'],
378 'chunkSize' => $chunkSize
379 ]
380 );
381 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
382 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
383 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
384 }
385
386 $status = $this->mUpload->addChunk(
387 $chunkPath, $chunkSize, $this->mParams['offset'] );
388 if ( !$status->isGood() ) {
389 $extradata = [
390 'offset' => $this->mUpload->getOffset(),
391 ];
392 $this->log->info( "Chunked upload stash failure {status} for {user}",
393 [
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()
401 ]
402 );
403 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
404 } else {
405 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
406 [
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()
413 ]
414 );
415 }
416 }
417
418 // Check we added the last chunk:
419 if ( $totalSoFar == $this->mParams['filesize'] ) {
420 if ( $this->mParams['async'] ) {
422 $this->getUser(),
423 $filekey,
424 [ 'result' => 'Poll',
425 'stage' => 'queued', 'status' => Status::newGood() ]
426 );
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",
438 [
439 'user' => $this->getUser()->getName(),
440 'filename' => $this->mParams['filename'] ?? '-',
441 'filekey' => $this->mParams['filekey'] ?? '-',
442 'filesize' => $this->mParams['filesize'],
443 'chunkSize' => $chunkSize,
444 ]
445 );
446 $result['result'] = 'Poll';
447 $result['stage'] = 'queued';
448 } else {
449 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
450 [
451 'user' => $this->getUser()->getName(),
452 'filename' => $this->mParams['filename'] ?? '-',
453 'filekey' => $this->mParams['filekey'] ?? '-',
454 'filesize' => $this->mParams['filesize'],
455 'chunkSize' => $chunkSize,
456 ]
457 );
458
459 $status = $this->mUpload->concatenateChunks();
460 if ( !$status->isGood() ) {
462 $this->getUser(),
463 $filekey,
464 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
465 );
466 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
467 [
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
474 ]
475 );
476 $this->dieStatusWithCode( $status, 'stashfailed' );
477 }
478
479 // We can only get warnings like 'duplicate' after concatenating the chunks
480 $warnings = $this->getApiWarnings();
481 if ( $warnings ) {
482 $result['warnings'] = $warnings;
483 }
484
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();
490
491 $result['result'] = 'Success';
492 }
493 } else {
495 $this->getUser(),
496 $filekey,
497 [
498 'result' => 'Continue',
499 'stage' => 'uploading',
500 'offset' => $totalSoFar,
501 'status' => Status::newGood(),
502 ]
503 );
504 $result['result'] = 'Continue';
505 $result['offset'] = $totalSoFar;
506 }
507
508 $result['filekey'] = $filekey;
509
510 return $result;
511 }
512
525 private function performStash( $failureMode, &$data = null ) {
526 $isPartial = (bool)$this->mParams['chunk'];
527 try {
528 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
529
530 if ( $status->isGood() && !$status->getValue() ) {
531 // Not actually a 'good' status...
532 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
533 }
534 } catch ( Exception $e ) {
535 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
536 $this->log->info( $debugMessage,
537 [
538 'user' => $this->getUser()->getName(),
539 'filename' => $this->mParams['filename'] ?? '-',
540 'filekey' => $this->mParams['filekey'] ?? '-'
541 ]
542 );
543
544 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
545 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
546 ) );
547 }
548
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'];
555 }
556
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,
563 [
564 'user' => $this->getUser()->getName(),
565 'filename' => $this->mParams['filename'] ?? '-',
566 'filekey' => $this->mParams['filekey'] ?? '-'
567 ]
568 );
569 }
570
571 $this->log->info( "Stash upload failure {status}",
572 [
573 'status' => (string)$status,
574 'user' => $this->getUser()->getName(),
575 'filename' => $this->mParams['filename'] ?? '-',
576 'filekey' => $this->mParams['filekey'] ?? '-'
577 ]
578 );
579 // Bad status
580 if ( $failureMode !== 'optional' ) {
581 $this->dieStatus( $status );
582 } else {
583 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
584 return null;
585 }
586 }
587
598 private function dieRecoverableError( $errors, $parameter = null ) {
599 $this->performStash( 'optional', $data );
600
601 if ( $parameter ) {
602 $data['invalidparameter'] = $parameter;
603 }
604
605 $sv = StatusValue::newGood();
606 foreach ( $errors as $error ) {
607 $msg = ApiMessage::create( $error );
608 $msg->setApiData( $msg->getApiData() + $data );
609 $sv->fatal( $msg );
610 }
611 $this->dieStatus( $sv );
612 }
613
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 );
630 }
631 $sv->fatal( $msg );
632 }
633 $this->dieStatus( $sv );
634 }
635
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' );
648 }
649
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}",
656 [
657 'user' => $this->getUser()->getName(),
658 'filename' => $this->mParams['filename'] ?? '-',
659 'filekey' => $this->mParams['filekey'] ?? '-'
660 ]
661 );
662 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
663 } elseif ( !$progress['status']->isGood() ) {
664 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
665 }
666 if ( isset( $progress['status']->value['verification'] ) ) {
667 $this->checkVerification( $progress['status']->value['verification'] );
668 }
669 if ( isset( $progress['status']->value['warnings'] ) ) {
670 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
671 if ( $warnings ) {
672 $progress['warnings'] = $warnings;
673 }
674 }
675 unset( $progress['status'] ); // remove Status object
676 $imageinfo = null;
677 if ( isset( $progress['imageinfo'] ) ) {
678 $imageinfo = $progress['imageinfo'];
679 unset( $progress['imageinfo'] );
680 }
681
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 );
687 }
688
689 return false;
690 }
691
692 // The following modules all require the filename parameter to be set
693 if ( $this->mParams['filename'] === null ) {
694 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
695 }
696
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' );
703 }
704
705 // handle new chunk
706 $this->mUpload->continueChunks(
707 $this->mParams['filename'],
708 $this->mParams['filekey'],
709 $this->getMain()->getUpload( 'chunk' )
710 );
711 } else {
712 if ( $this->mParams['offset'] !== 0 ) {
713 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
714 }
715
716 // handle first chunk
717 $this->mUpload->initialize(
718 $this->mParams['filename'],
719 $this->getMain()->getUpload( 'chunk' )
720 );
721 }
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' );
726 }
727
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']
733 );
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' );
740 }
741
742 $this->mUpload = new UploadFromFile();
743 $this->mUpload->initialize(
744 $this->mParams['filename'],
745 $this->getMain()->getUpload( 'file' )
746 );
747 } elseif ( isset( $this->mParams['url'] ) ) {
748 // Make sure upload by URL is enabled:
749 if ( !UploadFromUrl::isEnabled() ) {
750 $this->dieWithError( 'copyuploaddisabled' );
751 }
752
753 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
754 $this->dieWithError( 'apierror-copyuploadbaddomain' );
755 }
756
757 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
758 $this->dieWithError( 'apierror-copyuploadbadurl' );
759 }
760
761 $this->mUpload = new UploadFromUrl;
762 $this->mUpload->initialize( $this->mParams['filename'],
763 $this->mParams['url'] );
764 }
765
766 return true;
767 }
768
774 protected function checkPermissions( $user ) {
775 // Check whether the user has the appropriate permissions to upload anyway
776 $permission = $this->mUpload->isAllowed( $user );
777
778 if ( $permission !== true ) {
779 if ( !$user->isNamed() ) {
780 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
781 }
782
783 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
784 }
785
786 // Check blocks
787 if ( $user->isBlockedFromUpload() ) {
788 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
789 $this->dieBlocked( $user->getBlock() );
790 }
791 }
792
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' );
801 }
802 if ( !$this->mUpload->getTitle() ) {
803 $this->dieWithError( 'illegal-filename' );
804 }
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;
810 }
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;
818 }
819 } else {
820 wfDebug( __METHOD__ . " about to verify" );
821
822 $verification = $this->mUpload->verifyUpload();
823
824 if ( $verification['status'] === UploadBase::OK ) {
825 return;
826 } else {
827 $this->log->info( "File verification of {filename} failed for {user} because {result}",
828 [
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'] ?? ''
834 ]
835 );
836 }
837 }
838
839 $this->checkVerification( $verification );
840 }
841
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'
858 );
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' );
868
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
876
877 case UploadBase::FILETYPE_BADTYPE:
878 $extradata = [
879 'filetype' => $verification['finalExt'],
880 'allowed' => array_values( array_unique(
881 $this->getConfig()->get( MainConfigNames::FileExtensions ) ) )
882 ];
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
891 ];
892 ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
893
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;
902 }
903
904 $this->dieWithError( $msg, 'filetype-banned', $extradata );
905 // dieWithError prevents continuation
906
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'];
913 }
914 ApiResult::setIndexedTagName( $details, 'detail' );
915 $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
916 $this->dieWithError( $msg );
917 // dieWithError prevents continuation
918
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'] ] ] );
926 }
927 }
928
936 protected function getApiWarnings() {
937 $warnings = UploadBase::makeWarningsSerializable(
938 $this->mUpload->checkWarnings( $this->getUser() )
939 );
940
941 return $this->transformWarnings( $warnings );
942 }
943
944 protected function transformWarnings( $warnings ) {
945 if ( $warnings ) {
946 // Add indices
947 ApiResult::setIndexedTagName( $warnings, 'warning' );
948
949 if ( isset( $warnings['duplicate'] ) ) {
950 $dupes = array_column( $warnings['duplicate'], 'fileName' );
951 ApiResult::setIndexedTagName( $dupes, 'duplicate' );
952 $warnings['duplicate'] = $dupes;
953 }
954
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'];
960 }
961
962 if ( isset( $warnings['no-change'] ) ) {
963 $file = $warnings['no-change'];
964 unset( $warnings['no-change'] );
965
966 $warnings['nochange'] = [
967 'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] )
968 ];
969 }
970
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'] )
976 ];
977 }
978 unset( $warnings['duplicate-version'] );
979
980 ApiResult::setIndexedTagName( $dupes, 'ver' );
981 $warnings['duplicateversions'] = $dupes;
982 }
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'] );
986 }
987 }
988
989 return $warnings;
990 }
991
998 protected function handleStashException( $e ) {
999 $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
1000 [
1001 'user' => $this->getUser()->getName(),
1002 'error' => get_class( $e ),
1003 'filename' => $this->mParams['filename'] ?? '-',
1004 'filekey' => $this->mParams['filekey'] ?? '-'
1005 ]
1006 );
1007
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;
1034 }
1035 return StatusValue::newFatal(
1036 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1037 );
1038 }
1039
1047 protected function performUpload( $warnings ) {
1048 // Use comment as initial page text by default
1049 $this->mParams['text'] ??= $this->mParams['comment'];
1050
1052 $file = $this->mUpload->getLocalFile();
1053 $user = $this->getUser();
1054 $title = $file->getTitle();
1055
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'
1063 );
1064
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' )
1069 );
1070 }
1071 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams );
1072
1073 // Deprecated parameters
1074 if ( $this->mParams['watch'] ) {
1075 $watch = true;
1076 }
1077
1078 if ( $this->mParams['tags'] ) {
1079 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1080 if ( !$status->isOK() ) {
1081 $this->dieStatus( $status );
1082 }
1083 }
1084
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'] ) {
1091 [
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']
1101 ]
1102 );
1103 } elseif ( $this->mParams['url'] ) {
1104 $job = new UploadFromUrlJob(
1105 [
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']
1115 ]
1116 );
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 [];
1123 }
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' );
1130 }
1131 UploadBase::setSessionStatus(
1132 $this->getUser(),
1133 $cacheKey,
1134 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1135 );
1136
1137 $this->jobQueueGroup->push( $job );
1138 $this->log->info( "Sending publish job of {filename} for {user}",
1139 [
1140 'user' => $this->getUser()->getName(),
1141 'filename' => $this->mParams['filename'] ?? '-'
1142 ]
1143 );
1144 $result['result'] = 'Poll';
1145 $result['stage'] = 'queued';
1146 } else {
1148 $status = $this->mUpload->performUpload(
1149 $this->mParams['comment'],
1150 $this->mParams['text'],
1151 $watch,
1152 $this->getUser(),
1153 $this->mParams['tags'] ?? [],
1154 $watchlistExpiry
1155 );
1156
1157 if ( !$status->isGood() ) {
1158 $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1159 [
1160 'user' => $this->getUser()->getName(),
1161 'filename' => $this->mParams['filename'] ?? '-',
1162 'filekey' => $this->mParams['filekey'] ?? '-',
1163 'status' => (string)$status
1164 ]
1165 );
1166 $this->dieRecoverableError( $status->getMessages() );
1167 }
1168 $result['result'] = 'Success';
1169 }
1170
1171 $result['filename'] = $file->getName();
1172 if ( $warnings && count( $warnings ) > 0 ) {
1173 $result['warnings'] = $warnings;
1174 }
1175
1176 return $result;
1177 }
1178
1179 public function mustBePosted() {
1180 return true;
1181 }
1182
1183 public function isWriteMode() {
1184 return true;
1185 }
1186
1187 public function getAllowedParams() {
1188 $params = [
1189 'filename' => [
1190 ParamValidator::PARAM_TYPE => 'string',
1191 ],
1192 'comment' => [
1193 ParamValidator::PARAM_DEFAULT => ''
1194 ],
1195 'tags' => [
1196 ParamValidator::PARAM_TYPE => 'tags',
1197 ParamValidator::PARAM_ISMULTI => true,
1198 ],
1199 'text' => [
1200 ParamValidator::PARAM_TYPE => 'text',
1201 ],
1202 'watch' => [
1203 ParamValidator::PARAM_DEFAULT => false,
1204 ParamValidator::PARAM_DEPRECATED => true,
1205 ],
1206 ];
1207
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 ] );
1215
1216 $params += [
1217 'ignorewarnings' => false,
1218 'file' => [
1219 ParamValidator::PARAM_TYPE => 'upload',
1220 ],
1221 'url' => null,
1222 'filekey' => null,
1223 'sessionkey' => [
1224 ParamValidator::PARAM_DEPRECATED => true,
1225 ],
1226 'stash' => false,
1227
1228 'filesize' => [
1229 ParamValidator::PARAM_TYPE => 'integer',
1230 IntegerDef::PARAM_MIN => 0,
1231 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(),
1232 ],
1233 'offset' => [
1234 ParamValidator::PARAM_TYPE => 'integer',
1235 IntegerDef::PARAM_MIN => 0,
1236 ],
1237 'chunk' => [
1238 ParamValidator::PARAM_TYPE => 'upload',
1239 ],
1240
1241 'async' => false,
1242 'checkstatus' => false,
1243 ];
1244
1245 return $params;
1246 }
1247
1248 public function needsToken() {
1249 return 'csrf';
1250 }
1251
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',
1259 ];
1260 }
1261
1262 public function getHelpUrls() {
1263 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1264 }
1265}
1266
1268class_alias( ApiUpload::class, 'ApiUpload' );
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfShorthandToInteger(?string $string='', int $default=-1)
Converts shorthand byte notation to integer form.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
array $params
The job parameters.
getUpload()
Getter for the upload.
Assemble the segments of a chunked upload.
Recent changes tagging.
static canAddTagsAccompanyingChange(array $tags, ?Authority $performer=null, $checkBlock=true)
Is it OK to allow the user to apply all the specified tags at the same time as they edit/make the cha...
Handle enqueueing of background jobs.
get( $type)
Get the job queue object for a given queue type.
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:76
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1577
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:571
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition ApiBase.php:1425
getResult()
Get the result object.
Definition ApiBase.php:710
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition ApiBase.php:1820
dieStatus(StatusValue $status)
Throw an ApiUsageException based on the Status object.
Definition ApiBase.php:1632
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:851
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:78
static getPropertyNames( $filter=[])
Returns all possible parameters to iiprop.
static getInfo( $file, $prop, $result, $thumbParams=null, $opts=false)
Get result information for an image revision.
static getPropertyNames( $filter=null)
Returns all possible parameters to siiprop.
checkVerification(array $verification)
Performs file verification, dies on error.
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
UploadBase UploadFromChunks $mUpload
Definition ApiUpload.php:72
handleStashException( $e)
Handles a stash exception, giving a useful error to the user.
checkPermissions( $user)
Checks that the user has permissions to perform this upload.
selectUploadModule()
Select an upload module and set it to mUpload.
dieStatusWithCode( $status, $overrideCode, $moreExtraData=null)
Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from IApiMe...
__construct(ApiMain $mainModule, string $moduleName, JobQueueGroup $jobQueueGroup, WatchlistManager $watchlistManager, UserOptionsLookup $userOptionsLookup)
Definition ApiUpload.php:81
transformWarnings( $warnings)
static getMinUploadChunkSize(Config $config)
verifyUpload()
Performs file verification, dies on error.
execute()
Evaluates the parameters, performs the requested query, and sets up the result.
getUploadImageInfo(UploadBase $upload)
Gets image info about the file just uploaded.
getApiWarnings()
Check warnings.
needsToken()
Returns the token type this module requires in order to execute.
getExamplesMessages()
Returns usage examples for this module.
performUpload( $warnings)
Perform the actual upload.
getHelpUrls()
Return links to more detailed help pages about the module.
mustBePosted()
Indicates whether this module must be called with a POST request.
isWriteMode()
Indicates whether this module requires write access to the wiki.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const EnableAsyncUploads
Name constant for the EnableAsyncUploads setting, for use with Config::get()
const WatchlistExpiry
Name constant for the WatchlistExpiry setting, for use with Config::get()
const WatchlistExpiryMaxDuration
Name constant for the WatchlistExpiryMaxDuration setting, for use with Config::get()
Service locator for MediaWiki core services.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:155
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Provides access to user options.
internal since 1.36
Definition User.php:93
Upload a file from the upload stash into the local file repo.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newGood( $value=null)
Factory function for good results.
UploadBase and subclasses are the backend of MediaWiki's file uploads.
static setSessionStatus(UserIdentity $user, $statusKey, $value)
Set the current status of a chunked upload (used for polling).
getLocalFile()
Return the local file and initializes if necessary.
static getSessionStatus(UserIdentity $user, $statusKey)
Get the current status of a chunked upload (used for polling).
Implements uploading from chunks.
Implements regular file uploads.
Implements uploading from previously stored file.
Upload a file by URL, via the jobqueue.
Implements uploading from a HTTP resource.
initialize( $name, $url)
Entry point for API upload.
Service for formatting and validating API parameters.
Type definition for integer types.
trait ApiWatchlistTrait
An ApiWatchlistTrait adds class properties and convenience methods for APIs that allow you to watch a...
Interface for configuration instances.
Definition Config.php:32
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
getExpiryFromParams(array $params)
Get formatted expiry from the given parameters, or null if no expiry was provided.
getWatchlistValue(string $watchlist, PageIdentity $page, User $user, ?string $userOption=null)
Return true if we're to watch the page, false if not.
getWatchlistParams(array $watchOptions=[])
Get additional allow params specific to watchlisting.
if(count( $args)< 1) $job