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