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