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