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