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