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