MediaWiki master
ApiUpload.php
Go to the documentation of this file.
1<?php
14namespace MediaWiki\Api;
15
16use Exception;
49use Psr\Log\LoggerInterface;
50use StatusValue;
54use Wikimedia\Timestamp\TimestampFormat as TS;
55
59class ApiUpload extends ApiBase {
60
62
64 protected $mUpload = null;
65
67 protected $mParams;
68
69 private JobQueueGroup $jobQueueGroup;
70 private readonly LocalRepo $localRepo;
71
72 private LoggerInterface $log;
73
74 public function __construct(
75 ApiMain $mainModule,
76 string $moduleName,
77 JobQueueGroup $jobQueueGroup,
78 WatchlistManager $watchlistManager,
79 WatchedItemStoreInterface $watchedItemStore,
80 UserOptionsLookup $userOptionsLookup,
81 RepoGroup $repoGroup,
82 ) {
83 parent::__construct( $mainModule, $moduleName );
84 $this->jobQueueGroup = $jobQueueGroup;
85 $this->localRepo = $repoGroup->getLocalRepo();
86
87 // Variables needed in ApiWatchlistTrait trait
88 $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
89 $this->watchlistMaxDuration =
91 $this->watchlistManager = $watchlistManager;
92 $this->watchedItemStore = $watchedItemStore;
93 $this->userOptionsLookup = $userOptionsLookup;
94 $this->log = LoggerFactory::getInstance( 'upload' );
95 }
96
97 public function execute() {
98 // Check whether upload is enabled
99 if ( !UploadBase::isEnabled() ) {
100 $this->dieWithError( 'uploaddisabled' );
101 }
102
103 $user = $this->getUser();
104 $config = $this->getConfig();
105
106 // Parameter handling
107 $this->mParams = $this->extractRequestParams();
108 // Check if async mode is actually supported (jobs done in cli mode)
109 $this->mParams['async'] = $this->mParams['async'] &&
110 $config->get( MainConfigNames::EnableAsyncUploads ) &&
111 ( !$this->mParams['url'] || $config->get( MainConfigNames::EnableAsyncUploadsByURL ) );
112
113 // Copy the session key to the file key, for backward compatibility.
114 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
115 $this->mParams['filekey'] = $this->mParams['sessionkey'];
116 }
117
118 if ( !$this->mParams['checkstatus'] ) {
120 }
121
122 // Select an upload module
123 try {
124 if ( !$this->selectUploadModule() ) {
125 // not a true upload, but a status request or similar
126 return;
127 } elseif ( !$this->mUpload ) {
128 self::dieDebug( __METHOD__, 'No upload module set' );
129 }
130 } catch ( UploadStashException $e ) {
131 // 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 ) {
175 // XXX: don't spam exception log
176 $this->dieStatus( $this->handleStashException( $e ) );
177 }
178 $this->getResult()->addValue( null, $this->getModuleName(), $result );
179
180 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
181 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
182 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
183 if ( $result['result'] === 'Success' ) {
184 $imageinfo = $this->getUploadImageInfo( $this->mUpload );
185 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
186 }
187
188 // Cleanup any temporary mess
189 $this->mUpload->cleanupTempFile();
190 }
191
196 public static function getDummyInstance(): self {
197 $services = MediaWikiServices::getInstance();
198 return new ApiUpload(
199 // dummy object (XXX)
200 new ApiMain(),
201 'upload',
202 $services->getJobQueueGroup(),
203 $services->getWatchlistManager(),
204 $services->getWatchedItemStore(),
205 $services->getUserOptionsLookup(),
206 $services->getRepoGroup(),
207 );
208 }
209
222 public function getUploadImageInfo( UploadBase $upload ): array {
223 $stashFile = $upload->getStashFile();
224 if ( $stashFile ) {
225 $info = $this->getUploadImageInfoInternal( $stashFile, true );
226 } else {
227 $localFile = $upload->getLocalFile();
228 $info = $this->getUploadImageInfoInternal( $localFile, false );
229 }
230
231 return $info;
232 }
233
234 private function getUploadImageInfoInternal( File $file, bool $stashedImageInfos ): array {
235 $result = $this->getResult();
236 // Calling a different API module depending on whether the file was stashed is less than optimal.
237 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
238 if ( $stashedImageInfos ) {
239 $imParam = ApiQueryStashImageInfo::getPropertyNames();
240 $info = ApiQueryStashImageInfo::getInfo(
241 $file,
242 array_fill_keys( $imParam, true ),
243 $result
244 );
245 } else {
246 $imParam = ApiQueryImageInfo::getPropertyNames( [ 'uploadwarning' ] );
247 $info = ApiQueryImageInfo::getInfo(
248 $file,
249 array_fill_keys( $imParam, true ),
250 $result
251 );
252 }
253
254 return $info;
255 }
256
261 private function getContextResult() {
262 $warnings = $this->getApiWarnings();
263 if ( $warnings && !$this->mParams['ignorewarnings'] ) {
264 // Get warnings formatted in result array format
265 return $this->getWarningsResult( $warnings );
266 } elseif ( $this->mParams['chunk'] ) {
267 // Add chunk, and get result
268 return $this->getChunkResult( $warnings );
269 } elseif ( $this->mParams['stash'] ) {
270 // Stash the file and get stash result
271 return $this->getStashResult( $warnings );
272 }
273
274 // This is the most common case -- a normal upload with no warnings
275 // performUpload will return a formatted properly for the API with status
276 return $this->performUpload( $warnings );
277 }
278
284 private function getStashResult( $warnings ) {
285 $result = [ 'result' => 'Success' ];
286 if ( $warnings && count( $warnings ) > 0 ) {
287 $result['warnings'] = $warnings;
288 }
289 // Some uploads can request they be stashed, so as not to publish them immediately.
290 // In this case, a failure to stash ought to be fatal
291 $this->performStash( 'critical', $result );
292
293 return $result;
294 }
295
301 private function getWarningsResult( $warnings ) {
302 $result = [
303 'result' => 'Warning',
304 'warnings' => $warnings,
305 ];
306
307 // in case the warnings can be fixed with some further user action, let's stash this upload
308 // and return a key they can use to restart it
309 $this->performStash( 'optional', $result );
310
311 return $result;
312 }
313
320 public static function getMinUploadChunkSize( Config $config ) {
321 $configured = $config->get( MainConfigNames::MinUploadChunkSize );
322
323 // Leave some room for other POST parameters
324 $postMax = (
326 ini_get( 'post_max_size' ),
327 PHP_INT_MAX
328 ) ?: PHP_INT_MAX
329 ) - 1024;
330
331 // Ensure the minimum chunk size is less than PHP upload limits
332 // or the maximum upload size.
333 return min(
334 $configured,
335 UploadBase::getMaxUploadSize( 'file' ),
336 UploadBase::getMaxPhpUploadSize(),
337 $postMax
338 );
339 }
340
346 private function getChunkResult( $warnings ) {
347 $result = [];
348
349 if ( $warnings && count( $warnings ) > 0 ) {
350 $result['warnings'] = $warnings;
351 }
352
353 $chunkUpload = $this->getMain()->getUpload( 'chunk' );
354 $chunkPath = $chunkUpload->getTempName();
355 $chunkSize = $chunkUpload->getSize();
356 $totalSoFar = $this->mParams['offset'] + $chunkSize;
357 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
358
359 // Double check sizing
360 if ( $totalSoFar > $this->mParams['filesize'] ) {
361 $this->dieWithError( 'apierror-invalid-chunk' );
362 }
363
364 // Enforce minimum chunk size
365 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
366 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
367 }
368
369 if ( $this->mParams['offset'] == 0 ) {
370 $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
371 [
372 'user' => $this->getUser()->getName(),
373 'filename' => $this->mParams['filename'] ?? '-',
374 'filesize' => $this->mParams['filesize'],
375 'chunkSize' => $chunkSize
376 ]
377 );
378 $filekey = $this->performStash( 'critical' );
379 } else {
380 $filekey = $this->mParams['filekey'];
381
382 // Don't allow further uploads to an already-completed session
383 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
384 if ( !$progress ) {
385 // Probably can't get here, but check anyway just in case
386 $this->log->info( "Stash failed due to no session for {user}",
387 [
388 'user' => $this->getUser()->getName(),
389 'filename' => $this->mParams['filename'] ?? '-',
390 'filekey' => $this->mParams['filekey'] ?? '-',
391 'filesize' => $this->mParams['filesize'],
392 'chunkSize' => $chunkSize
393 ]
394 );
395 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
396 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
397 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
398 }
399
400 $status = $this->mUpload->addChunk(
401 $chunkPath, $chunkSize, $this->mParams['offset'] );
402 if ( !$status->isGood() ) {
403 $extradata = [
404 'offset' => $this->mUpload->getOffset(),
405 ];
406 $this->log->info( "Chunked upload stash failure {status} for {user}",
407 [
408 'status' => (string)$status,
409 'user' => $this->getUser()->getName(),
410 'filename' => $this->mParams['filename'] ?? '-',
411 'filekey' => $this->mParams['filekey'] ?? '-',
412 'filesize' => $this->mParams['filesize'],
413 'chunkSize' => $chunkSize,
414 'offset' => $this->mUpload->getOffset()
415 ]
416 );
417 $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
418 } else {
419 $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
420 [
421 'user' => $this->getUser()->getName(),
422 'filename' => $this->mParams['filename'] ?? '-',
423 'filekey' => $this->mParams['filekey'] ?? '-',
424 'filesize' => $this->mParams['filesize'],
425 'chunkSize' => $chunkSize,
426 'offset' => $this->mUpload->getOffset()
427 ]
428 );
429 }
430 }
431
432 // Check we added the last chunk:
433 if ( $totalSoFar == $this->mParams['filesize'] ) {
434 if ( $this->mParams['async'] ) {
435 UploadBase::setSessionStatus(
436 $this->getUser(),
437 $filekey,
438 [ 'result' => 'Poll',
439 'stage' => 'queued', 'status' => Status::newGood() ]
440 );
441 // It is important that this be lazyPush, as we do not want to insert
442 // into job queue until after the current transaction has completed since
443 // this depends on values in uploadstash table that were updated during
444 // the current transaction. (T350917)
445 $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
446 'filename' => $this->mParams['filename'],
447 'filekey' => $filekey,
448 'filesize' => $this->mParams['filesize'],
449 'session' => $this->getContext()->exportSession()
450 ] ) );
451 $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
452 [
453 'user' => $this->getUser()->getName(),
454 'filename' => $this->mParams['filename'] ?? '-',
455 'filekey' => $this->mParams['filekey'] ?? '-',
456 'filesize' => $this->mParams['filesize'],
457 'chunkSize' => $chunkSize,
458 ]
459 );
460 $result['result'] = 'Poll';
461 $result['stage'] = 'queued';
462 } else {
463 $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
464 [
465 'user' => $this->getUser()->getName(),
466 'filename' => $this->mParams['filename'] ?? '-',
467 'filekey' => $this->mParams['filekey'] ?? '-',
468 'filesize' => $this->mParams['filesize'],
469 'chunkSize' => $chunkSize,
470 ]
471 );
472
473 $status = $this->mUpload->concatenateChunks();
474 if ( !$status->isGood() ) {
475 UploadBase::setSessionStatus(
476 $this->getUser(),
477 $filekey,
478 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
479 );
480 $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
481 [
482 'user' => $this->getUser()->getName(),
483 'filename' => $this->mParams['filename'] ?? '-',
484 'filekey' => $this->mParams['filekey'] ?? '-',
485 'filesize' => $this->mParams['filesize'],
486 'chunkSize' => $chunkSize,
487 'status' => (string)$status
488 ]
489 );
490 $this->dieStatusWithCode( $status, 'stashfailed' );
491 }
492
493 // We can only get warnings like 'duplicate' after concatenating the chunks
494 $warnings = $this->getApiWarnings();
495 if ( $warnings ) {
496 $result['warnings'] = $warnings;
497 }
498
499 // The fully concatenated file has a new filekey. So remove
500 // the old filekey and fetch the new one.
501 UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
502 $this->mUpload->stash->removeFile( $filekey );
503 $filekey = $this->mUpload->getStashFile()->getFileKey();
504
505 $result['result'] = 'Success';
506 }
507 } else {
508 UploadBase::setSessionStatus(
509 $this->getUser(),
510 $filekey,
511 [
512 'result' => 'Continue',
513 'stage' => 'uploading',
514 'offset' => $totalSoFar,
515 'status' => Status::newGood(),
516 ]
517 );
518 $result['result'] = 'Continue';
519 $result['offset'] = $totalSoFar;
520 }
521
522 $result['filekey'] = $filekey;
523
524 return $result;
525 }
526
539 private function performStash( $failureMode, &$data = [] ) {
540 if ( $failureMode === 'optional' && $this->mUpload->skipStashFileAttempt() ) {
541 return null;
542 }
543
544 $isPartial = (bool)$this->mParams['chunk'];
545 try {
546 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
547
548 if ( $status->isGood() && !$status->getValue() ) {
549 // Not actually a 'good' status...
550 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
551 }
552 } catch ( Exception $e ) {
553 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
554 $this->log->info( $debugMessage,
555 [
556 'user' => $this->getUser()->getName(),
557 'filename' => $this->mParams['filename'] ?? '-',
558 'filekey' => $this->mParams['filekey'] ?? '-'
559 ]
560 );
561
562 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
563 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
564 ) );
565 }
566
567 if ( $status->isGood() ) {
568 $stashFile = $status->getValue();
569 $data['filekey'] = $stashFile->getFileKey();
570 // Backwards compatibility
571 $data['sessionkey'] = $data['filekey'];
572 return $data['filekey'];
573 }
574
575 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
576 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
577 // Statuses for it. Just extract the exception details and parse them ourselves.
578 [ $exceptionType, $message ] = $status->getMessage()->getParams();
579 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
580 $this->log->info( $debugMessage,
581 [
582 'user' => $this->getUser()->getName(),
583 'filename' => $this->mParams['filename'] ?? '-',
584 'filekey' => $this->mParams['filekey'] ?? '-'
585 ]
586 );
587 }
588
589 $this->log->info( "Stash upload failure {status}",
590 [
591 'status' => (string)$status,
592 'user' => $this->getUser()->getName(),
593 'filename' => $this->mParams['filename'] ?? '-',
594 'filekey' => $this->mParams['filekey'] ?? '-'
595 ]
596 );
597 // Bad status
598 if ( $failureMode !== 'optional' ) {
599 $this->dieStatus( $status );
600 } else {
601 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
602 return null;
603 }
604 }
605
615 private function dieRecoverableError( $errors, $parameter = null ): never {
616 $data = [];
617 $this->performStash( 'optional', $data );
618
619 if ( $parameter ) {
620 $data['invalidparameter'] = $parameter;
621 }
622
623 $sv = StatusValue::newGood();
624 foreach ( $errors as $error ) {
625 $msg = ApiMessage::create( $error );
626 $msg->setApiData( $msg->getApiData() + $data );
627 $sv->fatal( $msg );
628 }
629 $this->dieStatus( $sv );
630 }
631
642 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ): never {
643 $sv = StatusValue::newGood();
644 foreach ( $status->getMessages() as $error ) {
645 $msg = ApiMessage::create( $error, $overrideCode );
646 if ( $moreExtraData ) {
647 $msg->setApiData( $msg->getApiData() + $moreExtraData );
648 }
649 $sv->fatal( $msg );
650 }
651 $this->dieStatus( $sv );
652 }
653
661 protected function selectUploadModule() {
662 // chunk or one and only one of the following parameters is needed
663 if ( !$this->mParams['chunk'] ) {
664 $this->requireOnlyOneParameter( $this->mParams,
665 'filekey', 'file', 'url' );
666 }
667
668 // Status report for "upload to stash"/"upload from stash"/"upload by url"
669 if ( $this->mParams['checkstatus'] &&
670 ( $this->mParams['filekey'] || ( $this->mParams['url'] && $this->mParams['filename'] ) )
671 ) {
672 $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
673 $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
674 if ( !$progress ) {
675 $this->log->info( "Cannot check upload status due to missing upload session for {user}",
676 [
677 'user' => $this->getUser()->getName(),
678 'url' => $this->mParams['url'] ?? '-',
679 'filename' => $this->mParams['filename'] ?? '-',
680 'filekey' => $this->mParams['filekey'] ?? '-'
681 ]
682 );
683 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
684 } elseif ( !$progress['status']->isGood() ) {
685 $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
686 }
687 if ( isset( $progress['status']->value['verification'] ) ) {
688 $this->checkVerification( $progress['status']->value['verification'] );
689 }
690 if ( isset( $progress['status']->value['warnings'] ) ) {
691 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
692 if ( $warnings ) {
693 $progress['warnings'] = $warnings;
694 }
695 }
696 // remove Status object
697 unset( $progress['status'] );
698 $imageinfo = null;
699 if ( $progress['result'] === 'Success' ) {
700 if ( isset( $progress['filekey'] ) ) {
701 // assembled file, load stashed file from upload stash for imageinfo
702 $file = $this->localRepo->getUploadStash()->getFile( $progress['filekey'] );
703 if ( $file ) {
704 $imageinfo = $this->getUploadImageInfoInternal( $file, true );
705 }
706 } elseif ( isset( $progress['filename'] ) && isset( $progress['timestamp'] ) ) {
707 // published file, load local file from local repo for imageinfo
708 $file = $this->localRepo->findFile(
709 $progress['filename'],
710 [ 'time' => $progress['timestamp'], 'latest' => true ]
711 );
712 if ( $file ) {
713 $imageinfo = $this->getUploadImageInfoInternal( $file, false );
714 }
715 } elseif ( isset( $progress['imageinfo'] ) ) {
716 // status cache includes imageinfo from older entries (b/c for rollback of deployment)
717 $imageinfo = $progress['imageinfo'];
718 }
719 unset( $progress['imageinfo'] );
720 }
721
722 $this->getResult()->addValue( null, $this->getModuleName(), $progress );
723 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
724 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
725 if ( $imageinfo ) {
726 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
727 }
728
729 return false;
730 }
731
732 // The following modules all require the filename parameter to be set
733 if ( $this->mParams['filename'] === null ) {
734 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
735 }
736
737 if ( $this->mParams['chunk'] ) {
738 // Chunk upload
739 $this->mUpload = new UploadFromChunks( $this->getUser() );
740 if ( isset( $this->mParams['filekey'] ) ) {
741 if ( $this->mParams['offset'] === 0 ) {
742 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
743 }
744
745 // handle new chunk
746 $this->mUpload->continueChunks(
747 $this->mParams['filename'],
748 $this->mParams['filekey'],
749 $this->getMain()->getUpload( 'chunk' )
750 );
751 } else {
752 if ( $this->mParams['offset'] !== 0 ) {
753 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
754 }
755
756 // handle first chunk
757 $this->mUpload->initialize(
758 $this->mParams['filename'],
759 $this->getMain()->getUpload( 'chunk' )
760 );
761 }
762 } elseif ( isset( $this->mParams['filekey'] ) ) {
763 // Upload stashed in a previous request
764 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
765 $this->dieWithError( 'apierror-invalid-file-key' );
766 }
767
768 $this->mUpload = new UploadFromStash( $this->getUser() );
769 // This will not download the temp file in initialize() in async mode.
770 // We still have enough information to call checkWarnings() and such.
771 $this->mUpload->initialize(
772 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
773 );
774 } elseif ( isset( $this->mParams['file'] ) ) {
775 // Can't async upload directly from a POSTed file, we'd have to
776 // stash the file and then queue the publish job. The user should
777 // just submit the two API queries to perform those two steps.
778 if ( $this->mParams['async'] ) {
779 $this->dieWithError( 'apierror-cannot-async-upload-file' );
780 }
781
782 $this->mUpload = new UploadFromFile();
783 $this->mUpload->initialize(
784 $this->mParams['filename'],
785 $this->getMain()->getUpload( 'file' )
786 );
787 } elseif ( isset( $this->mParams['url'] ) ) {
788 // Make sure upload by URL is enabled:
789 if ( !UploadFromUrl::isEnabled() ) {
790 $this->dieWithError( 'copyuploaddisabled' );
791 }
792
793 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
794 $this->dieWithError( 'apierror-copyuploadbaddomain' );
795 }
796
797 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
798 $this->dieWithError( 'apierror-copyuploadbadurl' );
799 }
800
801 $this->mUpload = new UploadFromUrl;
802 // This will not create the temp file in initialize() in async mode.
803 // We still have enough information to call checkWarnings() and such.
804 $this->mUpload->initialize( $this->mParams['filename'],
805 $this->mParams['url'], !$this->mParams['async'] );
806 }
807
808 return true;
809 }
810
816 protected function checkPermissions( $user ) {
817 // Check whether the user has the appropriate permissions to upload anyway
818 $permission = $this->mUpload->isAllowed( $user );
819
820 if ( $permission !== true ) {
821 if ( !$user->isNamed() ) {
822 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
823 }
824
825 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
826 }
827
828 // Check blocks
829 if ( $user->isBlockedFromUpload() ) {
830 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
831 $this->dieBlocked( $user->getBlock() );
832 }
833 }
834
838 protected function verifyUpload() {
839 if ( $this->mParams['chunk'] ) {
840 $maxSize = UploadBase::getMaxUploadSize( 'file' );
841 if ( $this->mParams['filesize'] > $maxSize ) {
842 $this->dieWithError( 'file-too-large' );
843 }
844 if ( !$this->mUpload->getTitle() ) {
845 $this->dieWithError( 'illegal-filename' );
846 }
847 // file will be assembled after having uploaded the last chunk,
848 // so we can only validate the name at this point
849 $verification = $this->mUpload->validateName();
850 if ( $verification === true ) {
851 return;
852 }
853 } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
854 // file will be assembled/downloaded in a background process, so we
855 // can only validate the name at this point
856 // file verification will happen in background process
857 $verification = $this->mUpload->validateName();
858 if ( $verification === true ) {
859 return;
860 }
861 } else {
862 wfDebug( __METHOD__ . " about to verify" );
863
864 $verification = $this->mUpload->verifyUpload();
865
866 if ( $verification['status'] === UploadBase::OK ) {
867 return;
868 } else {
869 $this->log->info( "File verification of {filename} failed for {user} because {result}",
870 [
871 'user' => $this->getUser()->getName(),
872 'resultCode' => $verification['status'],
873 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
874 'filename' => $this->mParams['filename'] ?? '-',
875 'details' => $verification['details'] ?? ''
876 ]
877 );
878 }
879 }
880
881 $this->checkVerification( $verification );
882 }
883
889 protected function checkVerification( array $verification ): never {
890 $status = $this->mUpload->convertVerifyErrorToStatus( $verification );
891 if ( $status->isRecoverableError() ) {
892 $this->dieRecoverableError( [ $status->asApiMessage() ], $status->getInvalidParameter() );
893 // dieRecoverableError prevents continuation
894 }
895 $this->dieWithError( $status->asApiMessage() );
896 // dieWithError prevents continuation
897 }
898
906 protected function getApiWarnings() {
907 $warnings = UploadBase::makeWarningsSerializable(
908 $this->mUpload->checkWarnings( $this->getUser() )
909 );
910
911 return $this->transformWarnings( $warnings );
912 }
913
914 protected function transformWarnings( array $warnings ): array {
915 if ( $warnings ) {
916 // Add indices
917 ApiResult::setIndexedTagName( $warnings, 'warning' );
918
919 if ( isset( $warnings['duplicate'] ) ) {
920 $dupes = array_column( $warnings['duplicate'], 'fileName' );
921 ApiResult::setIndexedTagName( $dupes, 'duplicate' );
922 $warnings['duplicate'] = $dupes;
923 }
924
925 if ( isset( $warnings['exists'] ) ) {
926 $warning = $warnings['exists'];
927 unset( $warnings['exists'] );
928 $localFile = $warning['normalizedFile'] ?? $warning['file'];
929 $warnings[$warning['warning']] = $localFile['fileName'];
930 }
931
932 if ( isset( $warnings['no-change'] ) ) {
933 $file = $warnings['no-change'];
934 unset( $warnings['no-change'] );
935
936 $warnings['nochange'] = [
937 'timestamp' => wfTimestamp( TS::ISO_8601, $file['timestamp'] )
938 ];
939 }
940
941 if ( isset( $warnings['duplicate-version'] ) ) {
942 $dupes = [];
943 foreach ( $warnings['duplicate-version'] as $dupe ) {
944 $dupes[] = [
945 'timestamp' => wfTimestamp( TS::ISO_8601, $dupe['timestamp'] )
946 ];
947 }
948 unset( $warnings['duplicate-version'] );
949
950 ApiResult::setIndexedTagName( $dupes, 'ver' );
951 $warnings['duplicateversions'] = $dupes;
952 }
953 }
954
955 return $warnings;
956 }
957
964 protected function handleStashException( $e ) {
965 $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
966 [
967 'user' => $this->getUser()->getName(),
968 'error' => get_class( $e ),
969 'filename' => $this->mParams['filename'] ?? '-',
970 'filekey' => $this->mParams['filekey'] ?? '-'
971 ]
972 );
973
974 switch ( get_class( $e ) ) {
975 case UploadStashFileNotFoundException::class:
976 $wrap = 'apierror-stashedfilenotfound';
977 break;
978 case UploadStashBadPathException::class:
979 $wrap = 'apierror-stashpathinvalid';
980 break;
981 case UploadStashFileException::class:
982 $wrap = 'apierror-stashfilestorage';
983 break;
984 case UploadStashZeroLengthFileException::class:
985 $wrap = 'apierror-stashzerolength';
986 break;
987 case UploadStashNotLoggedInException::class:
988 return StatusValue::newFatal( ApiMessage::create(
989 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
990 ) );
991 case UploadStashWrongOwnerException::class:
992 $wrap = 'apierror-stashwrongowner';
993 break;
994 case UploadStashNoSuchKeyException::class:
995 $wrap = 'apierror-stashnosuchfilekey';
996 break;
997 default:
998 $wrap = [ 'uploadstash-exception', get_class( $e ) ];
999 break;
1000 }
1001 return StatusValue::newFatal(
1002 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1003 );
1004 }
1005
1013 protected function performUpload( $warnings ) {
1014 // Use comment as initial page text by default
1015 $this->mParams['text'] ??= $this->mParams['comment'];
1016
1018 $file = $this->mUpload->getLocalFile();
1019 $user = $this->getUser();
1020 $title = $file->getTitle();
1021
1022 // for preferences mode, we want to watch if 'watchdefault' is set,
1023 // or if the *file* doesn't exist, and either 'watchuploads' or
1024 // 'watchcreations' is set. But getWatchlistValue()'s automatic
1025 // handling checks if the *title* exists or not, so we need to check
1026 // all three preferences manually.
1027 $watch = $this->getWatchlistValue(
1028 $this->mParams['watchlist'], $title, $user, 'watchdefault'
1029 );
1030
1031 if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
1032 $watch = (
1033 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
1034 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
1035 );
1036 }
1037 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams, $title, $user );
1038
1039 // Deprecated parameters
1040 if ( $this->mParams['watch'] ) {
1041 $watch = true;
1042 }
1043
1044 if ( $this->mParams['tags'] ) {
1045 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1046 if ( !$status->isOK() ) {
1047 $this->dieStatus( $status );
1048 }
1049 }
1050
1051 // No errors, no warnings: do the upload
1052 $result = [];
1053 if ( $this->mParams['async'] ) {
1054 // Only stash uploads and copy uploads support async
1055 if ( $this->mParams['filekey'] ) {
1057 [
1058 'filename' => $this->mParams['filename'],
1059 'filekey' => $this->mParams['filekey'],
1060 'comment' => $this->mParams['comment'],
1061 'tags' => $this->mParams['tags'] ?? [],
1062 'text' => $this->mParams['text'],
1063 'watch' => $watch,
1064 'watchlistexpiry' => $watchlistExpiry,
1065 'session' => $this->getContext()->exportSession(),
1066 'ignorewarnings' => $this->mParams['ignorewarnings']
1067 ]
1068 );
1069 } elseif ( $this->mParams['url'] ) {
1070 $job = new UploadFromUrlJob(
1071 [
1072 'filename' => $this->mParams['filename'],
1073 'url' => $this->mParams['url'],
1074 'comment' => $this->mParams['comment'],
1075 'tags' => $this->mParams['tags'] ?? [],
1076 'text' => $this->mParams['text'],
1077 'watch' => $watch,
1078 'watchlistexpiry' => $watchlistExpiry,
1079 'session' => $this->getContext()->exportSession(),
1080 'ignorewarnings' => $this->mParams['ignorewarnings']
1081 ]
1082 );
1083 } else {
1084 $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1085 // We will never reach this, but it's here to help phan figure out
1086 // $job is never null
1087 // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1088 return [];
1089 }
1090 $cacheKey = $job->getCacheKey();
1091 // Check if an upload is already in progress.
1092 // the result can be Poll / Failure / Success
1093 $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey );
1094 if ( $progress && $progress['result'] === 'Poll' ) {
1095 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1096 }
1097 UploadBase::setSessionStatus(
1098 $this->getUser(),
1099 $cacheKey,
1100 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1101 );
1102
1103 $this->jobQueueGroup->push( $job );
1104 $this->log->info( "Sending publish job of {filename} for {user}",
1105 [
1106 'user' => $this->getUser()->getName(),
1107 'filename' => $this->mParams['filename'] ?? '-'
1108 ]
1109 );
1110 $result['result'] = 'Poll';
1111 $result['stage'] = 'queued';
1112 } else {
1113 $status = $this->mUpload->performUpload(
1114 $this->mParams['comment'],
1115 $this->mParams['text'],
1116 $watch,
1117 $this->getUser(),
1118 $this->mParams['tags'] ?? [],
1119 $watchlistExpiry
1120 );
1121
1122 if ( !$status->isGood() ) {
1123 $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1124 [
1125 'user' => $this->getUser()->getName(),
1126 'filename' => $this->mParams['filename'] ?? '-',
1127 'filekey' => $this->mParams['filekey'] ?? '-',
1128 'status' => (string)$status
1129 ]
1130 );
1131 $this->dieRecoverableError( $status->getMessages() );
1132 }
1133 $result['result'] = 'Success';
1134 }
1135
1136 $result['filename'] = $file->getName();
1137 if ( $warnings && count( $warnings ) > 0 ) {
1138 $result['warnings'] = $warnings;
1139 }
1140
1141 return $result;
1142 }
1143
1145 public function mustBePosted() {
1146 return true;
1147 }
1148
1150 public function isWriteMode() {
1151 return true;
1152 }
1153
1155 public function getAllowedParams() {
1156 $params = [
1157 'filename' => [
1158 ParamValidator::PARAM_TYPE => 'string',
1159 ],
1160 'comment' => [
1161 ParamValidator::PARAM_DEFAULT => ''
1162 ],
1163 'tags' => [
1164 ParamValidator::PARAM_TYPE => 'tags',
1165 ParamValidator::PARAM_ISMULTI => true,
1166 ],
1167 'text' => [
1168 ParamValidator::PARAM_TYPE => 'text',
1169 ],
1170 'watch' => [
1171 ParamValidator::PARAM_DEFAULT => false,
1172 ParamValidator::PARAM_DEPRECATED => true,
1173 ],
1174 ];
1175
1176 // Params appear in the docs in the order they are defined,
1177 // which is why this is here and not at the bottom.
1178 $params += $this->getWatchlistParams( [
1179 'watch',
1180 'preferences',
1181 'nochange',
1182 ] );
1183
1184 $params += [
1185 'ignorewarnings' => false,
1186 'file' => [
1187 ParamValidator::PARAM_TYPE => 'upload',
1188 ],
1189 'url' => null,
1190 'filekey' => null,
1191 'sessionkey' => [
1192 ParamValidator::PARAM_DEPRECATED => true,
1193 ],
1194 'stash' => false,
1195
1196 'filesize' => [
1197 ParamValidator::PARAM_TYPE => 'integer',
1198 IntegerDef::PARAM_MIN => 0,
1199 IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize( 'file' ),
1200 ],
1201 'offset' => [
1202 ParamValidator::PARAM_TYPE => 'integer',
1203 IntegerDef::PARAM_MIN => 0,
1204 ],
1205 'chunk' => [
1206 ParamValidator::PARAM_TYPE => 'upload',
1207 ],
1208
1209 'async' => false,
1210 'checkstatus' => false,
1211 ];
1212
1213 return $params;
1214 }
1215
1217 public function needsToken() {
1218 return 'csrf';
1219 }
1220
1222 protected function getExamplesMessages() {
1223 return [
1224 'action=upload&filename=Wiki.png' .
1225 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1226 => 'apihelp-upload-example-url',
1227 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1228 => 'apihelp-upload-example-filekey',
1229 ];
1230 }
1231
1233 public function getHelpUrls() {
1234 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1235 }
1236}
1237
1239class_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: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...
__construct(ApiMain $mainModule, string $moduleName, JobQueueGroup $jobQueueGroup, WatchlistManager $watchlistManager, WatchedItemStoreInterface $watchedItemStore, UserOptionsLookup $userOptionsLookup, RepoGroup $repoGroup,)
Definition ApiUpload.php:74
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:97
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:64
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:79
Local file in the wiki's own database.
Definition LocalFile.php:81
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:45
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
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:130
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