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