1<?php 2/** 3 * Copyright © 2008 - 2010 Bryan Tong Minh <Bryan.TongMinh@Gmail.com> 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 */ 22 23use MediaWiki\User\UserOptionsLookup; 24use MediaWiki\Watchlist\WatchlistManager; 25 26/** 27 * @ingroup API 28 */ 29class ApiUpload extends ApiBase { 30 31 use ApiWatchlistTrait; 32 33 /** @var UploadBase|UploadFromChunks */ 34 protected $mUpload = null; 35 36 protected $mParams; 37 38 /** @var JobQueueGroup */ 39 private $jobQueueGroup; 40 41 /** 42 * @param ApiMain $mainModule 43 * @param string $moduleName 44 * @param JobQueueGroup $jobQueueGroup 45 * @param WatchlistManager $watchlistManager 46 * @param UserOptionsLookup $userOptionsLookup 47 */ 48 public function __construct( 49 ApiMain $mainModule, 50 $moduleName, 51 JobQueueGroup $jobQueueGroup, 52 WatchlistManager $watchlistManager, 53 UserOptionsLookup $userOptionsLookup 54 ) { 55 parent::__construct( $mainModule, $moduleName ); 56 $this->jobQueueGroup = $jobQueueGroup; 57 58 // Variables needed in ApiWatchlistTrait trait 59 $this->watchlistExpiryEnabled = $this->getConfig()->get( 'WatchlistExpiry' ); 60 $this->watchlistMaxDuration = $this->getConfig()->get( 'WatchlistExpiryMaxDuration' ); 61 $this->watchlistManager = $watchlistManager; 62 $this->userOptionsLookup = $userOptionsLookup; 63 } 64 65 public function execute() { 66 // Check whether upload is enabled 67 if ( !UploadBase::isEnabled() ) { 68 $this->dieWithError( 'uploaddisabled' ); 69 } 70 71 $user = $this->getUser(); 72 73 // Parameter handling 74 $this->mParams = $this->extractRequestParams(); 75 $request = $this->getMain()->getRequest(); 76 // Check if async mode is actually supported (jobs done in cli mode) 77 $this->mParams['async'] = ( $this->mParams['async'] && 78 $this->getConfig()->get( 'EnableAsyncUploads' ) ); 79 // Add the uploaded file to the params array 80 $this->mParams['file'] = $request->getFileName( 'file' ); 81 $this->mParams['chunk'] = $request->getFileName( 'chunk' ); 82 83 // Copy the session key to the file key, for backward compatibility. 84 if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) { 85 $this->mParams['filekey'] = $this->mParams['sessionkey']; 86 } 87 88 // Select an upload module 89 try { 90 if ( !$this->selectUploadModule() ) { 91 return; // not a true upload, but a status request or similar 92 } elseif ( !isset( $this->mUpload ) ) { 93 $this->dieDebug( __METHOD__, 'No upload module set' ); 94 } 95 } catch ( UploadStashException $e ) { // XXX: don't spam exception log 96 $this->dieStatus( $this->handleStashException( $e ) ); 97 } 98 99 // First check permission to upload 100 $this->checkPermissions( $user ); 101 102 // Fetch the file (usually a no-op) 103 /** @var Status $status */ 104 $status = $this->mUpload->fetchFile(); 105 if ( !$status->isGood() ) { 106 $this->dieStatus( $status ); 107 } 108 109 // Check if the uploaded file is sane 110 $this->verifyUpload(); 111 112 // Check if the user has the rights to modify or overwrite the requested title 113 // (This check is irrelevant if stashing is already requested, since the errors 114 // can always be fixed by changing the title) 115 if ( !$this->mParams['stash'] ) { 116 $permErrors = $this->mUpload->verifyTitlePermissions( $user ); 117 if ( $permErrors !== true ) { 118 $this->dieRecoverableError( $permErrors, 'filename' ); 119 } 120 } 121 122 // Get the result based on the current upload context: 123 try { 124 $result = $this->getContextResult(); 125 } catch ( UploadStashException $e ) { // XXX: don't spam exception log 126 $this->dieStatus( $this->handleStashException( $e ) ); 127 } 128 $this->getResult()->addValue( null, $this->getModuleName(), $result ); 129 130 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large, 131 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993). 132 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive 133 if ( $result['result'] === 'Success' ) { 134 $imageinfo = $this->mUpload->getImageInfo( $this->getResult() ); 135 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo ); 136 } 137 138 // Cleanup any temporary mess 139 $this->mUpload->cleanupTempFile(); 140 } 141 142 /** 143 * Get an upload result based on upload context 144 * @return array 145 */ 146 private function getContextResult() { 147 $warnings = $this->getApiWarnings(); 148 if ( $warnings && !$this->mParams['ignorewarnings'] ) { 149 // Get warnings formatted in result array format 150 return $this->getWarningsResult( $warnings ); 151 } elseif ( $this->mParams['chunk'] ) { 152 // Add chunk, and get result 153 return $this->getChunkResult( $warnings ); 154 } elseif ( $this->mParams['stash'] ) { 155 // Stash the file and get stash result 156 return $this->getStashResult( $warnings ); 157 } 158 159 // Check throttle after we've handled warnings 160 if ( UploadBase::isThrottled( $this->getUser() ) 161 ) { 162 $this->dieWithError( 'apierror-ratelimited' ); 163 } 164 165 // This is the most common case -- a normal upload with no warnings 166 // performUpload will return a formatted properly for the API with status 167 return $this->performUpload( $warnings ); 168 } 169 170 /** 171 * Get Stash Result, throws an exception if the file could not be stashed. 172 * @param array $warnings Array of Api upload warnings 173 * @return array 174 */ 175 private function getStashResult( $warnings ) { 176 $result = []; 177 $result['result'] = 'Success'; 178 if ( $warnings && count( $warnings ) > 0 ) { 179 $result['warnings'] = $warnings; 180 } 181 // Some uploads can request they be stashed, so as not to publish them immediately. 182 // In this case, a failure to stash ought to be fatal 183 $this->performStash( 'critical', $result ); 184 185 return $result; 186 } 187 188 /** 189 * Get Warnings Result 190 * @param array $warnings Array of Api upload warnings 191 * @return array 192 */ 193 private function getWarningsResult( $warnings ) { 194 $result = []; 195 $result['result'] = 'Warning'; 196 $result['warnings'] = $warnings; 197 // in case the warnings can be fixed with some further user action, let's stash this upload 198 // and return a key they can use to restart it 199 $this->performStash( 'optional', $result ); 200 201 return $result; 202 } 203 204 /** 205 * @since 1.35 206 * @see $wgMinUploadChunkSize 207 * @param Config $config Site configuration for MinUploadChunkSize 208 * @return int 209 */ 210 public static function getMinUploadChunkSize( Config $config ) { 211 $configured = $config->get( 'MinUploadChunkSize' ); 212 213 // Leave some room for other POST parameters 214 $postMax = ( 215 wfShorthandToInteger( 216 ini_get( 'post_max_size' ), 217 PHP_INT_MAX 218 ) ?: PHP_INT_MAX 219 ) - 1024; 220 221 // Ensure the minimum chunk size is less than PHP upload limits 222 // or the maximum upload size. 223 return min( 224 $configured, 225 UploadBase::getMaxUploadSize( 'file' ), 226 UploadBase::getMaxPhpUploadSize(), 227 $postMax 228 ); 229 } 230 231 /** 232 * Get the result of a chunk upload. 233 * @param array $warnings Array of Api upload warnings 234 * @return array 235 */ 236 private function getChunkResult( $warnings ) { 237 $result = []; 238 239 if ( $warnings && count( $warnings ) > 0 ) { 240 $result['warnings'] = $warnings; 241 } 242 243 $request = $this->getMain()->getRequest(); 244 $chunkPath = $request->getFileTempname( 'chunk' ); 245 $chunkSize = $request->getUpload( 'chunk' )->getSize(); 246 $totalSoFar = $this->mParams['offset'] + $chunkSize; 247 $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() ); 248 249 // Sanity check sizing 250 if ( $totalSoFar > $this->mParams['filesize'] ) { 251 $this->dieWithError( 'apierror-invalid-chunk' ); 252 } 253 254 // Enforce minimum chunk size 255 if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) { 256 $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] ); 257 } 258 259 if ( $this->mParams['offset'] == 0 ) { 260 $filekey = $this->performStash( 'critical' ); 261 } else { 262 $filekey = $this->mParams['filekey']; 263 264 // Don't allow further uploads to an already-completed session 265 $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey ); 266 if ( !$progress ) { 267 // Probably can't get here, but check anyway just in case 268 $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' ); 269 } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) { 270 $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' ); 271 } 272 273 $status = $this->mUpload->addChunk( 274 $chunkPath, $chunkSize, $this->mParams['offset'] ); 275 if ( !$status->isGood() ) { 276 $extradata = [ 277 'offset' => $this->mUpload->getOffset(), 278 ]; 279 280 $this->dieStatusWithCode( $status, 'stashfailed', $extradata ); 281 } 282 } 283 284 // Check we added the last chunk: 285 if ( $totalSoFar == $this->mParams['filesize'] ) { 286 if ( $this->mParams['async'] ) { 287 UploadBase::setSessionStatus( 288 $this->getUser(), 289 $filekey, 290 [ 'result' => 'Poll', 291 'stage' => 'queued', 'status' => Status::newGood() ] 292 ); 293 $this->jobQueueGroup->push( new AssembleUploadChunksJob( 294 Title::makeTitle( NS_FILE, $filekey ), 295 [ 296 'filename' => $this->mParams['filename'], 297 'filekey' => $filekey, 298 'session' => $this->getContext()->exportSession() 299 ] 300 ) ); 301 $result['result'] = 'Poll'; 302 $result['stage'] = 'queued'; 303 } else { 304 $status = $this->mUpload->concatenateChunks(); 305 if ( !$status->isGood() ) { 306 UploadBase::setSessionStatus( 307 $this->getUser(), 308 $filekey, 309 [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ] 310 ); 311 $this->dieStatusWithCode( $status, 'stashfailed' ); 312 } 313 314 // We can only get warnings like 'duplicate' after concatenating the chunks 315 $warnings = $this->getApiWarnings(); 316 if ( $warnings ) { 317 $result['warnings'] = $warnings; 318 } 319 320 // The fully concatenated file has a new filekey. So remove 321 // the old filekey and fetch the new one. 322 UploadBase::setSessionStatus( $this->getUser(), $filekey, false ); 323 $this->mUpload->stash->removeFile( $filekey ); 324 $filekey = $this->mUpload->getStashFile()->getFileKey(); 325 326 $result['result'] = 'Success'; 327 } 328 } else { 329 UploadBase::setSessionStatus( 330 $this->getUser(), 331 $filekey, 332 [ 333 'result' => 'Continue', 334 'stage' => 'uploading', 335 'offset' => $totalSoFar, 336 'status' => Status::newGood(), 337 ] 338 ); 339 $result['result'] = 'Continue'; 340 $result['offset'] = $totalSoFar; 341 } 342 343 $result['filekey'] = $filekey; 344 345 return $result; 346 } 347 348 /** 349 * Stash the file and add the file key, or error information if it fails, to the data. 350 * 351 * @param string $failureMode What to do on failure to stash: 352 * - When 'critical', use dieStatus() to produce an error response and throw an exception. 353 * Use this when stashing the file was the primary purpose of the API request. 354 * - When 'optional', only add a 'stashfailed' key to the data and return null. 355 * Use this when some error happened for a non-stash upload and we're stashing the file 356 * only to save the client the trouble of re-uploading it. 357 * @param array|null &$data API result to which to add the information 358 * @return string|null File key 359 */ 360 private function performStash( $failureMode, &$data = null ) { 361 $isPartial = (bool)$this->mParams['chunk']; 362 try { 363 $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial ); 364 365 if ( $status->isGood() && !$status->getValue() ) { 366 // Not actually a 'good' status... 367 $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) ); 368 } 369 } catch ( Exception $e ) { 370 $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage(); 371 wfDebug( __METHOD__ . ' ' . $debugMessage ); 372 $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException( 373 $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ] 374 ) ); 375 } 376 377 if ( $status->isGood() ) { 378 $stashFile = $status->getValue(); 379 $data['filekey'] = $stashFile->getFileKey(); 380 // Backwards compatibility 381 $data['sessionkey'] = $data['filekey']; 382 return $data['filekey']; 383 } 384 385 if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) { 386 // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor 387 // Statuses for it. Just extract the exception details and parse them ourselves. 388 list( $exceptionType, $message ) = $status->getMessage()->getParams(); 389 $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message; 390 wfDebug( __METHOD__ . ' ' . $debugMessage ); 391 } 392 393 // Bad status 394 if ( $failureMode !== 'optional' ) { 395 $this->dieStatus( $status ); 396 } else { 397 $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status ); 398 return null; 399 } 400 } 401 402 /** 403 * Throw an error that the user can recover from by providing a better 404 * value for $parameter 405 * 406 * @param array $errors Array of Message objects, message keys, key+param 407 * arrays, or StatusValue::getErrors()-style arrays 408 * @param string|null $parameter Parameter that needs revising 409 * @throws ApiUsageException 410 */ 411 private function dieRecoverableError( $errors, $parameter = null ) { 412 $this->performStash( 'optional', $data ); 413 414 if ( $parameter ) { 415 $data['invalidparameter'] = $parameter; 416 } 417 418 $sv = StatusValue::newGood(); 419 foreach ( $errors as $error ) { 420 $msg = ApiMessage::create( $error ); 421 $msg->setApiData( $msg->getApiData() + $data ); 422 $sv->fatal( $msg ); 423 } 424 $this->dieStatus( $sv ); 425 } 426 427 /** 428 * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from 429 * IApiMessage. 430 * 431 * @param Status $status 432 * @param string $overrideCode Error code to use if there isn't one from IApiMessage 433 * @param array|null $moreExtraData 434 * @throws ApiUsageException 435 */ 436 public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) { 437 $sv = StatusValue::newGood(); 438 foreach ( $status->getErrors() as $error ) { 439 $msg = ApiMessage::create( $error, $overrideCode ); 440 if ( $moreExtraData ) { 441 $msg->setApiData( $msg->getApiData() + $moreExtraData ); 442 } 443 $sv->fatal( $msg ); 444 } 445 $this->dieStatus( $sv ); 446 } 447 448 /** 449 * Select an upload module and set it to mUpload. Dies on failure. If the 450 * request was a status request and not a true upload, returns false; 451 * otherwise true 452 * 453 * @return bool 454 * @suppress PhanTypeArraySuspiciousNullable False positives 455 */ 456 protected function selectUploadModule() { 457 $request = $this->getMain()->getRequest(); 458 459 // chunk or one and only one of the following parameters is needed 460 if ( !$this->mParams['chunk'] ) { 461 $this->requireOnlyOneParameter( $this->mParams, 462 'filekey', 'file', 'url' ); 463 } 464 465 // Status report for "upload to stash"/"upload from stash" 466 if ( $this->mParams['filekey'] && $this->mParams['checkstatus'] ) { 467 $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] ); 468 if ( !$progress ) { 469 $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' ); 470 } elseif ( !$progress['status']->isGood() ) { 471 $this->dieStatusWithCode( $progress['status'], 'stashfailed' ); 472 } 473 if ( isset( $progress['status']->value['verification'] ) ) { 474 $this->checkVerification( $progress['status']->value['verification'] ); 475 } 476 if ( isset( $progress['status']->value['warnings'] ) ) { 477 $warnings = $this->transformWarnings( $progress['status']->value['warnings'] ); 478 if ( $warnings ) { 479 $progress['warnings'] = $warnings; 480 } 481 } 482 unset( $progress['status'] ); // remove Status object 483 $imageinfo = null; 484 if ( isset( $progress['imageinfo'] ) ) { 485 $imageinfo = $progress['imageinfo']; 486 unset( $progress['imageinfo'] ); 487 } 488 489 $this->getResult()->addValue( null, $this->getModuleName(), $progress ); 490 // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large, 491 // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993). 492 if ( $imageinfo ) { 493 $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo ); 494 } 495 496 return false; 497 } 498 499 // The following modules all require the filename parameter to be set 500 if ( $this->mParams['filename'] === null ) { 501 $this->dieWithError( [ 'apierror-missingparam', 'filename' ] ); 502 } 503 504 if ( $this->mParams['chunk'] ) { 505 // Chunk upload 506 $this->mUpload = new UploadFromChunks( $this->getUser() ); 507 if ( isset( $this->mParams['filekey'] ) ) { 508 if ( $this->mParams['offset'] === 0 ) { 509 $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' ); 510 } 511 512 // handle new chunk 513 $this->mUpload->continueChunks( 514 $this->mParams['filename'], 515 $this->mParams['filekey'], 516 $request->getUpload( 'chunk' ) 517 ); 518 } else { 519 if ( $this->mParams['offset'] !== 0 ) { 520 $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' ); 521 } 522 523 // handle first chunk 524 $this->mUpload->initialize( 525 $this->mParams['filename'], 526 $request->getUpload( 'chunk' ) 527 ); 528 } 529 } elseif ( isset( $this->mParams['filekey'] ) ) { 530 // Upload stashed in a previous request 531 if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) { 532 $this->dieWithError( 'apierror-invalid-file-key' ); 533 } 534 535 $this->mUpload = new UploadFromStash( $this->getUser() ); 536 // This will not download the temp file in initialize() in async mode. 537 // We still have enough information to call checkWarnings() and such. 538 $this->mUpload->initialize( 539 $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async'] 540 ); 541 } elseif ( isset( $this->mParams['file'] ) ) { 542 // Can't async upload directly from a POSTed file, we'd have to 543 // stash the file and then queue the publish job. The user should 544 // just submit the two API queries to perform those two steps. 545 if ( $this->mParams['async'] ) { 546 $this->dieWithError( 'apierror-cannot-async-upload-file' ); 547 } 548 549 $this->mUpload = new UploadFromFile(); 550 $this->mUpload->initialize( 551 $this->mParams['filename'], 552 $request->getUpload( 'file' ) 553 ); 554 } elseif ( isset( $this->mParams['url'] ) ) { 555 // Make sure upload by URL is enabled: 556 if ( !UploadFromUrl::isEnabled() ) { 557 $this->dieWithError( 'copyuploaddisabled' ); 558 } 559 560 if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) { 561 $this->dieWithError( 'apierror-copyuploadbaddomain' ); 562 } 563 564 if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) { 565 $this->dieWithError( 'apierror-copyuploadbadurl' ); 566 } 567 568 $this->mUpload = new UploadFromUrl; 569 $this->mUpload->initialize( $this->mParams['filename'], 570 $this->mParams['url'] ); 571 } 572 573 return true; 574 } 575 576 /** 577 * Checks that the user has permissions to perform this upload. 578 * Dies with usage message on inadequate permissions. 579 * @param User $user The user to check. 580 */ 581 protected function checkPermissions( $user ) { 582 // Check whether the user has the appropriate permissions to upload anyway 583 $permission = $this->mUpload->isAllowed( $user ); 584 585 if ( $permission !== true ) { 586 if ( !$user->isRegistered() ) { 587 $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] ); 588 } 589 590 $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) ); 591 } 592 593 // Check blocks 594 if ( $user->isBlockedFromUpload() ) { 595 $this->dieBlocked( $user->getBlock() ); 596 } 597 598 // Global blocks 599 if ( $user->isBlockedGlobally() ) { 600 $this->dieBlocked( $user->getGlobalBlock() ); 601 } 602 } 603 604 /** 605 * Performs file verification, dies on error. 606 */ 607 protected function verifyUpload() { 608 if ( $this->mParams['chunk'] ) { 609 $maxSize = UploadBase::getMaxUploadSize(); 610 if ( $this->mParams['filesize'] > $maxSize ) { 611 $this->dieWithError( 'file-too-large' ); 612 } 613 if ( !$this->mUpload->getTitle() ) { 614 $this->dieWithError( 'illegal-filename' ); 615 } 616 // file will be assembled after having uploaded the last chunk, 617 // so we can only validate the name at this point 618 $verification = $this->mUpload->validateName(); 619 if ( $verification === true ) { 620 return; 621 } 622 } elseif ( $this->mParams['async'] && $this->mParams['filekey'] ) { 623 // file will be assembled in a background process, so we 624 // can only validate the name at this point 625 // file verification will happen in background process 626 $verification = $this->mUpload->validateName(); 627 if ( $verification === true ) { 628 return; 629 } 630 } else { 631 wfDebug( __METHOD__ . " about to verify" ); 632 633 $verification = $this->mUpload->verifyUpload(); 634 if ( $verification['status'] === UploadBase::OK ) { 635 return; 636 } 637 } 638 639 $this->checkVerification( $verification ); 640 } 641 642 /** 643 * Performs file verification, dies on error. 644 * @param array $verification 645 */ 646 protected function checkVerification( array $verification ) { 647 switch ( $verification['status'] ) { 648 // Recoverable errors 649 case UploadBase::MIN_LENGTH_PARTNAME: 650 $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' ); 651 // dieRecoverableError prevents continuation 652 case UploadBase::ILLEGAL_FILENAME: 653 $this->dieRecoverableError( 654 [ ApiMessage::create( 655 'illegal-filename', null, [ 'filename' => $verification['filtered'] ] 656 ) ], 'filename' 657 ); 658 // dieRecoverableError prevents continuation 659 case UploadBase::FILENAME_TOO_LONG: 660 $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' ); 661 // dieRecoverableError prevents continuation 662 case UploadBase::FILETYPE_MISSING: 663 $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' ); 664 // dieRecoverableError prevents continuation 665 case UploadBase::WINDOWS_NONASCII_FILENAME: 666 $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' ); 667 668 // Unrecoverable errors 669 case UploadBase::EMPTY_FILE: 670 $this->dieWithError( 'empty-file' ); 671 // dieWithError prevents continuation 672 case UploadBase::FILE_TOO_LARGE: 673 $this->dieWithError( 'file-too-large' ); 674 // dieWithError prevents continuation 675 676 case UploadBase::FILETYPE_BADTYPE: 677 $extradata = [ 678 'filetype' => $verification['finalExt'], 679 'allowed' => array_values( array_unique( $this->getConfig()->get( 'FileExtensions' ) ) ) 680 ]; 681 $extensions = array_unique( $this->getConfig()->get( 'FileExtensions' ) ); 682 $msg = [ 683 'filetype-banned-type', 684 null, // filled in below 685 Message::listParam( $extensions, 'comma' ), 686 count( $extensions ), 687 null, // filled in below 688 ]; 689 ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' ); 690 691 if ( isset( $verification['blacklistedExt'] ) ) { 692 $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' ); 693 $msg[4] = count( $verification['blacklistedExt'] ); 694 $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] ); 695 ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' ); 696 } else { 697 $msg[1] = $verification['finalExt']; 698 $msg[4] = 1; 699 } 700 701 $this->dieWithError( $msg, 'filetype-banned', $extradata ); 702 // dieWithError prevents continuation 703 704 case UploadBase::VERIFICATION_ERROR: 705 $msg = ApiMessage::create( $verification['details'], 'verification-error' ); 706 if ( $verification['details'][0] instanceof MessageSpecifier ) { 707 $details = array_merge( [ $msg->getKey() ], $msg->getParams() ); 708 } else { 709 $details = $verification['details']; 710 } 711 ApiResult::setIndexedTagName( $details, 'detail' ); 712 $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] ); 713 // @phan-suppress-next-line PhanTypeMismatchArgument 714 $this->dieWithError( $msg ); 715 // dieWithError prevents continuation 716 717 case UploadBase::HOOK_ABORTED: 718 $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error']; 719 $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] ); 720 // dieWithError prevents continuation 721 default: 722 $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error', 723 [ 'details' => [ 'code' => $verification['status'] ] ] ); 724 } 725 } 726 727 /** 728 * Check warnings. 729 * Returns a suitable array for inclusion into API results if there were warnings 730 * Returns the empty array if there were no warnings 731 * 732 * @return array 733 */ 734 protected function getApiWarnings() { 735 $warnings = UploadBase::makeWarningsSerializable( 736 $this->mUpload->checkWarnings( $this->getUser() ) 737 ); 738 739 return $this->transformWarnings( $warnings ); 740 } 741 742 protected function transformWarnings( $warnings ) { 743 if ( $warnings ) { 744 // Add indices 745 ApiResult::setIndexedTagName( $warnings, 'warning' ); 746 747 if ( isset( $warnings['duplicate'] ) ) { 748 $dupes = array_column( $warnings['duplicate'], 'fileName' ); 749 ApiResult::setIndexedTagName( $dupes, 'duplicate' ); 750 $warnings['duplicate'] = $dupes; 751 } 752 753 if ( isset( $warnings['exists'] ) ) { 754 $warning = $warnings['exists']; 755 unset( $warnings['exists'] ); 756 $localFile = $warning['normalizedFile'] ?? $warning['file']; 757 $warnings[$warning['warning']] = $localFile['fileName']; 758 } 759 760 if ( isset( $warnings['no-change'] ) ) { 761 $file = $warnings['no-change']; 762 unset( $warnings['no-change'] ); 763 764 $warnings['nochange'] = [ 765 'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] ) 766 ]; 767 } 768 769 if ( isset( $warnings['duplicate-version'] ) ) { 770 $dupes = []; 771 foreach ( $warnings['duplicate-version'] as $dupe ) { 772 $dupes[] = [ 773 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe['timestamp'] ) 774 ]; 775 } 776 unset( $warnings['duplicate-version'] ); 777 778 ApiResult::setIndexedTagName( $dupes, 'ver' ); 779 $warnings['duplicateversions'] = $dupes; 780 } 781 } 782 783 return $warnings; 784 } 785 786 /** 787 * Handles a stash exception, giving a useful error to the user. 788 * @todo Internationalize the exceptions then get rid of this 789 * @param Exception $e 790 * @return StatusValue 791 */ 792 protected function handleStashException( $e ) { 793 switch ( get_class( $e ) ) { 794 case UploadStashFileNotFoundException::class: 795 $wrap = 'apierror-stashedfilenotfound'; 796 break; 797 case UploadStashBadPathException::class: 798 $wrap = 'apierror-stashpathinvalid'; 799 break; 800 case UploadStashFileException::class: 801 $wrap = 'apierror-stashfilestorage'; 802 break; 803 case UploadStashZeroLengthFileException::class: 804 $wrap = 'apierror-stashzerolength'; 805 break; 806 case UploadStashNotLoggedInException::class: 807 return StatusValue::newFatal( ApiMessage::create( 808 [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin' 809 ) ); 810 case UploadStashWrongOwnerException::class: 811 $wrap = 'apierror-stashwrongowner'; 812 break; 813 case UploadStashNoSuchKeyException::class: 814 $wrap = 'apierror-stashnosuchfilekey'; 815 break; 816 default: 817 $wrap = [ 'uploadstash-exception', get_class( $e ) ]; 818 break; 819 } 820 return StatusValue::newFatal( 821 $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] ) 822 ); 823 } 824 825 /** 826 * Perform the actual upload. Returns a suitable result array on success; 827 * dies on failure. 828 * 829 * @param array $warnings Array of Api upload warnings 830 * @return array 831 */ 832 protected function performUpload( $warnings ) { 833 // Use comment as initial page text by default 834 if ( $this->mParams['text'] === null ) { 835 $this->mParams['text'] = $this->mParams['comment']; 836 } 837 838 /** @var LocalFile $file */ 839 $file = $this->mUpload->getLocalFile(); 840 $user = $this->getUser(); 841 $title = $file->getTitle(); 842 843 // for preferences mode, we want to watch if 'watchdefault' is set, 844 // or if the *file* doesn't exist, and either 'watchuploads' or 845 // 'watchcreations' is set. But getWatchlistValue()'s automatic 846 // handling checks if the *title* exists or not, so we need to check 847 // all three preferences manually. 848 $watch = $this->getWatchlistValue( 849 $this->mParams['watchlist'], $title, $user, 'watchdefault' 850 ); 851 852 if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) { 853 $watch = ( 854 $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) || 855 $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' ) 856 ); 857 } 858 $watchlistExpiry = $this->getExpiryFromParams( $this->mParams ); 859 860 // Deprecated parameters 861 if ( $this->mParams['watch'] ) { 862 $watch = true; 863 } 864 865 if ( $this->mParams['tags'] ) { 866 $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() ); 867 if ( !$status->isOK() ) { 868 $this->dieStatus( $status ); 869 } 870 } 871 872 // No errors, no warnings: do the upload 873 $result = []; 874 if ( $this->mParams['async'] ) { 875 $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] ); 876 if ( $progress && $progress['result'] === 'Poll' ) { 877 $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' ); 878 } 879 UploadBase::setSessionStatus( 880 $this->getUser(), 881 $this->mParams['filekey'], 882 [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ] 883 ); 884 $this->jobQueueGroup->push( new PublishStashedFileJob( 885 Title::makeTitle( NS_FILE, $this->mParams['filename'] ), 886 [ 887 'filename' => $this->mParams['filename'], 888 'filekey' => $this->mParams['filekey'], 889 'comment' => $this->mParams['comment'], 890 'tags' => $this->mParams['tags'], 891 'text' => $this->mParams['text'], 892 'watch' => $watch, 893 'watchlistexpiry' => $watchlistExpiry, 894 'session' => $this->getContext()->exportSession() 895 ] 896 ) ); 897 $result['result'] = 'Poll'; 898 $result['stage'] = 'queued'; 899 } else { 900 /** @var Status $status */ 901 $status = $this->mUpload->performUpload( 902 $this->mParams['comment'], 903 $this->mParams['text'], 904 $watch, 905 $this->getUser(), 906 $this->mParams['tags'], 907 $watchlistExpiry 908 ); 909 910 if ( !$status->isGood() ) { 911 $this->dieRecoverableError( $status->getErrors() ); 912 } 913 $result['result'] = 'Success'; 914 } 915 916 $result['filename'] = $file->getName(); 917 if ( $warnings && count( $warnings ) > 0 ) { 918 $result['warnings'] = $warnings; 919 } 920 921 return $result; 922 } 923 924 public function mustBePosted() { 925 return true; 926 } 927 928 public function isWriteMode() { 929 return true; 930 } 931 932 public function getAllowedParams() { 933 $params = [ 934 'filename' => [ 935 ApiBase::PARAM_TYPE => 'string', 936 ], 937 'comment' => [ 938 ApiBase::PARAM_DFLT => '' 939 ], 940 'tags' => [ 941 ApiBase::PARAM_TYPE => 'tags', 942 ApiBase::PARAM_ISMULTI => true, 943 ], 944 'text' => [ 945 ApiBase::PARAM_TYPE => 'text', 946 ], 947 'watch' => [ 948 ApiBase::PARAM_DFLT => false, 949 ApiBase::PARAM_DEPRECATED => true, 950 ], 951 ]; 952 953 // Params appear in the docs in the order they are defined, 954 // which is why this is here and not at the bottom. 955 $params += $this->getWatchlistParams( [ 956 'watch', 957 'preferences', 958 'nochange', 959 ] ); 960 961 $params += [ 962 'ignorewarnings' => false, 963 'file' => [ 964 ApiBase::PARAM_TYPE => 'upload', 965 ], 966 'url' => null, 967 'filekey' => null, 968 'sessionkey' => [ 969 ApiBase::PARAM_DEPRECATED => true, 970 ], 971 'stash' => false, 972 973 'filesize' => [ 974 ApiBase::PARAM_TYPE => 'integer', 975 ApiBase::PARAM_MIN => 0, 976 ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(), 977 ], 978 'offset' => [ 979 ApiBase::PARAM_TYPE => 'integer', 980 ApiBase::PARAM_MIN => 0, 981 ], 982 'chunk' => [ 983 ApiBase::PARAM_TYPE => 'upload', 984 ], 985 986 'async' => false, 987 'checkstatus' => false, 988 ]; 989 990 return $params; 991 } 992 993 public function needsToken() { 994 return 'csrf'; 995 } 996 997 protected function getExamplesMessages() { 998 return [ 999 'action=upload&filename=Wiki.png' . 1000 '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC' 1001 => 'apihelp-upload-example-url', 1002 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC' 1003 => 'apihelp-upload-example-filekey', 1004 ]; 1005 } 1006 1007 public function getHelpUrls() { 1008 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload'; 1009 } 1010} 1011