MediaWiki master
ApiUpload.php
Go to the documentation of this file.
1<?php
14namespace MediaWiki\Api;
15
16use Exception;
50use Psr\Log\LoggerInterface;
51use StatusValue;
55use Wikimedia\Timestamp\TimestampFormat as TS;
56
60class ApiUpload extends ApiBase {
61
63
65 protected $mUpload = null;
66
68 protected $mParams;
69
70 private readonly LocalRepo $localRepo;
71
72 private LoggerInterface $log;
73
74 public function __construct(
75 ApiMain $mainModule,
76 string $moduleName,
77 private readonly JobQueueGroup $jobQueueGroup,
78 WatchlistManager $watchlistManager,
79 WatchedItemStoreInterface $watchedItemStore,
80 UserOptionsLookup $userOptionsLookup,
81 RepoGroup $repoGroup,
82 ) {
83 parent::__construct( $mainModule, $moduleName );
84 $this->localRepo = $repoGroup->getLocalRepo();
85
86 // Variables needed in ApiWatchlistTrait trait
87 $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
88 $this->watchlistMaxDuration =
90 $this->watchlistManager = $watchlistManager;
91 $this->watchedItemStore = $watchedItemStore;
92 $this->userOptionsLookup = $userOptionsLookup;
93 $this->log = LoggerFactory::getInstance( 'upload' );
94 }
95
96 public function execute() {
97 // Check whether upload is enabled
98 if ( !UploadBase::isEnabled() ) {
99 $this->dieWithError( 'uploaddisabled' );
100 }
101
102 $user = $this->getUser();
103 $config = $this->getConfig();
104
105 // Parameter handling
106 $this->mParams = $this->extractRequestParams();
107 // Check if async mode is actually supported (jobs done in cli mode)
108 $this->mParams['async'] = $this->mParams['async'] &&
109 $config->get( MainConfigNames::EnableAsyncUploads ) &&
110 ( !$this->mParams['url'] || $config->get( MainConfigNames::EnableAsyncUploadsByURL ) );
111
112 // Copy the session key to the file key, for backward compatibility.
113 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
114 $this->mParams['filekey'] = $this->mParams['sessionkey'];
115 }
116
117 // 'text' is only used as the file page's text when actually
118 // publishing. A stash request never creates the page, so 'text' is
119 // ignored.
120 if ( $this->mParams['stash'] && $this->mParams['text'] !== null ) {
121 $this->addWarning( 'apiwarn-upload-textwithstash' );
122 }
123
124 // 'autotext' generates the file page's text when publishing. A stash
125 // request never creates the page, so 'autotext' is ignored.
126 if ( $this->mParams['stash'] && $this->mParams['autotext'] ) {
127 $this->addWarning( 'apiwarn-upload-autotextwithstash' );
128 }
129
130 if ( !$this->mParams['checkstatus'] ) {
132 }
133
134 // Select an upload module
135 try {
136 if ( !$this->selectUploadModule() ) {
137 // not a true upload, but a status request or similar
138 return;
139 } elseif ( !$this->mUpload ) {
140 self::dieDebug( __METHOD__, 'No upload module set' );
141 }
142 } catch ( UploadStashException $e ) {
143 // XXX: don't spam exception log
144 $this->dieStatus( $this->handleStashException( $e ) );
145 }
146
147 // First check permission to upload
148 $this->checkPermissions( $user );
149
150 // Fetch the file (usually a no-op)
151 // Skip for async upload from URL, where we just want to run checks.
153 if ( $this->mParams['async'] && $this->mParams['url'] ) {
154 $status = $this->mUpload->canFetchFile();
155 } else {
156 $status = $this->mUpload->fetchFile();
157 }
158
159 if ( !$status->isGood() ) {
160 $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
161 [
162 'user' => $this->getUser()->getName(),
163 'status' => (string)$status,
164 'filename' => $this->mParams['filename'] ?? '-',
165 ]
166 );
167 $this->dieStatus( $status );
168 }
169
170 // Check the uploaded file
171 $this->verifyUpload();
172
173 // Check if the user has the rights to modify or overwrite the requested title
174 // (This check is irrelevant if stashing is already requested, since the errors
175 // can always be fixed by changing the title)
176 if ( !$this->mParams['stash'] ) {
177 $status = $this->mUpload->authorizeUpload( $user );
178 if ( !$status->isGood() ) {
179 $this->dieRecoverableError( $status->getMessages(), 'filename' );
180 }
181 }
182
183 // Get the result based on the current upload context:
184 try {
185 $result = $this->getContextResult();
186 } catch ( UploadStashException $e ) {
187 // XXX: don't spam exception log
188 $this->dieStatus( $this->handleStashException( $e ) );
189 }
190 $this->getResult()->addValue( null, $this->getModuleName(), $result );
191
192 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
193 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
194 if ( $result['result'] === 'Success' ) {
195 $imageinfo = $this->getUploadImageInfo( $this->mUpload );
196 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
197 }
198
199 // Cleanup any temporary mess
200 $this->mUpload->cleanupTempFile();
201 }
202
207 public static function getDummyInstance(): self {
208 $services = MediaWikiServices::getInstance();
209 return new ApiUpload(
210 // dummy object (XXX)
211 new ApiMain(),
212 'upload',
213 $services->getJobQueueGroup(),
214 $services->getWatchlistManager(),
215 $services->getWatchedItemStore(),
216 $services->getUserOptionsLookup(),
217 $services->getRepoGroup(),
218 );
219 }
220
233 public function getUploadImageInfo( UploadBase $upload ): array {
234 $stashFile = $upload->getStashFile();
235 if ( $stashFile ) {
236 $info = $this->getUploadImageInfoInternal( $stashFile, true );
237 } else {
238 $localFile = $upload->getLocalFile();
239 $info = $this->getUploadImageInfoInternal( $localFile, false );
240 }
241
242 return $info;
243 }
244
245 private function getUploadImageInfoInternal( File $file, bool $stashedImageInfos ): array {
246 $result = $this->getResult();
247 // Calling a different API module depending on whether the file was stashed is less than optimal.
248 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
249
250 // TODO: Reduce to what is needed
251 //
252 // Prior to May 2026 this used ApiQueryImageInfo::getPropertyNames to fetch "all" properties
253 // (except "uploadwarning"). It was frozen to avoid adding "thumburls" (T426246) and become
254 // more intentional. Figuring out what's needed is hard due to many levels of indirection,
255 // including mediawiki.Upload.js and mediawiki.Upload.BookletLayout (via API action=upload)
256 // and UploadBase::getSessionStatus (via getUploadImageInfo, UploadJobTrait, UploadFromUrlJob).
257 $iiprops = [
258 'timestamp',
259 'canonicaltitle',
260 'url',
261 'size',
262 'dimensions',
263 'sha1',
264 'mime',
265 'thumbmime',
266 'metadata',
267 'commonmetadata',
268 'extmetadata',
269 'bitdepth',
270 'badfile',
271 ];
272 if ( $stashedImageInfos ) {
273 $imParam = $iiprops;
274 $info = ApiQueryStashImageInfo::getInfo(
275 $file,
276 array_fill_keys( $imParam, true ),
277 $result
278 );
279 } else {
280 $imParam = [
281 ...$iiprops,
282 'user',
283 'userid',
284 'comment',
285 'parsedcomment',
286 'mediatype',
287 'archivename',
288 ];
289 $info = ApiQueryImageInfo::getInfo(
290 $file,
291 array_fill_keys( $imParam, true ),
292 $result
293 );
294 }
295
296 return $info;
297 }
298
303 private function getContextResult() {
304 $warnings = $this->getApiWarnings();
305 if ( $warnings && !$this->mParams['ignorewarnings'] ) {
306 // Get warnings formatted in result array format
307 return $this->getWarningsResult( $warnings );
308 } elseif ( $this->mParams['chunk'] ) {
309 // Add chunk, and get result
310 return $this->getChunkResult( $warnings );
311 } elseif ( $this->mParams['stash'] ) {
312 // Stash the file and get stash result
313 return $this->getStashResult( $warnings );
314 }
315
316 // This is the most common case -- a normal upload with no warnings
317 // performUpload will return a formatted properly for the API with status
318 return $this->performUpload( $warnings );
319 }
320
326 private function getStashResult( $warnings ) {
327 $result = [ 'result' => 'Success' ];
328 if ( $warnings && count( $warnings ) > 0 ) {
329 $result['warnings'] = $warnings;
330 }
331 // Some uploads can request they be stashed, so as not to publish them immediately.
332 // In this case, a failure to stash ought to be fatal
333 $this->performStash( 'critical', $result );
334
335 return $result;
336 }
337
343 private function getWarningsResult( $warnings ) {
344 $result = [
345 'result' => 'Warning',
346 'warnings' => $warnings,
347 ];
348
349 // in case the warnings can be fixed with some further user action, let's stash this upload
350 // and return a key they can use to restart it
351 $this->performStash( 'optional', $result );
352
353 return $result;
354 }
355
362 public static function getMinUploadChunkSize( Config $config ) {
363 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
364
365 // Leave some room for other POST parameters
366 $postMax = (
368 ini_get( 'post_max_size' ),
369 PHP_INT_MAX
370 ) ?: PHP_INT_MAX
371 ) - 1024;
372
373 // Ensure the minimum chunk size is less than PHP upload limits
374 // or the maximum upload size.
375 return min(
376 $configured,
377 UploadBase::getMaxUploadSize( 'file' ),
378 UploadBase::getMaxPhpUploadSize(),
379 $postMax
380 );
381 }
382
388 private function getChunkResult( $warnings ) {
389 $result = [];
390
391 if ( $warnings && count( $warnings ) > 0 ) {
392 $result['warnings'] = $warnings;
393 }
394
395 $chunkUpload = $this->getMain()->getUpload( 'chunk' );
396 $chunkPath = $chunkUpload->getTempName();
397 $chunkSize = $chunkUpload->getSize();
398 $totalSoFar = $this->mParams['offset'] + $chunkSize;
399 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
400
401 // Double check sizing
402 if ( $totalSoFar > $this->mParams['filesize'] ) {
403 $this->dieWithError( 'apierror-invalid-chunk' );
404 }
405
406 // Enforce minimum chunk size
407 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
408 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
409 }
410
411 if ( $this->mParams['offset'] == 0 ) {
412 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
413 [
414 'user' => $this->getUser()->getName(),
415 'filename' => $this->mParams['filename'] ?? '-',
416 'filesize' => $this->mParams['filesize'],
417 'chunkSize' => $chunkSize
418 ]
419 );
420 $filekey = $this->performStash( 'critical' );
421 } else {
422 $filekey = $this->mParams['filekey'];
423
424 // Don't allow further uploads to an already-completed session
425 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
426 if ( !$progress ) {
427 // Probably can't get here, but check anyway just in case
428 $this->log->info( "Stash failed due to no session for {user}",
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 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
438 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
439 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
440 }
441
442 $status = $this->mUpload->addChunk(
443 $chunkPath, $chunkSize, $this->mParams['offset'] );
444 if ( !$status->isGood() ) {
445 $extradata = [
446 'offset' => $this->mUpload->getOffset(),
447 ];
448 $this->log->info( "Chunked upload stash failure {status} for {user}",
449 [
450 'status' => (string)$status,
451 'user' => $this->getUser()->getName(),
452 'filename' => $this->mParams['filename'] ?? '-',
453 'filekey' => $this->mParams['filekey'] ?? '-',
454 'filesize' => $this->mParams['filesize'],
455 'chunkSize' => $chunkSize,
456 'offset' => $this->mUpload->getOffset()
457 ]
458 );
459 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
460 } else {
461 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
462 [
463 'user' => $this->getUser()->getName(),
464 'filename' => $this->mParams['filename'] ?? '-',
465 'filekey' => $this->mParams['filekey'] ?? '-',
466 'filesize' => $this->mParams['filesize'],
467 'chunkSize' => $chunkSize,
468 'offset' => $this->mUpload->getOffset()
469 ]
470 );
471 }
472 }
473
474 // Check we added the last chunk:
475 if ( $totalSoFar == $this->mParams['filesize'] ) {
476 if ( $this->mParams['async'] ) {
477 UploadBase::setSessionStatus(
478 $this->getUser(),
479 $filekey,
480 [ 'result' => 'Poll',
481 'stage' => 'queued', 'status' => Status::newGood() ]
482 );
483 // It is important that this be lazyPush, as we do not want to insert
484 // into job queue until after the current transaction has completed since
485 // this depends on values in uploadstash table that were updated during
486 // the current transaction. (T350917)
487 $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
488 'filename' => $this->mParams['filename'],
489 'filekey' => $filekey,
490 'filesize' => $this->mParams['filesize'],
491 'session' => $this->getContext()->exportSession()
492 ] ) );
493 $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
494 [
495 'user' => $this->getUser()->getName(),
496 'filename' => $this->mParams['filename'] ?? '-',
497 'filekey' => $this->mParams['filekey'] ?? '-',
498 'filesize' => $this->mParams['filesize'],
499 'chunkSize' => $chunkSize,
500 ]
501 );
502 $result['result'] = 'Poll';
503 $result['stage'] = 'queued';
504 } else {
505 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
506 [
507 'user' => $this->getUser()->getName(),
508 'filename' => $this->mParams['filename'] ?? '-',
509 'filekey' => $this->mParams['filekey'] ?? '-',
510 'filesize' => $this->mParams['filesize'],
511 'chunkSize' => $chunkSize,
512 ]
513 );
514
515 $status = $this->mUpload->concatenateChunks();
516 if ( !$status->isGood() ) {
517 UploadBase::setSessionStatus(
518 $this->getUser(),
519 $filekey,
520 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
521 );
522 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
523 [
524 'user' => $this->getUser()->getName(),
525 'filename' => $this->mParams['filename'] ?? '-',
526 'filekey' => $this->mParams['filekey'] ?? '-',
527 'filesize' => $this->mParams['filesize'],
528 'chunkSize' => $chunkSize,
529 'status' => (string)$status
530 ]
531 );
532 $this->dieStatusWithCode( $status, 'stashfailed' );
533 }
534
535 // We can only get warnings like 'duplicate' after concatenating the chunks
536 $warnings = $this->getApiWarnings();
537 if ( $warnings ) {
538 $result['warnings'] = $warnings;
539 }
540
541 // The fully concatenated file has a new filekey. So remove
542 // the old filekey and fetch the new one.
543 UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
544 $this->mUpload->stash->removeFile( $filekey );
545 $filekey = $this->mUpload->getStashFile()->getFileKey();
546
547 $result['result'] = 'Success';
548 }
549 } else {
550 UploadBase::setSessionStatus(
551 $this->getUser(),
552 $filekey,
553 [
554 'result' => 'Continue',
555 'stage' => 'uploading',
556 'offset' => $totalSoFar,
557 'status' => Status::newGood(),
558 ]
559 );
560 $result['result'] = 'Continue';
561 $result['offset'] = $totalSoFar;
562 }
563
564 $result['filekey'] = $filekey;
565
566 return $result;
567 }
568
581 private function performStash( $failureMode, &$data = [] ) {
582 if ( $failureMode === 'optional' && $this->mUpload->skipStashFileAttempt() ) {
583 return null;
584 }
585
586 $isPartial = (bool)$this->mParams['chunk'];
587 try {
588 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
589
590 if ( $status->isGood() && !$status->getValue() ) {
591 // Not actually a 'good' status...
592 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
593 }
594 } catch ( Exception $e ) {
595 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
596 $this->log->info( $debugMessage,
597 [
598 'user' => $this->getUser()->getName(),
599 'filename' => $this->mParams['filename'] ?? '-',
600 'filekey' => $this->mParams['filekey'] ?? '-'
601 ]
602 );
603
604 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
605 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
606 ) );
607 }
608
609 if ( $status->isGood() ) {
610 $stashFile = $status->getValue();
611 $data['filekey'] = $stashFile->getFileKey();
612 // Backwards compatibility
613 $data['sessionkey'] = $data['filekey'];
614 return $data['filekey'];
615 }
616
617 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
618 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
619 // Statuses for it. Just extract the exception details and parse them ourselves.
620 [ $exceptionType, $message ] = $status->getMessage()->getParams();
621 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
622 $this->log->info( $debugMessage,
623 [
624 'user' => $this->getUser()->getName(),
625 'filename' => $this->mParams['filename'] ?? '-',
626 'filekey' => $this->mParams['filekey'] ?? '-'
627 ]
628 );
629 }
630
631 $this->log->info( "Stash upload failure {status}",
632 [
633 'status' => (string)$status,
634 'user' => $this->getUser()->getName(),
635 'filename' => $this->mParams['filename'] ?? '-',
636 'filekey' => $this->mParams['filekey'] ?? '-'
637 ]
638 );
639 // Bad status
640 if ( $failureMode !== 'optional' ) {
641 $this->dieStatus( $status );
642 } else {
643 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
644 return null;
645 }
646 }
647
657 private function dieRecoverableError( $errors, $parameter = null ): never {
658 $data = [];
659 $this->performStash( 'optional', $data );
660
661 if ( $parameter ) {
662 $data['invalidparameter'] = $parameter;
663 }
664
665 $sv = StatusValue::newGood();
666 foreach ( $errors as $error ) {
667 $msg = ApiMessage::create( $error );
668 $msg->setApiData( $msg->getApiData() + $data );
669 $sv->fatal( $msg );
670 }
671 $this->dieStatus( $sv );
672 }
673
684 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ): never {
685 $sv = StatusValue::newGood();
686 foreach ( $status->getMessages() as $error ) {
687 $msg = ApiMessage::create( $error, $overrideCode );
688 if ( $moreExtraData ) {
689 $msg->setApiData( $msg->getApiData() + $moreExtraData );
690 }
691 $sv->fatal( $msg );
692 }
693 $this->dieStatus( $sv );
694 }
695
703 protected function selectUploadModule() {
704 // chunk or one and only one of the following parameters is needed
705 if ( !$this->mParams['chunk'] ) {
706 $this->requireOnlyOneParameter( $this->mParams,
707 'filekey', 'file', 'url' );
708 }
709
710 // Status report for "upload to stash"/"upload from stash"/"upload by url"
711 if ( $this->mParams['checkstatus'] &&
712 ( $this->mParams['filekey'] || ( $this->mParams['url'] && $this->mParams['filename'] ) )
713 ) {
714 $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
715 $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
716 if ( !$progress ) {
717 $this->log->info( "Cannot check upload status due to missing upload session for {user}",
718 [
719 'user' => $this->getUser()->getName(),
720 'url' => $this->mParams['url'] ?? '-',
721 'filename' => $this->mParams['filename'] ?? '-',
722 'filekey' => $this->mParams['filekey'] ?? '-'
723 ]
724 );
725 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
726 } elseif ( !$progress['status']->isGood() ) {
727 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
728 }
729 if ( isset( $progress['status']->value['verification'] ) ) {
730 $this->checkVerification( $progress['status']->value['verification'] );
731 }
732 if ( isset( $progress['status']->value['warnings'] ) ) {
733 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
734 if ( $warnings ) {
735 $progress['warnings'] = $warnings;
736 }
737 }
738 // remove Status object
739 unset( $progress['status'] );
740 $imageinfo = null;
741 if ( $progress['result'] === 'Success' ) {
742 if ( isset( $progress['filekey'] ) ) {
743 // assembled file, load stashed file from upload stash for imageinfo
744 $file = $this->localRepo->getUploadStash()->getFile( $progress['filekey'] );
745 if ( $file ) {
746 $imageinfo = $this->getUploadImageInfoInternal( $file, true );
747 }
748 } elseif ( isset( $progress['filename'] ) && isset( $progress['timestamp'] ) ) {
749 // published file, load local file from local repo for imageinfo
750 $file = $this->localRepo->findFile(
751 $progress['filename'],
752 [ 'time' => $progress['timestamp'], 'latest' => true ]
753 );
754 if ( $file ) {
755 $imageinfo = $this->getUploadImageInfoInternal( $file, false );
756 }
757 } elseif ( isset( $progress['imageinfo'] ) ) {
758 // status cache includes imageinfo from older entries (b/c for rollback of deployment)
759 $imageinfo = $progress['imageinfo'];
760 }
761 unset( $progress['imageinfo'] );
762 }
763
764 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
765 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
766 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
767 if ( $imageinfo ) {
768 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
769 }
770
771 return false;
772 }
773
774 // The following modules all require the filename parameter to be set
775 if ( $this->mParams['filename'] === null ) {
776 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
777 }
778
779 if ( $this->mParams['chunk'] ) {
780 // Chunk upload
781 $this->mUpload = new UploadFromChunks( $this->getUser() );
782 if ( isset( $this->mParams['filekey'] ) ) {
783 if ( $this->mParams['offset'] === 0 ) {
784 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
785 }
786
787 // handle new chunk
788 $this->mUpload->continueChunks(
789 $this->mParams['filename'],
790 $this->mParams['filekey'],
791 $this->getMain()->getUpload( 'chunk' )
792 );
793 } else {
794 if ( $this->mParams['offset'] !== 0 ) {
795 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
796 }
797
798 // handle first chunk
799 $this->mUpload->initialize(
800 $this->mParams['filename'],
801 $this->getMain()->getUpload( 'chunk' )
802 );
803 }
804 } elseif ( isset( $this->mParams['filekey'] ) ) {
805 // Upload stashed in a previous request
806 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
807 $this->dieWithError( 'apierror-invalid-file-key' );
808 }
809
810 $this->mUpload = new UploadFromStash( $this->getUser() );
811 // This will not download the temp file in initialize() in async mode.
812 // We still have enough information to call checkWarnings() and such.
813 $this->mUpload->initialize(
814 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
815 );
816 } elseif ( isset( $this->mParams['file'] ) ) {
817 // Can't async upload directly from a POSTed file, we'd have to
818 // stash the file and then queue the publish job. The user should
819 // just submit the two API queries to perform those two steps.
820 if ( $this->mParams['async'] ) {
821 $this->dieWithError( 'apierror-cannot-async-upload-file' );
822 }
823
824 $this->mUpload = new UploadFromFile();
825 $this->mUpload->initialize(
826 $this->mParams['filename'],
827 $this->getMain()->getUpload( 'file' )
828 );
829 } elseif ( isset( $this->mParams['url'] ) ) {
830 // Make sure upload by URL is enabled:
831 if ( !UploadFromUrl::isEnabled() ) {
832 $this->dieWithError( 'copyuploaddisabled' );
833 }
834
835 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
836 $this->dieWithError( 'apierror-copyuploadbaddomain' );
837 }
838
839 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
840 $this->dieWithError( 'apierror-copyuploadbadurl' );
841 }
842
843 $this->mUpload = new UploadFromUrl;
844 // This will not create the temp file in initialize() in async mode.
845 // We still have enough information to call checkWarnings() and such.
846 $this->mUpload->initialize( $this->mParams['filename'],
847 $this->mParams['url'], !$this->mParams['async'] );
848 }
849
850 return true;
851 }
852
858 protected function checkPermissions( $user ) {
859 // Check whether the user has the appropriate permissions to upload anyway
860 $permission = $this->mUpload->isAllowed( $user );
861
862 if ( $permission !== true ) {
863 if ( !$user->isNamed() ) {
864 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
865 }
866
867 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
868 }
869
870 // Check blocks
871 if ( $user->isBlockedFromUpload() ) {
872 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
873 $this->dieBlocked( $user->getBlock() );
874 }
875 }
876
880 protected function verifyUpload() {
881 if ( $this->mParams['chunk'] ) {
882 $maxSize = UploadBase::getMaxUploadSize( 'file' );
883 if ( $this->mParams['filesize'] > $maxSize ) {
884 $this->dieWithError( 'file-too-large' );
885 }
886 if ( !$this->mUpload->getTitle() ) {
887 $this->dieWithError( 'illegal-filename' );
888 }
889 // file will be assembled after having uploaded the last chunk,
890 // so we can only validate the name at this point
891 $verification = $this->mUpload->validateName();
892 if ( $verification === true ) {
893 return;
894 }
895 } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
896 // file will be assembled/downloaded in a background process, so we
897 // can only validate the name at this point
898 // file verification will happen in background process
899 $verification = $this->mUpload->validateName();
900 if ( $verification === true ) {
901 return;
902 }
903 } else {
904 wfDebug( __METHOD__ . " about to verify" );
905
906 $verification = $this->mUpload->verifyUpload();
907
908 if ( $verification['status'] === UploadBase::OK ) {
909 return;
910 } else {
911 $this->log->info( "File verification of {filename} failed for {user} because {result}",
912 [
913 'user' => $this->getUser()->getName(),
914 'resultCode' => $verification['status'],
915 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
916 'filename' => $this->mParams['filename'] ?? '-',
917 'details' => $verification['details'] ?? ''
918 ]
919 );
920 }
921 }
922
923 $this->checkVerification( $verification );
924 }
925
931 protected function checkVerification( array $verification ): never {
932 $status = $this->mUpload->convertVerifyErrorToStatus( $verification );
933 if ( $status->isRecoverableError() ) {
934 $this->dieRecoverableError( [ $status->asApiMessage() ], $status->getInvalidParameter() );
935 // dieRecoverableError prevents continuation
936 }
937 $this->dieWithError( $status->asApiMessage() );
938 // dieWithError prevents continuation
939 }
940
948 protected function getApiWarnings() {
949 $warnings = UploadBase::makeWarningsSerializable(
950 $this->mUpload->checkWarnings( $this->getUser() )
951 );
952
953 return $this->transformWarnings( $warnings );
954 }
955
956 protected function transformWarnings( array $warnings ): array {
957 if ( $warnings ) {
958 // Add indices
959 ApiResult::setIndexedTagName( $warnings, 'warning' );
960
961 if ( isset( $warnings['duplicate'] ) ) {
962 $dupes = array_column( $warnings['duplicate'], 'fileName' );
963 ApiResult::setIndexedTagName( $dupes, 'duplicate' );
964 $warnings['duplicate'] = $dupes;
965 }
966
967 if ( isset( $warnings['exists'] ) ) {
968 $warning = $warnings['exists'];
969 unset( $warnings['exists'] );
970 $localFile = $warning['normalizedFile'] ?? $warning['file'];
971 $warnings[$warning['warning']] = $localFile['fileName'];
972 }
973
974 if ( isset( $warnings['no-change'] ) ) {
975 $file = $warnings['no-change'];
976 unset( $warnings['no-change'] );
977
978 $warnings['nochange'] = [
979 'timestamp' => wfTimestamp( TS::ISO_8601, $file['timestamp'] )
980 ];
981 }
982
983 if ( isset( $warnings['duplicate-version'] ) ) {
984 $dupes = [];
985 foreach ( $warnings['duplicate-version'] as $dupe ) {
986 $dupes[] = [
987 'timestamp' => wfTimestamp( TS::ISO_8601, $dupe['timestamp'] )
988 ];
989 }
990 unset( $warnings['duplicate-version'] );
991
992 ApiResult::setIndexedTagName( $dupes, 'ver' );
993 $warnings['duplicateversions'] = $dupes;
994 }
995 }
996
997 return $warnings;
998 }
999
1006 protected function handleStashException( $e ) {
1007 $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
1008 [
1009 'user' => $this->getUser()->getName(),
1010 'error' => get_class( $e ),
1011 'filename' => $this->mParams['filename'] ?? '-',
1012 'filekey' => $this->mParams['filekey'] ?? '-'
1013 ]
1014 );
1015
1016 switch ( get_class( $e ) ) {
1017 case UploadStashFileNotFoundException::class:
1018 $wrap = 'apierror-stashedfilenotfound';
1019 break;
1020 case UploadStashBadPathException::class:
1021 $wrap = 'apierror-stashpathinvalid';
1022 break;
1023 case UploadStashFileException::class:
1024 $wrap = 'apierror-stashfilestorage';
1025 break;
1026 case UploadStashZeroLengthFileException::class:
1027 $wrap = 'apierror-stashzerolength';
1028 break;
1029 case UploadStashNotLoggedInException::class:
1030 return StatusValue::newFatal( ApiMessage::create(
1031 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
1032 ) );
1033 case UploadStashWrongOwnerException::class:
1034 $wrap = 'apierror-stashwrongowner';
1035 break;
1036 case UploadStashNoSuchKeyException::class:
1037 $wrap = 'apierror-stashnosuchfilekey';
1038 break;
1039 default:
1040 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
1041 break;
1042 }
1043 return StatusValue::newFatal(
1044 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1045 );
1046 }
1047
1055 protected function performUpload( $warnings ) {
1056 if ( $this->mParams['autotext'] ) {
1057 // Generate the file page wikitext server-side from the provided
1058 // parameters, overriding any client-supplied 'text'. Extensions
1059 // may alter this text via the UploadForm:getInitialPageText hook,
1060 // just like Special:Upload does.
1061 $this->mParams['text'] = SpecialUpload::getInitialPageText(
1062 $this->mParams['comment'],
1063 $this->mParams['license'],
1064 $this->mParams['copystatus'],
1065 $this->mParams['source'],
1066 $this->getConfig()
1067 );
1068 } else {
1069 // Use comment as initial page text by default
1070 $this->mParams['text'] ??= $this->mParams['comment'];
1071 }
1072
1074 $file = $this->mUpload->getLocalFile();
1075 $user = $this->getUser();
1076 $title = $file->getTitle();
1077
1078 // for preferences mode, we want to watch if 'watchdefault' is set,
1079 // or if the *file* doesn't exist, and either 'watchuploads' or
1080 // 'watchcreations' is set. But getWatchlistValue()'s automatic
1081 // handling checks if the *title* exists or not, so we need to check
1082 // all three preferences manually.
1083 $watch = $this->getWatchlistValue(
1084 $this->mParams['watchlist'], $title, $user, 'watchdefault'
1085 );
1086
1087 if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
1088 $watch = (
1089 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
1090 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
1091 );
1092 }
1093 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams, $title, $user );
1094
1095 // Deprecated parameters
1096 if ( $this->mParams['watch'] ) {
1097 $watch = true;
1098 }
1099
1100 if ( $this->mParams['tags'] ) {
1101 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1102 if ( !$status->isOK() ) {
1103 $this->dieStatus( $status );
1104 }
1105 }
1106
1107 // No errors, no warnings: do the upload
1108 $result = [];
1109 if ( $this->mParams['async'] ) {
1110 // Only stash uploads and copy uploads support async
1111 if ( $this->mParams['filekey'] ) {
1113 [
1114 'filename' => $this->mParams['filename'],
1115 'filekey' => $this->mParams['filekey'],
1116 'comment' => $this->mParams['comment'],
1117 'tags' => $this->mParams['tags'] ?? [],
1118 'text' => $this->mParams['text'],
1119 'watch' => $watch,
1120 'watchlistexpiry' => $watchlistExpiry,
1121 'session' => $this->getContext()->exportSession(),
1122 'ignorewarnings' => $this->mParams['ignorewarnings']
1123 ]
1124 );
1125 } elseif ( $this->mParams['url'] ) {
1126 $job = new UploadFromUrlJob(
1127 [
1128 'filename' => $this->mParams['filename'],
1129 'url' => $this->mParams['url'],
1130 'comment' => $this->mParams['comment'],
1131 'tags' => $this->mParams['tags'] ?? [],
1132 'text' => $this->mParams['text'],
1133 'watch' => $watch,
1134 'watchlistexpiry' => $watchlistExpiry,
1135 'session' => $this->getContext()->exportSession(),
1136 'ignorewarnings' => $this->mParams['ignorewarnings']
1137 ]
1138 );
1139 } else {
1140 $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1141 // We will never reach this, but it's here to help phan figure out
1142 // $job is never null
1143 // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1144 return [];
1145 }
1146 $cacheKey = $job->getCacheKey();
1147 // Check if an upload is already in progress.
1148 // the result can be Poll / Failure / Success
1149 $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey );
1150 if ( $progress && $progress['result'] === 'Poll' ) {
1151 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1152 }
1153 UploadBase::setSessionStatus(
1154 $this->getUser(),
1155 $cacheKey,
1156 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1157 );
1158
1159 $this->jobQueueGroup->push( $job );
1160 $this->log->info( "Sending publish job of {filename} for {user}",
1161 [
1162 'user' => $this->getUser()->getName(),
1163 'filename' => $this->mParams['filename'] ?? '-'
1164 ]
1165 );
1166 $result['result'] = 'Poll';
1167 $result['stage'] = 'queued';
1168 } else {
1169 $status = $this->mUpload->performUpload(
1170 $this->mParams['comment'],
1171 $this->mParams['text'],
1172 $watch,
1173 $this->getUser(),
1174 $this->mParams['tags'] ?? [],
1175 $watchlistExpiry
1176 );
1177
1178 if ( !$status->isGood() ) {
1179 $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1180 [
1181 'user' => $this->getUser()->getName(),
1182 'filename' => $this->mParams['filename'] ?? '-',
1183 'filekey' => $this->mParams['filekey'] ?? '-',
1184 'status' => (string)$status
1185 ]
1186 );
1187 $this->dieRecoverableError( $status->getMessages() );
1188 }
1189 $result['result'] = 'Success';
1190 }
1191
1192 $result['filename'] = $file->getName();
1193 if ( $warnings && count( $warnings ) > 0 ) {
1194 $result['warnings'] = $warnings;
1195 }
1196
1197 return $result;
1198 }
1199
1201 public function mustBePosted() {
1202 return true;
1203 }
1204
1206 public function isWriteMode() {
1207 return true;
1208 }
1209
1211 public function getAllowedParams() {
1212 $params = [
1213 'filename' => [
1214 ParamValidator::PARAM_TYPE => 'string',
1215 ],
1216 'comment' => [
1217 ParamValidator::PARAM_DEFAULT => ''
1218 ],
1219 'tags' => [
1220 ParamValidator::PARAM_TYPE => 'tags',
1221 ParamValidator::PARAM_ISMULTI => true,
1222 ],
1223 'text' => [
1224 ParamValidator::PARAM_TYPE => 'text',
1225 ],
1226 'watch' => [
1227 ParamValidator::PARAM_DEFAULT => false,
1228 ParamValidator::PARAM_DEPRECATED => true,
1229 ],
1230 ];
1231
1232 // Params appear in the docs in the order they are defined,
1233 // which is why this is here and not at the bottom.
1234 $params += $this->getWatchlistParams( [
1235 'watch',
1236 'preferences',
1237 'nochange',
1238 ] );
1239
1240 $params += [
1241 'ignorewarnings' => false,
1242 'file' => [
1243 ParamValidator::PARAM_TYPE => 'upload',
1244 ],
1245 'url' => null,
1246 'filekey' => null,
1247 'sessionkey' => [
1248 ParamValidator::PARAM_DEPRECATED => true,
1249 ],
1250 'stash' => false,
1251
1252 'filesize' => [
1253 ParamValidator::PARAM_TYPE => 'integer',
1254 IntegerDef::PARAM_MIN => 0,
1255 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize( 'file' ),
1256 ],
1257 'offset' => [
1258 ParamValidator::PARAM_TYPE => 'integer',
1259 IntegerDef::PARAM_MIN => 0,
1260 ],
1261 'chunk' => [
1262 ParamValidator::PARAM_TYPE => 'upload',
1263 ],
1264
1265 'async' => false,
1266 'checkstatus' => false,
1267
1268 'autotext' => [
1269 ParamValidator::PARAM_TYPE => 'boolean',
1270 ParamValidator::PARAM_DEFAULT => false,
1271 ],
1272 'license' => [
1273 ParamValidator::PARAM_TYPE => 'string',
1274 ParamValidator::PARAM_DEFAULT => ''
1275 ],
1276 ];
1277
1278 if ( $this->getConfig()->get( MainConfigNames::UseCopyrightUpload ) ) {
1279 $params += [
1280 'copystatus' => [
1281 ParamValidator::PARAM_TYPE => 'string',
1282 ParamValidator::PARAM_DEFAULT => ''
1283 ],
1284 'source' => [
1285 ParamValidator::PARAM_TYPE => 'string',
1286 ParamValidator::PARAM_DEFAULT => ''
1287 ],
1288 ];
1289 }
1290
1291 return $params;
1292 }
1293
1295 public function needsToken() {
1296 return 'csrf';
1297 }
1298
1300 protected function getExamplesMessages() {
1301 return [
1302 'action=upload&filename=Wiki.png' .
1303 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1304 => 'apihelp-upload-example-url',
1305 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1306 => 'apihelp-upload-example-filekey',
1307 ];
1308 }
1309
1311 public function getHelpUrls() {
1312 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1313 }
1314}
1315
1317class_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:71
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:60
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1522
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:1369
getResult()
Get the result object.
Definition ApiBase.php:696
addWarning( $msg, $code=null, $data=null)
Add a warning for this module.
Definition ApiBase.php:1439
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition ApiBase.php:1759
dieStatus(StatusValue $status)
Throw an ApiUsageException based on the Status object.
Definition ApiBase.php:1573
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:66
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...
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:96
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...
__construct(ApiMain $mainModule, string $moduleName, private readonly JobQueueGroup $jobQueueGroup, WatchlistManager $watchlistManager, WatchedItemStoreInterface $watchedItemStore, UserOptionsLookup $userOptionsLookup, RepoGroup $repoGroup,)
Definition ApiUpload.php:74
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:65
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.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
Local file in the wiki's own database.
Definition LocalFile.php:80
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:44
Prioritized list of file repositories.
Definition RepoGroup.php:30
getLocalRepo()
Get the local repository, i.e.
Handle enqueueing of background jobs.
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 EnableAsyncUploadsByURL
Name constant for the EnableAsyncUploadsByURL 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
Form for uploading media files.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
UploadBase and subclasses are the backend of MediaWiki's file uploads.
getLocalFile()
Return the local file and initializes if necessary.
Implements uploading from chunks.
Implements regular file uploads.
Implements uploading from previously stored file.
Implements uploading from a HTTP resource.
initialize( $name, $url, $initTempFile=true)
Entry point for API upload.
Provides access to user options.
User class for the MediaWiki software.
Definition User.php:129
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newGood( $value=null)
Factory function for good results.
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