MediaWiki master
ApiUpload.php
Go to the documentation of this file.
1<?php
28namespace MediaWiki\Api;
29
30use Exception;
46use Psr\Log\LoggerInterface;
47use StatusValue;
48use UploadBase;
64
68class ApiUpload extends ApiBase {
69
71
73 protected $mUpload = null;
74
76 protected $mParams;
77
78 private JobQueueGroup $jobQueueGroup;
79
80 private LoggerInterface $log;
81
82 public function __construct(
83 ApiMain $mainModule,
84 string $moduleName,
85 JobQueueGroup $jobQueueGroup,
86 WatchlistManager $watchlistManager,
87 UserOptionsLookup $userOptionsLookup
88 ) {
89 parent::__construct( $mainModule, $moduleName );
90 $this->jobQueueGroup = $jobQueueGroup;
91
92 // Variables needed in ApiWatchlistTrait trait
93 $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
94 $this->watchlistMaxDuration =
96 $this->watchlistManager = $watchlistManager;
97 $this->userOptionsLookup = $userOptionsLookup;
98 $this->log = LoggerFactory::getInstance( 'upload' );
99 }
100
101 public function execute() {
102 // Check whether upload is enabled
103 if ( !UploadBase::isEnabled() ) {
104 $this->dieWithError( 'uploaddisabled' );
105 }
106
107 $user = $this->getUser();
108
109 // Parameter handling
110 $this->mParams = $this->extractRequestParams();
111 // Check if async mode is actually supported (jobs done in cli mode)
112 $this->mParams['async'] = ( $this->mParams['async'] &&
114
115 // Copy the session key to the file key, for backward compatibility.
116 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
117 $this->mParams['filekey'] = $this->mParams['sessionkey'];
118 }
119
120 if ( !$this->mParams['checkstatus'] ) {
122 }
123
124 // Select an upload module
125 try {
126 if ( !$this->selectUploadModule() ) {
127 return; // not a true upload, but a status request or similar
128 } elseif ( !$this->mUpload ) {
129 $this->dieDebug( __METHOD__, 'No upload module set' );
130 }
131 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
132 $this->dieStatus( $this->handleStashException( $e ) );
133 }
134
135 // First check permission to upload
136 $this->checkPermissions( $user );
137
138 // Fetch the file (usually a no-op)
139 // Skip for async upload from URL, where we just want to run checks.
141 if ( $this->mParams['async'] && $this->mParams['url'] ) {
142 $status = $this->mUpload->canFetchFile();
143 } else {
144 $status = $this->mUpload->fetchFile();
145 }
146
147 if ( !$status->isGood() ) {
148 $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
149 [
150 'user' => $this->getUser()->getName(),
151 'status' => (string)$status,
152 'filename' => $this->mParams['filename'] ?? '-',
153 ]
154 );
155 $this->dieStatus( $status );
156 }
157
158 // Check the uploaded file
159 $this->verifyUpload();
160
161 // Check if the user has the rights to modify or overwrite the requested title
162 // (This check is irrelevant if stashing is already requested, since the errors
163 // can always be fixed by changing the title)
164 if ( !$this->mParams['stash'] ) {
165 $status = $this->mUpload->authorizeUpload( $user );
166 if ( !$status->isGood() ) {
167 $this->dieRecoverableError( $status->getMessages(), 'filename' );
168 }
169 }
170
171 // Get the result based on the current upload context:
172 try {
173 $result = $this->getContextResult();
174 } catch ( UploadStashException $e ) { // XXX: don't spam exception log
175 $this->dieStatus( $this->handleStashException( $e ) );
176 }
177 $this->getResult()->addValue( null, $this->getModuleName(), $result );
178
179 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
180 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
181 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
182 if ( $result['result'] === 'Success' ) {
183 $imageinfo = $this->getUploadImageInfo( $this->mUpload );
184 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
185 }
186
187 // Cleanup any temporary mess
188 $this->mUpload->cleanupTempFile();
189 }
190
191 public static function getDummyInstance(): self {
192 $services = MediaWikiServices::getInstance();
193 $apiMain = new ApiMain(); // dummy object (XXX)
194 $apiUpload = new ApiUpload(
195 $apiMain,
196 'upload',
197 $services->getJobQueueGroup(),
198 $services->getWatchlistManager(),
199 $services->getUserOptionsLookup()
200 );
201
202 return $apiUpload;
203 }
204
219 public function getUploadImageInfo( UploadBase $upload ): array {
220 $result = $this->getResult();
221 $stashFile = $upload->getStashFile();
222
223 // Calling a different API module depending on whether the file was stashed is less than optimal.
224 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
225 if ( $stashFile ) {
228 $stashFile,
229 array_fill_keys( $imParam, true ),
230 $result
231 );
232 } else {
233 $localFile = $upload->getLocalFile();
236 $localFile,
237 array_fill_keys( $imParam, true ),
238 $result
239 );
240 }
241
242 return $info;
243 }
244
249 private function getContextResult() {
250 $warnings = $this->getApiWarnings();
251 if ( $warnings && !$this->mParams['ignorewarnings'] ) {
252 // Get warnings formatted in result array format
253 return $this->getWarningsResult( $warnings );
254 } elseif ( $this->mParams['chunk'] ) {
255 // Add chunk, and get result
256 return $this->getChunkResult( $warnings );
257 } elseif ( $this->mParams['stash'] ) {
258 // Stash the file and get stash result
259 return $this->getStashResult( $warnings );
260 }
261
262 // This is the most common case -- a normal upload with no warnings
263 // performUpload will return a formatted properly for the API with status
264 return $this->performUpload( $warnings );
265 }
266
272 private function getStashResult( $warnings ) {
273 $result = [];
274 $result['result'] = 'Success';
275 if ( $warnings && count( $warnings ) > 0 ) {
276 $result['warnings'] = $warnings;
277 }
278 // Some uploads can request they be stashed, so as not to publish them immediately.
279 // In this case, a failure to stash ought to be fatal
280 $this->performStash( 'critical', $result );
281
282 return $result;
283 }
284
290 private function getWarningsResult( $warnings ) {
291 $result = [];
292 $result['result'] = 'Warning';
293 $result['warnings'] = $warnings;
294 // in case the warnings can be fixed with some further user action, let's stash this upload
295 // and return a key they can use to restart it
296 $this->performStash( 'optional', $result );
297
298 return $result;
299 }
300
307 public static function getMinUploadChunkSize( Config $config ) {
308 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
309
310 // Leave some room for other POST parameters
311 $postMax = (
313 ini_get( 'post_max_size' ),
314 PHP_INT_MAX
315 ) ?: PHP_INT_MAX
316 ) - 1024;
317
318 // Ensure the minimum chunk size is less than PHP upload limits
319 // or the maximum upload size.
320 return min(
321 $configured,
322 UploadBase::getMaxUploadSize( 'file' ),
323 UploadBase::getMaxPhpUploadSize(),
324 $postMax
325 );
326 }
327
333 private function getChunkResult( $warnings ) {
334 $result = [];
335
336 if ( $warnings && count( $warnings ) > 0 ) {
337 $result['warnings'] = $warnings;
338 }
339
340 $chunkUpload = $this->getMain()->getUpload( 'chunk' );
341 $chunkPath = $chunkUpload->getTempName();
342 $chunkSize = $chunkUpload->getSize();
343 $totalSoFar = $this->mParams['offset'] + $chunkSize;
344 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
345
346 // Double check sizing
347 if ( $totalSoFar > $this->mParams['filesize'] ) {
348 $this->dieWithError( 'apierror-invalid-chunk' );
349 }
350
351 // Enforce minimum chunk size
352 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
353 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
354 }
355
356 if ( $this->mParams['offset'] == 0 ) {
357 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
358 [
359 'user' => $this->getUser()->getName(),
360 'filename' => $this->mParams['filename'] ?? '-',
361 'filesize' => $this->mParams['filesize'],
362 'chunkSize' => $chunkSize
363 ]
364 );
365 $filekey = $this->performStash( 'critical' );
366 } else {
367 $filekey = $this->mParams['filekey'];
368
369 // Don't allow further uploads to an already-completed session
370 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
371 if ( !$progress ) {
372 // Probably can't get here, but check anyway just in case
373 $this->log->info( "Stash failed due to no session for {user}",
374 [
375 'user' => $this->getUser()->getName(),
376 'filename' => $this->mParams['filename'] ?? '-',
377 'filekey' => $this->mParams['filekey'] ?? '-',
378 'filesize' => $this->mParams['filesize'],
379 'chunkSize' => $chunkSize
380 ]
381 );
382 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
383 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
384 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
385 }
386
387 $status = $this->mUpload->addChunk(
388 $chunkPath, $chunkSize, $this->mParams['offset'] );
389 if ( !$status->isGood() ) {
390 $extradata = [
391 'offset' => $this->mUpload->getOffset(),
392 ];
393 $this->log->info( "Chunked upload stash failure {status} for {user}",
394 [
395 'status' => (string)$status,
396 'user' => $this->getUser()->getName(),
397 'filename' => $this->mParams['filename'] ?? '-',
398 'filekey' => $this->mParams['filekey'] ?? '-',
399 'filesize' => $this->mParams['filesize'],
400 'chunkSize' => $chunkSize,
401 'offset' => $this->mUpload->getOffset()
402 ]
403 );
404 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
405 } else {
406 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
407 [
408 'user' => $this->getUser()->getName(),
409 'filename' => $this->mParams['filename'] ?? '-',
410 'filekey' => $this->mParams['filekey'] ?? '-',
411 'filesize' => $this->mParams['filesize'],
412 'chunkSize' => $chunkSize,
413 'offset' => $this->mUpload->getOffset()
414 ]
415 );
416 }
417 }
418
419 // Check we added the last chunk:
420 if ( $totalSoFar == $this->mParams['filesize'] ) {
421 if ( $this->mParams['async'] ) {
423 $this->getUser(),
424 $filekey,
425 [ 'result' => 'Poll',
426 'stage' => 'queued', 'status' => Status::newGood() ]
427 );
428 // It is important that this be lazyPush, as we do not want to insert
429 // into job queue until after the current transaction has completed since
430 // this depends on values in uploadstash table that were updated during
431 // the current transaction. (T350917)
432 $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
433 'filename' => $this->mParams['filename'],
434 'filekey' => $filekey,
435 'filesize' => $this->mParams['filesize'],
436 'session' => $this->getContext()->exportSession()
437 ] ) );
438 $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
439 [
440 'user' => $this->getUser()->getName(),
441 'filename' => $this->mParams['filename'] ?? '-',
442 'filekey' => $this->mParams['filekey'] ?? '-',
443 'filesize' => $this->mParams['filesize'],
444 'chunkSize' => $chunkSize,
445 ]
446 );
447 $result['result'] = 'Poll';
448 $result['stage'] = 'queued';
449 } else {
450 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
451 [
452 'user' => $this->getUser()->getName(),
453 'filename' => $this->mParams['filename'] ?? '-',
454 'filekey' => $this->mParams['filekey'] ?? '-',
455 'filesize' => $this->mParams['filesize'],
456 'chunkSize' => $chunkSize,
457 ]
458 );
459
460 $status = $this->mUpload->concatenateChunks();
461 if ( !$status->isGood() ) {
463 $this->getUser(),
464 $filekey,
465 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
466 );
467 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
468 [
469 'user' => $this->getUser()->getName(),
470 'filename' => $this->mParams['filename'] ?? '-',
471 'filekey' => $this->mParams['filekey'] ?? '-',
472 'filesize' => $this->mParams['filesize'],
473 'chunkSize' => $chunkSize,
474 'status' => (string)$status
475 ]
476 );
477 $this->dieStatusWithCode( $status, 'stashfailed' );
478 }
479
480 // We can only get warnings like 'duplicate' after concatenating the chunks
481 $warnings = $this->getApiWarnings();
482 if ( $warnings ) {
483 $result['warnings'] = $warnings;
484 }
485
486 // The fully concatenated file has a new filekey. So remove
487 // the old filekey and fetch the new one.
488 UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
489 $this->mUpload->stash->removeFile( $filekey );
490 $filekey = $this->mUpload->getStashFile()->getFileKey();
491
492 $result['result'] = 'Success';
493 }
494 } else {
496 $this->getUser(),
497 $filekey,
498 [
499 'result' => 'Continue',
500 'stage' => 'uploading',
501 'offset' => $totalSoFar,
502 'status' => Status::newGood(),
503 ]
504 );
505 $result['result'] = 'Continue';
506 $result['offset'] = $totalSoFar;
507 }
508
509 $result['filekey'] = $filekey;
510
511 return $result;
512 }
513
526 private function performStash( $failureMode, &$data = null ) {
527 $isPartial = (bool)$this->mParams['chunk'];
528 try {
529 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
530
531 if ( $status->isGood() && !$status->getValue() ) {
532 // Not actually a 'good' status...
533 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
534 }
535 } catch ( Exception $e ) {
536 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
537 $this->log->info( $debugMessage,
538 [
539 'user' => $this->getUser()->getName(),
540 'filename' => $this->mParams['filename'] ?? '-',
541 'filekey' => $this->mParams['filekey'] ?? '-'
542 ]
543 );
544
545 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
546 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
547 ) );
548 }
549
550 if ( $status->isGood() ) {
551 $stashFile = $status->getValue();
552 $data['filekey'] = $stashFile->getFileKey();
553 // Backwards compatibility
554 $data['sessionkey'] = $data['filekey'];
555 return $data['filekey'];
556 }
557
558 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
559 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
560 // Statuses for it. Just extract the exception details and parse them ourselves.
561 [ $exceptionType, $message ] = $status->getMessage()->getParams();
562 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
563 $this->log->info( $debugMessage,
564 [
565 'user' => $this->getUser()->getName(),
566 'filename' => $this->mParams['filename'] ?? '-',
567 'filekey' => $this->mParams['filekey'] ?? '-'
568 ]
569 );
570 }
571
572 $this->log->info( "Stash upload failure {status}",
573 [
574 'status' => (string)$status,
575 'user' => $this->getUser()->getName(),
576 'filename' => $this->mParams['filename'] ?? '-',
577 'filekey' => $this->mParams['filekey'] ?? '-'
578 ]
579 );
580 // Bad status
581 if ( $failureMode !== 'optional' ) {
582 $this->dieStatus( $status );
583 } else {
584 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
585 return null;
586 }
587 }
588
598 private function dieRecoverableError( $errors, $parameter = null ) {
599 $this->performStash( 'optional', $data );
600
601 if ( $parameter ) {
602 $data['invalidparameter'] = $parameter;
603 }
604
605 $sv = StatusValue::newGood();
606 foreach ( $errors as $error ) {
607 $msg = ApiMessage::create( $error );
608 $msg->setApiData( $msg->getApiData() + $data );
609 $sv->fatal( $msg );
610 }
611 $this->dieStatus( $sv );
612 }
613
624 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
625 $sv = StatusValue::newGood();
626 foreach ( $status->getMessages() as $error ) {
627 $msg = ApiMessage::create( $error, $overrideCode );
628 if ( $moreExtraData ) {
629 $msg->setApiData( $msg->getApiData() + $moreExtraData );
630 }
631 $sv->fatal( $msg );
632 }
633 $this->dieStatus( $sv );
634 }
635
643 protected function selectUploadModule() {
644 // chunk or one and only one of the following parameters is needed
645 if ( !$this->mParams['chunk'] ) {
646 $this->requireOnlyOneParameter( $this->mParams,
647 'filekey', 'file', 'url' );
648 }
649
650 // Status report for "upload to stash"/"upload from stash"/"upload by url"
651 if ( $this->mParams['checkstatus'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
652 $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
653 $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
654 if ( !$progress ) {
655 $this->log->info( "Cannot check upload status due to missing upload session for {user}",
656 [
657 'user' => $this->getUser()->getName(),
658 'filename' => $this->mParams['filename'] ?? '-',
659 'filekey' => $this->mParams['filekey'] ?? '-'
660 ]
661 );
662 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
663 } elseif ( !$progress['status']->isGood() ) {
664 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
665 }
666 if ( isset( $progress['status']->value['verification'] ) ) {
667 $this->checkVerification( $progress['status']->value['verification'] );
668 }
669 if ( isset( $progress['status']->value['warnings'] ) ) {
670 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
671 if ( $warnings ) {
672 $progress['warnings'] = $warnings;
673 }
674 }
675 unset( $progress['status'] ); // remove Status object
676 $imageinfo = null;
677 if ( isset( $progress['imageinfo'] ) ) {
678 $imageinfo = $progress['imageinfo'];
679 unset( $progress['imageinfo'] );
680 }
681
682 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
683 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
684 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
685 if ( $imageinfo ) {
686 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
687 }
688
689 return false;
690 }
691
692 // The following modules all require the filename parameter to be set
693 if ( $this->mParams['filename'] === null ) {
694 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
695 }
696
697 if ( $this->mParams['chunk'] ) {
698 // Chunk upload
699 $this->mUpload = new UploadFromChunks( $this->getUser() );
700 if ( isset( $this->mParams['filekey'] ) ) {
701 if ( $this->mParams['offset'] === 0 ) {
702 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
703 }
704
705 // handle new chunk
706 $this->mUpload->continueChunks(
707 $this->mParams['filename'],
708 $this->mParams['filekey'],
709 $this->getMain()->getUpload( 'chunk' )
710 );
711 } else {
712 if ( $this->mParams['offset'] !== 0 ) {
713 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
714 }
715
716 // handle first chunk
717 $this->mUpload->initialize(
718 $this->mParams['filename'],
719 $this->getMain()->getUpload( 'chunk' )
720 );
721 }
722 } elseif ( isset( $this->mParams['filekey'] ) ) {
723 // Upload stashed in a previous request
724 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
725 $this->dieWithError( 'apierror-invalid-file-key' );
726 }
727
728 $this->mUpload = new UploadFromStash( $this->getUser() );
729 // This will not download the temp file in initialize() in async mode.
730 // We still have enough information to call checkWarnings() and such.
731 $this->mUpload->initialize(
732 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
733 );
734 } elseif ( isset( $this->mParams['file'] ) ) {
735 // Can't async upload directly from a POSTed file, we'd have to
736 // stash the file and then queue the publish job. The user should
737 // just submit the two API queries to perform those two steps.
738 if ( $this->mParams['async'] ) {
739 $this->dieWithError( 'apierror-cannot-async-upload-file' );
740 }
741
742 $this->mUpload = new UploadFromFile();
743 $this->mUpload->initialize(
744 $this->mParams['filename'],
745 $this->getMain()->getUpload( 'file' )
746 );
747 } elseif ( isset( $this->mParams['url'] ) ) {
748 // Make sure upload by URL is enabled:
749 if ( !UploadFromUrl::isEnabled() ) {
750 $this->dieWithError( 'copyuploaddisabled' );
751 }
752
753 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
754 $this->dieWithError( 'apierror-copyuploadbaddomain' );
755 }
756
757 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
758 $this->dieWithError( 'apierror-copyuploadbadurl' );
759 }
760
761 $this->mUpload = new UploadFromUrl;
762 $this->mUpload->initialize( $this->mParams['filename'],
763 $this->mParams['url'] );
764 }
765
766 return true;
767 }
768
774 protected function checkPermissions( $user ) {
775 // Check whether the user has the appropriate permissions to upload anyway
776 $permission = $this->mUpload->isAllowed( $user );
777
778 if ( $permission !== true ) {
779 if ( !$user->isNamed() ) {
780 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
781 }
782
783 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
784 }
785
786 // Check blocks
787 if ( $user->isBlockedFromUpload() ) {
788 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
789 $this->dieBlocked( $user->getBlock() );
790 }
791 }
792
796 protected function verifyUpload() {
797 if ( $this->mParams['chunk'] ) {
798 $maxSize = UploadBase::getMaxUploadSize();
799 if ( $this->mParams['filesize'] > $maxSize ) {
800 $this->dieWithError( 'file-too-large' );
801 }
802 if ( !$this->mUpload->getTitle() ) {
803 $this->dieWithError( 'illegal-filename' );
804 }
805 // file will be assembled after having uploaded the last chunk,
806 // so we can only validate the name at this point
807 $verification = $this->mUpload->validateName();
808 if ( $verification === true ) {
809 return;
810 }
811 } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
812 // file will be assembled/downloaded in a background process, so we
813 // can only validate the name at this point
814 // file verification will happen in background process
815 $verification = $this->mUpload->validateName();
816 if ( $verification === true ) {
817 return;
818 }
819 } else {
820 wfDebug( __METHOD__ . " about to verify" );
821
822 $verification = $this->mUpload->verifyUpload();
823
824 if ( $verification['status'] === UploadBase::OK ) {
825 return;
826 } else {
827 $this->log->info( "File verification of {filename} failed for {user} because {result}",
828 [
829 'user' => $this->getUser()->getName(),
830 'resultCode' => $verification['status'],
831 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
832 'filename' => $this->mParams['filename'] ?? '-',
833 'details' => $verification['details'] ?? ''
834 ]
835 );
836 }
837 }
838
839 $this->checkVerification( $verification );
840 }
841
847 protected function checkVerification( array $verification ) {
848 $status = $this->mUpload->convertVerifyErrorToStatus( $verification );
849 if ( $status->isRecoverableError() ) {
850 $this->dieRecoverableError( [ $status->asApiMessage() ], $status->getInvalidParameter() );
851 // dieRecoverableError prevents continuation
852 }
853 $this->dieWithError( $status->asApiMessage() );
854 // dieWithError prevents continuation
855 }
856
864 protected function getApiWarnings() {
865 $warnings = UploadBase::makeWarningsSerializable(
866 $this->mUpload->checkWarnings( $this->getUser() )
867 );
868
869 return $this->transformWarnings( $warnings );
870 }
871
872 protected function transformWarnings( $warnings ) {
873 if ( $warnings ) {
874 // Add indices
875 ApiResult::setIndexedTagName( $warnings, 'warning' );
876
877 if ( isset( $warnings['duplicate'] ) ) {
878 $dupes = array_column( $warnings['duplicate'], 'fileName' );
879 ApiResult::setIndexedTagName( $dupes, 'duplicate' );
880 $warnings['duplicate'] = $dupes;
881 }
882
883 if ( isset( $warnings['exists'] ) ) {
884 $warning = $warnings['exists'];
885 unset( $warnings['exists'] );
886 $localFile = $warning['normalizedFile'] ?? $warning['file'];
887 $warnings[$warning['warning']] = $localFile['fileName'];
888 }
889
890 if ( isset( $warnings['no-change'] ) ) {
891 $file = $warnings['no-change'];
892 unset( $warnings['no-change'] );
893
894 $warnings['nochange'] = [
895 'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] )
896 ];
897 }
898
899 if ( isset( $warnings['duplicate-version'] ) ) {
900 $dupes = [];
901 foreach ( $warnings['duplicate-version'] as $dupe ) {
902 $dupes[] = [
903 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe['timestamp'] )
904 ];
905 }
906 unset( $warnings['duplicate-version'] );
907
908 ApiResult::setIndexedTagName( $dupes, 'ver' );
909 $warnings['duplicateversions'] = $dupes;
910 }
911 // We haven't downloaded the file, so this will result in an empty file warning
912 if ( $this->mParams['async'] && $this->mParams['url'] ) {
913 unset( $warnings['empty-file'] );
914 }
915 }
916
917 return $warnings;
918 }
919
926 protected function handleStashException( $e ) {
927 $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
928 [
929 'user' => $this->getUser()->getName(),
930 'error' => get_class( $e ),
931 'filename' => $this->mParams['filename'] ?? '-',
932 'filekey' => $this->mParams['filekey'] ?? '-'
933 ]
934 );
935
936 switch ( get_class( $e ) ) {
937 case UploadStashFileNotFoundException::class:
938 $wrap = 'apierror-stashedfilenotfound';
939 break;
940 case UploadStashBadPathException::class:
941 $wrap = 'apierror-stashpathinvalid';
942 break;
943 case UploadStashFileException::class:
944 $wrap = 'apierror-stashfilestorage';
945 break;
946 case UploadStashZeroLengthFileException::class:
947 $wrap = 'apierror-stashzerolength';
948 break;
949 case UploadStashNotLoggedInException::class:
950 return StatusValue::newFatal( ApiMessage::create(
951 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
952 ) );
953 case UploadStashWrongOwnerException::class:
954 $wrap = 'apierror-stashwrongowner';
955 break;
956 case UploadStashNoSuchKeyException::class:
957 $wrap = 'apierror-stashnosuchfilekey';
958 break;
959 default:
960 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
961 break;
962 }
963 return StatusValue::newFatal(
964 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
965 );
966 }
967
975 protected function performUpload( $warnings ) {
976 // Use comment as initial page text by default
977 $this->mParams['text'] ??= $this->mParams['comment'];
978
980 $file = $this->mUpload->getLocalFile();
981 $user = $this->getUser();
982 $title = $file->getTitle();
983
984 // for preferences mode, we want to watch if 'watchdefault' is set,
985 // or if the *file* doesn't exist, and either 'watchuploads' or
986 // 'watchcreations' is set. But getWatchlistValue()'s automatic
987 // handling checks if the *title* exists or not, so we need to check
988 // all three preferences manually.
989 $watch = $this->getWatchlistValue(
990 $this->mParams['watchlist'], $title, $user, 'watchdefault'
991 );
992
993 if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
994 $watch = (
995 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
996 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
997 );
998 }
999 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams );
1000
1001 // Deprecated parameters
1002 if ( $this->mParams['watch'] ) {
1003 $watch = true;
1004 }
1005
1006 if ( $this->mParams['tags'] ) {
1007 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1008 if ( !$status->isOK() ) {
1009 $this->dieStatus( $status );
1010 }
1011 }
1012
1013 // No errors, no warnings: do the upload
1014 $result = [];
1015 if ( $this->mParams['async'] ) {
1016 // Only stash uploads and copy uploads support async
1017 if ( $this->mParams['filekey'] ) {
1019 [
1020 'filename' => $this->mParams['filename'],
1021 'filekey' => $this->mParams['filekey'],
1022 'comment' => $this->mParams['comment'],
1023 'tags' => $this->mParams['tags'] ?? [],
1024 'text' => $this->mParams['text'],
1025 'watch' => $watch,
1026 'watchlistexpiry' => $watchlistExpiry,
1027 'session' => $this->getContext()->exportSession(),
1028 'ignorewarnings' => $this->mParams['ignorewarnings']
1029 ]
1030 );
1031 } elseif ( $this->mParams['url'] ) {
1032 $job = new UploadFromUrlJob(
1033 [
1034 'filename' => $this->mParams['filename'],
1035 'url' => $this->mParams['url'],
1036 'comment' => $this->mParams['comment'],
1037 'tags' => $this->mParams['tags'] ?? [],
1038 'text' => $this->mParams['text'],
1039 'watch' => $watch,
1040 'watchlistexpiry' => $watchlistExpiry,
1041 'session' => $this->getContext()->exportSession(),
1042 'ignorewarnings' => $this->mParams['ignorewarnings']
1043 ]
1044 );
1045 } else {
1046 $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1047 // We will never reach this, but it's here to help phan figure out
1048 // $job is never null
1049 // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1050 return [];
1051 }
1052 $cacheKey = $job->getCacheKey();
1053 // Check if an upload is already in progress.
1054 // the result can be Poll / Failure / Success
1055 $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey );
1056 if ( $progress && $progress['result'] === 'Poll' ) {
1057 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1058 }
1059 UploadBase::setSessionStatus(
1060 $this->getUser(),
1061 $cacheKey,
1062 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1063 );
1064
1065 $this->jobQueueGroup->push( $job );
1066 $this->log->info( "Sending publish job of {filename} for {user}",
1067 [
1068 'user' => $this->getUser()->getName(),
1069 'filename' => $this->mParams['filename'] ?? '-'
1070 ]
1071 );
1072 $result['result'] = 'Poll';
1073 $result['stage'] = 'queued';
1074 } else {
1076 $status = $this->mUpload->performUpload(
1077 $this->mParams['comment'],
1078 $this->mParams['text'],
1079 $watch,
1080 $this->getUser(),
1081 $this->mParams['tags'] ?? [],
1082 $watchlistExpiry
1083 );
1084
1085 if ( !$status->isGood() ) {
1086 $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1087 [
1088 'user' => $this->getUser()->getName(),
1089 'filename' => $this->mParams['filename'] ?? '-',
1090 'filekey' => $this->mParams['filekey'] ?? '-',
1091 'status' => (string)$status
1092 ]
1093 );
1094 $this->dieRecoverableError( $status->getMessages() );
1095 }
1096 $result['result'] = 'Success';
1097 }
1098
1099 $result['filename'] = $file->getName();
1100 if ( $warnings && count( $warnings ) > 0 ) {
1101 $result['warnings'] = $warnings;
1102 }
1103
1104 return $result;
1105 }
1106
1107 public function mustBePosted() {
1108 return true;
1109 }
1110
1111 public function isWriteMode() {
1112 return true;
1113 }
1114
1115 public function getAllowedParams() {
1116 $params = [
1117 'filename' => [
1118 ParamValidator::PARAM_TYPE => 'string',
1119 ],
1120 'comment' => [
1121 ParamValidator::PARAM_DEFAULT => ''
1122 ],
1123 'tags' => [
1124 ParamValidator::PARAM_TYPE => 'tags',
1125 ParamValidator::PARAM_ISMULTI => true,
1126 ],
1127 'text' => [
1128 ParamValidator::PARAM_TYPE => 'text',
1129 ],
1130 'watch' => [
1131 ParamValidator::PARAM_DEFAULT => false,
1132 ParamValidator::PARAM_DEPRECATED => true,
1133 ],
1134 ];
1135
1136 // Params appear in the docs in the order they are defined,
1137 // which is why this is here and not at the bottom.
1138 $params += $this->getWatchlistParams( [
1139 'watch',
1140 'preferences',
1141 'nochange',
1142 ] );
1143
1144 $params += [
1145 'ignorewarnings' => false,
1146 'file' => [
1147 ParamValidator::PARAM_TYPE => 'upload',
1148 ],
1149 'url' => null,
1150 'filekey' => null,
1151 'sessionkey' => [
1152 ParamValidator::PARAM_DEPRECATED => true,
1153 ],
1154 'stash' => false,
1155
1156 'filesize' => [
1157 ParamValidator::PARAM_TYPE => 'integer',
1158 IntegerDef::PARAM_MIN => 0,
1159 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(),
1160 ],
1161 'offset' => [
1162 ParamValidator::PARAM_TYPE => 'integer',
1163 IntegerDef::PARAM_MIN => 0,
1164 ],
1165 'chunk' => [
1166 ParamValidator::PARAM_TYPE => 'upload',
1167 ],
1168
1169 'async' => false,
1170 'checkstatus' => false,
1171 ];
1172
1173 return $params;
1174 }
1175
1176 public function needsToken() {
1177 return 'csrf';
1178 }
1179
1180 protected function getExamplesMessages() {
1181 return [
1182 'action=upload&filename=Wiki.png' .
1183 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1184 => 'apihelp-upload-example-url',
1185 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1186 => 'apihelp-upload-example-filekey',
1187 ];
1188 }
1189
1190 public function getHelpUrls() {
1191 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1192 }
1193}
1194
1196class_alias( ApiUpload::class, 'ApiUpload' );
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfShorthandToInteger(?string $string='', int $default=-1)
Converts shorthand byte notation to integer form.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:75
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1526
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:557
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition ApiBase.php:1374
getResult()
Get the result object.
Definition ApiBase.php:696
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition ApiBase.php:1763
dieStatus(StatusValue $status)
Throw an ApiUsageException based on the Status object.
Definition ApiBase.php:1577
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:837
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:79
static getPropertyNames( $filter=[])
Returns all possible parameters to iiprop.
static getInfo( $file, $prop, $result, $thumbParams=null, $opts=false)
Get result information for an image revision.
static getPropertyNames( $filter=null)
Returns all possible parameters to siiprop.
checkVerification(array $verification)
Performs file verification, dies on error.
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
handleStashException( $e)
Handles a stash exception, giving a useful error to the user.
checkPermissions( $user)
Checks that the user has permissions to perform this upload.
selectUploadModule()
Select an upload module and set it to mUpload.
dieStatusWithCode( $status, $overrideCode, $moreExtraData=null)
Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from IApiMe...
__construct(ApiMain $mainModule, string $moduleName, JobQueueGroup $jobQueueGroup, WatchlistManager $watchlistManager, UserOptionsLookup $userOptionsLookup)
Definition ApiUpload.php:82
transformWarnings( $warnings)
static getMinUploadChunkSize(Config $config)
verifyUpload()
Performs file verification, dies on error.
execute()
Evaluates the parameters, performs the requested query, and sets up the result.
getUploadImageInfo(UploadBase $upload)
Gets image info about the file just uploaded.
getApiWarnings()
Check warnings.
needsToken()
Returns the token type this module requires in order to execute.
getExamplesMessages()
Returns usage examples for this module.
UploadBase UploadFromChunks null $mUpload
Definition ApiUpload.php:73
performUpload( $warnings)
Perform the actual upload.
getHelpUrls()
Return links to more detailed help pages about the module.
mustBePosted()
Indicates whether this module must be called with a POST request.
isWriteMode()
Indicates whether this module requires write access to the wiki.
Recent changes tagging.
Local file in the wiki's own database.
Definition LocalFile.php:93
Handle enqueueing of background jobs.
get( $type)
Get the job queue object for a given queue type.
Assemble the segments of a chunked upload.
Upload a file from the upload stash into the local file repo.
Upload a file by URL, via the jobqueue.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const EnableAsyncUploads
Name constant for the EnableAsyncUploads setting, for use with Config::get()
const WatchlistExpiry
Name constant for the WatchlistExpiry setting, for use with Config::get()
const WatchlistExpiryMaxDuration
Name constant for the WatchlistExpiryMaxDuration setting, for use with Config::get()
Service locator for MediaWiki core services.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:157
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Provides access to user options.
User class for the MediaWiki software.
Definition User.php:123
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newGood( $value=null)
Factory function for good results.
UploadBase and subclasses are the backend of MediaWiki's file uploads.
static setSessionStatus(UserIdentity $user, $statusKey, $value)
Set the current status of a chunked upload (used for polling).
getLocalFile()
Return the local file and initializes if necessary.
static getSessionStatus(UserIdentity $user, $statusKey)
Get the current status of a chunked upload (used for polling).
Implements uploading from chunks.
Implements regular file uploads.
Implements uploading from previously stored file.
Implements uploading from a HTTP resource.
initialize( $name, $url)
Entry point for API upload.
Service for formatting and validating API parameters.
Type definition for integer types.
trait ApiWatchlistTrait
An ApiWatchlistTrait adds class properties and convenience methods for APIs that allow you to watch a...
Interface for configuration instances.
Definition Config.php:32
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
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.
getExpiryFromParams(array $params, ?UserIdentity $user=null, string $userOption='watchdefault-expiry')
Get formatted expiry from the given parameters.
if(count( $args)< 1) $job