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