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 // Check if async mode is actually supported (jobs done in cli mode)
93 $this->mParams['async'] = ( $this->mParams['async'] &&
94 $this->getConfig()->get( MainConfigNames::EnableAsyncUploads ) );
95
96 // Copy the session key to the file key, for backward compatibility.
97 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
98 $this->mParams['filekey'] = $this->mParams['sessionkey'];
99 }
100
101 if ( !$this->mParams['checkstatus'] ) {
103 }
104
105 // Select an upload module
106 try {
107 if ( !$this->selectUploadModule() ) {
108 return; // not a true upload, but a status request or similar
109 } elseif ( !isset( $this->mUpload ) ) {
110 $this->dieDebug( __METHOD__, 'No upload module set' );
111 }
112 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
113 $this->dieStatus( $this->handleStashException( $e ) );
114 }
115
116 // First check permission to upload
117 $this->checkPermissions( $user );
118
119 // Fetch the file (usually a no-op)
120 // Skip for async upload from URL, where we just want to run checks.
122 if ( $this->mParams['async'] && $this->mParams['url'] ) {
123 $status = $this->mUpload->canFetchFile();
124 } else {
125 $status = $this->mUpload->fetchFile();
126 }
127
128 if ( !$status->isGood() ) {
129 $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
130 [
131 'user' => $this->getUser()->getName(),
132 'status' => (string)$status,
133 'filename' => $this->mParams['filename'] ?? '-',
134 ]
135 );
136 $this->dieStatus( $status );
137 }
138
139 // Check the uploaded file
140 $this->verifyUpload();
141
142 // Check if the user has the rights to modify or overwrite the requested title
143 // (This check is irrelevant if stashing is already requested, since the errors
144 // can always be fixed by changing the title)
145 if ( !$this->mParams['stash'] ) {
146 $permErrors = $this->mUpload->verifyTitlePermissions( $user );
147 if ( $permErrors !== true ) {
148 $this->dieRecoverableError( $permErrors, 'filename' );
149 }
150 }
151
152 // Get the result based on the current upload context:
153 try {
154 $result = $this->getContextResult();
155 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
156 $this->dieStatus( $this->handleStashException( $e ) );
157 }
158 $this->getResult()->addValue( null, $this->getModuleName(), $result );
159
160 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
161 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
162 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
163 if ( $result['result'] === 'Success' ) {
164 $imageinfo = $this->getUploadImageInfo( $this->mUpload );
165 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
166 }
167
168 // Cleanup any temporary mess
169 $this->mUpload->cleanupTempFile();
170 }
171
172 public static function getDummyInstance(): self {
173 $services = MediaWikiServices::getInstance();
174 $apiMain = new ApiMain(); // dummy object (XXX)
175 $apiUpload = new ApiUpload(
176 $apiMain,
177 'upload',
178 $services->getJobQueueGroup(),
179 $services->getWatchlistManager(),
180 $services->getUserOptionsLookup()
181 );
182
183 return $apiUpload;
184 }
185
200 public function getUploadImageInfo( UploadBase $upload ): array {
201 $result = $this->getResult();
202 $stashFile = $upload->getStashFile();
203
204 // Calling a different API module depending on whether the file was stashed is less than optimal.
205 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
206 if ( $stashFile ) {
209 $stashFile,
210 array_fill_keys( $imParam, true ),
211 $result
212 );
213 } else {
214 $localFile = $upload->getLocalFile();
217 $localFile,
218 array_fill_keys( $imParam, true ),
219 $result
220 );
221 }
222
223 return $info;
224 }
225
230 private function getContextResult() {
231 $warnings = $this->getApiWarnings();
232 if ( $warnings && !$this->mParams['ignorewarnings'] ) {
233 // Get warnings formatted in result array format
234 return $this->getWarningsResult( $warnings );
235 } elseif ( $this->mParams['chunk'] ) {
236 // Add chunk, and get result
237 return $this->getChunkResult( $warnings );
238 } elseif ( $this->mParams['stash'] ) {
239 // Stash the file and get stash result
240 return $this->getStashResult( $warnings );
241 }
242
243 // This is the most common case -- a normal upload with no warnings
244 // performUpload will return a formatted properly for the API with status
245 return $this->performUpload( $warnings );
246 }
247
253 private function getStashResult( $warnings ) {
254 $result = [];
255 $result['result'] = 'Success';
256 if ( $warnings && count( $warnings ) > 0 ) {
257 $result['warnings'] = $warnings;
258 }
259 // Some uploads can request they be stashed, so as not to publish them immediately.
260 // In this case, a failure to stash ought to be fatal
261 $this->performStash( 'critical', $result );
262
263 return $result;
264 }
265
271 private function getWarningsResult( $warnings ) {
272 $result = [];
273 $result['result'] = 'Warning';
274 $result['warnings'] = $warnings;
275 // in case the warnings can be fixed with some further user action, let's stash this upload
276 // and return a key they can use to restart it
277 $this->performStash( 'optional', $result );
278
279 return $result;
280 }
281
288 public static function getMinUploadChunkSize( Config $config ) {
289 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
290
291 // Leave some room for other POST parameters
292 $postMax = (
294 ini_get( 'post_max_size' ),
295 PHP_INT_MAX
296 ) ?: PHP_INT_MAX
297 ) - 1024;
298
299 // Ensure the minimum chunk size is less than PHP upload limits
300 // or the maximum upload size.
301 return min(
302 $configured,
303 UploadBase::getMaxUploadSize( 'file' ),
304 UploadBase::getMaxPhpUploadSize(),
305 $postMax
306 );
307 }
308
314 private function getChunkResult( $warnings ) {
315 $result = [];
316
317 if ( $warnings && count( $warnings ) > 0 ) {
318 $result['warnings'] = $warnings;
319 }
320
321 $chunkUpload = $this->getMain()->getUpload( 'chunk' );
322 $chunkPath = $chunkUpload->getTempName();
323 $chunkSize = $chunkUpload->getSize();
324 $totalSoFar = $this->mParams['offset'] + $chunkSize;
325 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
326
327 // Double check sizing
328 if ( $totalSoFar > $this->mParams['filesize'] ) {
329 $this->dieWithError( 'apierror-invalid-chunk' );
330 }
331
332 // Enforce minimum chunk size
333 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
334 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
335 }
336
337 if ( $this->mParams['offset'] == 0 ) {
338 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
339 [
340 'user' => $this->getUser()->getName(),
341 'filename' => $this->mParams['filename'] ?? '-',
342 'filesize' => $this->mParams['filesize'],
343 'chunkSize' => $chunkSize
344 ]
345 );
346 $filekey = $this->performStash( 'critical' );
347 } else {
348 $filekey = $this->mParams['filekey'];
349
350 // Don't allow further uploads to an already-completed session
351 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
352 if ( !$progress ) {
353 // Probably can't get here, but check anyway just in case
354 $this->log->info( "Stash failed due to no session for {user}",
355 [
356 'user' => $this->getUser()->getName(),
357 'filename' => $this->mParams['filename'] ?? '-',
358 'filekey' => $this->mParams['filekey'] ?? '-',
359 'filesize' => $this->mParams['filesize'],
360 'chunkSize' => $chunkSize
361 ]
362 );
363 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
364 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
365 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
366 }
367
368 $status = $this->mUpload->addChunk(
369 $chunkPath, $chunkSize, $this->mParams['offset'] );
370 if ( !$status->isGood() ) {
371 $extradata = [
372 'offset' => $this->mUpload->getOffset(),
373 ];
374 $this->log->info( "Chunked upload stash failure {status} for {user}",
375 [
376 'status' => (string)$status,
377 'user' => $this->getUser()->getName(),
378 'filename' => $this->mParams['filename'] ?? '-',
379 'filekey' => $this->mParams['filekey'] ?? '-',
380 'filesize' => $this->mParams['filesize'],
381 'chunkSize' => $chunkSize,
382 'offset' => $this->mUpload->getOffset()
383 ]
384 );
385 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
386 } else {
387 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
388 [
389 'user' => $this->getUser()->getName(),
390 'filename' => $this->mParams['filename'] ?? '-',
391 'filekey' => $this->mParams['filekey'] ?? '-',
392 'filesize' => $this->mParams['filesize'],
393 'chunkSize' => $chunkSize,
394 'offset' => $this->mUpload->getOffset()
395 ]
396 );
397 }
398 }
399
400 // Check we added the last chunk:
401 if ( $totalSoFar == $this->mParams['filesize'] ) {
402 if ( $this->mParams['async'] ) {
404 $this->getUser(),
405 $filekey,
406 [ 'result' => 'Poll',
407 'stage' => 'queued', 'status' => Status::newGood() ]
408 );
409 // It is important that this be lazyPush, as we do not want to insert
410 // into job queue until after the current transaction has completed since
411 // this depends on values in uploadstash table that were updated during
412 // the current transaction. (T350917)
413 $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
414 'filename' => $this->mParams['filename'],
415 'filekey' => $filekey,
416 'filesize' => $this->mParams['filesize'],
417 'session' => $this->getContext()->exportSession()
418 ] ) );
419 $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
420 [
421 'user' => $this->getUser()->getName(),
422 'filename' => $this->mParams['filename'] ?? '-',
423 'filekey' => $this->mParams['filekey'] ?? '-',
424 'filesize' => $this->mParams['filesize'],
425 'chunkSize' => $chunkSize,
426 ]
427 );
428 $result['result'] = 'Poll';
429 $result['stage'] = 'queued';
430 } else {
431 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
432 [
433 'user' => $this->getUser()->getName(),
434 'filename' => $this->mParams['filename'] ?? '-',
435 'filekey' => $this->mParams['filekey'] ?? '-',
436 'filesize' => $this->mParams['filesize'],
437 'chunkSize' => $chunkSize,
438 ]
439 );
440
441 $status = $this->mUpload->concatenateChunks();
442 if ( !$status->isGood() ) {
444 $this->getUser(),
445 $filekey,
446 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
447 );
448 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
449 [
450 'user' => $this->getUser()->getName(),
451 'filename' => $this->mParams['filename'] ?? '-',
452 'filekey' => $this->mParams['filekey'] ?? '-',
453 'filesize' => $this->mParams['filesize'],
454 'chunkSize' => $chunkSize,
455 'status' => (string)$status
456 ]
457 );
458 $this->dieStatusWithCode( $status, 'stashfailed' );
459 }
460
461 // We can only get warnings like 'duplicate' after concatenating the chunks
462 $warnings = $this->getApiWarnings();
463 if ( $warnings ) {
464 $result['warnings'] = $warnings;
465 }
466
467 // The fully concatenated file has a new filekey. So remove
468 // the old filekey and fetch the new one.
469 UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
470 $this->mUpload->stash->removeFile( $filekey );
471 $filekey = $this->mUpload->getStashFile()->getFileKey();
472
473 $result['result'] = 'Success';
474 }
475 } else {
477 $this->getUser(),
478 $filekey,
479 [
480 'result' => 'Continue',
481 'stage' => 'uploading',
482 'offset' => $totalSoFar,
483 'status' => Status::newGood(),
484 ]
485 );
486 $result['result'] = 'Continue';
487 $result['offset'] = $totalSoFar;
488 }
489
490 $result['filekey'] = $filekey;
491
492 return $result;
493 }
494
507 private function performStash( $failureMode, &$data = null ) {
508 $isPartial = (bool)$this->mParams['chunk'];
509 try {
510 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
511
512 if ( $status->isGood() && !$status->getValue() ) {
513 // Not actually a 'good' status...
514 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
515 }
516 } catch ( Exception $e ) {
517 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
518 $this->log->info( $debugMessage,
519 [
520 'user' => $this->getUser()->getName(),
521 'filename' => $this->mParams['filename'] ?? '-',
522 'filekey' => $this->mParams['filekey'] ?? '-'
523 ]
524 );
525
526 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
527 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
528 ) );
529 }
530
531 if ( $status->isGood() ) {
532 $stashFile = $status->getValue();
533 $data['filekey'] = $stashFile->getFileKey();
534 // Backwards compatibility
535 $data['sessionkey'] = $data['filekey'];
536 return $data['filekey'];
537 }
538
539 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
540 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
541 // Statuses for it. Just extract the exception details and parse them ourselves.
542 [ $exceptionType, $message ] = $status->getMessage()->getParams();
543 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
544 $this->log->info( $debugMessage,
545 [
546 'user' => $this->getUser()->getName(),
547 'filename' => $this->mParams['filename'] ?? '-',
548 'filekey' => $this->mParams['filekey'] ?? '-'
549 ]
550 );
551 }
552
553 $this->log->info( "Stash upload failure {status}",
554 [
555 'status' => (string)$status,
556 'user' => $this->getUser()->getName(),
557 'filename' => $this->mParams['filename'] ?? '-',
558 'filekey' => $this->mParams['filekey'] ?? '-'
559 ]
560 );
561 // Bad status
562 if ( $failureMode !== 'optional' ) {
563 $this->dieStatus( $status );
564 } else {
565 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
566 return null;
567 }
568 }
569
580 private function dieRecoverableError( $errors, $parameter = null ) {
581 $this->performStash( 'optional', $data );
582
583 if ( $parameter ) {
584 $data['invalidparameter'] = $parameter;
585 }
586
587 $sv = StatusValue::newGood();
588 foreach ( $errors as $error ) {
589 $msg = ApiMessage::create( $error );
590 $msg->setApiData( $msg->getApiData() + $data );
591 $sv->fatal( $msg );
592 }
593 $this->dieStatus( $sv );
594 }
595
606 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
607 $sv = StatusValue::newGood();
608 foreach ( $status->getErrors() as $error ) {
609 $msg = ApiMessage::create( $error, $overrideCode );
610 if ( $moreExtraData ) {
611 $msg->setApiData( $msg->getApiData() + $moreExtraData );
612 }
613 $sv->fatal( $msg );
614 }
615 $this->dieStatus( $sv );
616 }
617
625 protected function selectUploadModule() {
626 // chunk or one and only one of the following parameters is needed
627 if ( !$this->mParams['chunk'] ) {
628 $this->requireOnlyOneParameter( $this->mParams,
629 'filekey', 'file', 'url' );
630 }
631
632 // Status report for "upload to stash"/"upload from stash"/"upload by url"
633 if ( $this->mParams['checkstatus'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
634 $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
635 $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
636 if ( !$progress ) {
637 $this->log->info( "Cannot check upload status due to missing upload session for {user}",
638 [
639 'user' => $this->getUser()->getName(),
640 'filename' => $this->mParams['filename'] ?? '-',
641 'filekey' => $this->mParams['filekey'] ?? '-'
642 ]
643 );
644 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
645 } elseif ( !$progress['status']->isGood() ) {
646 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
647 }
648 if ( isset( $progress['status']->value['verification'] ) ) {
649 $this->checkVerification( $progress['status']->value['verification'] );
650 }
651 if ( isset( $progress['status']->value['warnings'] ) ) {
652 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
653 if ( $warnings ) {
654 $progress['warnings'] = $warnings;
655 }
656 }
657 unset( $progress['status'] ); // remove Status object
658 $imageinfo = null;
659 if ( isset( $progress['imageinfo'] ) ) {
660 $imageinfo = $progress['imageinfo'];
661 unset( $progress['imageinfo'] );
662 }
663
664 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
665 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
666 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
667 if ( $imageinfo ) {
668 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
669 }
670
671 return false;
672 }
673
674 // The following modules all require the filename parameter to be set
675 if ( $this->mParams['filename'] === null ) {
676 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
677 }
678
679 if ( $this->mParams['chunk'] ) {
680 // Chunk upload
681 $this->mUpload = new UploadFromChunks( $this->getUser() );
682 if ( isset( $this->mParams['filekey'] ) ) {
683 if ( $this->mParams['offset'] === 0 ) {
684 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
685 }
686
687 // handle new chunk
688 $this->mUpload->continueChunks(
689 $this->mParams['filename'],
690 $this->mParams['filekey'],
691 $this->getMain()->getUpload( 'chunk' )
692 );
693 } else {
694 if ( $this->mParams['offset'] !== 0 ) {
695 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
696 }
697
698 // handle first chunk
699 $this->mUpload->initialize(
700 $this->mParams['filename'],
701 $this->getMain()->getUpload( 'chunk' )
702 );
703 }
704 } elseif ( isset( $this->mParams['filekey'] ) ) {
705 // Upload stashed in a previous request
706 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
707 $this->dieWithError( 'apierror-invalid-file-key' );
708 }
709
710 $this->mUpload = new UploadFromStash( $this->getUser() );
711 // This will not download the temp file in initialize() in async mode.
712 // We still have enough information to call checkWarnings() and such.
713 $this->mUpload->initialize(
714 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
715 );
716 } elseif ( isset( $this->mParams['file'] ) ) {
717 // Can't async upload directly from a POSTed file, we'd have to
718 // stash the file and then queue the publish job. The user should
719 // just submit the two API queries to perform those two steps.
720 if ( $this->mParams['async'] ) {
721 $this->dieWithError( 'apierror-cannot-async-upload-file' );
722 }
723
724 $this->mUpload = new UploadFromFile();
725 $this->mUpload->initialize(
726 $this->mParams['filename'],
727 $this->getMain()->getUpload( 'file' )
728 );
729 } elseif ( isset( $this->mParams['url'] ) ) {
730 // Make sure upload by URL is enabled:
731 if ( !UploadFromUrl::isEnabled() ) {
732 $this->dieWithError( 'copyuploaddisabled' );
733 }
734
735 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
736 $this->dieWithError( 'apierror-copyuploadbaddomain' );
737 }
738
739 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
740 $this->dieWithError( 'apierror-copyuploadbadurl' );
741 }
742
743 $this->mUpload = new UploadFromUrl;
744 $this->mUpload->initialize( $this->mParams['filename'],
745 $this->mParams['url'] );
746 }
747
748 return true;
749 }
750
756 protected function checkPermissions( $user ) {
757 // Check whether the user has the appropriate permissions to upload anyway
758 $permission = $this->mUpload->isAllowed( $user );
759
760 if ( $permission !== true ) {
761 if ( !$user->isNamed() ) {
762 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
763 }
764
765 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
766 }
767
768 // Check blocks
769 if ( $user->isBlockedFromUpload() ) {
770 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
771 $this->dieBlocked( $user->getBlock() );
772 }
773 }
774
778 protected function verifyUpload() {
779 if ( $this->mParams['chunk'] ) {
780 $maxSize = UploadBase::getMaxUploadSize();
781 if ( $this->mParams['filesize'] > $maxSize ) {
782 $this->dieWithError( 'file-too-large' );
783 }
784 if ( !$this->mUpload->getTitle() ) {
785 $this->dieWithError( 'illegal-filename' );
786 }
787 // file will be assembled after having uploaded the last chunk,
788 // so we can only validate the name at this point
789 $verification = $this->mUpload->validateName();
790 if ( $verification === true ) {
791 return;
792 }
793 } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
794 // file will be assembled/downloaded in a background process, so we
795 // can only validate the name at this point
796 // file verification will happen in background process
797 $verification = $this->mUpload->validateName();
798 if ( $verification === true ) {
799 return;
800 }
801 } else {
802 wfDebug( __METHOD__ . " about to verify" );
803
804 $verification = $this->mUpload->verifyUpload();
805
806 if ( $verification['status'] === UploadBase::OK ) {
807 return;
808 } else {
809 $this->log->info( "File verification of {filename} failed for {user} because {result}",
810 [
811 'user' => $this->getUser()->getName(),
812 'resultCode' => $verification['status'],
813 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
814 'filename' => $this->mParams['filename'] ?? '-',
815 'details' => $verification['details'] ?? ''
816 ]
817 );
818 }
819 }
820
821 $this->checkVerification( $verification );
822 }
823
829 protected function checkVerification( array $verification ) {
830 switch ( $verification['status'] ) {
831 // Recoverable errors
832 case UploadBase::MIN_LENGTH_PARTNAME:
833 $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
834 // dieRecoverableError prevents continuation
835 case UploadBase::ILLEGAL_FILENAME:
836 $this->dieRecoverableError(
837 [ ApiMessage::create(
838 'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
839 ) ], 'filename'
840 );
841 // dieRecoverableError prevents continuation
842 case UploadBase::FILENAME_TOO_LONG:
843 $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
844 // dieRecoverableError prevents continuation
845 case UploadBase::FILETYPE_MISSING:
846 $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
847 // dieRecoverableError prevents continuation
848 case UploadBase::WINDOWS_NONASCII_FILENAME:
849 $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
850
851 // Unrecoverable errors
852 case UploadBase::EMPTY_FILE:
853 $this->dieWithError( 'empty-file' );
854 // dieWithError prevents continuation
855 case UploadBase::FILE_TOO_LARGE:
856 $this->dieWithError( 'file-too-large' );
857 // dieWithError prevents continuation
858
859 case UploadBase::FILETYPE_BADTYPE:
860 $extradata = [
861 'filetype' => $verification['finalExt'],
862 'allowed' => array_values( array_unique(
863 $this->getConfig()->get( MainConfigNames::FileExtensions ) ) )
864 ];
865 $extensions =
866 array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
867 $msg = [
868 'filetype-banned-type',
869 null, // filled in below
870 Message::listParam( $extensions, 'comma' ),
871 count( $extensions ),
872 null, // filled in below
873 ];
874 ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
875
876 if ( isset( $verification['blacklistedExt'] ) ) {
877 $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' );
878 $msg[4] = count( $verification['blacklistedExt'] );
879 $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
880 ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
881 } else {
882 $msg[1] = $verification['finalExt'];
883 $msg[4] = 1;
884 }
885
886 $this->dieWithError( $msg, 'filetype-banned', $extradata );
887 // dieWithError prevents continuation
888
889 case UploadBase::VERIFICATION_ERROR:
890 $msg = ApiMessage::create( $verification['details'], 'verification-error' );
891 if ( $verification['details'][0] instanceof MessageSpecifier ) {
892 $details = [ $msg->getKey(), ...$msg->getParams() ];
893 } else {
894 $details = $verification['details'];
895 }
896 ApiResult::setIndexedTagName( $details, 'detail' );
897 $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
898 // @phan-suppress-next-line PhanTypeMismatchArgument
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->getErrors() );
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()
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.
getUpload()
Getter for the upload.
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:1539
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:680
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:820
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:541
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:1387
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:66
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