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