Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.36% covered (danger)
47.36%
323 / 682
38.46% covered (danger)
38.46%
10 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiUpload
47.36% covered (danger)
47.36%
323 / 682
38.46% covered (danger)
38.46%
10 / 26
3659.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 execute
64.44% covered (warning)
64.44%
29 / 45
0.00% covered (danger)
0.00%
0 / 1
27.51
 getDummyInstance
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getUploadImageInfo
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 getContextResult
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getStashResult
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getWarningsResult
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getMinUploadChunkSize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 getChunkResult
50.00% covered (danger)
50.00%
69 / 138
0.00% covered (danger)
0.00%
0 / 1
43.12
 performStash
20.93% covered (danger)
20.93%
9 / 43
0.00% covered (danger)
0.00%
0 / 1
31.22
 dieRecoverableError
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 dieStatusWithCode
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 selectUploadModule
44.16% covered (danger)
44.16%
34 / 77
0.00% covered (danger)
0.00%
0 / 1
143.73
 checkPermissions
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 verifyUpload
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
10.64
 checkVerification
4.35% covered (danger)
4.35%
2 / 46
0.00% covered (danger)
0.00%
0 / 1
211.91
 getApiWarnings
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 transformWarnings
51.72% covered (warning)
51.72%
15 / 29
0.00% covered (danger)
0.00%
0 / 1
18.11
 handleStashException
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 performUpload
28.26% covered (danger)
28.26%
26 / 92
0.00% covered (danger)
0.00%
0 / 1
110.52
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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  * @todo: create a UploadCommandFactory and UploadComand classes to share logic with Special:Upload
25  * @todo: split the different cases of upload in subclasses or submethods.
26  */
27
28use MediaWiki\Config\Config;
29use MediaWiki\Logger\LoggerFactory;
30use MediaWiki\MainConfigNames;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Status\Status;
33use MediaWiki\User\Options\UserOptionsLookup;
34use MediaWiki\User\User;
35use MediaWiki\Watchlist\WatchlistManager;
36use Psr\Log\LoggerInterface;
37use Wikimedia\ParamValidator\ParamValidator;
38use Wikimedia\ParamValidator\TypeDef\IntegerDef;
39
40/**
41 * @ingroup API
42 */
43class ApiUpload extends ApiBase {
44
45    use ApiWatchlistTrait;
46
47    /** @var UploadBase|UploadFromChunks */
48    protected $mUpload = null;
49
50    protected $mParams;
51
52    private JobQueueGroup $jobQueueGroup;
53
54    private LoggerInterface $log;
55
56    /**
57     * @param ApiMain $mainModule
58     * @param string $moduleName
59     * @param JobQueueGroup $jobQueueGroup
60     * @param WatchlistManager $watchlistManager
61     * @param UserOptionsLookup $userOptionsLookup
62     */
63    public function __construct(
64        ApiMain $mainModule,
65        $moduleName,
66        JobQueueGroup $jobQueueGroup,
67        WatchlistManager $watchlistManager,
68        UserOptionsLookup $userOptionsLookup
69    ) {
70        parent::__construct( $mainModule, $moduleName );
71        $this->jobQueueGroup = $jobQueueGroup;
72
73        // Variables needed in ApiWatchlistTrait trait
74        $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
75        $this->watchlistMaxDuration =
76            $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration );
77        $this->watchlistManager = $watchlistManager;
78        $this->userOptionsLookup = $userOptionsLookup;
79        $this->log = LoggerFactory::getInstance( 'upload' );
80    }
81
82    public function execute() {
83        // Check whether upload is enabled
84        if ( !UploadBase::isEnabled() ) {
85            $this->dieWithError( 'uploaddisabled' );
86        }
87
88        $user = $this->getUser();
89
90        // Parameter handling
91        $this->mParams = $this->extractRequestParams();
92        $request = $this->getMain()->getRequest();
93        // Check if async mode is actually supported (jobs done in cli mode)
94        $this->mParams['async'] = ( $this->mParams['async'] &&
95            $this->getConfig()->get( MainConfigNames::EnableAsyncUploads ) );
96        // Add the uploaded file to the params array
97        $this->mParams['file'] = $request->getFileName( 'file' );
98        $this->mParams['chunk'] = $request->getFileName( 'chunk' );
99
100        // Copy the session key to the file key, for backward compatibility.
101        if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
102            $this->mParams['filekey'] = $this->mParams['sessionkey'];
103        }
104
105        if ( !$this->mParams['checkstatus'] ) {
106            $this->useTransactionalTimeLimit();
107        }
108
109        // Select an upload module
110        try {
111            if ( !$this->selectUploadModule() ) {
112                return; // not a true upload, but a status request or similar
113            } elseif ( !isset( $this->mUpload ) ) {
114                $this->dieDebug( __METHOD__, 'No upload module set' );
115            }
116        } catch ( UploadStashException $e ) { // XXX: don't spam exception log
117            $this->dieStatus( $this->handleStashException( $e ) );
118        }
119
120        // First check permission to upload
121        $this->checkPermissions( $user );
122
123        // Fetch the file (usually a no-op)
124        // Skip for async upload from URL, where we just want to run checks.
125        /** @var Status $status */
126        if ( $this->mParams['async'] && $this->mParams['url'] ) {
127            $status = $this->mUpload->canFetchFile();
128        } else {
129            $status = $this->mUpload->fetchFile();
130        }
131
132        if ( !$status->isGood() ) {
133            $this->log->info( "Unable to fetch file {filename} for {user} because {status}",
134                [
135                    'user' => $this->getUser()->getName(),
136                    'status' => (string)$status,
137                    'filename' => $this->mParams['filename'] ?? '-',
138                ]
139            );
140            $this->dieStatus( $status );
141        }
142
143        // Check the uploaded file
144        $this->verifyUpload();
145
146        // Check if the user has the rights to modify or overwrite the requested title
147        // (This check is irrelevant if stashing is already requested, since the errors
148        //  can always be fixed by changing the title)
149        if ( !$this->mParams['stash'] ) {
150            $permErrors = $this->mUpload->verifyTitlePermissions( $user );
151            if ( $permErrors !== true ) {
152                $this->dieRecoverableError( $permErrors, 'filename' );
153            }
154        }
155
156        // Get the result based on the current upload context:
157        try {
158            $result = $this->getContextResult();
159        } catch ( UploadStashException $e ) { // XXX: don't spam exception log
160            $this->dieStatus( $this->handleStashException( $e ) );
161        }
162        $this->getResult()->addValue( null, $this->getModuleName(), $result );
163
164        // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
165        // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
166        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
167        if ( $result['result'] === 'Success' ) {
168            $imageinfo = $this->getUploadImageInfo( $this->mUpload );
169            $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
170        }
171
172        // Cleanup any temporary mess
173        $this->mUpload->cleanupTempFile();
174    }
175
176    public static function getDummyInstance(): self {
177        $services = MediaWikiServices::getInstance();
178        $apiMain = new ApiMain(); // dummy object (XXX)
179        $apiUpload = new ApiUpload(
180            $apiMain,
181            'upload',
182            $services->getJobQueueGroup(),
183            $services->getWatchlistManager(),
184            $services->getUserOptionsLookup()
185        );
186
187        return $apiUpload;
188    }
189
190    /**
191     * Gets image info about the file just uploaded.
192     *
193     * Also has the effect of setting metadata to be an 'indexed tag name' in
194     * returned API result if 'metadata' was requested. Oddly, we have to pass
195     * the "result" object down just so it can do that with the appropriate
196     * format, presumably.
197     *
198     * @internal For use in upload jobs and a deprecated method on UploadBase.
199     * @todo Extract the logic actually needed by the jobs, and separate it
200     *       from the structure used in API responses.
201     *
202     * @return array Image info
203     */
204    public function getUploadImageInfo( UploadBase $upload ): array {
205        $result = $this->getResult();
206        $stashFile = $upload->getStashFile();
207
208        // Calling a different API module depending on whether the file was stashed is less than optimal.
209        // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
210        if ( $stashFile ) {
211            $imParam = ApiQueryStashImageInfo::getPropertyNames();
212            $info = ApiQueryStashImageInfo::getInfo(
213                $stashFile,
214                array_fill_keys( $imParam, true ),
215                $result
216            );
217        } else {
218            $localFile = $upload->getLocalFile();
219            $imParam = ApiQueryImageInfo::getPropertyNames();
220            $info = ApiQueryImageInfo::getInfo(
221                $localFile,
222                array_fill_keys( $imParam, true ),
223                $result
224            );
225        }
226
227        return $info;
228    }
229
230    /**
231     * Get an upload result based on upload context
232     * @return array
233     */
234    private function getContextResult() {
235        $warnings = $this->getApiWarnings();
236        if ( $warnings && !$this->mParams['ignorewarnings'] ) {
237            // Get warnings formatted in result array format
238            return $this->getWarningsResult( $warnings );
239        } elseif ( $this->mParams['chunk'] ) {
240            // Add chunk, and get result
241            return $this->getChunkResult( $warnings );
242        } elseif ( $this->mParams['stash'] ) {
243            // Stash the file and get stash result
244            return $this->getStashResult( $warnings );
245        }
246
247        // This is the most common case -- a normal upload with no warnings
248        // performUpload will return a formatted properly for the API with status
249        return $this->performUpload( $warnings );
250    }
251
252    /**
253     * Get Stash Result, throws an exception if the file could not be stashed.
254     * @param array $warnings Array of Api upload warnings
255     * @return array
256     */
257    private function getStashResult( $warnings ) {
258        $result = [];
259        $result['result'] = 'Success';
260        if ( $warnings && count( $warnings ) > 0 ) {
261            $result['warnings'] = $warnings;
262        }
263        // Some uploads can request they be stashed, so as not to publish them immediately.
264        // In this case, a failure to stash ought to be fatal
265        $this->performStash( 'critical', $result );
266
267        return $result;
268    }
269
270    /**
271     * Get Warnings Result
272     * @param array $warnings Array of Api upload warnings
273     * @return array
274     */
275    private function getWarningsResult( $warnings ) {
276        $result = [];
277        $result['result'] = 'Warning';
278        $result['warnings'] = $warnings;
279        // in case the warnings can be fixed with some further user action, let's stash this upload
280        // and return a key they can use to restart it
281        $this->performStash( 'optional', $result );
282
283        return $result;
284    }
285
286    /**
287     * @since 1.35
288     * @see $wgMinUploadChunkSize
289     * @param Config $config Site configuration for MinUploadChunkSize
290     * @return int
291     */
292    public static function getMinUploadChunkSize( Config $config ) {
293        $configured = $config->get( MainConfigNames::MinUploadChunkSize );
294
295        // Leave some room for other POST parameters
296        $postMax = (
297            wfShorthandToInteger(
298                ini_get( 'post_max_size' ),
299                PHP_INT_MAX
300            ) ?: PHP_INT_MAX
301        ) - 1024;
302
303        // Ensure the minimum chunk size is less than PHP upload limits
304        // or the maximum upload size.
305        return min(
306            $configured,
307            UploadBase::getMaxUploadSize( 'file' ),
308            UploadBase::getMaxPhpUploadSize(),
309            $postMax
310        );
311    }
312
313    /**
314     * Get the result of a chunk upload.
315     * @param array $warnings Array of Api upload warnings
316     * @return array
317     */
318    private function getChunkResult( $warnings ) {
319        $result = [];
320
321        if ( $warnings && count( $warnings ) > 0 ) {
322            $result['warnings'] = $warnings;
323        }
324
325        $request = $this->getMain()->getRequest();
326        $chunkPath = $request->getFileTempname( 'chunk' );
327        $chunkSize = $request->getUpload( 'chunk' )->getSize();
328        $totalSoFar = $this->mParams['offset'] + $chunkSize;
329        $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() );
330
331        // Double check sizing
332        if ( $totalSoFar > $this->mParams['filesize'] ) {
333            $this->dieWithError( 'apierror-invalid-chunk' );
334        }
335
336        // Enforce minimum chunk size
337        if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
338            $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
339        }
340
341        if ( $this->mParams['offset'] == 0 ) {
342            $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}",
343                [
344                    'user' => $this->getUser()->getName(),
345                    'filename' => $this->mParams['filename'] ?? '-',
346                    'filesize' => $this->mParams['filesize'],
347                    'chunkSize' => $chunkSize
348                ]
349            );
350            $filekey = $this->performStash( 'critical' );
351        } else {
352            $filekey = $this->mParams['filekey'];
353
354            // Don't allow further uploads to an already-completed session
355            $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
356            if ( !$progress ) {
357                // Probably can't get here, but check anyway just in case
358                $this->log->info( "Stash failed due to no session for {user}",
359                    [
360                        'user' => $this->getUser()->getName(),
361                        'filename' => $this->mParams['filename'] ?? '-',
362                        'filekey' => $this->mParams['filekey'] ?? '-',
363                        'filesize' => $this->mParams['filesize'],
364                        'chunkSize' => $chunkSize
365                    ]
366                );
367                $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
368            } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
369                $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
370            }
371
372            $status = $this->mUpload->addChunk(
373                $chunkPath, $chunkSize, $this->mParams['offset'] );
374            if ( !$status->isGood() ) {
375                $extradata = [
376                    'offset' => $this->mUpload->getOffset(),
377                ];
378                $this->log->info( "Chunked upload stash failure {status} for {user}",
379                    [
380                        'status' => (string)$status,
381                        'user' => $this->getUser()->getName(),
382                        'filename' => $this->mParams['filename'] ?? '-',
383                        'filekey' => $this->mParams['filekey'] ?? '-',
384                        'filesize' => $this->mParams['filesize'],
385                        'chunkSize' => $chunkSize,
386                        'offset' => $this->mUpload->getOffset()
387                    ]
388                );
389                $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
390            } else {
391                $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}",
392                    [
393                        'user' => $this->getUser()->getName(),
394                        'filename' => $this->mParams['filename'] ?? '-',
395                        'filekey' => $this->mParams['filekey'] ?? '-',
396                        'filesize' => $this->mParams['filesize'],
397                        'chunkSize' => $chunkSize,
398                        'offset' => $this->mUpload->getOffset()
399                    ]
400                );
401            }
402        }
403
404        // Check we added the last chunk:
405        if ( $totalSoFar == $this->mParams['filesize'] ) {
406            if ( $this->mParams['async'] ) {
407                UploadBase::setSessionStatus(
408                    $this->getUser(),
409                    $filekey,
410                    [ 'result' => 'Poll',
411                        'stage' => 'queued', 'status' => Status::newGood() ]
412                );
413                // It is important that this be lazyPush, as we do not want to insert
414                // into job queue until after the current transaction has completed since
415                // this depends on values in uploadstash table that were updated during
416                // the current transaction. (T350917)
417                $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [
418                    'filename' => $this->mParams['filename'],
419                    'filekey' => $filekey,
420                    'filesize' => $this->mParams['filesize'],
421                    'session' => $this->getContext()->exportSession()
422                ] ) );
423                $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job",
424                    [
425                        'user' => $this->getUser()->getName(),
426                        'filename' => $this->mParams['filename'] ?? '-',
427                        'filekey' => $this->mParams['filekey'] ?? '-',
428                        'filesize' => $this->mParams['filesize'],
429                        'chunkSize' => $chunkSize,
430                    ]
431                );
432                $result['result'] = 'Poll';
433                $result['stage'] = 'queued';
434            } else {
435                $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately",
436                    [
437                        'user' => $this->getUser()->getName(),
438                        'filename' => $this->mParams['filename'] ?? '-',
439                        'filekey' => $this->mParams['filekey'] ?? '-',
440                        'filesize' => $this->mParams['filesize'],
441                        'chunkSize' => $chunkSize,
442                    ]
443                );
444
445                $status = $this->mUpload->concatenateChunks();
446                if ( !$status->isGood() ) {
447                    UploadBase::setSessionStatus(
448                        $this->getUser(),
449                        $filekey,
450                        [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
451                    );
452                    $this->log->info( "Non jobqueue assembly of {filename} failed because {status}",
453                        [
454                            'user' => $this->getUser()->getName(),
455                            'filename' => $this->mParams['filename'] ?? '-',
456                            'filekey' => $this->mParams['filekey'] ?? '-',
457                            'filesize' => $this->mParams['filesize'],
458                            'chunkSize' => $chunkSize,
459                            'status' => (string)$status
460                        ]
461                    );
462                    $this->dieStatusWithCode( $status, 'stashfailed' );
463                }
464
465                // We can only get warnings like 'duplicate' after concatenating the chunks
466                $warnings = $this->getApiWarnings();
467                if ( $warnings ) {
468                    $result['warnings'] = $warnings;
469                }
470
471                // The fully concatenated file has a new filekey. So remove
472                // the old filekey and fetch the new one.
473                UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
474                $this->mUpload->stash->removeFile( $filekey );
475                $filekey = $this->mUpload->getStashFile()->getFileKey();
476
477                $result['result'] = 'Success';
478            }
479        } else {
480            UploadBase::setSessionStatus(
481                $this->getUser(),
482                $filekey,
483                [
484                    'result' => 'Continue',
485                    'stage' => 'uploading',
486                    'offset' => $totalSoFar,
487                    'status' => Status::newGood(),
488                ]
489            );
490            $result['result'] = 'Continue';
491            $result['offset'] = $totalSoFar;
492        }
493
494        $result['filekey'] = $filekey;
495
496        return $result;
497    }
498
499    /**
500     * Stash the file and add the file key, or error information if it fails, to the data.
501     *
502     * @param string $failureMode What to do on failure to stash:
503     *   - When 'critical', use dieStatus() to produce an error response and throw an exception.
504     *     Use this when stashing the file was the primary purpose of the API request.
505     *   - When 'optional', only add a 'stashfailed' key to the data and return null.
506     *     Use this when some error happened for a non-stash upload and we're stashing the file
507     *     only to save the client the trouble of re-uploading it.
508     * @param array|null &$data API result to which to add the information
509     * @return string|null File key
510     */
511    private function performStash( $failureMode, &$data = null ) {
512        $isPartial = (bool)$this->mParams['chunk'];
513        try {
514            $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
515
516            if ( $status->isGood() && !$status->getValue() ) {
517                // Not actually a 'good' status...
518                $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
519            }
520        } catch ( Exception $e ) {
521            $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
522            $this->log->info( $debugMessage,
523                [
524                    'user' => $this->getUser()->getName(),
525                    'filename' => $this->mParams['filename'] ?? '-',
526                    'filekey' => $this->mParams['filekey'] ?? '-'
527                ]
528            );
529
530            $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
531                $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
532            ) );
533        }
534
535        if ( $status->isGood() ) {
536            $stashFile = $status->getValue();
537            $data['filekey'] = $stashFile->getFileKey();
538            // Backwards compatibility
539            $data['sessionkey'] = $data['filekey'];
540            return $data['filekey'];
541        }
542
543        if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
544            // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
545            // Statuses for it. Just extract the exception details and parse them ourselves.
546            [ $exceptionType, $message ] = $status->getMessage()->getParams();
547            $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
548            $this->log->info( $debugMessage,
549                [
550                    'user' => $this->getUser()->getName(),
551                    'filename' => $this->mParams['filename'] ?? '-',
552                    'filekey' => $this->mParams['filekey'] ?? '-'
553                ]
554            );
555        }
556
557        $this->log->info( "Stash upload failure {status}",
558            [
559                'status' => (string)$status,
560                'user' => $this->getUser()->getName(),
561                'filename' => $this->mParams['filename'] ?? '-',
562                'filekey' => $this->mParams['filekey'] ?? '-'
563            ]
564        );
565        // Bad status
566        if ( $failureMode !== 'optional' ) {
567            $this->dieStatus( $status );
568        } else {
569            $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
570            return null;
571        }
572    }
573
574    /**
575     * Throw an error that the user can recover from by providing a better
576     * value for $parameter
577     *
578     * @param array $errors Array of Message objects, message keys, key+param
579     *  arrays, or StatusValue::getErrors()-style arrays
580     * @param string|null $parameter Parameter that needs revising
581     * @throws ApiUsageException
582     * @return never
583     */
584    private function dieRecoverableError( $errors, $parameter = null ) {
585        $this->performStash( 'optional', $data );
586
587        if ( $parameter ) {
588            $data['invalidparameter'] = $parameter;
589        }
590
591        $sv = StatusValue::newGood();
592        foreach ( $errors as $error ) {
593            $msg = ApiMessage::create( $error );
594            $msg->setApiData( $msg->getApiData() + $data );
595            $sv->fatal( $msg );
596        }
597        $this->dieStatus( $sv );
598    }
599
600    /**
601     * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
602     * IApiMessage.
603     *
604     * @param Status $status
605     * @param string $overrideCode Error code to use if there isn't one from IApiMessage
606     * @param array|null $moreExtraData
607     * @throws ApiUsageException
608     * @return never
609     */
610    public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
611        $sv = StatusValue::newGood();
612        foreach ( $status->getErrors() as $error ) {
613            $msg = ApiMessage::create( $error, $overrideCode );
614            if ( $moreExtraData ) {
615                $msg->setApiData( $msg->getApiData() + $moreExtraData );
616            }
617            $sv->fatal( $msg );
618        }
619        $this->dieStatus( $sv );
620    }
621
622    /**
623     * Select an upload module and set it to mUpload. Dies on failure. If the
624     * request was a status request and not a true upload, returns false;
625     * otherwise true
626     *
627     * @return bool
628     */
629    protected function selectUploadModule() {
630        $request = $this->getMain()->getRequest();
631
632        // chunk or one and only one of the following parameters is needed
633        if ( !$this->mParams['chunk'] ) {
634            $this->requireOnlyOneParameter( $this->mParams,
635                'filekey', 'file', 'url' );
636        }
637
638        // Status report for "upload to stash"/"upload from stash"/"upload by url"
639        if ( $this->mParams['checkstatus'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
640            $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams );
641            $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey );
642            if ( !$progress ) {
643                $this->log->info( "Cannot check upload status due to missing upload session for {user}",
644                    [
645                        'user' => $this->getUser()->getName(),
646                        'filename' => $this->mParams['filename'] ?? '-',
647                        'filekey' => $this->mParams['filekey'] ?? '-'
648                    ]
649                );
650                $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' );
651            } elseif ( !$progress['status']->isGood() ) {
652                $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
653            }
654            if ( isset( $progress['status']->value['verification'] ) ) {
655                $this->checkVerification( $progress['status']->value['verification'] );
656            }
657            if ( isset( $progress['status']->value['warnings'] ) ) {
658                $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
659                if ( $warnings ) {
660                    $progress['warnings'] = $warnings;
661                }
662            }
663            unset( $progress['status'] ); // remove Status object
664            $imageinfo = null;
665            if ( isset( $progress['imageinfo'] ) ) {
666                $imageinfo = $progress['imageinfo'];
667                unset( $progress['imageinfo'] );
668            }
669
670            $this->getResult()->addValue( null, $this->getModuleName(), $progress );
671            // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
672            // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
673            if ( $imageinfo ) {
674                $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
675            }
676
677            return false;
678        }
679
680        // The following modules all require the filename parameter to be set
681        if ( $this->mParams['filename'] === null ) {
682            $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
683        }
684
685        if ( $this->mParams['chunk'] ) {
686            // Chunk upload
687            $this->mUpload = new UploadFromChunks( $this->getUser() );
688            if ( isset( $this->mParams['filekey'] ) ) {
689                if ( $this->mParams['offset'] === 0 ) {
690                    $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
691                }
692
693                // handle new chunk
694                $this->mUpload->continueChunks(
695                    $this->mParams['filename'],
696                    $this->mParams['filekey'],
697                    $request->getUpload( 'chunk' )
698                );
699            } else {
700                if ( $this->mParams['offset'] !== 0 ) {
701                    $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
702                }
703
704                // handle first chunk
705                $this->mUpload->initialize(
706                    $this->mParams['filename'],
707                    $request->getUpload( 'chunk' )
708                );
709            }
710        } elseif ( isset( $this->mParams['filekey'] ) ) {
711            // Upload stashed in a previous request
712            if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
713                $this->dieWithError( 'apierror-invalid-file-key' );
714            }
715
716            $this->mUpload = new UploadFromStash( $this->getUser() );
717            // This will not download the temp file in initialize() in async mode.
718            // We still have enough information to call checkWarnings() and such.
719            $this->mUpload->initialize(
720                $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
721            );
722        } elseif ( isset( $this->mParams['file'] ) ) {
723            // Can't async upload directly from a POSTed file, we'd have to
724            // stash the file and then queue the publish job. The user should
725            // just submit the two API queries to perform those two steps.
726            if ( $this->mParams['async'] ) {
727                $this->dieWithError( 'apierror-cannot-async-upload-file' );
728            }
729
730            $this->mUpload = new UploadFromFile();
731            $this->mUpload->initialize(
732                $this->mParams['filename'],
733                $request->getUpload( 'file' )
734            );
735        } elseif ( isset( $this->mParams['url'] ) ) {
736            // Make sure upload by URL is enabled:
737            if ( !UploadFromUrl::isEnabled() ) {
738                $this->dieWithError( 'copyuploaddisabled' );
739            }
740
741            if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
742                $this->dieWithError( 'apierror-copyuploadbaddomain' );
743            }
744
745            if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
746                $this->dieWithError( 'apierror-copyuploadbadurl' );
747            }
748
749            $this->mUpload = new UploadFromUrl;
750            $this->mUpload->initialize( $this->mParams['filename'],
751                $this->mParams['url'] );
752        }
753
754        return true;
755    }
756
757    /**
758     * Checks that the user has permissions to perform this upload.
759     * Dies with usage message on inadequate permissions.
760     * @param User $user The user to check.
761     */
762    protected function checkPermissions( $user ) {
763        // Check whether the user has the appropriate permissions to upload anyway
764        $permission = $this->mUpload->isAllowed( $user );
765
766        if ( $permission !== true ) {
767            if ( !$user->isNamed() ) {
768                $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
769            }
770
771            $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
772        }
773
774        // Check blocks
775        if ( $user->isBlockedFromUpload() ) {
776            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
777            $this->dieBlocked( $user->getBlock() );
778        }
779    }
780
781    /**
782     * Performs file verification, dies on error.
783     */
784    protected function verifyUpload() {
785        if ( $this->mParams['chunk'] ) {
786            $maxSize = UploadBase::getMaxUploadSize();
787            if ( $this->mParams['filesize'] > $maxSize ) {
788                $this->dieWithError( 'file-too-large' );
789            }
790            if ( !$this->mUpload->getTitle() ) {
791                $this->dieWithError( 'illegal-filename' );
792            }
793            // file will be assembled after having uploaded the last chunk,
794            // so we can only validate the name at this point
795            $verification = $this->mUpload->validateName();
796            if ( $verification === true ) {
797                return;
798            }
799        } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) {
800            // file will be assembled/downloaded in a background process, so we
801            // can only validate the name at this point
802            // file verification will happen in background process
803            $verification = $this->mUpload->validateName();
804            if ( $verification === true ) {
805                return;
806            }
807        } else {
808            wfDebug( __METHOD__ . " about to verify" );
809
810            $verification = $this->mUpload->verifyUpload();
811
812            if ( $verification['status'] === UploadBase::OK ) {
813                return;
814            } else {
815                $this->log->info( "File verification of {filename} failed for {user} because {result}",
816                    [
817                        'user' => $this->getUser()->getName(),
818                        'resultCode' => $verification['status'],
819                        'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ),
820                        'filename' => $this->mParams['filename'] ?? '-',
821                        'details' => $verification['details'] ?? ''
822                    ]
823                );
824            }
825        }
826
827        $this->checkVerification( $verification );
828    }
829
830    /**
831     * Performs file verification, dies on error.
832     * @param array $verification
833     * @return never
834     */
835    protected function checkVerification( array $verification ) {
836        switch ( $verification['status'] ) {
837            // Recoverable errors
838            case UploadBase::MIN_LENGTH_PARTNAME:
839                $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
840                // dieRecoverableError prevents continuation
841            case UploadBase::ILLEGAL_FILENAME:
842                $this->dieRecoverableError(
843                    [ ApiMessage::create(
844                        'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
845                    ) ], 'filename'
846                );
847                // dieRecoverableError prevents continuation
848            case UploadBase::FILENAME_TOO_LONG:
849                $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
850                // dieRecoverableError prevents continuation
851            case UploadBase::FILETYPE_MISSING:
852                $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
853                // dieRecoverableError prevents continuation
854            case UploadBase::WINDOWS_NONASCII_FILENAME:
855                $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
856
857            // Unrecoverable errors
858            case UploadBase::EMPTY_FILE:
859                $this->dieWithError( 'empty-file' );
860                // dieWithError prevents continuation
861            case UploadBase::FILE_TOO_LARGE:
862                $this->dieWithError( 'file-too-large' );
863                // dieWithError prevents continuation
864
865            case UploadBase::FILETYPE_BADTYPE:
866                $extradata = [
867                    'filetype' => $verification['finalExt'],
868                    'allowed' => array_values( array_unique(
869                        $this->getConfig()->get( MainConfigNames::FileExtensions ) ) )
870                ];
871                $extensions =
872                    array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
873                $msg = [
874                    'filetype-banned-type',
875                    null, // filled in below
876                    Message::listParam( $extensions, 'comma' ),
877                    count( $extensions ),
878                    null, // filled in below
879                ];
880                ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
881
882                if ( isset( $verification['blacklistedExt'] ) ) {
883                    $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' );
884                    $msg[4] = count( $verification['blacklistedExt'] );
885                    $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
886                    ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
887                } else {
888                    $msg[1] = $verification['finalExt'];
889                    $msg[4] = 1;
890                }
891
892                $this->dieWithError( $msg, 'filetype-banned', $extradata );
893                // dieWithError prevents continuation
894
895            case UploadBase::VERIFICATION_ERROR:
896                $msg = ApiMessage::create( $verification['details'], 'verification-error' );
897                if ( $verification['details'][0] instanceof MessageSpecifier ) {
898                    $details = [ $msg->getKey(), ...$msg->getParams() ];
899                } else {
900                    $details = $verification['details'];
901                }
902                ApiResult::setIndexedTagName( $details, 'detail' );
903                $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
904                // @phan-suppress-next-line PhanTypeMismatchArgument
905                $this->dieWithError( $msg );
906                // dieWithError prevents continuation
907
908            case UploadBase::HOOK_ABORTED:
909                $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error'];
910                $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] );
911                // dieWithError prevents continuation
912            default:
913                $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
914                    [ 'details' => [ 'code' => $verification['status'] ] ] );
915        }
916    }
917
918    /**
919     * Check warnings.
920     * Returns a suitable array for inclusion into API results if there were warnings
921     * Returns the empty array if there were no warnings
922     *
923     * @return array
924     */
925    protected function getApiWarnings() {
926        $warnings = UploadBase::makeWarningsSerializable(
927            $this->mUpload->checkWarnings( $this->getUser() )
928        );
929
930        return $this->transformWarnings( $warnings );
931    }
932
933    protected function transformWarnings( $warnings ) {
934        if ( $warnings ) {
935            // Add indices
936            ApiResult::setIndexedTagName( $warnings, 'warning' );
937
938            if ( isset( $warnings['duplicate'] ) ) {
939                $dupes = array_column( $warnings['duplicate'], 'fileName' );
940                ApiResult::setIndexedTagName( $dupes, 'duplicate' );
941                $warnings['duplicate'] = $dupes;
942            }
943
944            if ( isset( $warnings['exists'] ) ) {
945                $warning = $warnings['exists'];
946                unset( $warnings['exists'] );
947                $localFile = $warning['normalizedFile'] ?? $warning['file'];
948                $warnings[$warning['warning']] = $localFile['fileName'];
949            }
950
951            if ( isset( $warnings['no-change'] ) ) {
952                $file = $warnings['no-change'];
953                unset( $warnings['no-change'] );
954
955                $warnings['nochange'] = [
956                    'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] )
957                ];
958            }
959
960            if ( isset( $warnings['duplicate-version'] ) ) {
961                $dupes = [];
962                foreach ( $warnings['duplicate-version'] as $dupe ) {
963                    $dupes[] = [
964                        'timestamp' => wfTimestamp( TS_ISO_8601, $dupe['timestamp'] )
965                    ];
966                }
967                unset( $warnings['duplicate-version'] );
968
969                ApiResult::setIndexedTagName( $dupes, 'ver' );
970                $warnings['duplicateversions'] = $dupes;
971            }
972            // We haven't downloaded the file, so this will result in an empty file warning
973            if ( $this->mParams['async'] && $this->mParams['url'] ) {
974                unset( $warnings['empty-file'] );
975            }
976        }
977
978        return $warnings;
979    }
980
981    /**
982     * Handles a stash exception, giving a useful error to the user.
983     * @todo Internationalize the exceptions then get rid of this
984     * @param Exception $e
985     * @return StatusValue
986     */
987    protected function handleStashException( $e ) {
988        $this->log->info( "Upload stashing of {filename} failed for {user} because {error}",
989            [
990                'user' => $this->getUser()->getName(),
991                'error' => get_class( $e ),
992                'filename' => $this->mParams['filename'] ?? '-',
993                'filekey' => $this->mParams['filekey'] ?? '-'
994            ]
995        );
996
997        switch ( get_class( $e ) ) {
998            case UploadStashFileNotFoundException::class:
999                $wrap = 'apierror-stashedfilenotfound';
1000                break;
1001            case UploadStashBadPathException::class:
1002                $wrap = 'apierror-stashpathinvalid';
1003                break;
1004            case UploadStashFileException::class:
1005                $wrap = 'apierror-stashfilestorage';
1006                break;
1007            case UploadStashZeroLengthFileException::class:
1008                $wrap = 'apierror-stashzerolength';
1009                break;
1010            case UploadStashNotLoggedInException::class:
1011                return StatusValue::newFatal( ApiMessage::create(
1012                    [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
1013                ) );
1014            case UploadStashWrongOwnerException::class:
1015                $wrap = 'apierror-stashwrongowner';
1016                break;
1017            case UploadStashNoSuchKeyException::class:
1018                $wrap = 'apierror-stashnosuchfilekey';
1019                break;
1020            default:
1021                $wrap = [ 'uploadstash-exception', get_class( $e ) ];
1022                break;
1023        }
1024        return StatusValue::newFatal(
1025            $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
1026        );
1027    }
1028
1029    /**
1030     * Perform the actual upload. Returns a suitable result array on success;
1031     * dies on failure.
1032     *
1033     * @param array $warnings Array of Api upload warnings
1034     * @return array
1035     */
1036    protected function performUpload( $warnings ) {
1037        // Use comment as initial page text by default
1038        $this->mParams['text'] ??= $this->mParams['comment'];
1039
1040        /** @var LocalFile $file */
1041        $file = $this->mUpload->getLocalFile();
1042        $user = $this->getUser();
1043        $title = $file->getTitle();
1044
1045        // for preferences mode, we want to watch if 'watchdefault' is set,
1046        // or if the *file* doesn't exist, and either 'watchuploads' or
1047        // 'watchcreations' is set. But getWatchlistValue()'s automatic
1048        // handling checks if the *title* exists or not, so we need to check
1049        // all three preferences manually.
1050        $watch = $this->getWatchlistValue(
1051            $this->mParams['watchlist'], $title, $user, 'watchdefault'
1052        );
1053
1054        if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
1055            $watch = (
1056                $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) ||
1057                $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' )
1058            );
1059        }
1060        $watchlistExpiry = $this->getExpiryFromParams( $this->mParams );
1061
1062        // Deprecated parameters
1063        if ( $this->mParams['watch'] ) {
1064            $watch = true;
1065        }
1066
1067        if ( $this->mParams['tags'] ) {
1068            $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() );
1069            if ( !$status->isOK() ) {
1070                $this->dieStatus( $status );
1071            }
1072        }
1073
1074        // No errors, no warnings: do the upload
1075        $result = [];
1076        if ( $this->mParams['async'] ) {
1077            // Only stash uploads and copy uploads support async
1078            if ( $this->mParams['filekey'] ) {
1079                $job = new PublishStashedFileJob(
1080                    [
1081                        'filename' => $this->mParams['filename'],
1082                        'filekey' => $this->mParams['filekey'],
1083                        'comment' => $this->mParams['comment'],
1084                        'tags' => $this->mParams['tags'] ?? [],
1085                        'text' => $this->mParams['text'],
1086                        'watch' => $watch,
1087                        'watchlistexpiry' => $watchlistExpiry,
1088                        'session' => $this->getContext()->exportSession(),
1089                        'ignorewarnings' => $this->mParams['ignorewarnings']
1090                    ]
1091                    );
1092            } elseif ( $this->mParams['url'] ) {
1093                $job = new UploadFromUrlJob(
1094                    [
1095                        'filename' => $this->mParams['filename'],
1096                        'url' => $this->mParams['url'],
1097                        'comment' => $this->mParams['comment'],
1098                        'tags' => $this->mParams['tags'] ?? [],
1099                        'text' => $this->mParams['text'],
1100                        'watch' => $watch,
1101                        'watchlistexpiry' => $watchlistExpiry,
1102                        'session' => $this->getContext()->exportSession(),
1103                        'ignorewarnings' => $this->mParams['ignorewarnings']
1104                    ]
1105                    );
1106            } else {
1107                $this->dieWithError( 'apierror-no-async-support', 'publishfailed' );
1108                // We will never reach this, but it's here to help phan figure out
1109                // $job is never null
1110                // @phan-suppress-next-line PhanPluginUnreachableCode On purpose
1111                return [];
1112            }
1113            $cacheKey = $job->getCacheKey();
1114            // Check if an upload is already in progress.
1115            // the result can be Poll / Failure / Success
1116            $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey );
1117            if ( $progress && $progress['result'] === 'Poll' ) {
1118                $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
1119            }
1120            UploadBase::setSessionStatus(
1121                $this->getUser(),
1122                $cacheKey,
1123                [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
1124            );
1125
1126            $this->jobQueueGroup->push( $job );
1127            $this->log->info( "Sending publish job of {filename} for {user}",
1128                [
1129                    'user' => $this->getUser()->getName(),
1130                    'filename' => $this->mParams['filename'] ?? '-'
1131                ]
1132            );
1133            $result['result'] = 'Poll';
1134            $result['stage'] = 'queued';
1135        } else {
1136            /** @var Status $status */
1137            $status = $this->mUpload->performUpload(
1138                $this->mParams['comment'],
1139                $this->mParams['text'],
1140                $watch,
1141                $this->getUser(),
1142                $this->mParams['tags'] ?? [],
1143                $watchlistExpiry
1144            );
1145
1146            if ( !$status->isGood() ) {
1147                $this->log->info( "Non-async API upload publish failed for {user} because {status}",
1148                    [
1149                        'user' => $this->getUser()->getName(),
1150                        'filename' => $this->mParams['filename'] ?? '-',
1151                        'filekey' => $this->mParams['filekey'] ?? '-',
1152                        'status' => (string)$status
1153                    ]
1154                );
1155                $this->dieRecoverableError( $status->getErrors() );
1156            }
1157            $result['result'] = 'Success';
1158        }
1159
1160        $result['filename'] = $file->getName();
1161        if ( $warnings && count( $warnings ) > 0 ) {
1162            $result['warnings'] = $warnings;
1163        }
1164
1165        return $result;
1166    }
1167
1168    public function mustBePosted() {
1169        return true;
1170    }
1171
1172    public function isWriteMode() {
1173        return true;
1174    }
1175
1176    public function getAllowedParams() {
1177        $params = [
1178            'filename' => [
1179                ParamValidator::PARAM_TYPE => 'string',
1180            ],
1181            'comment' => [
1182                ParamValidator::PARAM_DEFAULT => ''
1183            ],
1184            'tags' => [
1185                ParamValidator::PARAM_TYPE => 'tags',
1186                ParamValidator::PARAM_ISMULTI => true,
1187            ],
1188            'text' => [
1189                ParamValidator::PARAM_TYPE => 'text',
1190            ],
1191            'watch' => [
1192                ParamValidator::PARAM_DEFAULT => false,
1193                ParamValidator::PARAM_DEPRECATED => true,
1194            ],
1195        ];
1196
1197        // Params appear in the docs in the order they are defined,
1198        // which is why this is here and not at the bottom.
1199        $params += $this->getWatchlistParams( [
1200            'watch',
1201            'preferences',
1202            'nochange',
1203        ] );
1204
1205        $params += [
1206            'ignorewarnings' => false,
1207            'file' => [
1208                ParamValidator::PARAM_TYPE => 'upload',
1209            ],
1210            'url' => null,
1211            'filekey' => null,
1212            'sessionkey' => [
1213                ParamValidator::PARAM_DEPRECATED => true,
1214            ],
1215            'stash' => false,
1216
1217            'filesize' => [
1218                ParamValidator::PARAM_TYPE => 'integer',
1219                IntegerDef::PARAM_MIN => 0,
1220                IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(),
1221            ],
1222            'offset' => [
1223                ParamValidator::PARAM_TYPE => 'integer',
1224                IntegerDef::PARAM_MIN => 0,
1225            ],
1226            'chunk' => [
1227                ParamValidator::PARAM_TYPE => 'upload',
1228            ],
1229
1230            'async' => false,
1231            'checkstatus' => false,
1232        ];
1233
1234        return $params;
1235    }
1236
1237    public function needsToken() {
1238        return 'csrf';
1239    }
1240
1241    protected function getExamplesMessages() {
1242        return [
1243            'action=upload&filename=Wiki.png' .
1244                '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
1245                => 'apihelp-upload-example-url',
1246            'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
1247                => 'apihelp-upload-example-filekey',
1248        ];
1249    }
1250
1251    public function getHelpUrls() {
1252        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
1253    }
1254}