]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/upload/UploadBase.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / upload / UploadBase.php
1 <?php
2 /**
3  * Base class for the backend of file upload.
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18  * http://www.gnu.org/copyleft/gpl.html
19  *
20  * @file
21  * @ingroup Upload
22  */
23 use MediaWiki\MediaWikiServices;
24
25 /**
26  * @defgroup Upload Upload related
27  */
28
29 /**
30  * @ingroup Upload
31  *
32  * UploadBase and subclasses are the backend of MediaWiki's file uploads.
33  * The frontends are formed by ApiUpload and SpecialUpload.
34  *
35  * @author Brion Vibber
36  * @author Bryan Tong Minh
37  * @author Michael Dale
38  */
39 abstract class UploadBase {
40         /** @var string Local file system path to the file to upload (or a local copy) */
41         protected $mTempPath;
42         /** @var TempFSFile|null Wrapper to handle deleting the temp file */
43         protected $tempFileObj;
44
45         protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType;
46         protected $mTitle = false, $mTitleError = 0;
47         protected $mFilteredName, $mFinalExtension;
48         protected $mLocalFile, $mStashFile, $mFileSize, $mFileProps;
49         protected $mBlackListedExtensions;
50         protected $mJavaDetected, $mSVGNSError;
51
52         protected static $safeXmlEncodings = [
53                 'UTF-8',
54                 'ISO-8859-1',
55                 'ISO-8859-2',
56                 'UTF-16',
57                 'UTF-32',
58                 'WINDOWS-1250',
59                 'WINDOWS-1251',
60                 'WINDOWS-1252',
61                 'WINDOWS-1253',
62                 'WINDOWS-1254',
63                 'WINDOWS-1255',
64                 'WINDOWS-1256',
65                 'WINDOWS-1257',
66                 'WINDOWS-1258',
67         ];
68
69         const SUCCESS = 0;
70         const OK = 0;
71         const EMPTY_FILE = 3;
72         const MIN_LENGTH_PARTNAME = 4;
73         const ILLEGAL_FILENAME = 5;
74         const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions()
75         const FILETYPE_MISSING = 8;
76         const FILETYPE_BADTYPE = 9;
77         const VERIFICATION_ERROR = 10;
78         const HOOK_ABORTED = 11;
79         const FILE_TOO_LARGE = 12;
80         const WINDOWS_NONASCII_FILENAME = 13;
81         const FILENAME_TOO_LONG = 14;
82
83         /**
84          * @param int $error
85          * @return string
86          */
87         public function getVerificationErrorCode( $error ) {
88                 $code_to_status = [
89                         self::EMPTY_FILE => 'empty-file',
90                         self::FILE_TOO_LARGE => 'file-too-large',
91                         self::FILETYPE_MISSING => 'filetype-missing',
92                         self::FILETYPE_BADTYPE => 'filetype-banned',
93                         self::MIN_LENGTH_PARTNAME => 'filename-tooshort',
94                         self::ILLEGAL_FILENAME => 'illegal-filename',
95                         self::OVERWRITE_EXISTING_FILE => 'overwrite',
96                         self::VERIFICATION_ERROR => 'verification-error',
97                         self::HOOK_ABORTED => 'hookaborted',
98                         self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename',
99                         self::FILENAME_TOO_LONG => 'filename-toolong',
100                 ];
101                 if ( isset( $code_to_status[$error] ) ) {
102                         return $code_to_status[$error];
103                 }
104
105                 return 'unknown-error';
106         }
107
108         /**
109          * Returns true if uploads are enabled.
110          * Can be override by subclasses.
111          * @return bool
112          */
113         public static function isEnabled() {
114                 global $wgEnableUploads;
115
116                 if ( !$wgEnableUploads ) {
117                         return false;
118                 }
119
120                 # Check php's file_uploads setting
121                 return wfIsHHVM() || wfIniGetBool( 'file_uploads' );
122         }
123
124         /**
125          * Returns true if the user can use this upload module or else a string
126          * identifying the missing permission.
127          * Can be overridden by subclasses.
128          *
129          * @param User $user
130          * @return bool|string
131          */
132         public static function isAllowed( $user ) {
133                 foreach ( [ 'upload', 'edit' ] as $permission ) {
134                         if ( !$user->isAllowed( $permission ) ) {
135                                 return $permission;
136                         }
137                 }
138
139                 return true;
140         }
141
142         /**
143          * Returns true if the user has surpassed the upload rate limit, false otherwise.
144          *
145          * @param User $user
146          * @return bool
147          */
148         public static function isThrottled( $user ) {
149                 return $user->pingLimiter( 'upload' );
150         }
151
152         // Upload handlers. Should probably just be a global.
153         private static $uploadHandlers = [ 'Stash', 'File', 'Url' ];
154
155         /**
156          * Create a form of UploadBase depending on wpSourceType and initializes it
157          *
158          * @param WebRequest &$request
159          * @param string|null $type
160          * @return null|UploadBase
161          */
162         public static function createFromRequest( &$request, $type = null ) {
163                 $type = $type ? $type : $request->getVal( 'wpSourceType', 'File' );
164
165                 if ( !$type ) {
166                         return null;
167                 }
168
169                 // Get the upload class
170                 $type = ucfirst( $type );
171
172                 // Give hooks the chance to handle this request
173                 $className = null;
174                 Hooks::run( 'UploadCreateFromRequest', [ $type, &$className ] );
175                 if ( is_null( $className ) ) {
176                         $className = 'UploadFrom' . $type;
177                         wfDebug( __METHOD__ . ": class name: $className\n" );
178                         if ( !in_array( $type, self::$uploadHandlers ) ) {
179                                 return null;
180                         }
181                 }
182
183                 // Check whether this upload class is enabled
184                 if ( !call_user_func( [ $className, 'isEnabled' ] ) ) {
185                         return null;
186                 }
187
188                 // Check whether the request is valid
189                 if ( !call_user_func( [ $className, 'isValidRequest' ], $request ) ) {
190                         return null;
191                 }
192
193                 /** @var UploadBase $handler */
194                 $handler = new $className;
195
196                 $handler->initializeFromRequest( $request );
197
198                 return $handler;
199         }
200
201         /**
202          * Check whether a request if valid for this handler
203          * @param WebRequest $request
204          * @return bool
205          */
206         public static function isValidRequest( $request ) {
207                 return false;
208         }
209
210         public function __construct() {
211         }
212
213         /**
214          * Returns the upload type. Should be overridden by child classes
215          *
216          * @since 1.18
217          * @return string
218          */
219         public function getSourceType() {
220                 return null;
221         }
222
223         /**
224          * Initialize the path information
225          * @param string $name The desired destination name
226          * @param string $tempPath The temporary path
227          * @param int $fileSize The file size
228          * @param bool $removeTempFile (false) remove the temporary file?
229          * @throws MWException
230          */
231         public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) {
232                 $this->mDesiredDestName = $name;
233                 if ( FileBackend::isStoragePath( $tempPath ) ) {
234                         throw new MWException( __METHOD__ . " given storage path `$tempPath`." );
235                 }
236
237                 $this->setTempFile( $tempPath, $fileSize );
238                 $this->mRemoveTempFile = $removeTempFile;
239         }
240
241         /**
242          * Initialize from a WebRequest. Override this in a subclass.
243          *
244          * @param WebRequest &$request
245          */
246         abstract public function initializeFromRequest( &$request );
247
248         /**
249          * @param string $tempPath File system path to temporary file containing the upload
250          * @param int $fileSize
251          */
252         protected function setTempFile( $tempPath, $fileSize = null ) {
253                 $this->mTempPath = $tempPath;
254                 $this->mFileSize = $fileSize ?: null;
255                 if ( strlen( $this->mTempPath ) && file_exists( $this->mTempPath ) ) {
256                         $this->tempFileObj = new TempFSFile( $this->mTempPath );
257                         if ( !$fileSize ) {
258                                 $this->mFileSize = filesize( $this->mTempPath );
259                         }
260                 } else {
261                         $this->tempFileObj = null;
262                 }
263         }
264
265         /**
266          * Fetch the file. Usually a no-op
267          * @return Status
268          */
269         public function fetchFile() {
270                 return Status::newGood();
271         }
272
273         /**
274          * Return true if the file is empty
275          * @return bool
276          */
277         public function isEmptyFile() {
278                 return empty( $this->mFileSize );
279         }
280
281         /**
282          * Return the file size
283          * @return int
284          */
285         public function getFileSize() {
286                 return $this->mFileSize;
287         }
288
289         /**
290          * Get the base 36 SHA1 of the file
291          * @return string
292          */
293         public function getTempFileSha1Base36() {
294                 return FSFile::getSha1Base36FromPath( $this->mTempPath );
295         }
296
297         /**
298          * @param string $srcPath The source path
299          * @return string|bool The real path if it was a virtual URL Returns false on failure
300          */
301         public function getRealPath( $srcPath ) {
302                 $repo = RepoGroup::singleton()->getLocalRepo();
303                 if ( $repo->isVirtualUrl( $srcPath ) ) {
304                         /** @todo Just make uploads work with storage paths UploadFromStash
305                          *  loads files via virtual URLs.
306                          */
307                         $tmpFile = $repo->getLocalCopy( $srcPath );
308                         if ( $tmpFile ) {
309                                 $tmpFile->bind( $this ); // keep alive with $this
310                         }
311                         $path = $tmpFile ? $tmpFile->getPath() : false;
312                 } else {
313                         $path = $srcPath;
314                 }
315
316                 return $path;
317         }
318
319         /**
320          * Verify whether the upload is sane.
321          * @return mixed Const self::OK or else an array with error information
322          */
323         public function verifyUpload() {
324                 /**
325                  * If there was no filename or a zero size given, give up quick.
326                  */
327                 if ( $this->isEmptyFile() ) {
328                         return [ 'status' => self::EMPTY_FILE ];
329                 }
330
331                 /**
332                  * Honor $wgMaxUploadSize
333                  */
334                 $maxSize = self::getMaxUploadSize( $this->getSourceType() );
335                 if ( $this->mFileSize > $maxSize ) {
336                         return [
337                                 'status' => self::FILE_TOO_LARGE,
338                                 'max' => $maxSize,
339                         ];
340                 }
341
342                 /**
343                  * Look at the contents of the file; if we can recognize the
344                  * type but it's corrupt or data of the wrong type, we should
345                  * probably not accept it.
346                  */
347                 $verification = $this->verifyFile();
348                 if ( $verification !== true ) {
349                         return [
350                                 'status' => self::VERIFICATION_ERROR,
351                                 'details' => $verification
352                         ];
353                 }
354
355                 /**
356                  * Make sure this file can be created
357                  */
358                 $result = $this->validateName();
359                 if ( $result !== true ) {
360                         return $result;
361                 }
362
363                 $error = '';
364                 if ( !Hooks::run( 'UploadVerification',
365                         [ $this->mDestName, $this->mTempPath, &$error ], '1.28' )
366                 ) {
367                         return [ 'status' => self::HOOK_ABORTED, 'error' => $error ];
368                 }
369
370                 return [ 'status' => self::OK ];
371         }
372
373         /**
374          * Verify that the name is valid and, if necessary, that we can overwrite
375          *
376          * @return mixed True if valid, otherwise and array with 'status'
377          * and other keys
378          */
379         public function validateName() {
380                 $nt = $this->getTitle();
381                 if ( is_null( $nt ) ) {
382                         $result = [ 'status' => $this->mTitleError ];
383                         if ( $this->mTitleError == self::ILLEGAL_FILENAME ) {
384                                 $result['filtered'] = $this->mFilteredName;
385                         }
386                         if ( $this->mTitleError == self::FILETYPE_BADTYPE ) {
387                                 $result['finalExt'] = $this->mFinalExtension;
388                                 if ( count( $this->mBlackListedExtensions ) ) {
389                                         $result['blacklistedExt'] = $this->mBlackListedExtensions;
390                                 }
391                         }
392
393                         return $result;
394                 }
395                 $this->mDestName = $this->getLocalFile()->getName();
396
397                 return true;
398         }
399
400         /**
401          * Verify the MIME type.
402          *
403          * @note Only checks that it is not an evil MIME. The "does it have
404          *  correct extension given its MIME type?" check is in verifyFile.
405          *  in `verifyFile()` that MIME type and file extension correlate.
406          * @param string $mime Representing the MIME
407          * @return mixed True if the file is verified, an array otherwise
408          */
409         protected function verifyMimeType( $mime ) {
410                 global $wgVerifyMimeType;
411                 if ( $wgVerifyMimeType ) {
412                         wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>\n" );
413                         global $wgMimeTypeBlacklist;
414                         if ( $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) {
415                                 return [ 'filetype-badmime', $mime ];
416                         }
417
418                         # Check what Internet Explorer would detect
419                         $fp = fopen( $this->mTempPath, 'rb' );
420                         $chunk = fread( $fp, 256 );
421                         fclose( $fp );
422
423                         $magic = MimeMagic::singleton();
424                         $extMime = $magic->guessTypesForExtension( $this->mFinalExtension );
425                         $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime );
426                         foreach ( $ieTypes as $ieType ) {
427                                 if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) {
428                                         return [ 'filetype-bad-ie-mime', $ieType ];
429                                 }
430                         }
431                 }
432
433                 return true;
434         }
435
436         /**
437          * Verifies that it's ok to include the uploaded file
438          *
439          * @return mixed True of the file is verified, array otherwise.
440          */
441         protected function verifyFile() {
442                 global $wgVerifyMimeType, $wgDisableUploadScriptChecks;
443
444                 $status = $this->verifyPartialFile();
445                 if ( $status !== true ) {
446                         return $status;
447                 }
448
449                 $mwProps = new MWFileProps( MimeMagic::singleton() );
450                 $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
451                 $mime = $this->mFileProps['mime'];
452
453                 if ( $wgVerifyMimeType ) {
454                         # XXX: Missing extension will be caught by validateName() via getTitle()
455                         if ( $this->mFinalExtension != '' && !$this->verifyExtension( $mime, $this->mFinalExtension ) ) {
456                                 return [ 'filetype-mime-mismatch', $this->mFinalExtension, $mime ];
457                         }
458                 }
459
460                 # check for htmlish code and javascript
461                 if ( !$wgDisableUploadScriptChecks ) {
462                         if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
463                                 $svgStatus = $this->detectScriptInSvg( $this->mTempPath, false );
464                                 if ( $svgStatus !== false ) {
465                                         return $svgStatus;
466                                 }
467                         }
468                 }
469
470                 $handler = MediaHandler::getHandler( $mime );
471                 if ( $handler ) {
472                         $handlerStatus = $handler->verifyUpload( $this->mTempPath );
473                         if ( !$handlerStatus->isOK() ) {
474                                 $errors = $handlerStatus->getErrorsArray();
475
476                                 return reset( $errors );
477                         }
478                 }
479
480                 $error = true;
481                 Hooks::run( 'UploadVerifyFile', [ $this, $mime, &$error ] );
482                 if ( $error !== true ) {
483                         if ( !is_array( $error ) ) {
484                                 $error = [ $error ];
485                         }
486                         return $error;
487                 }
488
489                 wfDebug( __METHOD__ . ": all clear; passing.\n" );
490
491                 return true;
492         }
493
494         /**
495          * A verification routine suitable for partial files
496          *
497          * Runs the blacklist checks, but not any checks that may
498          * assume the entire file is present.
499          *
500          * @return mixed True for valid or array with error message key.
501          */
502         protected function verifyPartialFile() {
503                 global $wgAllowJavaUploads, $wgDisableUploadScriptChecks;
504
505                 # getTitle() sets some internal parameters like $this->mFinalExtension
506                 $this->getTitle();
507
508                 $mwProps = new MWFileProps( MimeMagic::singleton() );
509                 $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
510
511                 # check MIME type, if desired
512                 $mime = $this->mFileProps['file-mime'];
513                 $status = $this->verifyMimeType( $mime );
514                 if ( $status !== true ) {
515                         return $status;
516                 }
517
518                 # check for htmlish code and javascript
519                 if ( !$wgDisableUploadScriptChecks ) {
520                         if ( self::detectScript( $this->mTempPath, $mime, $this->mFinalExtension ) ) {
521                                 return [ 'uploadscripted' ];
522                         }
523                         if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
524                                 $svgStatus = $this->detectScriptInSvg( $this->mTempPath, true );
525                                 if ( $svgStatus !== false ) {
526                                         return $svgStatus;
527                                 }
528                         }
529                 }
530
531                 # Check for Java applets, which if uploaded can bypass cross-site
532                 # restrictions.
533                 if ( !$wgAllowJavaUploads ) {
534                         $this->mJavaDetected = false;
535                         $zipStatus = ZipDirectoryReader::read( $this->mTempPath,
536                                 [ $this, 'zipEntryCallback' ] );
537                         if ( !$zipStatus->isOK() ) {
538                                 $errors = $zipStatus->getErrorsArray();
539                                 $error = reset( $errors );
540                                 if ( $error[0] !== 'zip-wrong-format' ) {
541                                         return $error;
542                                 }
543                         }
544                         if ( $this->mJavaDetected ) {
545                                 return [ 'uploadjava' ];
546                         }
547                 }
548
549                 # Scan the uploaded file for viruses
550                 $virus = $this->detectVirus( $this->mTempPath );
551                 if ( $virus ) {
552                         return [ 'uploadvirus', $virus ];
553                 }
554
555                 return true;
556         }
557
558         /**
559          * Callback for ZipDirectoryReader to detect Java class files.
560          *
561          * @param array $entry
562          */
563         public function zipEntryCallback( $entry ) {
564                 $names = [ $entry['name'] ];
565
566                 // If there is a null character, cut off the name at it, because JDK's
567                 // ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name
568                 // were constructed which had ".class\0" followed by a string chosen to
569                 // make the hash collide with the truncated name, that file could be
570                 // returned in response to a request for the .class file.
571                 $nullPos = strpos( $entry['name'], "\000" );
572                 if ( $nullPos !== false ) {
573                         $names[] = substr( $entry['name'], 0, $nullPos );
574                 }
575
576                 // If there is a trailing slash in the file name, we have to strip it,
577                 // because that's what ZIP_GetEntry() does.
578                 if ( preg_grep( '!\.class/?$!', $names ) ) {
579                         $this->mJavaDetected = true;
580                 }
581         }
582
583         /**
584          * Alias for verifyTitlePermissions. The function was originally
585          * 'verifyPermissions', but that suggests it's checking the user, when it's
586          * really checking the title + user combination.
587          *
588          * @param User $user User object to verify the permissions against
589          * @return mixed An array as returned by getUserPermissionsErrors or true
590          *   in case the user has proper permissions.
591          */
592         public function verifyPermissions( $user ) {
593                 return $this->verifyTitlePermissions( $user );
594         }
595
596         /**
597          * Check whether the user can edit, upload and create the image. This
598          * checks only against the current title; if it returns errors, it may
599          * very well be that another title will not give errors. Therefore
600          * isAllowed() should be called as well for generic is-user-blocked or
601          * can-user-upload checking.
602          *
603          * @param User $user User object to verify the permissions against
604          * @return mixed An array as returned by getUserPermissionsErrors or true
605          *   in case the user has proper permissions.
606          */
607         public function verifyTitlePermissions( $user ) {
608                 /**
609                  * If the image is protected, non-sysop users won't be able
610                  * to modify it by uploading a new revision.
611                  */
612                 $nt = $this->getTitle();
613                 if ( is_null( $nt ) ) {
614                         return true;
615                 }
616                 $permErrors = $nt->getUserPermissionsErrors( 'edit', $user );
617                 $permErrorsUpload = $nt->getUserPermissionsErrors( 'upload', $user );
618                 if ( !$nt->exists() ) {
619                         $permErrorsCreate = $nt->getUserPermissionsErrors( 'create', $user );
620                 } else {
621                         $permErrorsCreate = [];
622                 }
623                 if ( $permErrors || $permErrorsUpload || $permErrorsCreate ) {
624                         $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsUpload, $permErrors ) );
625                         $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) );
626
627                         return $permErrors;
628                 }
629
630                 $overwriteError = $this->checkOverwrite( $user );
631                 if ( $overwriteError !== true ) {
632                         return [ $overwriteError ];
633                 }
634
635                 return true;
636         }
637
638         /**
639          * Check for non fatal problems with the file.
640          *
641          * This should not assume that mTempPath is set.
642          *
643          * @return mixed[] Array of warnings
644          */
645         public function checkWarnings() {
646                 $warnings = [];
647
648                 $localFile = $this->getLocalFile();
649                 $localFile->load( File::READ_LATEST );
650                 $filename = $localFile->getName();
651                 $hash = $this->getTempFileSha1Base36();
652
653                 $badFileName = $this->checkBadFileName( $filename, $this->mDesiredDestName );
654                 if ( $badFileName !== null ) {
655                         $warnings['badfilename'] = $badFileName;
656                 }
657
658                 $unwantedFileExtensionDetails = $this->checkUnwantedFileExtensions( $this->mFinalExtension );
659                 if ( $unwantedFileExtensionDetails !== null ) {
660                         $warnings['filetype-unwanted-type'] = $unwantedFileExtensionDetails;
661                 }
662
663                 $fileSizeWarnings = $this->checkFileSize( $this->mFileSize );
664                 if ( $fileSizeWarnings ) {
665                         $warnings = array_merge( $warnings, $fileSizeWarnings );
666                 }
667
668                 $localFileExistsWarnings = $this->checkLocalFileExists( $localFile, $hash );
669                 if ( $localFileExistsWarnings ) {
670                         $warnings = array_merge( $warnings, $localFileExistsWarnings );
671                 }
672
673                 if ( $this->checkLocalFileWasDeleted( $localFile ) ) {
674                         $warnings['was-deleted'] = $filename;
675                 }
676
677                 $dupes = $this->checkAgainstExistingDupes( $hash );
678                 if ( $dupes ) {
679                         $warnings['duplicate'] = $dupes;
680                 }
681
682                 $archivedDupes = $this->checkAgainstArchiveDupes( $hash );
683                 if ( $archivedDupes !== null ) {
684                         $warnings['duplicate-archive'] = $archivedDupes;
685                 }
686
687                 return $warnings;
688         }
689
690         /**
691          * Check whether the resulting filename is different from the desired one,
692          * but ignore things like ucfirst() and spaces/underscore things
693          *
694          * @param string $filename
695          * @param string $desiredFileName
696          *
697          * @return string|null String that was determined to be bad or null if the filename is okay
698          */
699         private function checkBadFileName( $filename, $desiredFileName ) {
700                 $comparableName = str_replace( ' ', '_', $desiredFileName );
701                 $comparableName = Title::capitalize( $comparableName, NS_FILE );
702
703                 if ( $desiredFileName != $filename && $comparableName != $filename ) {
704                         return $filename;
705                 }
706
707                 return null;
708         }
709
710         /**
711          * @param string $fileExtension The file extension to check
712          *
713          * @return array|null array with the following keys:
714          *                    0 => string The final extension being used
715          *                    1 => string[] The extensions that are allowed
716          *                    2 => int The number of extensions that are allowed.
717          */
718         private function checkUnwantedFileExtensions( $fileExtension ) {
719                 global $wgCheckFileExtensions, $wgFileExtensions, $wgLang;
720
721                 if ( $wgCheckFileExtensions ) {
722                         $extensions = array_unique( $wgFileExtensions );
723                         if ( !$this->checkFileExtension( $fileExtension, $extensions ) ) {
724                                 return [
725                                         $fileExtension,
726                                         $wgLang->commaList( $extensions ),
727                                         count( $extensions )
728                                 ];
729                         }
730                 }
731
732                 return null;
733         }
734
735         /**
736          * @param int $fileSize
737          *
738          * @return array warnings
739          */
740         private function checkFileSize( $fileSize ) {
741                 global $wgUploadSizeWarning;
742
743                 $warnings = [];
744
745                 if ( $wgUploadSizeWarning && ( $fileSize > $wgUploadSizeWarning ) ) {
746                         $warnings['large-file'] = [ $wgUploadSizeWarning, $fileSize ];
747                 }
748
749                 if ( $fileSize == 0 ) {
750                         $warnings['empty-file'] = true;
751                 }
752
753                 return $warnings;
754         }
755
756         /**
757          * @param LocalFile $localFile
758          * @param string $hash sha1 hash of the file to check
759          *
760          * @return array warnings
761          */
762         private function checkLocalFileExists( LocalFile $localFile, $hash ) {
763                 $warnings = [];
764
765                 $exists = self::getExistsWarning( $localFile );
766                 if ( $exists !== false ) {
767                         $warnings['exists'] = $exists;
768
769                         // check if file is an exact duplicate of current file version
770                         if ( $hash === $localFile->getSha1() ) {
771                                 $warnings['no-change'] = $localFile;
772                         }
773
774                         // check if file is an exact duplicate of older versions of this file
775                         $history = $localFile->getHistory();
776                         foreach ( $history as $oldFile ) {
777                                 if ( $hash === $oldFile->getSha1() ) {
778                                         $warnings['duplicate-version'][] = $oldFile;
779                                 }
780                         }
781                 }
782
783                 return $warnings;
784         }
785
786         private function checkLocalFileWasDeleted( LocalFile $localFile ) {
787                 return $localFile->wasDeleted() && !$localFile->exists();
788         }
789
790         /**
791          * @param string $hash sha1 hash of the file to check
792          *
793          * @return File[] Duplicate files, if found.
794          */
795         private function checkAgainstExistingDupes( $hash ) {
796                 $dupes = RepoGroup::singleton()->findBySha1( $hash );
797                 $title = $this->getTitle();
798                 // Remove all matches against self
799                 foreach ( $dupes as $key => $dupe ) {
800                         if ( $title->equals( $dupe->getTitle() ) ) {
801                                 unset( $dupes[$key] );
802                         }
803                 }
804
805                 return $dupes;
806         }
807
808         /**
809          * @param string $hash sha1 hash of the file to check
810          *
811          * @return string|null Name of the dupe or empty string if discovered (depending on visibility)
812          *                     null if the check discovered no dupes.
813          */
814         private function checkAgainstArchiveDupes( $hash ) {
815                 $archivedFile = new ArchivedFile( null, 0, '', $hash );
816                 if ( $archivedFile->getID() > 0 ) {
817                         if ( $archivedFile->userCan( File::DELETED_FILE ) ) {
818                                 return $archivedFile->getName();
819                         } else {
820                                 return '';
821                         }
822                 }
823
824                 return null;
825         }
826
827         /**
828          * Really perform the upload. Stores the file in the local repo, watches
829          * if necessary and runs the UploadComplete hook.
830          *
831          * @param string $comment
832          * @param string $pageText
833          * @param bool $watch Whether the file page should be added to user's watchlist.
834          *   (This doesn't check $user's permissions.)
835          * @param User $user
836          * @param string[] $tags Change tags to add to the log entry and page revision.
837          *   (This doesn't check $user's permissions.)
838          * @return Status Indicating the whether the upload succeeded.
839          */
840         public function performUpload( $comment, $pageText, $watch, $user, $tags = [] ) {
841                 $this->getLocalFile()->load( File::READ_LATEST );
842                 $props = $this->mFileProps;
843
844                 $error = null;
845                 Hooks::run( 'UploadVerifyUpload', [ $this, $user, $props, $comment, $pageText, &$error ] );
846                 if ( $error ) {
847                         if ( !is_array( $error ) ) {
848                                 $error = [ $error ];
849                         }
850                         return call_user_func_array( 'Status::newFatal', $error );
851                 }
852
853                 $status = $this->getLocalFile()->upload(
854                         $this->mTempPath,
855                         $comment,
856                         $pageText,
857                         File::DELETE_SOURCE,
858                         $props,
859                         false,
860                         $user,
861                         $tags
862                 );
863
864                 if ( $status->isGood() ) {
865                         if ( $watch ) {
866                                 WatchAction::doWatch(
867                                         $this->getLocalFile()->getTitle(),
868                                         $user,
869                                         User::IGNORE_USER_RIGHTS
870                                 );
871                         }
872                         // Avoid PHP 7.1 warning of passing $this by reference
873                         $uploadBase = $this;
874                         Hooks::run( 'UploadComplete', [ &$uploadBase ] );
875
876                         $this->postProcessUpload();
877                 }
878
879                 return $status;
880         }
881
882         /**
883          * Perform extra steps after a successful upload.
884          *
885          * @since  1.25
886          */
887         public function postProcessUpload() {
888         }
889
890         /**
891          * Returns the title of the file to be uploaded. Sets mTitleError in case
892          * the name was illegal.
893          *
894          * @return Title|null The title of the file or null in case the name was illegal
895          */
896         public function getTitle() {
897                 if ( $this->mTitle !== false ) {
898                         return $this->mTitle;
899                 }
900                 if ( !is_string( $this->mDesiredDestName ) ) {
901                         $this->mTitleError = self::ILLEGAL_FILENAME;
902                         $this->mTitle = null;
903
904                         return $this->mTitle;
905                 }
906                 /* Assume that if a user specified File:Something.jpg, this is an error
907                  * and that the namespace prefix needs to be stripped of.
908                  */
909                 $title = Title::newFromText( $this->mDesiredDestName );
910                 if ( $title && $title->getNamespace() == NS_FILE ) {
911                         $this->mFilteredName = $title->getDBkey();
912                 } else {
913                         $this->mFilteredName = $this->mDesiredDestName;
914                 }
915
916                 # oi_archive_name is max 255 bytes, which include a timestamp and an
917                 # exclamation mark, so restrict file name to 240 bytes.
918                 if ( strlen( $this->mFilteredName ) > 240 ) {
919                         $this->mTitleError = self::FILENAME_TOO_LONG;
920                         $this->mTitle = null;
921
922                         return $this->mTitle;
923                 }
924
925                 /**
926                  * Chop off any directories in the given filename. Then
927                  * filter out illegal characters, and try to make a legible name
928                  * out of it. We'll strip some silently that Title would die on.
929                  */
930                 $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName );
931                 /* Normalize to title form before we do any further processing */
932                 $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
933                 if ( is_null( $nt ) ) {
934                         $this->mTitleError = self::ILLEGAL_FILENAME;
935                         $this->mTitle = null;
936
937                         return $this->mTitle;
938                 }
939                 $this->mFilteredName = $nt->getDBkey();
940
941                 /**
942                  * We'll want to blacklist against *any* 'extension', and use
943                  * only the final one for the whitelist.
944                  */
945                 list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName );
946
947                 if ( count( $ext ) ) {
948                         $this->mFinalExtension = trim( $ext[count( $ext ) - 1] );
949                 } else {
950                         $this->mFinalExtension = '';
951
952                         # No extension, try guessing one
953                         $magic = MimeMagic::singleton();
954                         $mime = $magic->guessMimeType( $this->mTempPath );
955                         if ( $mime !== 'unknown/unknown' ) {
956                                 # Get a space separated list of extensions
957                                 $extList = $magic->getExtensionsForType( $mime );
958                                 if ( $extList ) {
959                                         # Set the extension to the canonical extension
960                                         $this->mFinalExtension = strtok( $extList, ' ' );
961
962                                         # Fix up the other variables
963                                         $this->mFilteredName .= ".{$this->mFinalExtension}";
964                                         $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
965                                         $ext = [ $this->mFinalExtension ];
966                                 }
967                         }
968                 }
969
970                 /* Don't allow users to override the blacklist (check file extension) */
971                 global $wgCheckFileExtensions, $wgStrictFileExtensions;
972                 global $wgFileExtensions, $wgFileBlacklist;
973
974                 $blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist );
975
976                 if ( $this->mFinalExtension == '' ) {
977                         $this->mTitleError = self::FILETYPE_MISSING;
978                         $this->mTitle = null;
979
980                         return $this->mTitle;
981                 } elseif ( $blackListedExtensions ||
982                         ( $wgCheckFileExtensions && $wgStrictFileExtensions &&
983                                 !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) )
984                 ) {
985                         $this->mBlackListedExtensions = $blackListedExtensions;
986                         $this->mTitleError = self::FILETYPE_BADTYPE;
987                         $this->mTitle = null;
988
989                         return $this->mTitle;
990                 }
991
992                 // Windows may be broken with special characters, see T3780
993                 if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() )
994                         && !RepoGroup::singleton()->getLocalRepo()->backendSupportsUnicodePaths()
995                 ) {
996                         $this->mTitleError = self::WINDOWS_NONASCII_FILENAME;
997                         $this->mTitle = null;
998
999                         return $this->mTitle;
1000                 }
1001
1002                 # If there was more than one "extension", reassemble the base
1003                 # filename to prevent bogus complaints about length
1004                 if ( count( $ext ) > 1 ) {
1005                         $iterations = count( $ext ) - 1;
1006                         for ( $i = 0; $i < $iterations; $i++ ) {
1007                                 $partname .= '.' . $ext[$i];
1008                         }
1009                 }
1010
1011                 if ( strlen( $partname ) < 1 ) {
1012                         $this->mTitleError = self::MIN_LENGTH_PARTNAME;
1013                         $this->mTitle = null;
1014
1015                         return $this->mTitle;
1016                 }
1017
1018                 $this->mTitle = $nt;
1019
1020                 return $this->mTitle;
1021         }
1022
1023         /**
1024          * Return the local file and initializes if necessary.
1025          *
1026          * @return LocalFile|null
1027          */
1028         public function getLocalFile() {
1029                 if ( is_null( $this->mLocalFile ) ) {
1030                         $nt = $this->getTitle();
1031                         $this->mLocalFile = is_null( $nt ) ? null : wfLocalFile( $nt );
1032                 }
1033
1034                 return $this->mLocalFile;
1035         }
1036
1037         /**
1038          * @return UploadStashFile|null
1039          */
1040         public function getStashFile() {
1041                 return $this->mStashFile;
1042         }
1043
1044         /**
1045          * Like stashFile(), but respects extensions' wishes to prevent the stashing. verifyUpload() must
1046          * be called before calling this method (unless $isPartial is true).
1047          *
1048          * Upload stash exceptions are also caught and converted to an error status.
1049          *
1050          * @since 1.28
1051          * @param User $user
1052          * @param bool $isPartial Pass `true` if this is a part of a chunked upload (not a complete file).
1053          * @return Status If successful, value is an UploadStashFile instance
1054          */
1055         public function tryStashFile( User $user, $isPartial = false ) {
1056                 if ( !$isPartial ) {
1057                         $error = $this->runUploadStashFileHook( $user );
1058                         if ( $error ) {
1059                                 return call_user_func_array( 'Status::newFatal', $error );
1060                         }
1061                 }
1062                 try {
1063                         $file = $this->doStashFile( $user );
1064                         return Status::newGood( $file );
1065                 } catch ( UploadStashException $e ) {
1066                         return Status::newFatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
1067                 }
1068         }
1069
1070         /**
1071          * @param User $user
1072          * @return array|null Error message and parameters, null if there's no error
1073          */
1074         protected function runUploadStashFileHook( User $user ) {
1075                 $props = $this->mFileProps;
1076                 $error = null;
1077                 Hooks::run( 'UploadStashFile', [ $this, $user, $props, &$error ] );
1078                 if ( $error ) {
1079                         if ( !is_array( $error ) ) {
1080                                 $error = [ $error ];
1081                         }
1082                 }
1083                 return $error;
1084         }
1085
1086         /**
1087          * If the user does not supply all necessary information in the first upload
1088          * form submission (either by accident or by design) then we may want to
1089          * stash the file temporarily, get more information, and publish the file
1090          * later.
1091          *
1092          * This method will stash a file in a temporary directory for later
1093          * processing, and save the necessary descriptive info into the database.
1094          * This method returns the file object, which also has a 'fileKey' property
1095          * which can be passed through a form or API request to find this stashed
1096          * file again.
1097          *
1098          * @deprecated since 1.28 Use tryStashFile() instead
1099          * @param User $user
1100          * @return UploadStashFile Stashed file
1101          * @throws UploadStashBadPathException
1102          * @throws UploadStashFileException
1103          * @throws UploadStashNotLoggedInException
1104          */
1105         public function stashFile( User $user = null ) {
1106                 return $this->doStashFile( $user );
1107         }
1108
1109         /**
1110          * Implementation for stashFile() and tryStashFile().
1111          *
1112          * @param User $user
1113          * @return UploadStashFile Stashed file
1114          */
1115         protected function doStashFile( User $user = null ) {
1116                 $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user );
1117                 $file = $stash->stashFile( $this->mTempPath, $this->getSourceType() );
1118                 $this->mStashFile = $file;
1119
1120                 return $file;
1121         }
1122
1123         /**
1124          * Stash a file in a temporary directory, returning a key which can be used
1125          * to find the file again. See stashFile().
1126          *
1127          * @deprecated since 1.28
1128          * @return string File key
1129          */
1130         public function stashFileGetKey() {
1131                 wfDeprecated( __METHOD__, '1.28' );
1132                 return $this->doStashFile()->getFileKey();
1133         }
1134
1135         /**
1136          * alias for stashFileGetKey, for backwards compatibility
1137          *
1138          * @deprecated since 1.28
1139          * @return string File key
1140          */
1141         public function stashSession() {
1142                 wfDeprecated( __METHOD__, '1.28' );
1143                 return $this->doStashFile()->getFileKey();
1144         }
1145
1146         /**
1147          * If we've modified the upload file we need to manually remove it
1148          * on exit to clean up.
1149          */
1150         public function cleanupTempFile() {
1151                 if ( $this->mRemoveTempFile && $this->tempFileObj ) {
1152                         // Delete when all relevant TempFSFile handles go out of scope
1153                         wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal\n" );
1154                         $this->tempFileObj->autocollect();
1155                 }
1156         }
1157
1158         public function getTempPath() {
1159                 return $this->mTempPath;
1160         }
1161
1162         /**
1163          * Split a file into a base name and all dot-delimited 'extensions'
1164          * on the end. Some web server configurations will fall back to
1165          * earlier pseudo-'extensions' to determine type and execute
1166          * scripts, so the blacklist needs to check them all.
1167          *
1168          * @param string $filename
1169          * @return array
1170          */
1171         public static function splitExtensions( $filename ) {
1172                 $bits = explode( '.', $filename );
1173                 $basename = array_shift( $bits );
1174
1175                 return [ $basename, $bits ];
1176         }
1177
1178         /**
1179          * Perform case-insensitive match against a list of file extensions.
1180          * Returns true if the extension is in the list.
1181          *
1182          * @param string $ext
1183          * @param array $list
1184          * @return bool
1185          */
1186         public static function checkFileExtension( $ext, $list ) {
1187                 return in_array( strtolower( $ext ), $list );
1188         }
1189
1190         /**
1191          * Perform case-insensitive match against a list of file extensions.
1192          * Returns an array of matching extensions.
1193          *
1194          * @param array $ext
1195          * @param array $list
1196          * @return bool
1197          */
1198         public static function checkFileExtensionList( $ext, $list ) {
1199                 return array_intersect( array_map( 'strtolower', $ext ), $list );
1200         }
1201
1202         /**
1203          * Checks if the MIME type of the uploaded file matches the file extension.
1204          *
1205          * @param string $mime The MIME type of the uploaded file
1206          * @param string $extension The filename extension that the file is to be served with
1207          * @return bool
1208          */
1209         public static function verifyExtension( $mime, $extension ) {
1210                 $magic = MimeMagic::singleton();
1211
1212                 if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) {
1213                         if ( !$magic->isRecognizableExtension( $extension ) ) {
1214                                 wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " .
1215                                         "unrecognized extension '$extension', can't verify\n" );
1216
1217                                 return true;
1218                         } else {
1219                                 wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " .
1220                                         "recognized extension '$extension', so probably invalid file\n" );
1221
1222                                 return false;
1223                         }
1224                 }
1225
1226                 $match = $magic->isMatchingExtension( $extension, $mime );
1227
1228                 if ( $match === null ) {
1229                         if ( $magic->getTypesForExtension( $extension ) !== null ) {
1230                                 wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension\n" );
1231
1232                                 return false;
1233                         } else {
1234                                 wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file\n" );
1235
1236                                 return true;
1237                         }
1238                 } elseif ( $match === true ) {
1239                         wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file\n" );
1240
1241                         /** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */
1242                         return true;
1243                 } else {
1244                         wfDebug( __METHOD__
1245                                 . ": mime type $mime mismatches file extension $extension, rejecting file\n" );
1246
1247                         return false;
1248                 }
1249         }
1250
1251         /**
1252          * Heuristic for detecting files that *could* contain JavaScript instructions or
1253          * things that may look like HTML to a browser and are thus
1254          * potentially harmful. The present implementation will produce false
1255          * positives in some situations.
1256          *
1257          * @param string $file Pathname to the temporary upload file
1258          * @param string $mime The MIME type of the file
1259          * @param string $extension The extension of the file
1260          * @return bool True if the file contains something looking like embedded scripts
1261          */
1262         public static function detectScript( $file, $mime, $extension ) {
1263                 global $wgAllowTitlesInSVG;
1264
1265                 # ugly hack: for text files, always look at the entire file.
1266                 # For binary field, just check the first K.
1267
1268                 if ( strpos( $mime, 'text/' ) === 0 ) {
1269                         $chunk = file_get_contents( $file );
1270                 } else {
1271                         $fp = fopen( $file, 'rb' );
1272                         $chunk = fread( $fp, 1024 );
1273                         fclose( $fp );
1274                 }
1275
1276                 $chunk = strtolower( $chunk );
1277
1278                 if ( !$chunk ) {
1279                         return false;
1280                 }
1281
1282                 # decode from UTF-16 if needed (could be used for obfuscation).
1283                 if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) {
1284                         $enc = 'UTF-16BE';
1285                 } elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) {
1286                         $enc = 'UTF-16LE';
1287                 } else {
1288                         $enc = null;
1289                 }
1290
1291                 if ( $enc ) {
1292                         $chunk = iconv( $enc, "ASCII//IGNORE", $chunk );
1293                 }
1294
1295                 $chunk = trim( $chunk );
1296
1297                 /** @todo FIXME: Convert from UTF-16 if necessary! */
1298                 wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" );
1299
1300                 # check for HTML doctype
1301                 if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) {
1302                         return true;
1303                 }
1304
1305                 // Some browsers will interpret obscure xml encodings as UTF-8, while
1306                 // PHP/expat will interpret the given encoding in the xml declaration (T49304)
1307                 if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) {
1308                         if ( self::checkXMLEncodingMissmatch( $file ) ) {
1309                                 return true;
1310                         }
1311                 }
1312
1313                 /**
1314                  * Internet Explorer for Windows performs some really stupid file type
1315                  * autodetection which can cause it to interpret valid image files as HTML
1316                  * and potentially execute JavaScript, creating a cross-site scripting
1317                  * attack vectors.
1318                  *
1319                  * Apple's Safari browser also performs some unsafe file type autodetection
1320                  * which can cause legitimate files to be interpreted as HTML if the
1321                  * web server is not correctly configured to send the right content-type
1322                  * (or if you're really uploading plain text and octet streams!)
1323                  *
1324                  * Returns true if IE is likely to mistake the given file for HTML.
1325                  * Also returns true if Safari would mistake the given file for HTML
1326                  * when served with a generic content-type.
1327                  */
1328                 $tags = [
1329                         '<a href',
1330                         '<body',
1331                         '<head',
1332                         '<html', # also in safari
1333                         '<img',
1334                         '<pre',
1335                         '<script', # also in safari
1336                         '<table'
1337                 ];
1338
1339                 if ( !$wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) {
1340                         $tags[] = '<title';
1341                 }
1342
1343                 foreach ( $tags as $tag ) {
1344                         if ( false !== strpos( $chunk, $tag ) ) {
1345                                 wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" );
1346
1347                                 return true;
1348                         }
1349                 }
1350
1351                 /*
1352                  * look for JavaScript
1353                  */
1354
1355                 # resolve entity-refs to look at attributes. may be harsh on big files... cache result?
1356                 $chunk = Sanitizer::decodeCharReferences( $chunk );
1357
1358                 # look for script-types
1359                 if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) {
1360                         wfDebug( __METHOD__ . ": found script types\n" );
1361
1362                         return true;
1363                 }
1364
1365                 # look for html-style script-urls
1366                 if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1367                         wfDebug( __METHOD__ . ": found html-style script urls\n" );
1368
1369                         return true;
1370                 }
1371
1372                 # look for css-style script-urls
1373                 if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1374                         wfDebug( __METHOD__ . ": found css-style script urls\n" );
1375
1376                         return true;
1377                 }
1378
1379                 wfDebug( __METHOD__ . ": no scripts found\n" );
1380
1381                 return false;
1382         }
1383
1384         /**
1385          * Check a whitelist of xml encodings that are known not to be interpreted differently
1386          * by the server's xml parser (expat) and some common browsers.
1387          *
1388          * @param string $file Pathname to the temporary upload file
1389          * @return bool True if the file contains an encoding that could be misinterpreted
1390          */
1391         public static function checkXMLEncodingMissmatch( $file ) {
1392                 global $wgSVGMetadataCutoff;
1393                 $contents = file_get_contents( $file, false, null, -1, $wgSVGMetadataCutoff );
1394                 $encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si';
1395
1396                 if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) {
1397                         if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1398                                 && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1399                         ) {
1400                                 wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1401
1402                                 return true;
1403                         }
1404                 } elseif ( preg_match( "!<\?xml\b!si", $contents ) ) {
1405                         // Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1406                         // bytes. There shouldn't be a legitimate reason for this to happen.
1407                         wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1408
1409                         return true;
1410                 } elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) {
1411                         // EBCDIC encoded XML
1412                         wfDebug( __METHOD__ . ": EBCDIC Encoded XML\n" );
1413
1414                         return true;
1415                 }
1416
1417                 // It's possible the file is encoded with multi-byte encoding, so re-encode attempt to
1418                 // detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings
1419                 $attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ];
1420                 foreach ( $attemptEncodings as $encoding ) {
1421                         MediaWiki\suppressWarnings();
1422                         $str = iconv( $encoding, 'UTF-8', $contents );
1423                         MediaWiki\restoreWarnings();
1424                         if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) {
1425                                 if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1426                                         && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1427                                 ) {
1428                                         wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1429
1430                                         return true;
1431                                 }
1432                         } elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) {
1433                                 // Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1434                                 // bytes. There shouldn't be a legitimate reason for this to happen.
1435                                 wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1436
1437                                 return true;
1438                         }
1439                 }
1440
1441                 return false;
1442         }
1443
1444         /**
1445          * @param string $filename
1446          * @param bool $partial
1447          * @return mixed False of the file is verified (does not contain scripts), array otherwise.
1448          */
1449         protected function detectScriptInSvg( $filename, $partial ) {
1450                 $this->mSVGNSError = false;
1451                 $check = new XmlTypeCheck(
1452                         $filename,
1453                         [ $this, 'checkSvgScriptCallback' ],
1454                         true,
1455                         [
1456                                 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback',
1457                                 'external_dtd_handler' => 'UploadBase::checkSvgExternalDTD',
1458                         ]
1459                 );
1460                 if ( $check->wellFormed !== true ) {
1461                         // Invalid xml (T60553)
1462                         // But only when non-partial (T67724)
1463                         return $partial ? false : [ 'uploadinvalidxml' ];
1464                 } elseif ( $check->filterMatch ) {
1465                         if ( $this->mSVGNSError ) {
1466                                 return [ 'uploadscriptednamespace', $this->mSVGNSError ];
1467                         }
1468
1469                         return $check->filterMatchType;
1470                 }
1471
1472                 return false;
1473         }
1474
1475         /**
1476          * Callback to filter SVG Processing Instructions.
1477          * @param string $target Processing instruction name
1478          * @param string $data Processing instruction attribute and value
1479          * @return bool (true if the filter identified something bad)
1480          */
1481         public static function checkSvgPICallback( $target, $data ) {
1482                 // Don't allow external stylesheets (T59550)
1483                 if ( preg_match( '/xml-stylesheet/i', $target ) ) {
1484                         return [ 'upload-scripted-pi-callback' ];
1485                 }
1486
1487                 return false;
1488         }
1489
1490         /**
1491          * Verify that DTD urls referenced are only the standard dtds
1492          *
1493          * Browsers seem to ignore external dtds. However just to be on the
1494          * safe side, only allow dtds from the svg standard.
1495          *
1496          * @param string $type PUBLIC or SYSTEM
1497          * @param string $publicId The well-known public identifier for the dtd
1498          * @param string $systemId The url for the external dtd
1499          * @return bool|array
1500          */
1501         public static function checkSvgExternalDTD( $type, $publicId, $systemId ) {
1502                 // This doesn't include the XHTML+MathML+SVG doctype since we don't
1503                 // allow XHTML anyways.
1504                 $allowedDTDs = [
1505                         'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd',
1506                         'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd',
1507                         'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd',
1508                         'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd',
1509                         // https://phabricator.wikimedia.org/T168856
1510                         'http://www.w3.org/TR/2001/PR-SVG-20010719/DTD/svg10.dtd',
1511                 ];
1512                 if ( $type !== 'PUBLIC'
1513                         || !in_array( $systemId, $allowedDTDs )
1514                         || strpos( $publicId, "-//W3C//" ) !== 0
1515                 ) {
1516                         return [ 'upload-scripted-dtd' ];
1517                 }
1518                 return false;
1519         }
1520
1521         /**
1522          * @todo Replace this with a whitelist filter!
1523          * @param string $element
1524          * @param array $attribs
1525          * @param array $data
1526          * @return bool
1527          */
1528         public function checkSvgScriptCallback( $element, $attribs, $data = null ) {
1529                 list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element );
1530
1531                 // We specifically don't include:
1532                 // http://www.w3.org/1999/xhtml (T62771)
1533                 static $validNamespaces = [
1534                         '',
1535                         'adobe:ns:meta/',
1536                         'http://creativecommons.org/ns#',
1537                         'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd',
1538                         'http://ns.adobe.com/adobeillustrator/10.0/',
1539                         'http://ns.adobe.com/adobesvgviewerextensions/3.0/',
1540                         'http://ns.adobe.com/extensibility/1.0/',
1541                         'http://ns.adobe.com/flows/1.0/',
1542                         'http://ns.adobe.com/illustrator/1.0/',
1543                         'http://ns.adobe.com/imagereplacement/1.0/',
1544                         'http://ns.adobe.com/pdf/1.3/',
1545                         'http://ns.adobe.com/photoshop/1.0/',
1546                         'http://ns.adobe.com/saveforweb/1.0/',
1547                         'http://ns.adobe.com/variables/1.0/',
1548                         'http://ns.adobe.com/xap/1.0/',
1549                         'http://ns.adobe.com/xap/1.0/g/',
1550                         'http://ns.adobe.com/xap/1.0/g/img/',
1551                         'http://ns.adobe.com/xap/1.0/mm/',
1552                         'http://ns.adobe.com/xap/1.0/rights/',
1553                         'http://ns.adobe.com/xap/1.0/stype/dimensions#',
1554                         'http://ns.adobe.com/xap/1.0/stype/font#',
1555                         'http://ns.adobe.com/xap/1.0/stype/manifestitem#',
1556                         'http://ns.adobe.com/xap/1.0/stype/resourceevent#',
1557                         'http://ns.adobe.com/xap/1.0/stype/resourceref#',
1558                         'http://ns.adobe.com/xap/1.0/t/pg/',
1559                         'http://purl.org/dc/elements/1.1/',
1560                         'http://purl.org/dc/elements/1.1',
1561                         'http://schemas.microsoft.com/visio/2003/svgextensions/',
1562                         'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd',
1563                         'http://taptrix.com/inkpad/svg_extensions',
1564                         'http://web.resource.org/cc/',
1565                         'http://www.freesoftware.fsf.org/bkchem/cdml',
1566                         'http://www.inkscape.org/namespaces/inkscape',
1567                         'http://www.opengis.net/gml',
1568                         'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
1569                         'http://www.w3.org/2000/svg',
1570                         'http://www.w3.org/tr/rec-rdf-syntax/',
1571                         'http://www.w3.org/2000/01/rdf-schema#',
1572                 ];
1573
1574                 // Inkscape mangles namespace definitions created by Adobe Illustrator.
1575                 // This is nasty but harmless. (T144827)
1576                 $isBuggyInkscape = preg_match( '/^&(#38;)*ns_[a-z_]+;$/', $namespace );
1577
1578                 if ( !( $isBuggyInkscape || in_array( $namespace, $validNamespaces ) ) ) {
1579                         wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file.\n" );
1580                         /** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */
1581                         $this->mSVGNSError = $namespace;
1582
1583                         return true;
1584                 }
1585
1586                 /*
1587                  * check for elements that can contain javascript
1588                  */
1589                 if ( $strippedElement == 'script' ) {
1590                         wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file.\n" );
1591
1592                         return [ 'uploaded-script-svg', $strippedElement ];
1593                 }
1594
1595                 # e.g., <svg xmlns="http://www.w3.org/2000/svg">
1596                 #  <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>
1597                 if ( $strippedElement == 'handler' ) {
1598                         wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1599
1600                         return [ 'uploaded-script-svg', $strippedElement ];
1601                 }
1602
1603                 # SVG reported in Feb '12 that used xml:stylesheet to generate javascript block
1604                 if ( $strippedElement == 'stylesheet' ) {
1605                         wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1606
1607                         return [ 'uploaded-script-svg', $strippedElement ];
1608                 }
1609
1610                 # Block iframes, in case they pass the namespace check
1611                 if ( $strippedElement == 'iframe' ) {
1612                         wfDebug( __METHOD__ . ": iframe in uploaded file.\n" );
1613
1614                         return [ 'uploaded-script-svg', $strippedElement ];
1615                 }
1616
1617                 # Check <style> css
1618                 if ( $strippedElement == 'style'
1619                         && self::checkCssFragment( Sanitizer::normalizeCss( $data ) )
1620                 ) {
1621                         wfDebug( __METHOD__ . ": hostile css in style element.\n" );
1622                         return [ 'uploaded-hostile-svg' ];
1623                 }
1624
1625                 foreach ( $attribs as $attrib => $value ) {
1626                         $stripped = $this->stripXmlNamespace( $attrib );
1627                         $value = strtolower( $value );
1628
1629                         if ( substr( $stripped, 0, 2 ) == 'on' ) {
1630                                 wfDebug( __METHOD__
1631                                         . ": Found event-handler attribute '$attrib'='$value' in uploaded file.\n" );
1632
1633                                 return [ 'uploaded-event-handler-on-svg', $attrib, $value ];
1634                         }
1635
1636                         # Do not allow relative links, or unsafe url schemas.
1637                         # For <a> tags, only data:, http: and https: and same-document
1638                         # fragment links are allowed. For all other tags, only data:
1639                         # and fragment are allowed.
1640                         if ( $stripped == 'href'
1641                                 && $value !== ''
1642                                 && strpos( $value, 'data:' ) !== 0
1643                                 && strpos( $value, '#' ) !== 0
1644                         ) {
1645                                 if ( !( $strippedElement === 'a'
1646                                         && preg_match( '!^https?://!i', $value ) )
1647                                 ) {
1648                                         wfDebug( __METHOD__ . ": Found href attribute <$strippedElement "
1649                                                 . "'$attrib'='$value' in uploaded file.\n" );
1650
1651                                         return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ];
1652                                 }
1653                         }
1654
1655                         # only allow data: targets that should be safe. This prevents vectors like,
1656                         # image/svg, text/xml, application/xml, and text/html, which can contain scripts
1657                         if ( $stripped == 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) {
1658                                 // rfc2397 parameters. This is only slightly slower than (;[\w;]+)*.
1659                                 // @codingStandardsIgnoreStart Generic.Files.LineLength
1660                                 $parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?';
1661                                 // @codingStandardsIgnoreEnd
1662
1663                                 if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|png)$parameters,!i", $value ) ) {
1664                                         wfDebug( __METHOD__ . ": Found href to unwhitelisted data: uri "
1665                                                 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1666                                         return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ];
1667                                 }
1668                         }
1669
1670                         # Change href with animate from (http://html5sec.org/#137).
1671                         if ( $stripped === 'attributename'
1672                                 && $strippedElement === 'animate'
1673                                 && $this->stripXmlNamespace( $value ) == 'href'
1674                         ) {
1675                                 wfDebug( __METHOD__ . ": Found animate that might be changing href using from "
1676                                         . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1677
1678                                 return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ];
1679                         }
1680
1681                         # use set/animate to add event-handler attribute to parent
1682                         if ( ( $strippedElement == 'set' || $strippedElement == 'animate' )
1683                                 && $stripped == 'attributename'
1684                                 && substr( $value, 0, 2 ) == 'on'
1685                         ) {
1686                                 wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with "
1687                                         . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1688
1689                                 return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ];
1690                         }
1691
1692                         # use set to add href attribute to parent element
1693                         if ( $strippedElement == 'set'
1694                                 && $stripped == 'attributename'
1695                                 && strpos( $value, 'href' ) !== false
1696                         ) {
1697                                 wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file.\n" );
1698
1699                                 return [ 'uploaded-setting-href-svg' ];
1700                         }
1701
1702                         # use set to add a remote / data / script target to an element
1703                         if ( $strippedElement == 'set'
1704                                 && $stripped == 'to'
1705                                 && preg_match( '!(http|https|data|script):!sim', $value )
1706                         ) {
1707                                 wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file.\n" );
1708
1709                                 return [ 'uploaded-wrong-setting-svg', $value ];
1710                         }
1711
1712                         # use handler attribute with remote / data / script
1713                         if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) {
1714                                 wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script "
1715                                         . "'$attrib'='$value' in uploaded file.\n" );
1716
1717                                 return [ 'uploaded-setting-handler-svg', $attrib, $value ];
1718                         }
1719
1720                         # use CSS styles to bring in remote code
1721                         if ( $stripped == 'style'
1722                                 && self::checkCssFragment( Sanitizer::normalizeCss( $value ) )
1723                         ) {
1724                                 wfDebug( __METHOD__ . ": Found svg setting a style with "
1725                                         . "remote url '$attrib'='$value' in uploaded file.\n" );
1726                                 return [ 'uploaded-remote-url-svg', $attrib, $value ];
1727                         }
1728
1729                         # Several attributes can include css, css character escaping isn't allowed
1730                         $cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker',
1731                                 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ];
1732                         if ( in_array( $stripped, $cssAttrs )
1733                                 && self::checkCssFragment( $value )
1734                         ) {
1735                                 wfDebug( __METHOD__ . ": Found svg setting a style with "
1736                                         . "remote url '$attrib'='$value' in uploaded file.\n" );
1737                                 return [ 'uploaded-remote-url-svg', $attrib, $value ];
1738                         }
1739
1740                         # image filters can pull in url, which could be svg that executes scripts
1741                         if ( $strippedElement == 'image'
1742                                 && $stripped == 'filter'
1743                                 && preg_match( '!url\s*\(!sim', $value )
1744                         ) {
1745                                 wfDebug( __METHOD__ . ": Found image filter with url: "
1746                                         . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1747
1748                                 return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ];
1749                         }
1750                 }
1751
1752                 return false; // No scripts detected
1753         }
1754
1755         /**
1756          * Check a block of CSS or CSS fragment for anything that looks like
1757          * it is bringing in remote code.
1758          * @param string $value a string of CSS
1759          * @param bool $propOnly only check css properties (start regex with :)
1760          * @return bool true if the CSS contains an illegal string, false if otherwise
1761          */
1762         private static function checkCssFragment( $value ) {
1763                 # Forbid external stylesheets, for both reliability and to protect viewer's privacy
1764                 if ( stripos( $value, '@import' ) !== false ) {
1765                         return true;
1766                 }
1767
1768                 # We allow @font-face to embed fonts with data: urls, so we snip the string
1769                 # 'url' out so this case won't match when we check for urls below
1770                 $pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im';
1771                 $value = preg_replace( $pattern, '$1$2', $value );
1772
1773                 # Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS
1774                 # properties filter and accelerator don't seem to be useful for xss in SVG files.
1775                 # Expression and -o-link don't seem to work either, but filtering them here in case.
1776                 # Additionally, we catch remote urls like url("http:..., url('http:..., url(http:...,
1777                 # but not local ones such as url("#..., url('#..., url(#....
1778                 if ( preg_match( '!expression
1779                                 | -o-link\s*:
1780                                 | -o-link-source\s*:
1781                                 | -o-replace\s*:!imx', $value ) ) {
1782                         return true;
1783                 }
1784
1785                 if ( preg_match_all(
1786                                 "!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim",
1787                                 $value,
1788                                 $matches
1789                         ) !== 0
1790                 ) {
1791                         # TODO: redo this in one regex. Until then, url("#whatever") matches the first
1792                         foreach ( $matches[1] as $match ) {
1793                                 if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) {
1794                                         return true;
1795                                 }
1796                         }
1797                 }
1798
1799                 if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) {
1800                         return true;
1801                 }
1802
1803                 return false;
1804         }
1805
1806         /**
1807          * Divide the element name passed by the xml parser to the callback into URI and prifix.
1808          * @param string $element
1809          * @return array Containing the namespace URI and prefix
1810          */
1811         private static function splitXmlNamespace( $element ) {
1812                 // 'http://www.w3.org/2000/svg:script' -> [ 'http://www.w3.org/2000/svg', 'script' ]
1813                 $parts = explode( ':', strtolower( $element ) );
1814                 $name = array_pop( $parts );
1815                 $ns = implode( ':', $parts );
1816
1817                 return [ $ns, $name ];
1818         }
1819
1820         /**
1821          * @param string $name
1822          * @return string
1823          */
1824         private function stripXmlNamespace( $name ) {
1825                 // 'http://www.w3.org/2000/svg:script' -> 'script'
1826                 $parts = explode( ':', strtolower( $name ) );
1827
1828                 return array_pop( $parts );
1829         }
1830
1831         /**
1832          * Generic wrapper function for a virus scanner program.
1833          * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
1834          * $wgAntivirusRequired may be used to deny upload if the scan fails.
1835          *
1836          * @param string $file Pathname to the temporary upload file
1837          * @return mixed False if not virus is found, null if the scan fails or is disabled,
1838          *   or a string containing feedback from the virus scanner if a virus was found.
1839          *   If textual feedback is missing but a virus was found, this function returns true.
1840          */
1841         public static function detectVirus( $file ) {
1842                 global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut;
1843
1844                 if ( !$wgAntivirus ) {
1845                         wfDebug( __METHOD__ . ": virus scanner disabled\n" );
1846
1847                         return null;
1848                 }
1849
1850                 if ( !$wgAntivirusSetup[$wgAntivirus] ) {
1851                         wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus\n" );
1852                         $wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>",
1853                                 [ 'virus-badscanner', $wgAntivirus ] );
1854
1855                         return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus";
1856                 }
1857
1858                 # look up scanner configuration
1859                 $command = $wgAntivirusSetup[$wgAntivirus]['command'];
1860                 $exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap'];
1861                 $msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ) ?
1862                         $wgAntivirusSetup[$wgAntivirus]['messagepattern'] : null;
1863
1864                 if ( strpos( $command, "%f" ) === false ) {
1865                         # simple pattern: append file to scan
1866                         $command .= " " . wfEscapeShellArg( $file );
1867                 } else {
1868                         # complex pattern: replace "%f" with file to scan
1869                         $command = str_replace( "%f", wfEscapeShellArg( $file ), $command );
1870                 }
1871
1872                 wfDebug( __METHOD__ . ": running virus scan: $command \n" );
1873
1874                 # execute virus scanner
1875                 $exitCode = false;
1876
1877                 # NOTE: there's a 50 line workaround to make stderr redirection work on windows, too.
1878                 #      that does not seem to be worth the pain.
1879                 #      Ask me (Duesentrieb) about it if it's ever needed.
1880                 $output = wfShellExecWithStderr( $command, $exitCode );
1881
1882                 # map exit code to AV_xxx constants.
1883                 $mappedCode = $exitCode;
1884                 if ( $exitCodeMap ) {
1885                         if ( isset( $exitCodeMap[$exitCode] ) ) {
1886                                 $mappedCode = $exitCodeMap[$exitCode];
1887                         } elseif ( isset( $exitCodeMap["*"] ) ) {
1888                                 $mappedCode = $exitCodeMap["*"];
1889                         }
1890                 }
1891
1892                 /* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false,
1893                  * so we need the strict equalities === and thus can't use a switch here
1894                  */
1895                 if ( $mappedCode === AV_SCAN_FAILED ) {
1896                         # scan failed (code was mapped to false by $exitCodeMap)
1897                         wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode).\n" );
1898
1899                         $output = $wgAntivirusRequired
1900                                 ? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text()
1901                                 : null;
1902                 } elseif ( $mappedCode === AV_SCAN_ABORTED ) {
1903                         # scan failed because filetype is unknown (probably imune)
1904                         wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode).\n" );
1905                         $output = null;
1906                 } elseif ( $mappedCode === AV_NO_VIRUS ) {
1907                         # no virus found
1908                         wfDebug( __METHOD__ . ": file passed virus scan.\n" );
1909                         $output = false;
1910                 } else {
1911                         $output = trim( $output );
1912
1913                         if ( !$output ) {
1914                                 $output = true; # if there's no output, return true
1915                         } elseif ( $msgPattern ) {
1916                                 $groups = [];
1917                                 if ( preg_match( $msgPattern, $output, $groups ) ) {
1918                                         if ( $groups[1] ) {
1919                                                 $output = $groups[1];
1920                                         }
1921                                 }
1922                         }
1923
1924                         wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output \n" );
1925                 }
1926
1927                 return $output;
1928         }
1929
1930         /**
1931          * Check if there's an overwrite conflict and, if so, if restrictions
1932          * forbid this user from performing the upload.
1933          *
1934          * @param User $user
1935          *
1936          * @return mixed True on success, array on failure
1937          */
1938         private function checkOverwrite( $user ) {
1939                 // First check whether the local file can be overwritten
1940                 $file = $this->getLocalFile();
1941                 $file->load( File::READ_LATEST );
1942                 if ( $file->exists() ) {
1943                         if ( !self::userCanReUpload( $user, $file ) ) {
1944                                 return [ 'fileexists-forbidden', $file->getName() ];
1945                         } else {
1946                                 return true;
1947                         }
1948                 }
1949
1950                 /* Check shared conflicts: if the local file does not exist, but
1951                  * wfFindFile finds a file, it exists in a shared repository.
1952                  */
1953                 $file = wfFindFile( $this->getTitle(), [ 'latest' => true ] );
1954                 if ( $file && !$user->isAllowed( 'reupload-shared' ) ) {
1955                         return [ 'fileexists-shared-forbidden', $file->getName() ];
1956                 }
1957
1958                 return true;
1959         }
1960
1961         /**
1962          * Check if a user is the last uploader
1963          *
1964          * @param User $user
1965          * @param File $img
1966          * @return bool
1967          */
1968         public static function userCanReUpload( User $user, File $img ) {
1969                 if ( $user->isAllowed( 'reupload' ) ) {
1970                         return true; // non-conditional
1971                 } elseif ( !$user->isAllowed( 'reupload-own' ) ) {
1972                         return false;
1973                 }
1974
1975                 if ( !( $img instanceof LocalFile ) ) {
1976                         return false;
1977                 }
1978
1979                 $img->load();
1980
1981                 return $user->getId() == $img->getUser( 'id' );
1982         }
1983
1984         /**
1985          * Helper function that does various existence checks for a file.
1986          * The following checks are performed:
1987          * - The file exists
1988          * - Article with the same name as the file exists
1989          * - File exists with normalized extension
1990          * - The file looks like a thumbnail and the original exists
1991          *
1992          * @param File $file The File object to check
1993          * @return mixed False if the file does not exists, else an array
1994          */
1995         public static function getExistsWarning( $file ) {
1996                 if ( $file->exists() ) {
1997                         return [ 'warning' => 'exists', 'file' => $file ];
1998                 }
1999
2000                 if ( $file->getTitle()->getArticleID() ) {
2001                         return [ 'warning' => 'page-exists', 'file' => $file ];
2002                 }
2003
2004                 if ( strpos( $file->getName(), '.' ) == false ) {
2005                         $partname = $file->getName();
2006                         $extension = '';
2007                 } else {
2008                         $n = strrpos( $file->getName(), '.' );
2009                         $extension = substr( $file->getName(), $n + 1 );
2010                         $partname = substr( $file->getName(), 0, $n );
2011                 }
2012                 $normalizedExtension = File::normalizeExtension( $extension );
2013
2014                 if ( $normalizedExtension != $extension ) {
2015                         // We're not using the normalized form of the extension.
2016                         // Normal form is lowercase, using most common of alternate
2017                         // extensions (eg 'jpg' rather than 'JPEG').
2018
2019                         // Check for another file using the normalized form...
2020                         $nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" );
2021                         $file_lc = wfLocalFile( $nt_lc );
2022
2023                         if ( $file_lc->exists() ) {
2024                                 return [
2025                                         'warning' => 'exists-normalized',
2026                                         'file' => $file,
2027                                         'normalizedFile' => $file_lc
2028                                 ];
2029                         }
2030                 }
2031
2032                 // Check for files with the same name but a different extension
2033                 $similarFiles = RepoGroup::singleton()->getLocalRepo()->findFilesByPrefix(
2034                         "{$partname}.", 1 );
2035                 if ( count( $similarFiles ) ) {
2036                         return [
2037                                 'warning' => 'exists-normalized',
2038                                 'file' => $file,
2039                                 'normalizedFile' => $similarFiles[0],
2040                         ];
2041                 }
2042
2043                 if ( self::isThumbName( $file->getName() ) ) {
2044                         # Check for filenames like 50px- or 180px-, these are mostly thumbnails
2045                         $nt_thb = Title::newFromText(
2046                                 substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension,
2047                                 NS_FILE
2048                         );
2049                         $file_thb = wfLocalFile( $nt_thb );
2050                         if ( $file_thb->exists() ) {
2051                                 return [
2052                                         'warning' => 'thumb',
2053                                         'file' => $file,
2054                                         'thumbFile' => $file_thb
2055                                 ];
2056                         } else {
2057                                 // File does not exist, but we just don't like the name
2058                                 return [
2059                                         'warning' => 'thumb-name',
2060                                         'file' => $file,
2061                                         'thumbFile' => $file_thb
2062                                 ];
2063                         }
2064                 }
2065
2066                 foreach ( self::getFilenamePrefixBlacklist() as $prefix ) {
2067                         if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) {
2068                                 return [
2069                                         'warning' => 'bad-prefix',
2070                                         'file' => $file,
2071                                         'prefix' => $prefix
2072                                 ];
2073                         }
2074                 }
2075
2076                 return false;
2077         }
2078
2079         /**
2080          * Helper function that checks whether the filename looks like a thumbnail
2081          * @param string $filename
2082          * @return bool
2083          */
2084         public static function isThumbName( $filename ) {
2085                 $n = strrpos( $filename, '.' );
2086                 $partname = $n ? substr( $filename, 0, $n ) : $filename;
2087
2088                 return (
2089                         substr( $partname, 3, 3 ) == 'px-' ||
2090                         substr( $partname, 2, 3 ) == 'px-'
2091                 ) &&
2092                 preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) );
2093         }
2094
2095         /**
2096          * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]]
2097          *
2098          * @return array List of prefixes
2099          */
2100         public static function getFilenamePrefixBlacklist() {
2101                 $blacklist = [];
2102                 $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage();
2103                 if ( !$message->isDisabled() ) {
2104                         $lines = explode( "\n", $message->plain() );
2105                         foreach ( $lines as $line ) {
2106                                 // Remove comment lines
2107                                 $comment = substr( trim( $line ), 0, 1 );
2108                                 if ( $comment == '#' || $comment == '' ) {
2109                                         continue;
2110                                 }
2111                                 // Remove additional comments after a prefix
2112                                 $comment = strpos( $line, '#' );
2113                                 if ( $comment > 0 ) {
2114                                         $line = substr( $line, 0, $comment - 1 );
2115                                 }
2116                                 $blacklist[] = trim( $line );
2117                         }
2118                 }
2119
2120                 return $blacklist;
2121         }
2122
2123         /**
2124          * Gets image info about the file just uploaded.
2125          *
2126          * Also has the effect of setting metadata to be an 'indexed tag name' in
2127          * returned API result if 'metadata' was requested. Oddly, we have to pass
2128          * the "result" object down just so it can do that with the appropriate
2129          * format, presumably.
2130          *
2131          * @param ApiResult $result
2132          * @return array Image info
2133          */
2134         public function getImageInfo( $result ) {
2135                 $localFile = $this->getLocalFile();
2136                 $stashFile = $this->getStashFile();
2137                 // Calling a different API module depending on whether the file was stashed is less than optimal.
2138                 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
2139                 if ( $stashFile ) {
2140                         $imParam = ApiQueryStashImageInfo::getPropertyNames();
2141                         $info = ApiQueryStashImageInfo::getInfo( $stashFile, array_flip( $imParam ), $result );
2142                 } else {
2143                         $imParam = ApiQueryImageInfo::getPropertyNames();
2144                         $info = ApiQueryImageInfo::getInfo( $localFile, array_flip( $imParam ), $result );
2145                 }
2146
2147                 return $info;
2148         }
2149
2150         /**
2151          * @param array $error
2152          * @return Status
2153          */
2154         public function convertVerifyErrorToStatus( $error ) {
2155                 $code = $error['status'];
2156                 unset( $code['status'] );
2157
2158                 return Status::newFatal( $this->getVerificationErrorCode( $code ), $error );
2159         }
2160
2161         /**
2162          * Get the MediaWiki maximum uploaded file size for given type of upload, based on
2163          * $wgMaxUploadSize.
2164          *
2165          * @param null|string $forType
2166          * @return int
2167          */
2168         public static function getMaxUploadSize( $forType = null ) {
2169                 global $wgMaxUploadSize;
2170
2171                 if ( is_array( $wgMaxUploadSize ) ) {
2172                         if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) {
2173                                 return $wgMaxUploadSize[$forType];
2174                         } else {
2175                                 return $wgMaxUploadSize['*'];
2176                         }
2177                 } else {
2178                         return intval( $wgMaxUploadSize );
2179                 }
2180         }
2181
2182         /**
2183          * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the
2184          * limit can't be guessed, returns a very large number (PHP_INT_MAX).
2185          *
2186          * @since 1.27
2187          * @return int
2188          */
2189         public static function getMaxPhpUploadSize() {
2190                 $phpMaxFileSize = wfShorthandToInteger(
2191                         ini_get( 'upload_max_filesize' ) ?: ini_get( 'hhvm.server.upload.upload_max_file_size' ),
2192                         PHP_INT_MAX
2193                 );
2194                 $phpMaxPostSize = wfShorthandToInteger(
2195                         ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
2196                         PHP_INT_MAX
2197                 ) ?: PHP_INT_MAX;
2198                 return min( $phpMaxFileSize, $phpMaxPostSize );
2199         }
2200
2201         /**
2202          * Get the current status of a chunked upload (used for polling)
2203          *
2204          * The value will be read from cache.
2205          *
2206          * @param User $user
2207          * @param string $statusKey
2208          * @return Status[]|bool
2209          */
2210         public static function getSessionStatus( User $user, $statusKey ) {
2211                 $cache = MediaWikiServices::getInstance()->getMainObjectStash();
2212                 $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
2213
2214                 return $cache->get( $key );
2215         }
2216
2217         /**
2218          * Set the current status of a chunked upload (used for polling)
2219          *
2220          * The value will be set in cache for 1 day
2221          *
2222          * @param User $user
2223          * @param string $statusKey
2224          * @param array|bool $value
2225          * @return void
2226          */
2227         public static function setSessionStatus( User $user, $statusKey, $value ) {
2228                 $cache = MediaWikiServices::getInstance()->getMainObjectStash();
2229                 $key = $cache->makeKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
2230
2231                 if ( $value === false ) {
2232                         $cache->delete( $key );
2233                 } else {
2234                         $cache->set( $key, $value, $cache::TTL_DAY );
2235                 }
2236         }
2237 }