MediaWiki master
ApiUpload.php
Go to the documentation of this file.
1<?php
36use Psr\Log\LoggerInterface;
39
43class ApiUpload extends ApiBase {
44
46
48 protected $mUpload = null;
49
50 protected $mParams;
51
52 private JobQueueGroup $jobQueueGroup;
53
54 private LoggerInterface $log;
55
63 public function __construct(
64 ApiMain $mainModule,
65 $moduleName,
66 JobQueueGroup $jobQueueGroup,
67 WatchlistManager $watchlistManager,
68 UserOptionsLookup $userOptionsLookup
69 ) {
70 parent::__construct( $mainModule, $moduleName );
71 $this->jobQueueGroup = $jobQueueGroup;
72
73 // Variables needed in ApiWatchlistTrait trait
74 $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
75 $this->watchlistMaxDuration =
76 $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration );
77 $this->watchlistManager = $watchlistManager;
78 $this->userOptionsLookup = $userOptionsLookup;
79 $this->log = LoggerFactory::getInstance( 'upload' );
80 }
81
82 public function execute() {
83 // Check whether upload is enabled
84 if ( !UploadBase::isEnabled() ) {
85 $this->dieWithError( 'uploaddisabled' );
86 }
87
88 $user = $this->getUser();
89
90 // Parameter handling
91 $this->mParams = $this->extractRequestParams();
92 $request = $this->getMain()->getRequest();
93 // Check if async mode is actually supported (jobs done in cli mode)
94 $this->mParams['async'] = ( $this->mParams['async'] &&
95 $this->getConfig()->get( MainConfigNames::EnableAsyncUploads ) );
96 // Add the uploaded file to the params array
97 $this->mParams['file'] = $request->getFileName( 'file' );
98 $this->mParams['chunk'] = $request->getFileName( 'chunk' );
99
100 // Copy the session key to the file key, for backward compatibility.
101 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
102 $this->mParams['filekey'] = $this->mParams['sessionkey'];
103 }
104
105 if ( !$this->mParams['checkstatus'] ) {
107 }
108
109 // Select an upload module
110 try {
111 if ( !$this->selectUploadModule() ) {
112 return; // not a true upload, but a status request or similar
113 } elseif ( !isset( $this->mUpload ) ) {
114 $this->dieDebug( __METHOD__, 'No upload module set' );
115 }
116 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
117 $this->dieStatus( $this->handleStashException( $e ) );
118 }
119
120 // First check permission to upload
121 $this->checkPermissions( $user );
122
123 // Fetch the file (usually a no-op)
124 // Skip for async upload from URL, where we just want to run checks.
126 if ( $this->mParams['async'] && $this->mParams['url'] ) {
127 $status = $this->mUpload->canFetchFile();
128 } else {
129 $status = $this->mUpload->fetchFile();
130 }
131
132 if ( !$status->isGood() ) {
133 $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
134 [
135 'user' => $this->getUser()->getName(),
136 'status' => (string)$status,
137 'filename' => $this->mParams['filename'] ?? '-',
138 ]
139 );
140 $this->dieStatus( $status );
141 }
142
143 // Check the uploaded file
144 $this->verifyUpload();
145
146 // Check if the user has the rights to modify or overwrite the requested title
147 // (This check is irrelevant if stashing is already requested, since the errors
148 // can always be fixed by changing the title)
149 if ( !$this->mParams['stash'] ) {
150 $permErrors = $this->mUpload->verifyTitlePermissions( $user );
151 if ( $permErrors !== true ) {
152 $this->dieRecoverableError( $permErrors, 'filename' );
153 }
154 }
155
156 // Get the result based on the current upload context:
157 try {
158 $result = $this->getContextResult();
159 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
160 $this->dieStatus( $this->handleStashException( $e ) );
161 }
162 $this->getResult()->addValue( null, $this->getModuleName(), $result );
163
164 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
165 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
166 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
167 if ( $result['result'] === 'Success' ) {
168 $imageinfo = $this->getUploadImageInfo( $this->mUpload );
169 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
170 }
171
172 // Cleanup any temporary mess
173 $this->mUpload->cleanupTempFile();
174 }
175
176 public static function getDummyInstance(): self {
177 $services = MediaWikiServices::getInstance();
178 $apiMain = new ApiMain(); // dummy object (XXX)
179 $apiUpload = new ApiUpload(
180 $apiMain,
181 'upload',
182 $services->getJobQueueGroup(),
183 $services->getWatchlistManager(),
184 $services->getUserOptionsLookup()
185 );
186
187 return $apiUpload;
188 }
189
204 public function getUploadImageInfo( UploadBase $upload ): array {
205 $result = $this->getResult();
206 $stashFile = $upload->getStashFile();
207
208 // Calling a different API module depending on whether the file was stashed is less than optimal.
209 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
210 if ( $stashFile ) {
213 $stashFile,
214 array_fill_keys( $imParam, true ),
215 $result
216 );
217 } else {
218 $localFile = $upload->getLocalFile();
221 $localFile,
222 array_fill_keys( $imParam, true ),
223 $result
224 );
225 }
226
227 return $info;
228 }
229
234 private function getContextResult() {
235 $warnings = $this->getApiWarnings();
236 if ( $warnings && !$this->mParams['ignorewarnings'] ) {
237 // Get warnings formatted in result array format
238 return $this->getWarningsResult( $warnings );
239 } elseif ( $this->mParams['chunk'] ) {
240 // Add chunk, and get result
241 return $this->getChunkResult( $warnings );
242 } elseif ( $this->mParams['stash'] ) {
243 // Stash the file and get stash result
244 return $this->getStashResult( $warnings );
245 }
246
247 // This is the most common case -- a normal upload with no warnings
248 // performUpload will return a formatted properly for the API with status
249 return $this->performUpload( $warnings );
250 }
251
257 private function getStashResult( $warnings ) {
258 $result = [];
259 $result['result'] = 'Success';
260 if ( $warnings && count( $warnings ) > 0 ) {
261 $result['warnings'] = $warnings;
262 }
263 // Some uploads can request they be stashed, so as not to publish them immediately.
264 // In this case, a failure to stash ought to be fatal
265 $this->performStash( 'critical', $result );
266
267 return $result;
268 }
269
275 private function getWarningsResult( $warnings ) {
276 $result = [];
277 $result['result'] = 'Warning';
278 $result['warnings'] = $warnings;
279 // in case the warnings can be fixed with some further user action, let's stash this upload
280 // and return a key they can use to restart it
281 $this->performStash( 'optional', $result );
282
283 return $result;
284 }
285
292 public static function getMinUploadChunkSize( Config $config ) {
293 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
294
295 // Leave some room for other POST parameters
296 $postMax = (
298 ini_get( 'post_max_size' ),
299 PHP_INT_MAX
300 ) ?: PHP_INT_MAX
301 ) - 1024;
302
303 // Ensure the minimum chunk size is less than PHP upload limits
304 // or the maximum upload size.
305 return min(
306 $configured,
307 UploadBase::getMaxUploadSize( 'file' ),
308 UploadBase::getMaxPhpUploadSize(),
309 $postMax
310 );
311 }
312
318 private function getChunkResult( $warnings ) {
319 $result = [];
320
321 if ( $warnings && count( $warnings ) > 0 ) {
322 $result['warnings'] = $warnings;
323 }
324
325 $request = $this->getMain()->getRequest();
326 $chunkPath = $request->getFileTempname( 'chunk' );
327 $chunkSize = $request->getUpload( 'chunk' )->getSize();
328 $totalSoFar = $this->mParams['offset'] + $chunkSize;
329 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
330
331 // Double check sizing
332 if ( $totalSoFar > $this->mParams['filesize'] ) {
333 $this->dieWithError( 'apierror-invalid-chunk' );
334 }
335
336 // Enforce minimum chunk size
337 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
338 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
339 }
340
341 if ( $this->mParams['offset'] == 0 ) {
342 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
343 [
344 'user' => $this->getUser()->getName(),
345 'filename' => $this->mParams['filename'] ?? '-',
346 'filesize' => $this->mParams['filesize'],
347 'chunkSize' => $chunkSize
348 ]
349 );
350 $filekey = $this->performStash( 'critical' );
351 } else {
352 $filekey = $this->mParams['filekey'];
353
354 // Don't allow further uploads to an already-completed session
355 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
356 if ( !$progress ) {
357 // Probably can't get here, but check anyway just in case
358 $this->log->info( "Stash failed due to no session for {user}",
359 [
360 'user' => $this->getUser()->getName(),
361 'filename' => $this->mParams['filename'] ?? '-',
362 'filekey' => $this->mParams['filekey'] ?? '-',
363 'filesize' => $this->mParams['filesize'],
364 'chunkSize' => $chunkSize
365 ]
366 );
367 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
368 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
369 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
370 }
371
372 $status = $this->mUpload->addChunk(
373 $chunkPath, $chunkSize, $this->mParams['offset'] );
374 if ( !$status->isGood() ) {
375 $extradata = [
376 'offset' => $this->mUpload->getOffset(),
377 ];
378 $this->log->info( "Chunked upload stash failure {status} for {user}",
379 [
380 'status' => (string)$status,
381 'user' => $this->getUser()->getName(),
382 'filename' => $this->mParams['filename'] ?? '-',
383 'filekey' => $this->mParams['filekey'] ?? '-',
384 'filesize' => $this->mParams['filesize'],
385 'chunkSize' => $chunkSize,
386 'offset' => $this->mUpload->getOffset()
387 ]
388 );
389 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
390 } else {
391 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
392 [
393 'user' => $this->getUser()->getName(),
394 'filename' => $this->mParams['filename'] ?? '-',
395 'filekey' => $this->mParams['filekey'] ?? '-',
396 'filesize' => $this->mParams['filesize'],
397 'chunkSize' => $chunkSize,
398 'offset' => $this->mUpload->getOffset()
399 ]
400 );
401 }
402 }
403
404 // Check we added the last chunk:
405 if ( $totalSoFar == $this->mParams['filesize'] ) {
406 if ( $this->mParams['async'] ) {
408 $this->getUser(),
409 $filekey,
410 [ 'result' => 'Poll',
411 'stage' => 'queued', 'status' => Status::newGood() ]
412 );
413 // It is important that this be lazyPush, as we do not want to insert
414 // into job queue until after the current transaction has completed since
415 // this depends on values in uploadstash table that were updated during
416 // the current transaction. (T350917)
417 $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
418 'filename' => $this->mParams['filename'],
419 'filekey' => $filekey,
420 'filesize' => $this->mParams['filesize'],
421 'session' => $this->getContext()->exportSession()
422 ] ) );
423 $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
424 [
425 'user' => $this->getUser()->getName(),
426 'filename' => $this->mParams['filename'] ?? '-',
427 'filekey' => $this->mParams['filekey'] ?? '-',
428 'filesize' => $this->mParams['filesize'],
429 'chunkSize' => $chunkSize,
430 ]
431 );
432 $result['result'] = 'Poll';
433 $result['stage'] = 'queued';
434 } else {
435 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
436 [
437 'user' => $this->getUser()->getName(),
438 'filename' => $this->mParams['filename'] ?? '-',
439 'filekey' => $this->mParams['filekey'] ?? '-',
440 'filesize' => $this->mParams['filesize'],
441 'chunkSize' => $chunkSize,
442 ]
443 );
444
445 $status = $this->mUpload->concatenateChunks();
446 if ( !$status->isGood() ) {
448 $this->getUser(),
449 $filekey,
450 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
451 );
452 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
453 [
454 'user' => $this->getUser()->getName(),
455 'filename' => $this->mParams['filename'] ?? '-',
456 'filekey' => $this->mParams['filekey'] ?? '-',
457 'filesize' => $this->mParams['filesize'],
458 'chunkSize' => $chunkSize,
459 'status' => (string)$status
460 ]
461 );
462 $this->dieStatusWithCode( $status, 'stashfailed' );
463 }
464
465 // We can only get warnings like 'duplicate' after concatenating the chunks
466 $warnings = $this->getApiWarnings();
467 if ( $warnings ) {
468 $result['warnings'] = $warnings;
469 }
470
471 // The fully concatenated file has a new filekey. So remove
472 // the old filekey and fetch the new one.
473 UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
474 $this->mUpload->stash->removeFile( $filekey );
475 $filekey = $this->mUpload->getStashFile()->getFileKey();
476
477 $result['result'] = 'Success';
478 }
479 } else {
481 $this->getUser(),
482 $filekey,
483 [
484 'result' => 'Continue',
485 'stage' => 'uploading',
486 'offset' => $totalSoFar,
487 'status' => Status::newGood(),
488 ]
489 );
490 $result['result'] = 'Continue';
491 $result['offset'] = $totalSoFar;
492 }
493
494 $result['filekey'] = $filekey;
495
496 return $result;
497 }
498
511 private function performStash( $failureMode, &$data = null ) {
512 $isPartial = (bool)$this->mParams['chunk'];
513 try {
514 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
515
516 if ( $status->isGood() && !$status->getValue() ) {
517 // Not actually a 'good' status...
518 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
519 }
520 } catch ( Exception $e ) {
521 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
522 $this->log->info( $debugMessage,
523 [
524 'user' => $this->getUser()->getName(),
525 'filename' => $this->mParams['filename'] ?? '-',
526 'filekey' => $this->mParams['filekey'] ?? '-'
527 ]
528 );
529
530 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
531 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
532 ) );
533 }
534
535 if ( $status->isGood() ) {
536 $stashFile = $status->getValue();
537 $data['filekey'] = $stashFile->getFileKey();
538 // Backwards compatibility
539 $data['sessionkey'] = $data['filekey'];
540 return $data['filekey'];
541 }
542
543 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
544 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
545 // Statuses for it. Just extract the exception details and parse them ourselves.
546 [ $exceptionType, $message ] = $status->getMessage()->getParams();
547 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
548 $this->log->info( $debugMessage,
549 [
550 'user' => $this->getUser()->getName(),
551 'filename' => $this->mParams['filename'] ?? '-',
552 'filekey' => $this->mParams['filekey'] ?? '-'
553 ]
554 );
555 }
556
557 $this->log->info( "Stash upload failure {status}",
558 [
559 'status' => (string)$status,
560 'user' => $this->getUser()->getName(),
561 'filename' => $this->mParams['filename'] ?? '-',
562 'filekey' => $this->mParams['filekey'] ?? '-'
563 ]
564 );
565 // Bad status
566 if ( $failureMode !== 'optional' ) {
567 $this->dieStatus( $status );
568 } else {
569 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
570 return null;
571 }
572 }
573
584 private function dieRecoverableError( $errors, $parameter = null ) {
585 $this->performStash( 'optional', $data );
586
587 if ( $parameter ) {
588 $data['invalidparameter'] = $parameter;
589 }
590
591 $sv = StatusValue::newGood();
592 foreach ( $errors as $error ) {
593 $msg = ApiMessage::create( $error );
594 $msg->setApiData( $msg->getApiData() + $data );
595 $sv->fatal( $msg );
596 }
597 $this->dieStatus( $sv );
598 }
599
610 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
611 $sv = StatusValue::newGood();
612 foreach ( $status->getErrors() as $error ) {
613 $msg = ApiMessage::create( $error, $overrideCode );
614 if ( $moreExtraData ) {
615 $msg->setApiData( $msg->getApiData() + $moreExtraData );
616 }
617 $sv->fatal( $msg );
618 }
619 $this->dieStatus( $sv );
620 }
621
629 protected function selectUploadModule() {
630 $request = $this->getMain()->getRequest();
631
632 // chunk or one and only one of the following parameters is needed
633 if ( !$this->mParams['chunk'] ) {
634 $this->requireOnlyOneParameter( $this->mParams,
635 'filekey', 'file', 'url' );
636 }
637
638 // Status report for "upload to stash"/"upload from stash"/"upload by url"
639 if ( $this->mParams['checkstatus'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
640 $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
641 $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
642 if ( !$progress ) {
643 $this->log->info( "Cannot check upload status due to missing upload session for {user}",
644 [
645 'user' => $this->getUser()->getName(),
646 'filename' => $this->mParams['filename'] ?? '-',
647 'filekey' => $this->mParams['filekey'] ?? '-'
648 ]
649 );
650 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
651 } elseif ( !$progress['status']->isGood() ) {
652 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
653 }
654 if ( isset( $progress['status']->value['verification'] ) ) {
655 $this->checkVerification( $progress['status']->value['verification'] );
656 }
657 if ( isset( $progress['status']->value['warnings'] ) ) {
658 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
659 if ( $warnings ) {
660 $progress['warnings'] = $warnings;
661 }
662 }
663 unset( $progress['status'] ); // remove Status object
664 $imageinfo = null;
665 if ( isset( $progress['imageinfo'] ) ) {
666 $imageinfo = $progress['imageinfo'];
667 unset( $progress['imageinfo'] );
668 }
669
670 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
671 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
672 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
673 if ( $imageinfo ) {
674 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
675 }
676
677 return false;
678 }
679
680 // The following modules all require the filename parameter to be set
681 if ( $this->mParams['filename'] === null ) {
682 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
683 }
684
685 if ( $this->mParams['chunk'] ) {
686 // Chunk upload
687 $this->mUpload = new UploadFromChunks( $this->getUser() );
688 if ( isset( $this->mParams['filekey'] ) ) {
689 if ( $this->mParams['offset'] === 0 ) {
690 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
691 }
692
693 // handle new chunk
694 $this->mUpload->continueChunks(
695 $this->mParams['filename'],
696 $this->mParams['filekey'],
697 $request->getUpload( 'chunk' )
698 );
699 } else {
700 if ( $this->mParams['offset'] !== 0 ) {
701 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
702 }
703
704 // handle first chunk
705 $this->mUpload->initialize(
706 $this->mParams['filename'],
707 $request->getUpload( 'chunk' )
708 );
709 }
710 } elseif ( isset( $this->mParams['filekey'] ) ) {
711 // Upload stashed in a previous request
712 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
713 $this->dieWithError( 'apierror-invalid-file-key' );
714 }
715
716 $this->mUpload = new UploadFromStash( $this->getUser() );
717 // This will not download the temp file in initialize() in async mode.
718 // We still have enough information to call checkWarnings() and such.
719 $this->mUpload->initialize(
720 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
721 );
722 } elseif ( isset( $this->mParams['file'] ) ) {
723 // Can't async upload directly from a POSTed file, we'd have to
724 // stash the file and then queue the publish job. The user should
725 // just submit the two API queries to perform those two steps.
726 if ( $this->mParams['async'] ) {
727 $this->dieWithError( 'apierror-cannot-async-upload-file' );
728 }
729
730 $this->mUpload = new UploadFromFile();
731 $this->mUpload->initialize(
732 $this->mParams['filename'],
733 $request->getUpload( 'file' )
734 );
735 } elseif ( isset( $this->mParams['url'] ) ) {
736 // Make sure upload by URL is enabled:
737 if ( !UploadFromUrl::isEnabled() ) {
738 $this->dieWithError( 'copyuploaddisabled' );
739 }
740
741 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
742 $this->dieWithError( 'apierror-copyuploadbaddomain' );
743 }
744
745 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
746 $this->dieWithError( 'apierror-copyuploadbadurl' );
747 }
748
749 $this->mUpload = new UploadFromUrl;
750 $this->mUpload->initialize( $this->mParams['filename'],
751 $this->mParams['url'] );
752 }
753
754 return true;
755 }
756
762 protected function checkPermissions( $user ) {
763 // Check whether the user has the appropriate permissions to upload anyway
764 $permission = $this->mUpload->isAllowed( $user );
765
766 if ( $permission !== true ) {
767 if ( !$user->isNamed() ) {
768 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
769 }
770
771 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
772 }
773
774 // Check blocks
775 if ( $user->isBlockedFromUpload() ) {
776 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
777 $this->dieBlocked( $user->getBlock() );
778 }
779 }
780
784 protected function verifyUpload() {
785 if ( $this->mParams['chunk'] ) {
786 $maxSize = UploadBase::getMaxUploadSize();
787 if ( $this->mParams['filesize'] > $maxSize ) {
788 $this->dieWithError( 'file-too-large' );
789 }
790 if ( !$this->mUpload->getTitle() ) {
791 $this->dieWithError( 'illegal-filename' );
792 }
793 // file will be assembled after having uploaded the last chunk,
794 // so we can only validate the name at this point
795 $verification = $this->mUpload->validateName();
796 if ( $verification === true ) {
797 return;
798 }
799 } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
800 // file will be assembled/downloaded in a background process, so we
801 // can only validate the name at this point
802 // file verification will happen in background process
803 $verification = $this->mUpload->validateName();
804 if ( $verification === true ) {
805 return;
806 }
807 } else {
808 wfDebug( __METHOD__ . " about to verify" );
809
810 $verification = $this->mUpload->verifyUpload();
811
812 if ( $verification['status'] === UploadBase::OK ) {
813 return;
814 } else {
815 $this->log->info( "File verification of {filename} failed for {user} because {result}",
816 [
817 'user' => $this->getUser()->getName(),
818 'resultCode' => $verification['status'],
819 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
820 'filename' => $this->mParams['filename'] ?? '-',
821 'details' => $verification['details'] ?? ''
822 ]
823 );
824 }
825 }
826
827 $this->checkVerification( $verification );
828 }
829
835 protected function checkVerification( array $verification ) {
836 switch ( $verification['status'] ) {
837 // Recoverable errors
838 case UploadBase::MIN_LENGTH_PARTNAME:
839 $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
840 // dieRecoverableError prevents continuation
841 case UploadBase::ILLEGAL_FILENAME:
842 $this->dieRecoverableError(
843 [ ApiMessage::create(
844 'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
845 ) ], 'filename'
846 );
847 // dieRecoverableError prevents continuation
848 case UploadBase::FILENAME_TOO_LONG:
849 $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
850 // dieRecoverableError prevents continuation
851 case UploadBase::FILETYPE_MISSING:
852 $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
853 // dieRecoverableError prevents continuation
854 case UploadBase::WINDOWS_NONASCII_FILENAME:
855 $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
856
857 // Unrecoverable errors
858 case UploadBase::EMPTY_FILE:
859 $this->dieWithError( 'empty-file' );
860 // dieWithError prevents continuation
861 case UploadBase::FILE_TOO_LARGE:
862 $this->dieWithError( 'file-too-large' );
863 // dieWithError prevents continuation
864
865 case UploadBase::FILETYPE_BADTYPE:
866 $extradata = [
867 'filetype' => $verification['finalExt'],
868 'allowed' => array_values( array_unique(
869 $this->getConfig()->get( MainConfigNames::FileExtensions ) ) )
870 ];
871 $extensions =
872 array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
873 $msg = [
874 'filetype-banned-type',
875 null, // filled in below
876 Message::listParam( $extensions, 'comma' ),
877 count( $extensions ),
878 null, // filled in below
879 ];
880 ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
881
882 if ( isset( $verification['blacklistedExt'] ) ) {
883 $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' );
884 $msg[4] = count( $verification['blacklistedExt'] );
885 $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
886 ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
887 } else {
888 $msg[1] = $verification['finalExt'];
889 $msg[4] = 1;
890 }
891
892 $this->dieWithError( $msg, 'filetype-banned', $extradata );
893 // dieWithError prevents continuation
894
895 case UploadBase::VERIFICATION_ERROR:
896 $msg = ApiMessage::create( $verification['details'], 'verification-error' );
897 if ( $verification['details'][0] instanceof MessageSpecifier ) {
898 $details = [ $msg->getKey(), ...$msg->getParams() ];
899 } else {
900 $details = $verification['details'];
901 }
902 ApiResult::setIndexedTagName( $details, 'detail' );
903 $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
904 // @phan-suppress-next-line PhanTypeMismatchArgument
905 $this->dieWithError( $msg );
906 // dieWithError prevents continuation
907
908 case UploadBase::HOOK_ABORTED:
909 $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error'];
910 $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] );
911 // dieWithError prevents continuation
912 default:
913 $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
914 [ 'details' => [ 'code' => $verification['status'] ] ] );
915 }
916 }
917
925 protected function getApiWarnings() {
926 $warnings = UploadBase::makeWarningsSerializable(
927 $this->mUpload->checkWarnings( $this->getUser() )
928 );
929
930 return $this->transformWarnings( $warnings );
931 }
932
933 protected function transformWarnings( $warnings ) {
934 if ( $warnings ) {
935 // Add indices
936 ApiResult::setIndexedTagName( $warnings, 'warning' );
937
938 if ( isset( $warnings['duplicate'] ) ) {
939 $dupes = array_column( $warnings['duplicate'], 'fileName' );
940 ApiResult::setIndexedTagName( $dupes, 'duplicate' );
941 $warnings['duplicate'] = $dupes;
942 }
943
944 if ( isset( $warnings['exists'] ) ) {
945 $warning = $warnings['exists'];
946 unset( $warnings['exists'] );
947 $localFile = $warning['normalizedFile'] ?? $warning['file'];
948 $warnings[$warning['warning']] = $localFile['fileName'];
949 }
950
951 if ( isset( $warnings['no-change'] ) ) {
952 $file = $warnings['no-change'];
953 unset( $warnings['no-change'] );
954
955 $warnings['nochange'] = [
956 'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] )
957 ];
958 }
959
960 if ( isset( $warnings['duplicate-version'] ) ) {
961 $dupes = [];
962 foreach ( $warnings['duplicate-version'] as $dupe ) {
963 $dupes[] = [
964 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe['timestamp'] )
965 ];
966 }
967 unset( $warnings['duplicate-version'] );
968
969 ApiResult::setIndexedTagName( $dupes, 'ver' );
970 $warnings['duplicateversions'] = $dupes;
971 }
972 // We haven't downloaded the file, so this will result in an empty file warning
973 if ( $this->mParams['async'] && $this->mParams['url'] ) {
974 unset( $warnings['empty-file'] );
975 }
976 }
977
978 return $warnings;
979 }
980
987 protected function handleStashException( $e ) {
988 $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
989 [
990 'user' => $this->getUser()->getName(),
991 'error' => get_class( $e ),
992 'filename' => $this->mParams['filename'] ?? '-',
993 'filekey' => $this->mParams['filekey'] ?? '-'
994 ]
995 );
996
997 switch ( get_class( $e ) ) {
998 case UploadStashFileNotFoundException::class:
999 $wrap = 'apierror-stashedfilenotfound';
1000 break;
1001 case UploadStashBadPathException::class:
1002 $wrap = 'apierror-stashpathinvalid';
1003 break;
1004 case UploadStashFileException::class:
1005 $wrap = 'apierror-stashfilestorage';
1006 break;
1007 case UploadStashZeroLengthFileException::class:
1008 $wrap = 'apierror-stashzerolength';
1009 break;
1010 case UploadStashNotLoggedInException::class:
1011 return StatusValue::newFatal( ApiMessage::create(
1012 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
1013 ) );
1014 case UploadStashWrongOwnerException::class:
1015 $wrap = 'apierror-stashwrongowner';
1016 break;
1017 case UploadStashNoSuchKeyException::class:
1018 $wrap = 'apierror-stashnosuchfilekey';
1019 break;
1020 default:
1021 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
1022 break;
1023 }
1024 return StatusValue::newFatal(
1025 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1026 );
1027 }
1028
1036 protected function performUpload( $warnings ) {
1037 // Use comment as initial page text by default
1038 $this->mParams['text'] ??= $this->mParams['comment'];
1039
1041 $file = $this->mUpload->getLocalFile();
1042 $user = $this->getUser();
1043 $title = $file->getTitle();
1044
1045 // for preferences mode, we want to watch if 'watchdefault' is set,
1046 // or if the *file* doesn't exist, and either 'watchuploads' or
1047 // 'watchcreations' is set. But getWatchlistValue()'s automatic
1048 // handling checks if the *title* exists or not, so we need to check
1049 // all three preferences manually.
1050 $watch = $this->getWatchlistValue(
1051 $this->mParams['watchlist'], $title, $user, 'watchdefault'
1052 );
1053
1054 if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
1055 $watch = (
1056 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
1057 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
1058 );
1059 }
1060 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams );
1061
1062 // Deprecated parameters
1063 if ( $this->mParams['watch'] ) {
1064 $watch = true;
1065 }
1066
1067 if ( $this->mParams['tags'] ) {
1068 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1069 if ( !$status->isOK() ) {
1070 $this->dieStatus( $status );
1071 }
1072 }
1073
1074 // No errors, no warnings: do the upload
1075 $result = [];
1076 if ( $this->mParams['async'] ) {
1077 // Only stash uploads and copy uploads support async
1078 if ( $this->mParams['filekey'] ) {
1080 [
1081 'filename' => $this->mParams['filename'],
1082 'filekey' => $this->mParams['filekey'],
1083 'comment' => $this->mParams['comment'],
1084 'tags' => $this->mParams['tags'] ?? [],
1085 'text' => $this->mParams['text'],
1086 'watch' => $watch,
1087 'watchlistexpiry' => $watchlistExpiry,
1088 'session' => $this->getContext()->exportSession(),
1089 'ignorewarnings' => $this->mParams['ignorewarnings']
1090 ]
1091 );
1092 } elseif ( $this->mParams['url'] ) {
1093 $job = new UploadFromUrlJob(
1094 [
1095 'filename' => $this->mParams['filename'],
1096 'url' => $this->mParams['url'],
1097 'comment' => $this->mParams['comment'],
1098 'tags' => $this->mParams['tags'] ?? [],
1099 'text' => $this->mParams['text'],
1100 'watch' => $watch,
1101 'watchlistexpiry' => $watchlistExpiry,
1102 'session' => $this->getContext()->exportSession(),
1103 'ignorewarnings' => $this->mParams['ignorewarnings']
1104 ]
1105 );
1106 } else {
1107 $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1108 // We will never reach this, but it's here to help phan figure out
1109 // $job is never null
1110 // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1111 return [];
1112 }
1113 $cacheKey = $job->getCacheKey();
1114 // Check if an upload is already in progress.
1115 // the result can be Poll / Failure / Success
1116 $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey );
1117 if ( $progress && $progress['result'] === 'Poll' ) {
1118 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1119 }
1120 UploadBase::setSessionStatus(
1121 $this->getUser(),
1122 $cacheKey,
1123 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1124 );
1125
1126 $this->jobQueueGroup->push( $job );
1127 $this->log->info( "Sending publish job of {filename} for {user}",
1128 [
1129 'user' => $this->getUser()->getName(),
1130 'filename' => $this->mParams['filename'] ?? '-'
1131 ]
1132 );
1133 $result['result'] = 'Poll';
1134 $result['stage'] = 'queued';
1135 } else {
1137 $status = $this->mUpload->performUpload(
1138 $this->mParams['comment'],
1139 $this->mParams['text'],
1140 $watch,
1141 $this->getUser(),
1142 $this->mParams['tags'] ?? [],
1143 $watchlistExpiry
1144 );
1145
1146 if ( !$status->isGood() ) {
1147 $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1148 [
1149 'user' => $this->getUser()->getName(),
1150 'filename' => $this->mParams['filename'] ?? '-',
1151 'filekey' => $this->mParams['filekey'] ?? '-',
1152 'status' => (string)$status
1153 ]
1154 );
1155 $this->dieRecoverableError( $status->getErrors() );
1156 }
1157 $result['result'] = 'Success';
1158 }
1159
1160 $result['filename'] = $file->getName();
1161 if ( $warnings && count( $warnings ) > 0 ) {
1162 $result['warnings'] = $warnings;
1163 }
1164
1165 return $result;
1166 }
1167
1168 public function mustBePosted() {
1169 return true;
1170 }
1171
1172 public function isWriteMode() {
1173 return true;
1174 }
1175
1176 public function getAllowedParams() {
1177 $params = [
1178 'filename' => [
1179 ParamValidator::PARAM_TYPE => 'string',
1180 ],
1181 'comment' => [
1182 ParamValidator::PARAM_DEFAULT => ''
1183 ],
1184 'tags' => [
1185 ParamValidator::PARAM_TYPE => 'tags',
1186 ParamValidator::PARAM_ISMULTI => true,
1187 ],
1188 'text' => [
1189 ParamValidator::PARAM_TYPE => 'text',
1190 ],
1191 'watch' => [
1192 ParamValidator::PARAM_DEFAULT => false,
1193 ParamValidator::PARAM_DEPRECATED => true,
1194 ],
1195 ];
1196
1197 // Params appear in the docs in the order they are defined,
1198 // which is why this is here and not at the bottom.
1199 $params += $this->getWatchlistParams( [
1200 'watch',
1201 'preferences',
1202 'nochange',
1203 ] );
1204
1205 $params += [
1206 'ignorewarnings' => false,
1207 'file' => [
1208 ParamValidator::PARAM_TYPE => 'upload',
1209 ],
1210 'url' => null,
1211 'filekey' => null,
1212 'sessionkey' => [
1213 ParamValidator::PARAM_DEPRECATED => true,
1214 ],
1215 'stash' => false,
1216
1217 'filesize' => [
1218 ParamValidator::PARAM_TYPE => 'integer',
1219 IntegerDef::PARAM_MIN => 0,
1220 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(),
1221 ],
1222 'offset' => [
1223 ParamValidator::PARAM_TYPE => 'integer',
1224 IntegerDef::PARAM_MIN => 0,
1225 ],
1226 'chunk' => [
1227 ParamValidator::PARAM_TYPE => 'upload',
1228 ],
1229
1230 'async' => false,
1231 'checkstatus' => false,
1232 ];
1233
1234 return $params;
1235 }
1236
1237 public function needsToken() {
1238 return 'csrf';
1239 }
1240
1241 protected function getExamplesMessages() {
1242 return [
1243 'action=upload&filename=Wiki.png' .
1244 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1245 => 'apihelp-upload-example-url',
1246 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1247 => 'apihelp-upload-example-filekey',
1248 ];
1249 }
1250
1251 public function getHelpUrls() {
1252 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1253 }
1254}
getUser()
getAuthority()
getWatchlistValue(string $watchlist, Title $title, User $user, ?string $userOption=null)
Return true if we're to watch the page, false if not.
getExpiryFromParams(array $params)
Get formatted expiry from the given parameters, or null if no expiry was provided.
getWatchlistParams(array $watchOptions=[])
Get additional allow params specific to watchlisting.
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.
getContext()
array $params
The job parameters.
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:64
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1533
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition ApiBase.php:1777
getMain()
Get the main module.
Definition ApiBase.php:550
getResult()
Get the result object.
Definition ApiBase.php:671
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:811
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:532
dieStatus(StatusValue $status)
Throw an ApiUsageException based on the Status object.
Definition ApiBase.php:1589
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition ApiBase.php:1381
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:65
Extension of Message implementing IApiMessage.
static create( $msg, $code=null, array $data=null)
Create an IApiMessage for the message.
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.
execute()
Evaluates the parameters, performs the requested query, and sets up the result.
Definition ApiUpload.php:82
checkPermissions( $user)
Checks that the user has permissions to perform this upload.
verifyUpload()
Performs file verification, dies on error.
UploadBase UploadFromChunks $mUpload
Definition ApiUpload.php:48
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
transformWarnings( $warnings)
handleStashException( $e)
Handles a stash exception, giving a useful error to the user.
getHelpUrls()
Return links to more detailed help pages about the module.
dieStatusWithCode( $status, $overrideCode, $moreExtraData=null)
Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from IApiMe...
isWriteMode()
Indicates whether this module requires write mode.
static getMinUploadChunkSize(Config $config)
getUploadImageInfo(UploadBase $upload)
Gets image info about the file just uploaded.
__construct(ApiMain $mainModule, $moduleName, JobQueueGroup $jobQueueGroup, WatchlistManager $watchlistManager, UserOptionsLookup $userOptionsLookup)
Definition ApiUpload.php:63
getExamplesMessages()
Returns usage examples for this module.
selectUploadModule()
Select an upload module and set it to mUpload.
needsToken()
Returns the token type this module requires in order to execute.
performUpload( $warnings)
Perform the actual upload.
static getDummyInstance()
checkVerification(array $verification)
Performs file verification, dies on error.
mustBePosted()
Indicates whether this module must be called with a POST request.
getApiWarnings()
Check warnings.
Assemble the segments of a chunked upload.
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.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
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.
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.
static getCacheKey( $params)
Provides a caching key for an upload from url set of parameters Used to set the status of an async jo...
static isAllowedHost( $url)
Checks whether the URL is for an allowed host The domains in the allowlist can include wildcard chara...
static isAllowedUrl( $url)
Checks whether the URL is not allowed.
static isEnabled()
Checks if the upload from URL feature is enabled.
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.".
if(count( $args)< 1) $job