Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.29% covered (danger)
9.29%
55 / 592
0.00% covered (danger)
0.00%
0 / 64
CRAP
0.00% covered (danger)
0.00%
0 / 1
UploadBase
9.31% covered (danger)
9.31%
55 / 591
0.00% covered (danger)
0.00%
0 / 64
35997.34
0.00% covered (danger)
0.00%
0 / 1
 getVerificationErrorCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isAllowed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isThrottled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createFromRequest
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 isValidRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDesiredDestName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourceType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initializePathInfo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 initializeFromRequest
n/a
0 / 0
n/a
0 / 0
0
 setTempFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 fetchFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canFetchFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEmptyFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFileSize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTempFileSha1Base36
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 getRealPath
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 verifyUpload
50.00% covered (danger)
50.00%
9 / 18
0.00% covered (danger)
0.00%
0 / 1
8.12
 validateName
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 verifyMimeType
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 verifyFile
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 getFileProps
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 verifyPartialFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 authorizeUpload
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 checkWarnings
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
 makeWarningsSerializable
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 unserializeWarnings
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 checkBadFileName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 checkUnwantedFileExtensions
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 checkFileSize
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 checkLocalFileExists
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 checkLocalFileWasDeleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 checkAgainstExistingDupes
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 checkAgainstArchiveDupes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 performUpload
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 postProcessUpload
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
67.65% covered (warning)
67.65%
46 / 68
0.00% covered (danger)
0.00%
0 / 1
35.93
 getLocalFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getStashFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tryStashFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 skipStashFileAttempt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runUploadStashFileHook
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 doStashFile
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 cleanupTempFile
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getTempPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 splitExtensions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 checkFileExtension
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkFileExtensionList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 verifyExtension
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 detectScript
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 detectVirus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 checkOverwrite
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 userCanReUpload
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getExistsWarning
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
132
 isThumbName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getFilenamePrefixBlacklist
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getImageInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 convertVerifyErrorToStatus
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
182
 getMaxUploadSize
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getMaxPhpUploadSize
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getSessionStatus
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setSessionStatus
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
30
 getUploadSessionKey
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getUploadSessionStore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Base class for the backend of file upload.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Upload
8 */
9
10namespace MediaWiki\Upload;
11
12use InvalidArgumentException;
13use LogicException;
14use MediaWiki\Api\ApiMessage;
15use MediaWiki\Api\ApiResult;
16use MediaWiki\Api\ApiUpload;
17use MediaWiki\Context\RequestContext;
18use MediaWiki\FileRepo\File\ArchivedFile;
19use MediaWiki\FileRepo\File\File;
20use MediaWiki\FileRepo\File\LocalFile;
21use MediaWiki\FileRepo\FileRepo;
22use MediaWiki\HookContainer\HookRunner;
23use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Message\Message;
28use MediaWiki\Permissions\Authority;
29use MediaWiki\Permissions\PermissionStatus;
30use MediaWiki\Request\WebRequest;
31use MediaWiki\Status\Status;
32use MediaWiki\Title\Title;
33use MediaWiki\Upload\Exception\UploadStashException;
34use MediaWiki\User\User;
35use MediaWiki\User\UserIdentity;
36use MediaWiki\Utils\MWFileProps;
37use Wikimedia\FileBackend\FileBackend;
38use Wikimedia\FileBackend\FSFile\FSFile;
39use Wikimedia\FileBackend\FSFile\TempFSFile;
40use Wikimedia\Message\ListType;
41use Wikimedia\Message\MessageParam;
42use Wikimedia\Message\MessageSpecifier;
43use Wikimedia\ObjectCache\BagOStuff;
44use Wikimedia\Rdbms\IDBAccessObject;
45
46/**
47 * @defgroup Upload Upload related
48 */
49
50/**
51 * @ingroup Upload
52 *
53 * UploadBase and subclasses are the backend of MediaWiki's file uploads.
54 * The frontends are formed by ApiUpload and SpecialUpload.
55 *
56 * @stable to extend
57 *
58 * @author Brooke Vibber
59 * @author Bryan Tong Minh
60 * @author Michael Dale
61 */
62abstract class UploadBase {
63    use ProtectedHookAccessorTrait;
64
65    /** @var string|null Local file system path to the file to upload (or a local copy) */
66    protected $mTempPath;
67    /** @var TempFSFile|null Wrapper to handle deleting the temp file */
68    protected $tempFileObj;
69    /** @var string|null */
70    protected $mDesiredDestName;
71    /** @var string|null */
72    protected $mDestName;
73    /** @var bool|null */
74    protected $mRemoveTempFile;
75    /** @var string|null */
76    protected $mSourceType;
77    /** @var Title|false|null */
78    protected $mTitle = false;
79    /** @var int */
80    protected $mTitleError = 0;
81    /** @var string|null */
82    protected $mFilteredName;
83    /** @var string|null */
84    protected $mFinalExtension;
85    /** @var LocalFile|null */
86    protected $mLocalFile;
87    /** @var UploadStashFile|null */
88    protected $mStashFile;
89    /** @var int|null */
90    protected $mFileSize;
91    /** @var array|null */
92    protected $mFileProps;
93    /** @var string[] */
94    protected $mBlackListedExtensions;
95
96    private UploadVerification $uploadVerification;
97
98    public const SUCCESS = 0;
99    public const OK = 0;
100    public const EMPTY_FILE = 3;
101    public const MIN_LENGTH_PARTNAME = 4;
102    public const ILLEGAL_FILENAME = 5;
103    public const FILETYPE_MISSING = 8;
104    public const FILETYPE_BADTYPE = 9;
105    public const VERIFICATION_ERROR = 10;
106    public const FILE_TOO_LARGE = 12;
107    public const WINDOWS_NONASCII_FILENAME = 13;
108    public const FILENAME_TOO_LONG = 14;
109
110    private const CODE_TO_STATUS = [
111        self::EMPTY_FILE => 'empty-file',
112        self::FILE_TOO_LARGE => 'file-too-large',
113        self::FILETYPE_MISSING => 'filetype-missing',
114        self::FILETYPE_BADTYPE => 'filetype-banned',
115        self::MIN_LENGTH_PARTNAME => 'filename-tooshort',
116        self::ILLEGAL_FILENAME => 'illegal-filename',
117        self::VERIFICATION_ERROR => 'verification-error',
118        self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename',
119        self::FILENAME_TOO_LONG => 'filename-toolong',
120    ];
121
122    private const CORE_UPLOAD_HANDLERS = [
123        'Stash' => UploadFromStash::class,
124        'File' => UploadFromFile::class,
125        'Url' => UploadFromUrl::class,
126    ];
127
128    /**
129     * @param int $error
130     * @return string
131     */
132    public function getVerificationErrorCode( $error ) {
133        return self::CODE_TO_STATUS[$error] ?? 'unknown-error';
134    }
135
136    /**
137     * Returns true if uploads are enabled.
138     * Can be override by subclasses.
139     * @stable to override
140     * @return bool
141     */
142    public static function isEnabled() {
143        $enableUploads = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::EnableUploads );
144
145        return $enableUploads && wfIniGetBool( 'file_uploads' );
146    }
147
148    /**
149     * Returns true if the user can use this upload module or else a string
150     * identifying the missing permission.
151     * Can be overridden by subclasses.
152     *
153     * @param Authority $performer
154     * @return bool|string
155     */
156    public static function isAllowed( Authority $performer ) {
157        foreach ( [ 'upload', 'edit' ] as $permission ) {
158            if ( !$performer->isAllowed( $permission ) ) {
159                return $permission;
160            }
161        }
162
163        return true;
164    }
165
166    /**
167     * Returns true if the user has surpassed the upload rate limit, false otherwise.
168     *
169     * @deprecated since 1.41, use authorizeUpload() instead.
170     *  Rate limit checks are now implicit in permission checks.
171     *
172     * @param User $user
173     * @return bool
174     */
175    public static function isThrottled( $user ) {
176        wfDeprecated( __METHOD__, '1.41' );
177        return $user->pingLimiter( 'upload' );
178    }
179
180    /**
181     * Create a form of UploadBase depending on wpSourceType and initializes it.
182     *
183     * @param WebRequest &$request
184     * @param string|null $type
185     * @return null|self
186     */
187    public static function createFromRequest( &$request, $type = null ) {
188        $type = $type ?: $request->getVal( 'wpSourceType', 'File' );
189
190        if ( !$type ) {
191            return null;
192        }
193
194        // Get the upload class
195        $type = ucfirst( $type );
196
197        // Give hooks the chance to handle this request
198        /** @var class-string<self>|null $className */
199        $className = null;
200        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
201            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
202            ->onUploadCreateFromRequest( $type, $className );
203        if ( $className === null ) {
204            if ( !isset( self::CORE_UPLOAD_HANDLERS[$type] ) ) {
205                wfDebug( __METHOD__ . ": no class name for $type" );
206                return null;
207            }
208            $className = self::CORE_UPLOAD_HANDLERS[$type];
209            wfDebug( __METHOD__ . ": class name: $className" );
210        }
211
212        if ( !$className::isEnabled() || !$className::isValidRequest( $request ) ) {
213            return null;
214        }
215
216        /** @var self $handler */
217        $handler = new $className;
218
219        $handler->initializeFromRequest( $request );
220
221        return $handler;
222    }
223
224    /**
225     * Check whether a request if valid for this handler.
226     * @param WebRequest $request
227     * @return bool
228     */
229    public static function isValidRequest( $request ) {
230        return false;
231    }
232
233    /**
234     * Get the desired destination name.
235     * @return string|null
236     */
237    public function getDesiredDestName() {
238        return $this->mDesiredDestName;
239    }
240
241    /**
242     * @stable to call
243     */
244    public function __construct() {
245        $this->uploadVerification = MediaWikiServices::getInstance()->getUploadVerification();
246    }
247
248    /**
249     * Returns the upload type. Should be overridden by child classes.
250     *
251     * @since 1.18
252     * @stable to override
253     * @return string|null
254     */
255    public function getSourceType() {
256        return null;
257    }
258
259    /**
260     * @param string $name The desired destination name
261     * @param string|null $tempPath Callers should make sure this is not a storage path
262     * @param int|null $fileSize
263     * @param bool $removeTempFile (false) remove the temporary file?
264     */
265    public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) {
266        $this->mDesiredDestName = $name;
267        if ( FileBackend::isStoragePath( $tempPath ) ) {
268            throw new InvalidArgumentException( __METHOD__ . " given storage path `$tempPath`." );
269        }
270
271        $this->setTempFile( $tempPath, $fileSize );
272        $this->mRemoveTempFile = $removeTempFile;
273    }
274
275    /**
276     * Initialize from a WebRequest. Override this in a subclass.
277     *
278     * @param WebRequest &$request
279     */
280    abstract public function initializeFromRequest( &$request );
281
282    /**
283     * @param string|null $tempPath File system path to temporary file containing the upload
284     * @param int|null $fileSize
285     */
286    protected function setTempFile( $tempPath, $fileSize = null ) {
287        $this->mTempPath = $tempPath ?? '';
288        $this->mFileSize = $fileSize ?: null;
289        $this->mFileProps = null;
290        if ( $this->mTempPath !== '' && file_exists( $this->mTempPath ) ) {
291            $this->tempFileObj = new TempFSFile( $this->mTempPath );
292            if ( !$fileSize ) {
293                $this->mFileSize = filesize( $this->mTempPath );
294            }
295        } else {
296            $this->tempFileObj = null;
297        }
298    }
299
300    /**
301     * Fetch the file. Usually a no-op.
302     * @stable to override
303     * @return Status
304     */
305    public function fetchFile() {
306        return Status::newGood();
307    }
308
309    /**
310     * Perform checks to see if the file can be fetched. Usually a no-op.
311     * @stable to override
312     * @return Status
313     */
314    public function canFetchFile() {
315        return Status::newGood();
316    }
317
318    /**
319     * Return true if the file is empty.
320     * @return bool
321     */
322    public function isEmptyFile() {
323        return !$this->mFileSize;
324    }
325
326    /**
327     * Return the file size.
328     * @return int
329     */
330    public function getFileSize() {
331        return $this->mFileSize;
332    }
333
334    /**
335     * Get the base 36 SHA1 of the file.
336     * @stable to override
337     * @return string|false
338     */
339    public function getTempFileSha1Base36() {
340        // Use cached version if we already have it.
341        if ( $this->mFileProps && is_string( $this->mFileProps['sha1'] ) ) {
342            return $this->mFileProps['sha1'];
343        }
344        return $this->mTempPath !== '' ? FSFile::getSha1Base36FromPath( $this->mTempPath ) : false;
345    }
346
347    /**
348     * @param string $srcPath The source path
349     * @return string|false The real path if it was a virtual URL Returns false on failure
350     */
351    public function getRealPath( $srcPath ) {
352        $repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
353        if ( FileRepo::isVirtualUrl( $srcPath ) ) {
354            /** @todo Just make uploads work with storage paths UploadFromStash
355             *  loads files via virtual URLs.
356             */
357            $tmpFile = $repo->getLocalCopy( $srcPath );
358            if ( $tmpFile ) {
359                $tmpFile->bind( $this ); // keep alive with $this
360            }
361            $path = $tmpFile ? $tmpFile->getPath() : false;
362        } else {
363            $path = $srcPath;
364        }
365
366        return $path;
367    }
368
369    /**
370     * Verify whether the upload is sensible.
371     *
372     * Return a status array representing the outcome of the verification.
373     * Possible keys are:
374     * - 'status': set to self::OK in case of success, or to one of the error constants defined in
375     *   this class in case of failure
376     * - 'max': set to the maximum allowed file size ($wgMaxUploadSize) if the upload is too large
377     * - 'details': set to error details if the file type is valid but contents are corrupt
378     * - 'filtered': set to the sanitized file name if the requested file name is invalid
379     * - 'finalExt': set to the file's file extension if it is not an allowed file extension
380     * - 'blacklistedExt': set to the list of disallowed file extensions if the current file extension
381     *    is not allowed for uploads and the list is not empty
382     *
383     * @stable to override
384     * @return mixed[] array representing the result of the verification
385     */
386    public function verifyUpload() {
387        /**
388         * If there was no filename or a zero size given, give up quick.
389         */
390        if ( $this->isEmptyFile() ) {
391            return [ 'status' => self::EMPTY_FILE ];
392        }
393
394        /**
395         * Honor $wgMaxUploadSize
396         */
397        $maxSize = self::getMaxUploadSize( $this->getSourceType() );
398        if ( $this->mFileSize > $maxSize ) {
399            return [
400                'status' => self::FILE_TOO_LARGE,
401                'max' => $maxSize,
402            ];
403        }
404
405        /**
406         * Look at the contents of the file; if we can recognize the
407         * type, but it's corrupt or data of the wrong type, we should
408         * probably not accept it.
409         */
410        $verification = $this->verifyFile();
411        if ( $verification !== true ) {
412            return [
413                'status' => self::VERIFICATION_ERROR,
414                'details' => $verification
415            ];
416        }
417
418        /**
419         * Make sure this file can be created
420         */
421        $result = $this->validateName();
422        if ( $result !== true ) {
423            return $result;
424        }
425
426        return [ 'status' => self::OK ];
427    }
428
429    /**
430     * Verify that the name is valid and, if necessary, that we can overwrite
431     *
432     * @return array|bool True if valid, otherwise an array with 'status'
433     * and other keys
434     */
435    public function validateName() {
436        $nt = $this->getTitle();
437        if ( $nt === null ) {
438            $result = [ 'status' => $this->mTitleError ];
439            if ( $this->mTitleError === self::ILLEGAL_FILENAME ) {
440                $result['filtered'] = $this->mFilteredName;
441            }
442            if ( $this->mTitleError === self::FILETYPE_BADTYPE ) {
443                $result['finalExt'] = $this->mFinalExtension;
444                if ( count( $this->mBlackListedExtensions ) ) {
445                    $result['blacklistedExt'] = $this->mBlackListedExtensions;
446                }
447            }
448
449            return $result;
450        }
451        $this->mDestName = $this->getLocalFile()->getName();
452
453        return true;
454    }
455
456    /**
457     * Verify the MIME type.
458     *
459     * @note Only checks that it is not an evil MIME.
460     *  The "does it have the correct file extension given its MIME type?" check is in verifyFile.
461     * @param string $mime Representing the MIME
462     * @return array|bool True if the file is verified, an array otherwise
463     */
464    protected function verifyMimeType( $mime ) {
465        $verifyMimeType = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::VerifyMimeType );
466        if ( $verifyMimeType ) {
467            wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>" );
468            $mimeTypeExclusions = MediaWikiServices::getInstance()->getMainConfig()
469                ->get( MainConfigNames::MimeTypeExclusions );
470            if ( self::checkFileExtension( $mime, $mimeTypeExclusions ) ) {
471                return [ 'filetype-badmime', $mime ];
472            }
473        }
474
475        return true;
476    }
477
478    /**
479     * Verifies that it's ok to include the uploaded file
480     *
481     * @return array|true True of the file is verified, array otherwise.
482     */
483    protected function verifyFile() {
484        $status = $this->verifyPartialFile();
485        if ( $status !== true ) {
486            return $status;
487        }
488
489        $res = $this->uploadVerification->verifyFile(
490            $this->mTempPath,
491            (string)$this->mFinalExtension,
492            $this->getFileProps()
493        );
494        if ( $res !== true ) {
495            return $res;
496        }
497
498        $error = true;
499        $mime = $this->getFileProps()['mime'];
500        $this->getHookRunner()->onUploadVerifyFile( $this, $mime, $error );
501        if ( $error !== true ) {
502            if ( !is_array( $error ) ) {
503                $error = [ $error ];
504            }
505            return $error;
506        }
507
508        return true;
509    }
510
511    /**
512     * File props is very expensive on large files (due to sha1 calc)
513     * so it is important we save the result to reuse
514     *
515     * @return array List of file properties
516     */
517    protected function getFileProps(): array {
518        // Force $this->mFinalExtension to be populated.
519        $this->getTitle();
520
521        if ( !is_array( $this->mFileProps ) ) {
522            $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() );
523            $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
524        }
525        return $this->mFileProps;
526    }
527
528    /**
529     * A verification routine suitable for partial files
530     *
531     * Runs the deny list checks, but not any checks that may
532     * assume the entire file is present.
533     *
534     * @return array|true True, if the file is valid, else an array with error message key.
535     * @phan-return non-empty-array|true
536     */
537    protected function verifyPartialFile() {
538        // Needed to set mFinalExtension.
539        $this->getTitle();
540        return $this->uploadVerification->verifyPartialFile(
541            $this->mTempPath,
542            (string)$this->mFinalExtension,
543            $this->getFileProps()
544        );
545    }
546
547    /**
548     * Check whether the user can upload the image. This method checks against the current title.
549     * Use verifyUpload() or validateName() first to check that the title is valid.
550     */
551    public function authorizeUpload( Authority $performer ): PermissionStatus {
552        $status = PermissionStatus::newEmpty();
553
554        $nt = $this->getTitle();
555        if ( $nt === null ) {
556            throw new LogicException( __METHOD__ . ' must only be called with valid title' );
557        }
558
559        $performer->authorizeWrite( 'edit', $nt, $status );
560        $performer->authorizeWrite( 'upload', $nt, $status );
561        if ( !$status->isGood() ) {
562            // If the user can't upload at all, don't display additional errors about re-uploading
563            return $status;
564        }
565
566        $overwriteError = $this->checkOverwrite( $performer );
567        if ( $overwriteError !== true ) {
568            $status->fatal( ...$overwriteError );
569        }
570
571        return $status;
572    }
573
574    /**
575     * Check for non fatal problems with the file.
576     *
577     * This should not assume that mTempPath is set.
578     *
579     * @param User|null $user Accepted since 1.35; not passing the parameter or
580     *  passing null to it is deprecated since 1.46
581     *
582     * @return mixed[] Array of warnings
583     */
584    public function checkWarnings( $user = null ) {
585        if ( $user === null ) {
586            wfDeprecatedMsg(
587                'Calling UploadBase::checkWarnings without a user was deprecated in 1.46',
588                '1.46'
589            );
590            $user = RequestContext::getMain()->getUser();
591        }
592
593        $warnings = [];
594
595        $localFile = $this->getLocalFile();
596        $localFile->load( IDBAccessObject::READ_LATEST );
597        $filename = $localFile->getName();
598        $hash = $this->getTempFileSha1Base36();
599
600        $badFileName = $this->checkBadFileName( $filename, $this->mDesiredDestName );
601        if ( $badFileName !== null ) {
602            $warnings['badfilename'] = $badFileName;
603        }
604
605        $unwantedFileExtensionDetails = $this->checkUnwantedFileExtensions( (string)$this->mFinalExtension );
606        if ( $unwantedFileExtensionDetails !== null ) {
607            $warnings['filetype-unwanted-type'] = $unwantedFileExtensionDetails;
608        }
609
610        $fileSizeWarnings = $this->checkFileSize( $this->mFileSize );
611        if ( $fileSizeWarnings ) {
612            $warnings = array_merge( $warnings, $fileSizeWarnings );
613        }
614
615        $localFileExistsWarnings = $this->checkLocalFileExists( $localFile, $hash );
616        if ( $localFileExistsWarnings ) {
617            $warnings = array_merge( $warnings, $localFileExistsWarnings );
618        }
619
620        if ( $this->checkLocalFileWasDeleted( $localFile ) ) {
621            $warnings['was-deleted'] = $filename;
622        }
623
624        // If a file with the same name exists locally then the local file has already been tested
625        // for duplication of content
626        $ignoreLocalDupes = isset( $warnings['exists'] );
627        $dupes = $this->checkAgainstExistingDupes( $hash, $ignoreLocalDupes );
628        if ( $dupes ) {
629            $warnings['duplicate'] = $dupes;
630        }
631
632        $archivedDupes = $this->checkAgainstArchiveDupes( $hash, $user );
633        if ( $archivedDupes !== null ) {
634            $warnings['duplicate-archive'] = $archivedDupes;
635        }
636
637        return $warnings;
638    }
639
640    /**
641     * Convert the warnings array returned by checkWarnings() to something that
642     * can be serialized, and that is suitable for inclusion directly in action API results.
643     *
644     * File objects will be converted to an associative array with the following keys:
645     *
646     *   - fileName: The name of the file
647     *   - timestamp: The upload timestamp
648     *
649     * @param mixed[] $warnings
650     * @return mixed[]
651     */
652    public static function makeWarningsSerializable( $warnings ) {
653        array_walk_recursive( $warnings, static function ( &$param, $key ) {
654            if ( $param instanceof File ) {
655                $param = [
656                    'fileName' => $param->getName(),
657                    'timestamp' => $param->getTimestamp()
658                ];
659            } elseif ( $param instanceof MessageParam ) {
660                // Do nothing (T390001)
661            } elseif ( is_object( $param ) ) {
662                throw new InvalidArgumentException(
663                    'UploadBase::makeWarningsSerializable: ' .
664                    'Unexpected object of class ' . get_class( $param ) );
665            }
666        } );
667        return $warnings;
668    }
669
670    /**
671     * Convert the serialized warnings array created by makeWarningsSerializable()
672     * back to the output of checkWarnings().
673     *
674     * @param mixed[] $warnings
675     * @return mixed[]
676     */
677    public static function unserializeWarnings( $warnings ) {
678        foreach ( $warnings as $key => $value ) {
679            if ( is_array( $value ) ) {
680                if ( isset( $value['fileName'] ) && isset( $value['timestamp'] ) ) {
681                    $warnings[$key] = MediaWikiServices::getInstance()->getRepoGroup()->findFile(
682                        $value['fileName'],
683                        [ 'time' => $value['timestamp'] ]
684                    );
685                } else {
686                    $warnings[$key] = self::unserializeWarnings( $value );
687                }
688            }
689        }
690        return $warnings;
691    }
692
693    /**
694     * Check whether the resulting filename is different from the desired one,
695     * but ignore things like ucfirst() and spaces/underscore things
696     *
697     * @param string $filename
698     * @param string $desiredFileName
699     *
700     * @return string|null String that was determined to be bad or null if the filename is okay
701     */
702    private function checkBadFileName( $filename, $desiredFileName ) {
703        $comparableName = str_replace( ' ', '_', $desiredFileName );
704        $comparableName = Title::capitalize( $comparableName, NS_FILE );
705
706        if ( $desiredFileName != $filename && $comparableName != $filename ) {
707            return $filename;
708        }
709
710        return null;
711    }
712
713    /**
714     * @param string $fileExtension The file extension to check
715     *
716     * @return array|null array with the following keys:
717     *                    0 => string The final extension being used
718     *                    1 => string[] The extensions that are allowed
719     *                    2 => int The number of extensions that are allowed.
720     */
721    private function checkUnwantedFileExtensions( $fileExtension ) {
722        $checkFileExtensions = MediaWikiServices::getInstance()->getMainConfig()
723            ->get( MainConfigNames::CheckFileExtensions );
724        $fileExtensions = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::FileExtensions );
725        if ( $checkFileExtensions ) {
726            $extensions = array_unique( $fileExtensions );
727            if ( !self::checkFileExtension( $fileExtension, $extensions ) ) {
728                return [
729                    $fileExtension,
730                    Message::listParam( $extensions, ListType::COMMA ),
731                    count( $extensions )
732                ];
733            }
734        }
735
736        return null;
737    }
738
739    /**
740     * @param int|null $fileSize
741     *
742     * @return array warnings
743     */
744    private function checkFileSize( $fileSize ) {
745        $uploadSizeWarning = MediaWikiServices::getInstance()->getMainConfig()
746            ->get( MainConfigNames::UploadSizeWarning );
747
748        $warnings = [];
749
750        if ( $uploadSizeWarning && $fileSize !== null && $fileSize > $uploadSizeWarning ) {
751            $warnings['large-file'] = [
752                Message::sizeParam( $uploadSizeWarning ),
753                Message::sizeParam( $fileSize ),
754            ];
755        }
756
757        if ( $fileSize === 0 ) {
758            $warnings['empty-file'] = true;
759        }
760
761        return $warnings;
762    }
763
764    /**
765     * @param LocalFile $localFile
766     * @param string|false $hash sha1 hash of the file to check
767     *
768     * @return array warnings
769     */
770    private function checkLocalFileExists( LocalFile $localFile, $hash ) {
771        $warnings = [];
772
773        $exists = self::getExistsWarning( $localFile );
774        if ( $exists !== false ) {
775            $warnings['exists'] = $exists;
776
777            // check if file is an exact duplicate of current file version
778            if ( $hash !== false && $hash === $localFile->getSha1() ) {
779                $warnings['no-change'] = $localFile;
780            }
781
782            // check if file is an exact duplicate of older versions of this file
783            $history = $localFile->getHistory();
784            foreach ( $history as $oldFile ) {
785                if ( $hash === $oldFile->getSha1() ) {
786                    $warnings['duplicate-version'][] = $oldFile;
787                }
788            }
789        }
790
791        return $warnings;
792    }
793
794    private function checkLocalFileWasDeleted( LocalFile $localFile ): bool {
795        return $localFile->wasDeleted() && !$localFile->exists();
796    }
797
798    /**
799     * @param string|false $hash sha1 hash of the file to check
800     * @param bool $ignoreLocalDupes True to ignore local duplicates
801     *
802     * @return File[] Duplicate files, if found.
803     */
804    private function checkAgainstExistingDupes( $hash, $ignoreLocalDupes ) {
805        if ( $hash === false ) {
806            return [];
807        }
808        $dupes = MediaWikiServices::getInstance()->getRepoGroup()->findBySha1( $hash );
809        $title = $this->getTitle();
810        foreach ( $dupes as $key => $dupe ) {
811            if (
812                ( $dupe instanceof LocalFile ) &&
813                $ignoreLocalDupes &&
814                $title->equals( $dupe->getTitle() )
815            ) {
816                unset( $dupes[$key] );
817            }
818        }
819
820        return $dupes;
821    }
822
823    /**
824     * @param string|false $hash sha1 hash of the file to check
825     * @param Authority $performer
826     *
827     * @return string|null Name of the dupe or empty string if discovered (depending on visibility)
828     *                     null if the check discovered no dupes.
829     */
830    private function checkAgainstArchiveDupes( $hash, Authority $performer ) {
831        if ( $hash === false ) {
832            return null;
833        }
834        $archivedFile = new ArchivedFile( null, 0, '', $hash );
835        if ( $archivedFile->getID() > 0 ) {
836            if ( $archivedFile->userCan( File::DELETED_FILE, $performer ) ) {
837                return $archivedFile->getName();
838            }
839            return '';
840        }
841
842        return null;
843    }
844
845    /**
846     * Really perform the upload. Stores the file in the local repo, watches
847     * if necessary and runs the UploadComplete hook.
848     *
849     * @param string $comment
850     * @param string|false $pageText
851     * @param bool $watch Whether the file page should be added to user's watchlist.
852     *   (This doesn't check $user's permissions.)
853     * @param User $user
854     * @param string[] $tags Change tags to add to the log entry and page revision.
855     *   (This doesn't check $user's permissions.)
856     * @param string|null $watchlistExpiry Optional watchlist expiry timestamp in any format
857     *   acceptable to wfTimestamp().
858     * @return Status Indicating the whether the upload succeeded.
859     *
860     * @since 1.35 Accepts $watchlistExpiry parameter.
861     */
862    public function performUpload(
863        $comment, $pageText, $watch, $user, $tags = [], ?string $watchlistExpiry = null
864    ) {
865        $this->getLocalFile()->load( IDBAccessObject::READ_LATEST );
866        $props = $this->mFileProps;
867
868        $error = null;
869        $this->getHookRunner()->onUploadVerifyUpload( $this, $user, $props, $comment, $pageText, $error );
870        if ( $error ) {
871            if ( !is_array( $error ) ) {
872                $error = [ $error ];
873            }
874            return Status::newFatal( ...$error );
875        }
876
877        $status = $this->getLocalFile()->upload(
878            $this->mTempPath,
879            $comment,
880            $pageText !== false ? $pageText : '',
881            File::DELETE_SOURCE,
882            $props,
883            false,
884            $user,
885            $tags
886        );
887
888        if ( $status->isGood() ) {
889            if ( $watch ) {
890                MediaWikiServices::getInstance()->getWatchlistManager()->addWatchIgnoringRights(
891                    $user,
892                    $this->getLocalFile()->getTitle(),
893                    $watchlistExpiry
894                );
895            }
896            $this->getHookRunner()->onUploadComplete( $this );
897
898            $this->postProcessUpload();
899        }
900
901        return $status;
902    }
903
904    /**
905     * Perform extra steps after a successful upload.
906     *
907     * @stable to override
908     * @since  1.25
909     */
910    public function postProcessUpload() {
911    }
912
913    /**
914     * Returns the title of the file to be uploaded. Sets mTitleError in case
915     * the name was illegal.
916     *
917     * @return Title|null The title of the file or null in case the name was illegal
918     */
919    public function getTitle() {
920        if ( $this->mTitle !== false ) {
921            return $this->mTitle;
922        }
923        if ( !is_string( $this->mDesiredDestName ) ) {
924            $this->mTitleError = self::ILLEGAL_FILENAME;
925            $this->mTitle = null;
926
927            return $this->mTitle;
928        }
929        /* Assume that if a user specified File:Something.jpg, this is an error
930         * and that the namespace prefix needs to be stripped of.
931         */
932        $title = Title::newFromText( $this->mDesiredDestName );
933        if ( $title && $title->getNamespace() === NS_FILE ) {
934            $this->mFilteredName = $title->getDBkey();
935        } else {
936            $this->mFilteredName = $this->mDesiredDestName;
937        }
938
939        # oi_archive_name is max 255 bytes, which include a timestamp and an
940        # exclamation mark, so restrict file name to 240 bytes.
941        if ( strlen( $this->mFilteredName ) > 240 ) {
942            $this->mTitleError = self::FILENAME_TOO_LONG;
943            $this->mTitle = null;
944
945            return $this->mTitle;
946        }
947
948        /**
949         * Chop off any directories in the given filename. Then
950         * filter out illegal characters, and try to make a legible name
951         * out of it. We'll strip some silently that Title would die on.
952         */
953        $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName );
954        /* Normalize to title form before we do any further processing */
955        $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
956        if ( $nt === null ) {
957            $this->mTitleError = self::ILLEGAL_FILENAME;
958            $this->mTitle = null;
959
960            return $this->mTitle;
961        }
962        $this->mFilteredName = $nt->getDBkey();
963
964        /**
965         * We'll want to prevent against *any* 'extension', and use
966         * only the final one for the allow list.
967         */
968        [ $partname, $ext ] = self::splitExtensions( $this->mFilteredName );
969
970        if ( $ext !== [] ) {
971            $this->mFinalExtension = trim( end( $ext ) );
972        } else {
973            $this->mFinalExtension = '';
974
975            // No extension, try guessing one from the temporary file
976            // FIXME: Sometimes we mTempPath isn't set yet here, possibly due to an unrealistic
977            // or incomplete test case in UploadBaseTest (T272328)
978            if ( $this->mTempPath !== null ) {
979                $magic = MediaWikiServices::getInstance()->getMimeAnalyzer();
980                $mime = $magic->guessMimeType( $this->mTempPath );
981                if ( $mime !== 'unknown/unknown' ) {
982                    # Get a space separated list of extensions
983                    $mimeExt = $magic->getExtensionFromMimeTypeOrNull( $mime );
984                    if ( $mimeExt !== null ) {
985                        # Set the extension to the canonical extension
986                        $this->mFinalExtension = $mimeExt;
987
988                        # Fix up the other variables
989                        $this->mFilteredName .= ".{$this->mFinalExtension}";
990                        $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
991                        $ext = [ $this->mFinalExtension ];
992                    }
993                }
994            }
995        }
996
997        // Don't allow users to override the list of prohibited file extensions (check file extension)
998        $config = MediaWikiServices::getInstance()->getMainConfig();
999        $checkFileExtensions = $config->get( MainConfigNames::CheckFileExtensions );
1000        $strictFileExtensions = $config->get( MainConfigNames::StrictFileExtensions );
1001        $fileExtensions = $config->get( MainConfigNames::FileExtensions );
1002        $prohibitedFileExtensions = $config->get( MainConfigNames::ProhibitedFileExtensions );
1003
1004        $badList = self::checkFileExtensionList( $ext, $prohibitedFileExtensions );
1005
1006        if ( $this->mFinalExtension == '' ) {
1007            $this->mTitleError = self::FILETYPE_MISSING;
1008            $this->mTitle = null;
1009
1010            return $this->mTitle;
1011        }
1012
1013        if ( $badList ||
1014            ( $checkFileExtensions && $strictFileExtensions &&
1015                !self::checkFileExtension( $this->mFinalExtension, $fileExtensions ) )
1016        ) {
1017            $this->mBlackListedExtensions = $badList;
1018            $this->mTitleError = self::FILETYPE_BADTYPE;
1019            $this->mTitle = null;
1020
1021            return $this->mTitle;
1022        }
1023
1024        // Windows may be broken with special characters, see T3780
1025        if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() )
1026            && !MediaWikiServices::getInstance()->getRepoGroup()
1027                ->getLocalRepo()->backendSupportsUnicodePaths()
1028        ) {
1029            $this->mTitleError = self::WINDOWS_NONASCII_FILENAME;
1030            $this->mTitle = null;
1031
1032            return $this->mTitle;
1033        }
1034
1035        # If there was more than one file "extension", reassemble the base
1036        # filename to prevent bogus complaints about length
1037        if ( count( $ext ) > 1 ) {
1038            $iterations = count( $ext ) - 1;
1039            for ( $i = 0; $i < $iterations; $i++ ) {
1040                $partname .= '.' . $ext[$i];
1041            }
1042        }
1043
1044        if ( strlen( $partname ) < 1 ) {
1045            $this->mTitleError = self::MIN_LENGTH_PARTNAME;
1046            $this->mTitle = null;
1047
1048            return $this->mTitle;
1049        }
1050
1051        $this->mTitle = $nt;
1052
1053        return $this->mTitle;
1054    }
1055
1056    /**
1057     * Return the local file and initializes if necessary.
1058     *
1059     * @stable to override
1060     * @return LocalFile|null
1061     */
1062    public function getLocalFile() {
1063        if ( $this->mLocalFile === null ) {
1064            $nt = $this->getTitle();
1065            $this->mLocalFile = $nt === null
1066                ? null
1067                : MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()->newFile( $nt );
1068        }
1069
1070        return $this->mLocalFile;
1071    }
1072
1073    /**
1074     * @return UploadStashFile|null
1075     */
1076    public function getStashFile() {
1077        return $this->mStashFile;
1078    }
1079
1080    /**
1081     * Like stashFile(), but respects extensions' wishes to prevent the stashing. verifyUpload() must
1082     * be called before calling this method (unless $isPartial is true).
1083     *
1084     * Upload stash exceptions are also caught and converted to an error status.
1085     *
1086     * @since 1.28
1087     * @stable to override
1088     * @param User $user
1089     * @param bool $isPartial Pass `true` if this is a part of a chunked upload (not a complete file).
1090     * @return Status If successful, value is an UploadStashFile instance
1091     */
1092    public function tryStashFile( User $user, $isPartial = false ) {
1093        if ( !$isPartial ) {
1094            $error = $this->runUploadStashFileHook( $user );
1095            if ( $error ) {
1096                return Status::newFatal( ...$error );
1097            }
1098        }
1099        try {
1100            $file = $this->doStashFile( $user );
1101            return Status::newGood( $file );
1102        } catch ( UploadStashException $e ) {
1103            return Status::newFatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
1104        }
1105    }
1106
1107    /**
1108     * Check, if stash file attempt should be skipped,
1109     * for example when the file is already known to stash.
1110     *
1111     * @since 1.46
1112     * @stable to override
1113     */
1114    public function skipStashFileAttempt(): bool {
1115        return $this->getStashFile() !== null;
1116    }
1117
1118    /**
1119     * @param User $user
1120     * @return array|null Error message and parameters, null if there's no error
1121     */
1122    protected function runUploadStashFileHook( User $user ) {
1123        $props = $this->mFileProps;
1124        $error = null;
1125        $this->getHookRunner()->onUploadStashFile( $this, $user, $props, $error );
1126        if ( $error && !is_array( $error ) ) {
1127            $error = [ $error ];
1128        }
1129        return $error;
1130    }
1131
1132    /**
1133     * Implementation for stashFile() and tryStashFile().
1134     *
1135     * @stable to override
1136     * @param User|null $user
1137     * @return UploadStashFile Stashed file
1138     */
1139    protected function doStashFile( ?User $user = null ) {
1140        $stash = MediaWikiServices::getInstance()->getRepoGroup()
1141            ->getLocalRepo()->getUploadStash( $user );
1142        $file = $stash->stashFile( $this->mTempPath, $this->getSourceType(), $this->mFileProps );
1143        $this->mStashFile = $file;
1144
1145        return $file;
1146    }
1147
1148    /**
1149     * If we've modified the upload file, then we need to manually remove it
1150     * on exit to clean up.
1151     */
1152    public function cleanupTempFile() {
1153        if ( $this->mRemoveTempFile && $this->tempFileObj ) {
1154            // Delete when all relevant TempFSFile handles go out of scope
1155            wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal" );
1156            $this->tempFileObj->autocollect();
1157        }
1158    }
1159
1160    /**
1161     * @return string|null
1162     */
1163    public function getTempPath() {
1164        return $this->mTempPath;
1165    }
1166
1167    /**
1168     * Split a file into a base name and all dot-delimited 'extensions'
1169     * on the end. Some web server configurations will fall back to
1170     * earlier pseudo-'extensions' to determine type and execute
1171     * scripts, so we need to check them all.
1172     *
1173     * @param string $filename
1174     * @return array [ string, string[] ]
1175     */
1176    public static function splitExtensions( $filename ) {
1177        $bits = explode( '.', $filename );
1178        $basename = array_shift( $bits );
1179
1180        return [ $basename, $bits ];
1181    }
1182
1183    /**
1184     * Perform case-insensitive match against a list of file extensions.
1185     *
1186     * @param string $ext File extension
1187     * @param array $list
1188     * @return bool Returns true if the extension is in the list.
1189     */
1190    public static function checkFileExtension( $ext, $list ) {
1191        return in_array( strtolower( $ext ?? '' ), $list, true );
1192    }
1193
1194    /**
1195     * Perform case-insensitive match against a list of file extensions.
1196     * Returns an array of matching extensions.
1197     *
1198     * @param string[] $ext File extensions
1199     * @param string[] $list
1200     * @return string[]
1201     */
1202    public static function checkFileExtensionList( $ext, $list ) {
1203        return array_intersect( array_map( 'strtolower', $ext ), $list );
1204    }
1205
1206    /**
1207     * Checks if the MIME type of the uploaded file matches the file extension.
1208     *
1209     * @deprecated 1.45
1210     * @param string $mime The MIME type of the uploaded file
1211     * @param string $extension The filename extension that the file is to be served with
1212     * @return bool
1213     */
1214    public static function verifyExtension( $mime, $extension ) {
1215        wfDeprecated( __METHOD__, '1.45' );
1216        // External callers should probably be using verifyFile and not this method.
1217        $verify = MediaWikiServices::getInstance()->getUploadVerification();
1218        return $verify->verifyExtension( $mime, $extension );
1219    }
1220
1221    /**
1222     * Heuristic for detecting files that *could* contain JavaScript instructions or
1223     * things that may look like HTML to a browser and are thus
1224     * potentially harmful. The present implementation will produce false
1225     * positives in some situations.
1226     *
1227     * @warning This only does some of the checks and should not be used to verify files by itself.
1228     *
1229     * @deprecated 1.45 use UploadVerification::verifyFile() instead
1230     * @param string|null $file Pathname to the temporary upload file
1231     * @param string $mime The MIME type of the file
1232     * @param string|null $extension The extension of the file
1233     * @return bool True if the file contains something looking like embedded scripts
1234     */
1235    public static function detectScript( $file, $mime, $extension ) {
1236        wfDeprecated( __METHOD__, '1.45' );
1237        // When replacing usage of this in extensions, use UploadVerification::verifyFile.
1238        // detectScript is unlikely to be the method you want.
1239        $verify = MediaWikiServices::getInstance()->getUploadVerification();
1240        return $verify->detectScript( $file, $mime, $extension );
1241    }
1242
1243    /**
1244     * Generic wrapper function for a virus scanner program.
1245     * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
1246     * $wgAntivirusRequired may be used to deny upload if the scan fails.
1247     *
1248     * @param string $file Pathname to the temporary upload file
1249     * @return bool|null|string False if not virus is found, null if the scan fails or is disabled,
1250     *   or a string containing feedback from the virus scanner if a virus was found.
1251     *   If textual feedback is missing but a virus was found, this function returns true.
1252     * @deprecated 1.45 Use UploadVerification->detectVirus() directly.
1253     */
1254    public static function detectVirus( $file ) {
1255        wfDeprecated( __METHOD__, '1.45' );
1256        $uploadVerification = MediaWikiServices::getInstance()->getUploadVerification();
1257        return $uploadVerification->detectVirus( $file );
1258    }
1259
1260    /**
1261     * Check if there's a file overwrite conflict and, if so, if restrictions
1262     * forbid this user from performing the upload.
1263     *
1264     * @param Authority $performer
1265     *
1266     * @return bool|array
1267     * @phan-return true|non-empty-array
1268     */
1269    private function checkOverwrite( Authority $performer ) {
1270        // First check whether the local file can be overwritten
1271        $file = $this->getLocalFile();
1272        $file->load( IDBAccessObject::READ_LATEST );
1273        if ( $file->exists() ) {
1274            if ( !self::userCanReUpload( $performer, $file ) ) {
1275                return [ 'fileexists-forbidden', $file->getName() ];
1276            }
1277
1278            return true;
1279        }
1280
1281        $services = MediaWikiServices::getInstance();
1282
1283        /* Check shared conflicts: if the local file does not exist, but
1284         * RepoGroup::findFile finds a file, it exists in a shared repository.
1285         */
1286        $file = $services->getRepoGroup()->findFile( $this->getTitle(), [ 'latest' => true ] );
1287        if ( $file && !$performer->isAllowed( 'reupload-shared' ) ) {
1288            return [ 'fileexists-shared-forbidden', $file->getName() ];
1289        }
1290
1291        return true;
1292    }
1293
1294    /**
1295     * Check if a user is the last uploader
1296     *
1297     * @param Authority $performer
1298     * @param File $img
1299     * @return bool
1300     */
1301    public static function userCanReUpload( Authority $performer, File $img ) {
1302        if ( $performer->isAllowed( 'reupload' ) ) {
1303            return true; // non-conditional
1304        }
1305
1306        if ( !$performer->isAllowed( 'reupload-own' ) ) {
1307            return false;
1308        }
1309
1310        if ( !( $img instanceof LocalFile ) ) {
1311            return false;
1312        }
1313
1314        return $performer->getUser()->equals( $img->getUploader( File::RAW ) );
1315    }
1316
1317    /**
1318     * Helper function that does various existence checks for a file.
1319     * The following checks are performed:
1320     * - If the file exists
1321     * - If an article with the same name as the file exists
1322     * - If a file exists with normalized extension
1323     * - If the file looks like a thumbnail and the original exists
1324     *
1325     * @param File $file The File object to check
1326     * @return array|false False if the file does not exist, else an array
1327     */
1328    public static function getExistsWarning( $file ) {
1329        if ( $file->exists() ) {
1330            return [ 'warning' => 'exists', 'file' => $file ];
1331        }
1332
1333        if ( $file->getTitle()->getArticleID() ) {
1334            return [ 'warning' => 'page-exists', 'file' => $file ];
1335        }
1336
1337        $n = strrpos( $file->getName(), '.' );
1338        if ( $n > 0 ) {
1339            $partname = substr( $file->getName(), 0, $n );
1340            $extension = substr( $file->getName(), $n + 1 );
1341        } else {
1342            $partname = $file->getName();
1343            $extension = '';
1344        }
1345        $normalizedExtension = File::normalizeExtension( $extension );
1346        $localRepo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
1347
1348        if ( $normalizedExtension != $extension ) {
1349            // We're not using the normalized form of the extension.
1350            // Normal form is lowercase, using most common of alternate
1351            // extensions (e.g. 'jpg' rather than 'JPEG').
1352
1353            // Check for another file using the normalized form...
1354            $nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" );
1355            $file_lc = $localRepo->newFile( $nt_lc );
1356
1357            if ( $file_lc->exists() ) {
1358                return [
1359                    'warning' => 'exists-normalized',
1360                    'file' => $file,
1361                    'normalizedFile' => $file_lc
1362                ];
1363            }
1364        }
1365
1366        // Check for files with the same name but a different extension
1367        $similarFiles = $localRepo->findFilesByPrefix( "{$partname}.", 1 );
1368        if ( count( $similarFiles ) ) {
1369            return [
1370                'warning' => 'exists-normalized',
1371                'file' => $file,
1372                'normalizedFile' => $similarFiles[0],
1373            ];
1374        }
1375
1376        if ( self::isThumbName( $file->getName() ) ) {
1377            // Check for filenames like 50px- or 180px-, these are mostly thumbnails
1378            $nt_thb = Title::newFromText(
1379                substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension,
1380                NS_FILE
1381            );
1382            $file_thb = $localRepo->newFile( $nt_thb );
1383            if ( $file_thb->exists() ) {
1384                return [
1385                    'warning' => 'thumb',
1386                    'file' => $file,
1387                    'thumbFile' => $file_thb
1388                ];
1389            }
1390
1391            // The file does not exist, but we just don't like the name
1392            return [
1393                'warning' => 'thumb-name',
1394                'file' => $file,
1395                'thumbFile' => $file_thb
1396            ];
1397        }
1398
1399        foreach ( self::getFilenamePrefixBlacklist() as $prefix ) {
1400            if ( str_starts_with( $partname, $prefix ) ) {
1401                return [
1402                    'warning' => 'bad-prefix',
1403                    'file' => $file,
1404                    'prefix' => $prefix
1405                ];
1406            }
1407        }
1408
1409        return false;
1410    }
1411
1412    /**
1413     * Helper function that checks whether the filename looks like a thumbnail
1414     * @param string $filename
1415     * @return bool
1416     */
1417    public static function isThumbName( $filename ) {
1418        $n = strrpos( $filename, '.' );
1419        $partname = $n ? substr( $filename, 0, $n ) : $filename;
1420
1421        return (
1422                substr( $partname, 3, 3 ) === 'px-' ||
1423                substr( $partname, 2, 3 ) === 'px-'
1424            ) && preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) );
1425    }
1426
1427    /**
1428     * Get a list of disallowed filename prefixes from [[MediaWiki:Filename-prefix-blacklist]]
1429     *
1430     * @return string[] List of prefixes
1431     */
1432    public static function getFilenamePrefixBlacklist() {
1433        $list = [];
1434        $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage();
1435        if ( !$message->isDisabled() ) {
1436            $lines = explode( "\n", $message->plain() );
1437            foreach ( $lines as $line ) {
1438                // Remove comment lines
1439                $comment = substr( trim( $line ), 0, 1 );
1440                if ( $comment === '#' || $comment == '' ) {
1441                    continue;
1442                }
1443                // Remove additional comments after a prefix
1444                $comment = strpos( $line, '#' );
1445                if ( $comment > 0 ) {
1446                    $line = substr( $line, 0, $comment - 1 );
1447                }
1448                $list[] = trim( $line );
1449            }
1450        }
1451
1452        return $list;
1453    }
1454
1455    /**
1456     * Gets image info about the file just uploaded.
1457     *
1458     * @deprecated since 1.42, subclasses of ApiUpload can use
1459     * ApiUpload::getUploadImageInfo() instead.
1460     *
1461     * @param ?ApiResult $result unused since 1.42
1462     * @return array Image info
1463     */
1464    public function getImageInfo( $result = null ) {
1465        $apiUpload = ApiUpload::getDummyInstance();
1466        return $apiUpload->getUploadImageInfo( $this );
1467    }
1468
1469    public function convertVerifyErrorToStatus( array $error ): UploadVerificationStatus {
1470        switch ( $error['status'] ) {
1471            /** Statuses that only require name changing */
1472            case self::MIN_LENGTH_PARTNAME:
1473                return UploadVerificationStatus::newFatal( 'filename-tooshort' )
1474                    ->setRecoverableError( true )
1475                    ->setInvalidParameter( 'filename' );
1476
1477            case self::ILLEGAL_FILENAME:
1478                return UploadVerificationStatus::newFatal( 'illegal-filename' )
1479                    ->setRecoverableError( true )
1480                    ->setInvalidParameter( 'filename' )
1481                    ->setApiData( [ 'filename' => $error['filtered'] ] );
1482
1483            case self::FILENAME_TOO_LONG:
1484                return UploadVerificationStatus::newFatal( 'filename-toolong' )
1485                    ->setRecoverableError( true )
1486                    ->setInvalidParameter( 'filename' );
1487
1488            case self::FILETYPE_MISSING:
1489                return UploadVerificationStatus::newFatal( 'filetype-missing' )
1490                    ->setRecoverableError( true )
1491                    ->setInvalidParameter( 'filename' );
1492
1493            case self::WINDOWS_NONASCII_FILENAME:
1494                return UploadVerificationStatus::newFatal( 'windows-nonascii-filename' )
1495                    ->setRecoverableError( true )
1496                    ->setInvalidParameter( 'filename' );
1497
1498            /** Statuses that require reuploading */
1499            case self::EMPTY_FILE:
1500                return UploadVerificationStatus::newFatal( 'empty-file' );
1501
1502            case self::FILE_TOO_LARGE:
1503                return UploadVerificationStatus::newFatal( 'file-too-large' );
1504
1505            case self::FILETYPE_BADTYPE:
1506                $extensions = array_unique( MediaWikiServices::getInstance()
1507                    ->getMainConfig()->get( MainConfigNames::FileExtensions ) );
1508                $extradata = [
1509                    'filetype' => $error['finalExt'],
1510                    'allowed' => array_values( $extensions ),
1511                ];
1512                ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
1513                if ( isset( $error['blacklistedExt'] ) ) {
1514                    $bannedTypes = $error['blacklistedExt'];
1515                    $extradata['blacklisted'] = array_values( $bannedTypes );
1516                    ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
1517                } else {
1518                    $bannedTypes = [ $error['finalExt'] ];
1519                }
1520                '@phan-var string[] $bannedTypes';
1521                return UploadVerificationStatus::newFatal(
1522                    'filetype-banned-type',
1523                    Message::listParam( $bannedTypes, ListType::COMMA ),
1524                    Message::listParam( $extensions, ListType::COMMA ),
1525                    count( $extensions ),
1526                    // Add PLURAL support for the first parameter. This results
1527                    // in a bit unlogical parameter sequence, but does not break
1528                    // old translations
1529                    count( $bannedTypes )
1530                )
1531                    ->setApiCode( 'filetype-banned' )
1532                    ->setApiData( $extradata );
1533
1534            case self::VERIFICATION_ERROR:
1535                $msg = ApiMessage::create( $error['details'], 'verification-error' );
1536                if ( $error['details'][0] instanceof MessageSpecifier ) {
1537                    $apiDetails = [ $msg->getKey(), ...$msg->getParams() ];
1538                } else {
1539                    $apiDetails = $error['details'];
1540                }
1541                ApiResult::setIndexedTagName( $apiDetails, 'detail' );
1542                $msg->setApiData( $msg->getApiData() + [ 'details' => $apiDetails ] );
1543                return UploadVerificationStatus::newFatal( $msg );
1544
1545            default:
1546                // @codeCoverageIgnoreStart
1547                return UploadVerificationStatus::newFatal( 'upload-unknownerror-nocode' )
1548                    ->setApiCode( 'unknown-error' )
1549                    ->setApiData( [ 'details' => [ 'code' => $error['status'] ] ] );
1550                // @codeCoverageIgnoreEnd
1551        }
1552    }
1553
1554    /**
1555     * Get MediaWiki's maximum uploaded file size for a given type of upload, based on
1556     * $wgMaxUploadSize.
1557     *
1558     * @param null|string $forType
1559     * @return int
1560     */
1561    public static function getMaxUploadSize( $forType = null ) {
1562        $maxUploadSize = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxUploadSize );
1563
1564        if ( is_array( $maxUploadSize ) ) {
1565            if ( $forType !== null && isset( $maxUploadSize[$forType] ) ) {
1566                return $maxUploadSize[$forType];
1567            }
1568            return $maxUploadSize['*'];
1569        }
1570        return intval( $maxUploadSize );
1571    }
1572
1573    /**
1574     * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the
1575     * limit can't be guessed, return a very large number (PHP_INT_MAX) instead.
1576     *
1577     * @since 1.27
1578     * @return int
1579     */
1580    public static function getMaxPhpUploadSize() {
1581        $phpMaxFileSize = wfShorthandToInteger(
1582            ini_get( 'upload_max_filesize' ),
1583            PHP_INT_MAX
1584        );
1585        $phpMaxPostSize = wfShorthandToInteger(
1586            ini_get( 'post_max_size' ),
1587            PHP_INT_MAX
1588        ) ?: PHP_INT_MAX;
1589        return min( $phpMaxFileSize, $phpMaxPostSize );
1590    }
1591
1592    /**
1593     * Get the current status of a chunked upload (used for polling).
1594     *
1595     * This should only be called during POST requests since we
1596     * fetch from dc-local MainStash, and from a GET request we can't
1597     * know that the value is available or up-to-date.
1598     *
1599     * @param UserIdentity $user
1600     * @param string $statusKey
1601     * @return mixed[]|false
1602     */
1603    public static function getSessionStatus( UserIdentity $user, $statusKey ) {
1604        $store = self::getUploadSessionStore();
1605        $key = self::getUploadSessionKey( $store, $user, $statusKey );
1606
1607        return $store->get( $key );
1608    }
1609
1610    /**
1611     * Set the current status of a chunked upload (used for polling).
1612     *
1613     * The value will be set in cache for 1 day.
1614     *
1615     * This should only be called during POST requests.
1616     *
1617     * @param UserIdentity $user
1618     * @param string $statusKey
1619     * @param array|false $value
1620     * @return void
1621     */
1622    public static function setSessionStatus( UserIdentity $user, $statusKey, $value ) {
1623        $store = self::getUploadSessionStore();
1624        $key = self::getUploadSessionKey( $store, $user, $statusKey );
1625        $logger = LoggerFactory::getInstance( 'upload' );
1626
1627        if ( is_array( $value ) && ( $value['result'] ?? '' ) === 'Failure' ) {
1628            $logger->info( 'Upload session {key} for {user} set to failure {status} at {stage}',
1629                [
1630                    'result' => $value['result'] ?? '',
1631                    'stage' => $value['stage'] ?? 'unknown',
1632                    'user' => $user->getName(),
1633                    'status' => (string)( $value['status'] ?? '-' ),
1634                    'filekey' => $value['filekey'] ?? '',
1635                    'key' => $statusKey
1636                ]
1637            );
1638        } elseif ( is_array( $value ) ) {
1639            $logger->debug( 'Upload session {key} for {user} changed {status} at {stage}',
1640                [
1641                    'result' => $value['result'] ?? '',
1642                    'stage' => $value['stage'] ?? 'unknown',
1643                    'user' => $user->getName(),
1644                    'status' => (string)( $value['status'] ?? '-' ),
1645                    'filekey' => $value['filekey'] ?? '',
1646                    'key' => $statusKey
1647                ]
1648            );
1649        } else {
1650            $logger->debug( "Upload session {key} deleted for {user}",
1651                [
1652                    'value' => $value,
1653                    'key' => $statusKey,
1654                    'user' => $user->getName()
1655                ]
1656            );
1657        }
1658
1659        if ( $value === false ) {
1660            $store->delete( $key );
1661        } else {
1662            $store->set( $key, $value, $store::TTL_DAY );
1663        }
1664    }
1665
1666    /**
1667     * @param BagOStuff $store
1668     * @param UserIdentity $user
1669     * @param string $statusKey
1670     * @return string
1671     */
1672    private static function getUploadSessionKey( BagOStuff $store, UserIdentity $user, $statusKey ) {
1673        return $store->makeKey(
1674            'uploadstatus',
1675            $user->isRegistered() ? $user->getId() : md5( $user->getName() ),
1676            $statusKey
1677        );
1678    }
1679
1680    /**
1681     * @return BagOStuff
1682     */
1683    private static function getUploadSessionStore() {
1684        return MediaWikiServices::getInstance()->getMainObjectStash();
1685    }
1686}
1687
1688/** @deprecated class alias since 1.46 */
1689class_alias( UploadBase::class, 'UploadBase' );