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