Index: trunk/phase3/includes/specials/SpecialUpload.php |
— | — | @@ -19,21 +19,37 @@ |
20 | 20 | * @ingroup SpecialPage |
21 | 21 | */ |
22 | 22 | class UploadForm { |
| 23 | + const SUCCESS = 0; |
| 24 | + const BEFORE_PROCESSING = 1; |
| 25 | + const LARGE_FILE_SERVER = 2; |
| 26 | + const EMPTY_FILE = 3; |
| 27 | + const MIN_LENGHT_PARTNAME = 4; |
| 28 | + const ILLEGAL_FILENAME = 5; |
| 29 | + const PROTECTED_PAGE = 6; |
| 30 | + const OVERWRITE_EXISTING_FILE = 7; |
| 31 | + const FILETYPE_MISSING = 8; |
| 32 | + const FILETYPE_BADTYPE = 9; |
| 33 | + const VERIFICATION_ERROR = 10; |
| 34 | + const UPLOAD_VERIFICATION_ERROR = 11; |
| 35 | + const UPLOAD_WARNING = 12; |
| 36 | + const INTERNAL_ERROR = 13; |
| 37 | + |
23 | 38 | /**#@+ |
24 | 39 | * @access private |
25 | 40 | */ |
26 | | - var $mComment, $mLicense, $mIgnoreWarning; |
| 41 | + var $mComment, $mLicense, $mIgnoreWarning, $mCurlError; |
| 42 | + var $mDestName, $mTempPath, $mFileSize, $mFileProps; |
27 | 43 | var $mCopyrightStatus, $mCopyrightSource, $mReUpload, $mAction, $mUploadClicked; |
28 | | - var $mDestWarningAck; |
| 44 | + var $mSrcName, $mSessionKey, $mStashed, $mDesiredDestName, $mRemoveTempFile, $mSourceType; |
| 45 | + var $mDestWarningAck, $mCurlDestHandle; |
29 | 46 | var $mLocalFile; |
30 | | - |
31 | | - var $mUpload; // Instance of UploadBase or derivative |
32 | 47 | |
33 | 48 | # Placeholders for text injection by hooks (must be HTML) |
34 | 49 | # extensions should take care to _append_ to the present value |
35 | 50 | var $uploadFormTextTop; |
36 | 51 | var $uploadFormTextAfterSummary; |
37 | 52 | |
| 53 | + const SESSION_VERSION = 1; |
38 | 54 | /**#@-*/ |
39 | 55 | |
40 | 56 | /** |
— | — | @@ -41,7 +57,8 @@ |
42 | 58 | * Get data POSTed through the form and assign them to the object |
43 | 59 | * @param $request Data posted. |
44 | 60 | */ |
45 | | - function __construct( &$request ) { |
| 61 | + function UploadForm( &$request ) { |
| 62 | + global $wgAllowCopyUploads; |
46 | 63 | $this->mDesiredDestName = $request->getText( 'wpDestFile' ); |
47 | 64 | $this->mIgnoreWarning = $request->getCheck( 'wpIgnoreWarning' ); |
48 | 65 | $this->mComment = $request->getText( 'wpUploadDescription' ); |
— | — | @@ -68,70 +85,161 @@ |
69 | 86 | |
70 | 87 | $this->mAction = $request->getVal( 'action' ); |
71 | 88 | |
72 | | - $desiredDestName = $request->getText( 'wpDestFile' ); |
73 | | - if( !$desiredDestName ) |
74 | | - $desiredDestName = $request->getText( 'wpUploadFile' ); |
75 | | - |
76 | 89 | $this->mSessionKey = $request->getInt( 'wpSessionKey' ); |
77 | 90 | if( !empty( $this->mSessionKey ) && |
78 | | - isset( $_SESSION['wsUploadData'][$this->mSessionKey]['version'] ) && |
79 | | - $_SESSION['wsUploadData'][$this->mSessionKey]['version'] == |
80 | | - UploadBase::SESSION_VERSION ) { |
| 91 | + isset( $_SESSION['wsUploadData'][$this->mSessionKey]['version'] ) && |
| 92 | + $_SESSION['wsUploadData'][$this->mSessionKey]['version'] == self::SESSION_VERSION ) { |
81 | 93 | /** |
82 | 94 | * Confirming a temporarily stashed upload. |
83 | 95 | * We don't want path names to be forged, so we keep |
84 | 96 | * them in the session on the server and just give |
85 | 97 | * an opaque key to the user agent. |
86 | 98 | */ |
87 | | - |
88 | | - $this->mUpload = new UploadFromStash( $desiredDestName ); |
89 | 99 | $data = $_SESSION['wsUploadData'][$this->mSessionKey]; |
90 | | - $this->mUpload->initialize( $data ); |
91 | | - |
| 100 | + $this->mTempPath = $data['mTempPath']; |
| 101 | + $this->mFileSize = $data['mFileSize']; |
| 102 | + $this->mSrcName = $data['mSrcName']; |
| 103 | + $this->mFileProps = $data['mFileProps']; |
| 104 | + $this->mCurlError = 0/*UPLOAD_ERR_OK*/; |
| 105 | + $this->mStashed = true; |
| 106 | + $this->mRemoveTempFile = false; |
92 | 107 | } else { |
93 | 108 | /** |
94 | 109 | *Check for a newly uploaded file. |
95 | 110 | */ |
96 | | - if( UploadFromUrl::isEnabled() && $this->mSourceType == 'web' ) { |
97 | | - $this->mUpload = new UploadFromUrl( $desiredDestName ); |
98 | | - $this->mUpload->initialize( $request->getText( 'wpUploadFileURL' ) ); |
| 111 | + if( $wgAllowCopyUploads && $this->mSourceType == 'web' ) { |
| 112 | + $this->initializeFromUrl( $request ); |
99 | 113 | } else { |
100 | | - $this->mUpload = new UploadFromUpload( $desiredDestName ); |
101 | | - $this->mUpload->initialize( |
102 | | - $request->getFileTempName( 'wpUploadFile' ), |
103 | | - $request->getFileSize( 'wpUploadFile' ), |
104 | | - $request->getFileName( 'wpUploadFile' ) |
105 | | - ); |
| 114 | + $this->initializeFromUpload( $request ); |
106 | 115 | } |
107 | 116 | } |
108 | 117 | } |
109 | 118 | |
| 119 | + /** |
| 120 | + * Initialize the uploaded file from PHP data |
| 121 | + * @access private |
| 122 | + */ |
| 123 | + function initializeFromUpload( $request ) { |
| 124 | + $this->mTempPath = $request->getFileTempName( 'wpUploadFile' ); |
| 125 | + $this->mFileSize = $request->getFileSize( 'wpUploadFile' ); |
| 126 | + $this->mSrcName = $request->getFileName( 'wpUploadFile' ); |
| 127 | + $this->mCurlError = $request->getUploadError( 'wpUploadFile' ); |
| 128 | + $this->mSessionKey = false; |
| 129 | + $this->mStashed = false; |
| 130 | + $this->mRemoveTempFile = false; // PHP will handle this |
| 131 | + } |
110 | 132 | |
| 133 | + /** |
| 134 | + * Copy a web file to a temporary file |
| 135 | + * @access private |
| 136 | + */ |
| 137 | + function initializeFromUrl( $request ) { |
| 138 | + global $wgTmpDirectory; |
| 139 | + $url = $request->getText( 'wpUploadFileURL' ); |
| 140 | + $local_file = tempnam( $wgTmpDirectory, 'WEBUPLOAD' ); |
111 | 141 | |
| 142 | + $this->mTempPath = $local_file; |
| 143 | + $this->mFileSize = 0; # Will be set by curlCopy |
| 144 | + $this->mCurlError = $this->curlCopy( $url, $local_file ); |
| 145 | + $pathParts = explode( '/', $url ); |
| 146 | + $this->mSrcName = array_pop( $pathParts ); |
| 147 | + $this->mSessionKey = false; |
| 148 | + $this->mStashed = false; |
| 149 | + |
| 150 | + // PHP won't auto-cleanup the file |
| 151 | + $this->mRemoveTempFile = file_exists( $local_file ); |
| 152 | + } |
| 153 | + |
112 | 154 | /** |
| 155 | + * Safe copy from URL |
| 156 | + * Returns true if there was an error, false otherwise |
| 157 | + */ |
| 158 | + private function curlCopy( $url, $dest ) { |
| 159 | + global $wgUser, $wgOut; |
| 160 | + |
| 161 | + if( !$wgUser->isAllowed( 'upload_by_url' ) ) { |
| 162 | + $wgOut->permissionRequired( 'upload_by_url' ); |
| 163 | + return true; |
| 164 | + } |
| 165 | + |
| 166 | + # Maybe remove some pasting blanks :-) |
| 167 | + $url = trim( $url ); |
| 168 | + if( stripos($url, 'http://') !== 0 && stripos($url, 'ftp://') !== 0 ) { |
| 169 | + # Only HTTP or FTP URLs |
| 170 | + $wgOut->showErrorPage( 'upload-proto-error', 'upload-proto-error-text' ); |
| 171 | + return true; |
| 172 | + } |
| 173 | + |
| 174 | + # Open temporary file |
| 175 | + $this->mCurlDestHandle = @fopen( $this->mTempPath, "wb" ); |
| 176 | + if( $this->mCurlDestHandle === false ) { |
| 177 | + # Could not open temporary file to write in |
| 178 | + $wgOut->showErrorPage( 'upload-file-error', 'upload-file-error-text'); |
| 179 | + return true; |
| 180 | + } |
| 181 | + |
| 182 | + $ch = curl_init(); |
| 183 | + curl_setopt( $ch, CURLOPT_HTTP_VERSION, 1.0); # Probably not needed, but apparently can work around some bug |
| 184 | + curl_setopt( $ch, CURLOPT_TIMEOUT, 10); # 10 seconds timeout |
| 185 | + curl_setopt( $ch, CURLOPT_LOW_SPEED_LIMIT, 512); # 0.5KB per second minimum transfer speed |
| 186 | + curl_setopt( $ch, CURLOPT_URL, $url); |
| 187 | + curl_setopt( $ch, CURLOPT_WRITEFUNCTION, array( $this, 'uploadCurlCallback' ) ); |
| 188 | + curl_exec( $ch ); |
| 189 | + $error = curl_errno( $ch ) ? true : false; |
| 190 | + $errornum = curl_errno( $ch ); |
| 191 | + // if ( $error ) print curl_error ( $ch ) ; # Debugging output |
| 192 | + curl_close( $ch ); |
| 193 | + |
| 194 | + fclose( $this->mCurlDestHandle ); |
| 195 | + unset( $this->mCurlDestHandle ); |
| 196 | + if( $error ) { |
| 197 | + unlink( $dest ); |
| 198 | + if( wfEmptyMsg( "upload-curl-error$errornum", wfMsg("upload-curl-error$errornum") ) ) |
| 199 | + $wgOut->showErrorPage( 'upload-misc-error', 'upload-misc-error-text' ); |
| 200 | + else |
| 201 | + $wgOut->showErrorPage( "upload-curl-error$errornum", "upload-curl-error$errornum-text" ); |
| 202 | + } |
| 203 | + |
| 204 | + return $error; |
| 205 | + } |
| 206 | + |
| 207 | + /** |
| 208 | + * Callback function for CURL-based web transfer |
| 209 | + * Write data to file unless we've passed the length limit; |
| 210 | + * if so, abort immediately. |
| 211 | + * @access private |
| 212 | + */ |
| 213 | + function uploadCurlCallback( $ch, $data ) { |
| 214 | + global $wgMaxUploadSize; |
| 215 | + $length = strlen( $data ); |
| 216 | + $this->mFileSize += $length; |
| 217 | + if( $this->mFileSize > $wgMaxUploadSize ) { |
| 218 | + return 0; |
| 219 | + } |
| 220 | + fwrite( $this->mCurlDestHandle, $data ); |
| 221 | + return $length; |
| 222 | + } |
| 223 | + |
| 224 | + /** |
113 | 225 | * Start doing stuff |
114 | 226 | * @access public |
115 | 227 | */ |
116 | 228 | function execute() { |
117 | 229 | global $wgUser, $wgOut; |
| 230 | + global $wgEnableUploads; |
118 | 231 | |
119 | 232 | # Check uploading enabled |
120 | | - if( !UploadBase::isEnabled() ) { |
| 233 | + if( !$wgEnableUploads ) { |
121 | 234 | $wgOut->showErrorPage( 'uploaddisabled', 'uploaddisabledtext', array( $this->mDesiredDestName ) ); |
122 | 235 | return; |
123 | 236 | } |
124 | 237 | |
125 | 238 | # Check permissions |
126 | | - if( $this->mUpload ) { |
127 | | - $permission = $this->mUpload->isAllowed( $wgUser ); |
128 | | - } else { |
129 | | - $permission = $wgUser->isAllowed( 'upload' ) ? true : 'upload'; |
130 | | - } |
131 | | - if( $permission !== true ) { |
| 239 | + if( !$wgUser->isAllowed( 'upload' ) ) { |
132 | 240 | if( !$wgUser->isLoggedIn() ) { |
133 | 241 | $wgOut->showErrorPage( 'uploadnologin', 'uploadnologintext' ); |
134 | 242 | } else { |
135 | | - $wgOut->permissionRequired( $permission ); |
| 243 | + $wgOut->permissionRequired( 'upload' ); |
136 | 244 | } |
137 | 245 | return; |
138 | 246 | } |
— | — | @@ -148,25 +256,20 @@ |
149 | 257 | } |
150 | 258 | |
151 | 259 | if( $this->mReUpload ) { |
152 | | - // User did not choose to ignore warnings |
153 | | - if( !$this->mUpload->unsaveUploadedFile() ) { |
| 260 | + if( !$this->unsaveUploadedFile() ) { |
154 | 261 | return; |
155 | 262 | } |
156 | 263 | # Because it is probably checked and shouldn't be |
157 | 264 | $this->mIgnoreWarning = false; |
158 | 265 | |
159 | 266 | $this->mainUploadForm(); |
160 | | - } elseif( $this->mUpload && ( |
161 | | - 'submit' == $this->mAction || |
162 | | - $this->mUploadClicked |
163 | | - ) ) { |
| 267 | + } else if( 'submit' == $this->mAction || $this->mUploadClicked ) { |
164 | 268 | $this->processUpload(); |
165 | 269 | } else { |
166 | 270 | $this->mainUploadForm(); |
167 | 271 | } |
168 | | - |
169 | | - if( $this->mUpload ) |
170 | | - $this->mUpload->cleanupTempFile(); |
| 272 | + |
| 273 | + $this->cleanupTempFile(); |
171 | 274 | } |
172 | 275 | |
173 | 276 | /** |
— | — | @@ -182,45 +285,44 @@ |
183 | 286 | $value = $this->internalProcessUpload( $details ); |
184 | 287 | |
185 | 288 | switch($value) { |
186 | | - case UploadBase::SUCCESS: |
| 289 | + case self::SUCCESS: |
187 | 290 | $wgOut->redirect( $this->mLocalFile->getTitle()->getFullURL() ); |
188 | 291 | break; |
189 | 292 | |
190 | | - case UploadBase::BEFORE_PROCESSING: |
191 | | - // Do... nothing? Why? |
| 293 | + case self::BEFORE_PROCESSING: |
192 | 294 | break; |
193 | 295 | |
194 | | - case UploadBase::LARGE_FILE_SERVER: |
| 296 | + case self::LARGE_FILE_SERVER: |
195 | 297 | $this->mainUploadForm( wfMsgHtml( 'largefileserver' ) ); |
196 | 298 | break; |
197 | 299 | |
198 | | - case UploadBase::EMPTY_FILE: |
| 300 | + case self::EMPTY_FILE: |
199 | 301 | $this->mainUploadForm( wfMsgHtml( 'emptyfile' ) ); |
200 | 302 | break; |
201 | 303 | |
202 | | - case UploadBase::MIN_LENGTH_PARTNAME: |
| 304 | + case self::MIN_LENGHT_PARTNAME: |
203 | 305 | $this->mainUploadForm( wfMsgHtml( 'minlength1' ) ); |
204 | 306 | break; |
205 | 307 | |
206 | | - case UploadBase::ILLEGAL_FILENAME: |
207 | | - $this->uploadError( wfMsgExt( 'illegalfilename', |
208 | | - 'parseinline', $details['filtered'] ) ); |
| 308 | + case self::ILLEGAL_FILENAME: |
| 309 | + $filtered = $details['filtered']; |
| 310 | + $this->uploadError( wfMsgWikiHtml( 'illegalfilename', htmlspecialchars( $filtered ) ) ); |
209 | 311 | break; |
210 | 312 | |
211 | | - case UploadBase::PROTECTED_PAGE: |
| 313 | + case self::PROTECTED_PAGE: |
212 | 314 | $wgOut->showPermissionsErrorPage( $details['permissionserrors'] ); |
213 | 315 | break; |
214 | 316 | |
215 | | - case UploadBase::OVERWRITE_EXISTING_FILE: |
216 | | - $this->uploadError( wfMsgExt( $details['overwrite'], |
217 | | - 'parseinline' ) ); |
| 317 | + case self::OVERWRITE_EXISTING_FILE: |
| 318 | + $errorText = $details['overwrite']; |
| 319 | + $this->uploadError( $wgOut->parse( $errorText ) ); |
218 | 320 | break; |
219 | 321 | |
220 | | - case UploadBase::FILETYPE_MISSING: |
| 322 | + case self::FILETYPE_MISSING: |
221 | 323 | $this->uploadError( wfMsgExt( 'filetype-missing', array ( 'parseinline' ) ) ); |
222 | 324 | break; |
223 | 325 | |
224 | | - case UploadBase::FILETYPE_BADTYPE: |
| 326 | + case self::FILETYPE_BADTYPE: |
225 | 327 | $finalExt = $details['finalExt']; |
226 | 328 | $this->uploadError( |
227 | 329 | wfMsgExt( 'filetype-banned-type', |
— | — | @@ -230,30 +332,29 @@ |
231 | 333 | wfMsgExt( 'comma-separator', array( 'escapenoentities' ) ), |
232 | 334 | $wgFileExtensions |
233 | 335 | ), |
234 | | - $wgLang->formatNum( count( $wgFileExtensions ) ) |
| 336 | + $wgLang->formatNum( count($wgFileExtensions) ) |
235 | 337 | ) |
236 | 338 | ); |
237 | 339 | break; |
238 | 340 | |
239 | | - case UploadBase::VERIFICATION_ERROR: |
240 | | - $args = $details['veri']; |
241 | | - $code = array_shift( $args ); |
242 | | - $this->uploadError( wfMsgExt( $code, 'parseinline', $args ) ); |
| 341 | + case self::VERIFICATION_ERROR: |
| 342 | + $veri = $details['veri']; |
| 343 | + $this->uploadError( $veri->toString() ); |
243 | 344 | break; |
244 | 345 | |
245 | | - case UploadBase::UPLOAD_VERIFICATION_ERROR: |
| 346 | + case self::UPLOAD_VERIFICATION_ERROR: |
246 | 347 | $error = $details['error']; |
247 | | - $this->uploadError( wfMsgExt( $error, 'parseinline' ) ); |
| 348 | + $this->uploadError( $error ); |
248 | 349 | break; |
249 | 350 | |
250 | | - case UploadBase::UPLOAD_WARNING: |
| 351 | + case self::UPLOAD_WARNING: |
251 | 352 | $warning = $details['warning']; |
252 | 353 | $this->uploadWarning( $warning ); |
253 | 354 | break; |
254 | 355 | |
255 | | - case UploadBase::INTERNAL_ERROR: |
256 | | - $status = $details['internal']; |
257 | | - $this->showError( $wgOut->parse( $status->getWikiText() ) ); |
| 356 | + case self::INTERNAL_ERROR: |
| 357 | + $internal = $details['internal']; |
| 358 | + $this->showError( $internal ); |
258 | 359 | break; |
259 | 360 | |
260 | 361 | default: |
— | — | @@ -275,54 +376,207 @@ |
276 | 377 | if( !wfRunHooks( 'UploadForm:BeforeProcessing', array( &$this ) ) ) |
277 | 378 | { |
278 | 379 | wfDebug( "Hook 'UploadForm:BeforeProcessing' broke processing the file." ); |
279 | | - return UploadBase::BEFORE_PROCESSING; |
| 380 | + return self::BEFORE_PROCESSING; |
280 | 381 | } |
281 | 382 | |
282 | 383 | /** |
| 384 | + * If there was no filename or a zero size given, give up quick. |
| 385 | + */ |
| 386 | + if( trim( $this->mSrcName ) == '' || empty( $this->mFileSize ) ) { |
| 387 | + return self::EMPTY_FILE; |
| 388 | + } |
| 389 | + |
| 390 | + /* Check for curl error */ |
| 391 | + if( $this->mCurlError ) { |
| 392 | + return self::BEFORE_PROCESSING; |
| 393 | + } |
| 394 | + |
| 395 | + /** |
| 396 | + * Chop off any directories in the given filename. Then |
| 397 | + * filter out illegal characters, and try to make a legible name |
| 398 | + * out of it. We'll strip some silently that Title would die on. |
| 399 | + */ |
| 400 | + if( $this->mDesiredDestName ) { |
| 401 | + $basename = $this->mDesiredDestName; |
| 402 | + } else { |
| 403 | + $basename = $this->mSrcName; |
| 404 | + } |
| 405 | + $filtered = wfStripIllegalFilenameChars( $basename ); |
| 406 | + |
| 407 | + /** |
| 408 | + * We'll want to blacklist against *any* 'extension', and use |
| 409 | + * only the final one for the whitelist. |
| 410 | + */ |
| 411 | + list( $partname, $ext ) = $this->splitExtensions( $filtered ); |
| 412 | + |
| 413 | + if( count( $ext ) ) { |
| 414 | + $finalExt = $ext[count( $ext ) - 1]; |
| 415 | + } else { |
| 416 | + $finalExt = ''; |
| 417 | + } |
| 418 | + |
| 419 | + # If there was more than one "extension", reassemble the base |
| 420 | + # filename to prevent bogus complaints about length |
| 421 | + if( count( $ext ) > 1 ) { |
| 422 | + for( $i = 0; $i < count( $ext ) - 1; $i++ ) |
| 423 | + $partname .= '.' . $ext[$i]; |
| 424 | + } |
| 425 | + |
| 426 | + if( strlen( $partname ) < 1 ) { |
| 427 | + return self::MIN_LENGHT_PARTNAME; |
| 428 | + } |
| 429 | + |
| 430 | + $nt = Title::makeTitleSafe( NS_IMAGE, $filtered ); |
| 431 | + if( is_null( $nt ) ) { |
| 432 | + $resultDetails = array( 'filtered' => $filtered ); |
| 433 | + return self::ILLEGAL_FILENAME; |
| 434 | + } |
| 435 | + $this->mLocalFile = wfLocalFile( $nt ); |
| 436 | + $this->mDestName = $this->mLocalFile->getName(); |
| 437 | + |
| 438 | + /** |
283 | 439 | * If the image is protected, non-sysop users won't be able |
284 | 440 | * to modify it by uploading a new revision. |
285 | 441 | */ |
286 | | - $permErrors = $this->mUpload->verifyPermissions( $wgUser ); |
287 | | - if( $permErrors !== true ) { |
| 442 | + $permErrors = $nt->getUserPermissionsErrors( 'edit', $wgUser ); |
| 443 | + $permErrorsUpload = $nt->getUserPermissionsErrors( 'upload', $wgUser ); |
| 444 | + $permErrorsCreate = ( $nt->exists() ? array() : $nt->getUserPermissionsErrors( 'create', $wgUser ) ); |
| 445 | + |
| 446 | + if( $permErrors || $permErrorsUpload || $permErrorsCreate ) { |
| 447 | + // merge all the problems into one list, avoiding duplicates |
| 448 | + $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsUpload, $permErrors ) ); |
| 449 | + $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) ); |
288 | 450 | $resultDetails = array( 'permissionserrors' => $permErrors ); |
289 | | - return UploadBase::PROTECTED_PAGE; |
| 451 | + return self::PROTECTED_PAGE; |
290 | 452 | } |
291 | 453 | |
292 | | - // Check whether this is a sane upload |
293 | | - $result = $this->mUpload->verifyUpload( $resultDetails ); |
294 | | - if( $result != UploadBase::OK ) |
295 | | - return $result; |
| 454 | + /** |
| 455 | + * In some cases we may forbid overwriting of existing files. |
| 456 | + */ |
| 457 | + $overwrite = $this->checkOverwrite( $this->mDestName ); |
| 458 | + if( $overwrite !== true ) { |
| 459 | + $resultDetails = array( 'overwrite' => $overwrite ); |
| 460 | + return self::OVERWRITE_EXISTING_FILE; |
| 461 | + } |
296 | 462 | |
297 | | - $this->mLocalFile = $this->mUpload->getLocalFile(); |
| 463 | + /* Don't allow users to override the blacklist (check file extension) */ |
| 464 | + global $wgCheckFileExtensions, $wgStrictFileExtensions; |
| 465 | + global $wgFileExtensions, $wgFileBlacklist; |
| 466 | + if ($finalExt == '') { |
| 467 | + return self::FILETYPE_MISSING; |
| 468 | + } elseif ( $this->checkFileExtensionList( $ext, $wgFileBlacklist ) || |
| 469 | + ($wgCheckFileExtensions && $wgStrictFileExtensions && |
| 470 | + !$this->checkFileExtension( $finalExt, $wgFileExtensions ) ) ) { |
| 471 | + $resultDetails = array( 'finalExt' => $finalExt ); |
| 472 | + return self::FILETYPE_BADTYPE; |
| 473 | + } |
298 | 474 | |
299 | | - if( !$this->mIgnoreWarning ) { |
300 | | - $warnings = $this->mUpload->checkWarnings(); |
301 | | - |
302 | | - if( count( $warnings ) ) { |
303 | | - $resultDetails = array( 'warning' => $warnings ); |
304 | | - return UploadBase::UPLOAD_WARNING; |
| 475 | + /** |
| 476 | + * Look at the contents of the file; if we can recognize the |
| 477 | + * type but it's corrupt or data of the wrong type, we should |
| 478 | + * probably not accept it. |
| 479 | + */ |
| 480 | + if( !$this->mStashed ) { |
| 481 | + $this->mFileProps = File::getPropsFromPath( $this->mTempPath, $finalExt ); |
| 482 | + $this->checkMacBinary(); |
| 483 | + $veri = $this->verify( $this->mTempPath, $finalExt ); |
| 484 | + |
| 485 | + if( $veri !== true ) { //it's a wiki error... |
| 486 | + $resultDetails = array( 'veri' => $veri ); |
| 487 | + return self::VERIFICATION_ERROR; |
305 | 488 | } |
| 489 | + |
| 490 | + /** |
| 491 | + * Provide an opportunity for extensions to add further checks |
| 492 | + */ |
| 493 | + $error = ''; |
| 494 | + if( !wfRunHooks( 'UploadVerification', |
| 495 | + array( $this->mDestName, $this->mTempPath, &$error ) ) ) { |
| 496 | + $resultDetails = array( 'error' => $error ); |
| 497 | + return self::UPLOAD_VERIFICATION_ERROR; |
| 498 | + } |
306 | 499 | } |
307 | 500 | |
308 | 501 | |
309 | 502 | /** |
| 503 | + * Check for non-fatal conditions |
| 504 | + */ |
| 505 | + if ( ! $this->mIgnoreWarning ) { |
| 506 | + $warning = ''; |
| 507 | + |
| 508 | + global $wgCapitalLinks; |
| 509 | + if( $wgCapitalLinks ) { |
| 510 | + $filtered = ucfirst( $filtered ); |
| 511 | + } |
| 512 | + if( $basename != $filtered ) { |
| 513 | + $warning .= '<li>'.wfMsgHtml( 'badfilename', htmlspecialchars( $this->mDestName ) ).'</li>'; |
| 514 | + } |
| 515 | + |
| 516 | + global $wgCheckFileExtensions; |
| 517 | + if ( $wgCheckFileExtensions ) { |
| 518 | + if ( !$this->checkFileExtension( $finalExt, $wgFileExtensions ) ) { |
| 519 | + global $wgLang; |
| 520 | + $warning .= '<li>' . |
| 521 | + wfMsgExt( 'filetype-unwanted-type', |
| 522 | + array( 'parseinline' ), |
| 523 | + htmlspecialchars( $finalExt ), |
| 524 | + implode( |
| 525 | + wfMsgExt( 'comma-separator', array( 'escapenoentities' ) ), |
| 526 | + $wgFileExtensions |
| 527 | + ), |
| 528 | + $wgLang->formatNum( count($wgFileExtensions) ) |
| 529 | + ) . '</li>'; |
| 530 | + } |
| 531 | + } |
| 532 | + |
| 533 | + global $wgUploadSizeWarning; |
| 534 | + if ( $wgUploadSizeWarning && ( $this->mFileSize > $wgUploadSizeWarning ) ) { |
| 535 | + $skin = $wgUser->getSkin(); |
| 536 | + $wsize = $skin->formatSize( $wgUploadSizeWarning ); |
| 537 | + $asize = $skin->formatSize( $this->mFileSize ); |
| 538 | + $warning .= '<li>' . wfMsgHtml( 'large-file', $wsize, $asize ) . '</li>'; |
| 539 | + } |
| 540 | + if ( $this->mFileSize == 0 ) { |
| 541 | + $warning .= '<li>'.wfMsgHtml( 'emptyfile' ).'</li>'; |
| 542 | + } |
| 543 | + |
| 544 | + if ( !$this->mDestWarningAck ) { |
| 545 | + $warning .= self::getExistsWarning( $this->mLocalFile ); |
| 546 | + } |
| 547 | + |
| 548 | + $warning .= $this->getDupeWarning( $this->mTempPath ); |
| 549 | + |
| 550 | + if( $warning != '' ) { |
| 551 | + /** |
| 552 | + * Stash the file in a temporary location; the user can choose |
| 553 | + * to let it through and we'll complete the upload then. |
| 554 | + */ |
| 555 | + $resultDetails = array( 'warning' => $warning ); |
| 556 | + return self::UPLOAD_WARNING; |
| 557 | + } |
| 558 | + } |
| 559 | + |
| 560 | + /** |
310 | 561 | * Try actually saving the thing... |
311 | | - * It will show an error form on failure. No it will not. |
| 562 | + * It will show an error form on failure. |
312 | 563 | */ |
313 | 564 | $pageText = self::getInitialPageText( $this->mComment, $this->mLicense, |
314 | 565 | $this->mCopyrightStatus, $this->mCopyrightSource ); |
315 | 566 | |
316 | | - $status = $this->mUpload->performUpload( $this->mComment, $pageText, $this->mWatchthis, $wgUser ); |
317 | | - |
| 567 | + $status = $this->mLocalFile->upload( $this->mTempPath, $this->mComment, $pageText, |
| 568 | + File::DELETE_SOURCE, $this->mFileProps ); |
318 | 569 | if ( !$status->isGood() ) { |
319 | | - $resultDetails = array( 'internal' => $status ); |
320 | | - return UploadBase::INTERNAL_ERROR; |
| 570 | + $resultDetails = array( 'internal' => $status->getWikiText() ); |
| 571 | + return self::INTERNAL_ERROR; |
321 | 572 | } else { |
| 573 | + if ( $this->mWatchthis ) { |
| 574 | + global $wgUser; |
| 575 | + $wgUser->addWatch( $this->mLocalFile->getTitle() ); |
| 576 | + } |
322 | 577 | // Success, redirect to description page |
323 | | - // WTF WTF WTF? |
324 | 578 | $img = null; // @todo: added to avoid passing a ref to null - should this be defined somewhere? |
325 | | - wfRunHooks( 'SpecialUploadComplete', array( &$this ) ); |
326 | | - return UploadBase::SUCCESS; |
| 579 | + wfRunHooks( 'UploadComplete', array( &$this ) ); |
| 580 | + return self::SUCCESS; |
327 | 581 | } |
328 | 582 | } |
329 | 583 | |
— | — | @@ -332,21 +586,37 @@ |
333 | 587 | * Returns an HTML fragment consisting of one or more LI elements if there is a warning |
334 | 588 | * Returns an empty string if there is no warning |
335 | 589 | */ |
336 | | - static function getExistsWarning( $exists ) { |
| 590 | + static function getExistsWarning( $file ) { |
337 | 591 | global $wgUser, $wgContLang; |
338 | | - |
339 | | - if( $exists === false ) |
340 | | - return ''; |
341 | | - |
| 592 | + // Check for uppercase extension. We allow these filenames but check if an image |
| 593 | + // with lowercase extension exists already |
342 | 594 | $warning = ''; |
343 | 595 | $align = $wgContLang->isRtl() ? 'left' : 'right'; |
344 | 596 | |
345 | | - list( $existsType, $file ) = $exists; |
| 597 | + if( strpos( $file->getName(), '.' ) == false ) { |
| 598 | + $partname = $file->getName(); |
| 599 | + $rawExtension = ''; |
| 600 | + } else { |
| 601 | + $n = strrpos( $file->getName(), '.' ); |
| 602 | + $rawExtension = substr( $file->getName(), $n + 1 ); |
| 603 | + $partname = substr( $file->getName(), 0, $n ); |
| 604 | + } |
346 | 605 | |
347 | 606 | $sk = $wgUser->getSkin(); |
348 | 607 | |
349 | | - if( $existsType == 'exists' ) { |
350 | | - // Exact match |
| 608 | + if ( $rawExtension != $file->getExtension() ) { |
| 609 | + // We're not using the normalized form of the extension. |
| 610 | + // Normal form is lowercase, using most common of alternate |
| 611 | + // extensions (eg 'jpg' rather than 'JPEG'). |
| 612 | + // |
| 613 | + // Check for another file using the normalized form... |
| 614 | + $nt_lc = Title::makeTitle( NS_IMAGE, $partname . '.' . $file->getExtension() ); |
| 615 | + $file_lc = wfLocalFile( $nt_lc ); |
| 616 | + } else { |
| 617 | + $file_lc = false; |
| 618 | + } |
| 619 | + |
| 620 | + if( $file->exists() ) { |
351 | 621 | $dlink = $sk->makeKnownLinkObj( $file->getTitle() ); |
352 | 622 | if ( $file->allowInlineDisplay() ) { |
353 | 623 | $dlink2 = $sk->makeImageLinkObj( $file->getTitle(), wfMsgExt( 'fileexists-thumb', 'parseinline' ), |
— | — | @@ -361,18 +631,18 @@ |
362 | 632 | |
363 | 633 | $warning .= '<li>' . wfMsgExt( 'fileexists', array('parseinline','replaceafter'), $dlink ) . '</li>' . $dlink2; |
364 | 634 | |
365 | | - } elseif( $existsType == 'page-exists' ) { |
| 635 | + } elseif( $file->getTitle()->getArticleID() ) { |
366 | 636 | $lnk = $sk->makeKnownLinkObj( $file->getTitle(), '', 'redirect=no' ); |
367 | 637 | $warning .= '<li>' . wfMsgExt( 'filepageexists', array( 'parseinline', 'replaceafter' ), $lnk ) . '</li>'; |
368 | | - } elseif ( $existsType == 'exists-normalized' ) { |
| 638 | + } elseif ( $file_lc && $file_lc->exists() ) { |
369 | 639 | # Check if image with lowercase extension exists. |
370 | 640 | # It's not forbidden but in 99% it makes no sense to upload the same filename with uppercase extension |
371 | | - $dlink = $sk->makeKnownLinkObj( $file->getTitle() ); |
372 | | - if ( $file->allowInlineDisplay() ) { |
373 | | - $dlink2 = $sk->makeImageLinkObj( $file->getTitle(), wfMsgExt( 'fileexists-thumb', 'parseinline' ), |
374 | | - $file->getTitle()->getText(), $align, array(), false, true ); |
375 | | - } elseif ( !$file->allowInlineDisplay() && $file->isSafeFile() ) { |
376 | | - $icon = $file->iconThumb(); |
| 641 | + $dlink = $sk->makeKnownLinkObj( $nt_lc ); |
| 642 | + if ( $file_lc->allowInlineDisplay() ) { |
| 643 | + $dlink2 = $sk->makeImageLinkObj( $nt_lc, wfMsgExt( 'fileexists-thumb', 'parseinline' ), |
| 644 | + $nt_lc->getText(), $align, array(), false, true ); |
| 645 | + } elseif ( !$file_lc->allowInlineDisplay() && $file_lc->isSafeFile() ) { |
| 646 | + $icon = $file_lc->iconThumb(); |
377 | 647 | $dlink2 = '<div style="float:' . $align . '" id="mw-media-icon">' . |
378 | 648 | $icon->toHtml( array( 'desc-link' => true ) ) . '<br />' . $dlink . '</div>'; |
379 | 649 | } else { |
— | — | @@ -384,27 +654,57 @@ |
385 | 655 | $file->getTitle()->getPrefixedText(), $dlink ) . |
386 | 656 | '</li>' . $dlink2; |
387 | 657 | |
388 | | - } elseif ( $existsType == 'thumb' ) { |
389 | | - # Check if an image without leading '180px-' (or similiar) exists |
390 | | - $dlink = $sk->makeKnownLinkObj( $file->getTitle() ); |
391 | | - if ( $file->allowInlineDisplay() ) { |
392 | | - $dlink2 = $sk->makeImageLinkObj( $file->getTitle(), |
393 | | - wfMsgExt( 'fileexists-thumb', 'parseinline' ), |
394 | | - $file->getTitle()->getText(), $align, array(), false, true ); |
395 | | - } elseif ( !$file->allowInlineDisplay() && $file->isSafeFile() ) { |
396 | | - $icon = $file->iconThumb(); |
397 | | - $dlink2 = '<div style="float:' . $align . '" id="mw-media-icon">' . |
398 | | - $icon->toHtml( array( 'desc-link' => true ) ) . '<br />' . |
399 | | - $dlink . '</div>'; |
| 658 | + } elseif ( ( substr( $partname , 3, 3 ) == 'px-' || substr( $partname , 2, 3 ) == 'px-' ) |
| 659 | + && ereg( "[0-9]{2}" , substr( $partname , 0, 2) ) ) |
| 660 | + { |
| 661 | + # Check for filenames like 50px- or 180px-, these are mostly thumbnails |
| 662 | + $nt_thb = Title::newFromText( substr( $partname , strpos( $partname , '-' ) +1 ) . '.' . $rawExtension ); |
| 663 | + $file_thb = wfLocalFile( $nt_thb ); |
| 664 | + if ($file_thb->exists() ) { |
| 665 | + # Check if an image without leading '180px-' (or similiar) exists |
| 666 | + $dlink = $sk->makeKnownLinkObj( $nt_thb); |
| 667 | + if ( $file_thb->allowInlineDisplay() ) { |
| 668 | + $dlink2 = $sk->makeImageLinkObj( $nt_thb, |
| 669 | + wfMsgExt( 'fileexists-thumb', 'parseinline' ), |
| 670 | + $nt_thb->getText(), $align, array(), false, true ); |
| 671 | + } elseif ( !$file_thb->allowInlineDisplay() && $file_thb->isSafeFile() ) { |
| 672 | + $icon = $file_thb->iconThumb(); |
| 673 | + $dlink2 = '<div style="float:' . $align . '" id="mw-media-icon">' . |
| 674 | + $icon->toHtml( array( 'desc-link' => true ) ) . '<br />' . |
| 675 | + $dlink . '</div>'; |
| 676 | + } else { |
| 677 | + $dlink2 = ''; |
| 678 | + } |
| 679 | + |
| 680 | + $warning .= '<li>' . wfMsgExt( 'fileexists-thumbnail-yes', 'parsemag', $dlink ) . |
| 681 | + '</li>' . $dlink2; |
400 | 682 | } else { |
401 | | - $dlink2 = ''; |
| 683 | + # Image w/o '180px-' does not exists, but we do not like these filenames |
| 684 | + $warning .= '<li>' . wfMsgExt( 'file-thumbnail-no', 'parseinline' , |
| 685 | + substr( $partname , 0, strpos( $partname , '-' ) +1 ) ) . '</li>'; |
402 | 686 | } |
403 | | - $warning .= '<li>' . wfMsgExt( 'fileexists-thumbnail-yes', 'parsemag', $dlink ) . |
404 | | - '</li>' . $dlink2; |
405 | 687 | } |
| 688 | + |
| 689 | + $filenamePrefixBlacklist = self::getFilenamePrefixBlacklist(); |
| 690 | + # Do the match |
| 691 | + foreach( $filenamePrefixBlacklist as $prefix ) { |
| 692 | + if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) { |
| 693 | + $warning .= '<li>' . wfMsgExt( 'filename-bad-prefix', 'parseinline', $prefix ) . '</li>'; |
| 694 | + break; |
| 695 | + } |
| 696 | + } |
| 697 | + |
| 698 | + if ( $file->wasDeleted() && !$file->exists() ) { |
| 699 | + # If the file existed before and was deleted, warn the user of this |
| 700 | + # Don't bother doing so if the file exists now, however |
| 701 | + $ltitle = SpecialPage::getTitleFor( 'Log' ); |
| 702 | + $llink = $sk->makeKnownLinkObj( $ltitle, wfMsgHtml( 'deletionlog' ), |
| 703 | + 'type=delete&page=' . $file->getTitle()->getPrefixedUrl() ); |
| 704 | + $warning .= '<li>' . wfMsgWikiHtml( 'filewasdeleted', $llink ) . '</li>'; |
| 705 | + } |
406 | 706 | return $warning; |
407 | 707 | } |
408 | | - |
| 708 | + |
409 | 709 | /** |
410 | 710 | * Get a list of warnings |
411 | 711 | * |
— | — | @@ -420,9 +720,7 @@ |
421 | 721 | } |
422 | 722 | $s = ' '; |
423 | 723 | if ( $file ) { |
424 | | - $exists = UploadBase::getExistsWarning( $file ); |
425 | | - $warning = self::getExistsWarning( $exists ); |
426 | | - // FIXME: We probably also want the prefix blacklist and the wasdeleted check here |
| 724 | + $warning = self::getExistsWarning( $file ); |
427 | 725 | if ( $warning !== '' ) { |
428 | 726 | $s = "<ul>$warning</ul>"; |
429 | 727 | } |
— | — | @@ -453,7 +751,9 @@ |
454 | 752 | * Check for duplicate files and throw up a warning before the upload |
455 | 753 | * completes. |
456 | 754 | */ |
457 | | - public static function getDupeWarning( $dupes ) { |
| 755 | + function getDupeWarning( $tempfile ) { |
| 756 | + $hash = File::sha1Base36( $tempfile ); |
| 757 | + $dupes = RepoGroup::singleton()->findBySha1( $hash ); |
458 | 758 | if( $dupes ) { |
459 | 759 | global $wgOut; |
460 | 760 | $msg = "<gallery>"; |
— | — | @@ -473,15 +773,95 @@ |
474 | 774 | } |
475 | 775 | |
476 | 776 | /** |
| 777 | + * Get a list of blacklisted filename prefixes from [[MediaWiki:filename-prefix-blacklist]] |
| 778 | + * |
| 779 | + * @return array list of prefixes |
| 780 | + */ |
| 781 | + public static function getFilenamePrefixBlacklist() { |
| 782 | + $blacklist = array(); |
| 783 | + $message = wfMsgForContent( 'filename-prefix-blacklist' ); |
| 784 | + if( $message && !( wfEmptyMsg( 'filename-prefix-blacklist', $message ) || $message == '-' ) ) { |
| 785 | + $lines = explode( "\n", $message ); |
| 786 | + foreach( $lines as $line ) { |
| 787 | + // Remove comment lines |
| 788 | + $comment = substr( trim( $line ), 0, 1 ); |
| 789 | + if ( $comment == '#' || $comment == '' ) { |
| 790 | + continue; |
| 791 | + } |
| 792 | + // Remove additional comments after a prefix |
| 793 | + $comment = strpos( $line, '#' ); |
| 794 | + if ( $comment > 0 ) { |
| 795 | + $line = substr( $line, 0, $comment-1 ); |
| 796 | + } |
| 797 | + $blacklist[] = trim( $line ); |
| 798 | + } |
| 799 | + } |
| 800 | + return $blacklist; |
| 801 | + } |
| 802 | + |
| 803 | + /** |
| 804 | + * Stash a file in a temporary directory for later processing |
| 805 | + * after the user has confirmed it. |
| 806 | + * |
| 807 | + * If the user doesn't explicitly cancel or accept, these files |
| 808 | + * can accumulate in the temp directory. |
| 809 | + * |
| 810 | + * @param string $saveName - the destination filename |
| 811 | + * @param string $tempName - the source temporary file to save |
| 812 | + * @return string - full path the stashed file, or false on failure |
| 813 | + * @access private |
| 814 | + */ |
| 815 | + function saveTempUploadedFile( $saveName, $tempName ) { |
| 816 | + global $wgOut; |
| 817 | + $repo = RepoGroup::singleton()->getLocalRepo(); |
| 818 | + $status = $repo->storeTemp( $saveName, $tempName ); |
| 819 | + if ( !$status->isGood() ) { |
| 820 | + $this->showError( $status->getWikiText() ); |
| 821 | + return false; |
| 822 | + } else { |
| 823 | + return $status->value; |
| 824 | + } |
| 825 | + } |
| 826 | + |
| 827 | + /** |
| 828 | + * Stash a file in a temporary directory for later processing, |
| 829 | + * and save the necessary descriptive info into the session. |
| 830 | + * Returns a key value which will be passed through a form |
| 831 | + * to pick up the path info on a later invocation. |
| 832 | + * |
| 833 | + * @return int |
| 834 | + * @access private |
| 835 | + */ |
| 836 | + function stashSession() { |
| 837 | + $stash = $this->saveTempUploadedFile( $this->mDestName, $this->mTempPath ); |
| 838 | + |
| 839 | + if( !$stash ) { |
| 840 | + # Couldn't save the file. |
| 841 | + return false; |
| 842 | + } |
| 843 | + |
| 844 | + $key = mt_rand( 0, 0x7fffffff ); |
| 845 | + $_SESSION['wsUploadData'][$key] = array( |
| 846 | + 'mTempPath' => $stash, |
| 847 | + 'mFileSize' => $this->mFileSize, |
| 848 | + 'mSrcName' => $this->mSrcName, |
| 849 | + 'mFileProps' => $this->mFileProps, |
| 850 | + 'version' => self::SESSION_VERSION, |
| 851 | + ); |
| 852 | + return $key; |
| 853 | + } |
| 854 | + |
| 855 | + /** |
477 | 856 | * Remove a temporarily kept file stashed by saveTempUploadedFile(). |
478 | 857 | * @access private |
479 | 858 | * @return success |
480 | 859 | */ |
481 | 860 | function unsaveUploadedFile() { |
482 | 861 | global $wgOut; |
483 | | - $success = $this->mUpload->unsaveUploadedFile(); |
| 862 | + $repo = RepoGroup::singleton()->getLocalRepo(); |
| 863 | + $success = $repo->freeTemp( $this->mTempPath ); |
484 | 864 | if ( ! $success ) { |
485 | | - $wgOut->showFileDeleteError( $this->mUpload->getTempPath() ); |
| 865 | + $wgOut->showFileDeleteError( $this->mTempPath ); |
486 | 866 | return false; |
487 | 867 | } else { |
488 | 868 | return true; |
— | — | @@ -508,43 +888,19 @@ |
509 | 889 | * @param string $warning as HTML |
510 | 890 | * @access private |
511 | 891 | */ |
512 | | - function uploadWarning( $warnings ) { |
513 | | - global $wgOut, $wgUser; |
| 892 | + function uploadWarning( $warning ) { |
| 893 | + global $wgOut; |
514 | 894 | global $wgUseCopyrightUpload; |
515 | 895 | |
516 | | - $this->mSessionKey = $this->mUpload->stashSession(); |
| 896 | + $this->mSessionKey = $this->stashSession(); |
517 | 897 | if( !$this->mSessionKey ) { |
518 | 898 | # Couldn't save file; an error has been displayed so let's go. |
519 | 899 | return; |
520 | 900 | } |
521 | | - |
522 | | - $sk = $wgUser->getSkin(); |
523 | 901 | |
524 | 902 | $wgOut->addHTML( '<h2>' . wfMsgHtml( 'uploadwarning' ) . "</h2>\n" ); |
525 | | - $wgOut->addHTML( '<ul class="warning">' ); |
526 | | - foreach( $warnings as $warning => $args ) { |
527 | | - $msg = null; |
528 | | - if( $warning == 'exists' ) { |
529 | | - if ( !$this->mDestWarningAck ) |
530 | | - $msg = self::getExistsWarning( $args ); |
531 | | - } elseif( $warning == 'duplicate' ) { |
532 | | - $msg = $this->getDupeWarning( $args ); |
533 | | - } elseif( $warning == 'filewasdeleted' ) { |
534 | | - $ltitle = SpecialPage::getTitleFor( 'Log' ); |
535 | | - $llink = $sk->makeKnownLinkObj( $ltitle, wfMsgHtml( 'deletionlog' ), |
536 | | - 'type=delete&page=' . $args->getPrefixedUrl() ); |
537 | | - $msg = "\t<li>" . wfMsgWikiHtml( 'filewasdeleted', $llink ) . "</li>\n"; |
538 | | - } else { |
539 | | - if( is_bool( $args ) ) |
540 | | - $args = array(); |
541 | | - elseif( !is_array( $args ) ) |
542 | | - $args = array( $args ); |
543 | | - $msg = "\t<li>" . wfMsgExt( $warning, 'parseinline', $args ) . "</li>\n"; |
544 | | - } |
545 | | - if( $msg ) |
546 | | - $wgOut->addHTML( $msg ); |
547 | | - } |
548 | | - |
| 903 | + $wgOut->addHTML( '<ul class="warning">' . $warning . "</ul>\n" ); |
| 904 | + |
549 | 905 | $titleObj = SpecialPage::getTitleFor( 'Upload' ); |
550 | 906 | |
551 | 907 | if ( $wgUseCopyrightUpload ) { |
— | — | @@ -580,7 +936,7 @@ |
581 | 937 | function mainUploadForm( $msg='' ) { |
582 | 938 | global $wgOut, $wgUser, $wgLang, $wgMaxUploadSize; |
583 | 939 | global $wgUseCopyrightUpload, $wgUseAjax, $wgAjaxUploadDestCheck, $wgAjaxLicensePreview; |
584 | | - global $wgRequest; |
| 940 | + global $wgRequest, $wgAllowCopyUploads; |
585 | 941 | global $wgStylePath, $wgStyleVersion; |
586 | 942 | |
587 | 943 | $useAjaxDestCheck = $wgUseAjax && $wgAjaxUploadDestCheck; |
— | — | @@ -688,7 +1044,7 @@ |
689 | 1045 | default: |
690 | 1046 | $val2 = $val; |
691 | 1047 | } |
692 | | - $val2 = UploadFromUrl::isEnabled() ? min( $wgMaxUploadSize, $val2 ) : $val2; |
| 1048 | + $val2 = $wgAllowCopyUploads ? min( $wgMaxUploadSize, $val2 ) : $val2; |
693 | 1049 | $maxUploadSize = '<div id="mw-upload-maxfilesize">' . |
694 | 1050 | wfMsgExt( 'upload-maxfilesize', array( 'parseinline', 'escapenoentities' ), |
695 | 1051 | $wgLang->formatSize( $val2 ) ) . |
— | — | @@ -717,7 +1073,7 @@ |
718 | 1074 | $warningChecked = $this->mIgnoreWarning ? 'checked' : ''; |
719 | 1075 | |
720 | 1076 | // Prepare form for upload or upload/copy |
721 | | - if( UploadFromUrl::isEnabled() && $wgUser->isAllowed( 'upload_by_url' ) ) { |
| 1077 | + if( $wgAllowCopyUploads && $wgUser->isAllowed( 'upload_by_url' ) ) { |
722 | 1078 | $filename_form = |
723 | 1079 | "<input type='radio' id='wpSourceTypeFile' name='wpSourceType' value='file' " . |
724 | 1080 | "onchange='toggle_element_activation(\"wpUploadFileURL\",\"wpUploadFile\")' checked='checked' />" . |
— | — | @@ -915,17 +1271,412 @@ |
916 | 1272 | } |
917 | 1273 | } |
918 | 1274 | |
| 1275 | + /** |
| 1276 | + * Split a file into a base name and all dot-delimited 'extensions' |
| 1277 | + * on the end. Some web server configurations will fall back to |
| 1278 | + * earlier pseudo-'extensions' to determine type and execute |
| 1279 | + * scripts, so the blacklist needs to check them all. |
| 1280 | + * |
| 1281 | + * @return array |
| 1282 | + */ |
| 1283 | + function splitExtensions( $filename ) { |
| 1284 | + $bits = explode( '.', $filename ); |
| 1285 | + $basename = array_shift( $bits ); |
| 1286 | + return array( $basename, $bits ); |
| 1287 | + } |
| 1288 | + |
| 1289 | + /** |
| 1290 | + * Perform case-insensitive match against a list of file extensions. |
| 1291 | + * Returns true if the extension is in the list. |
| 1292 | + * |
| 1293 | + * @param string $ext |
| 1294 | + * @param array $list |
| 1295 | + * @return bool |
| 1296 | + */ |
| 1297 | + function checkFileExtension( $ext, $list ) { |
| 1298 | + return in_array( strtolower( $ext ), $list ); |
| 1299 | + } |
| 1300 | + |
| 1301 | + /** |
| 1302 | + * Perform case-insensitive match against a list of file extensions. |
| 1303 | + * Returns true if any of the extensions are in the list. |
| 1304 | + * |
| 1305 | + * @param array $ext |
| 1306 | + * @param array $list |
| 1307 | + * @return bool |
| 1308 | + */ |
| 1309 | + function checkFileExtensionList( $ext, $list ) { |
| 1310 | + foreach( $ext as $e ) { |
| 1311 | + if( in_array( strtolower( $e ), $list ) ) { |
| 1312 | + return true; |
| 1313 | + } |
| 1314 | + } |
| 1315 | + return false; |
| 1316 | + } |
| 1317 | + |
| 1318 | + /** |
| 1319 | + * Verifies that it's ok to include the uploaded file |
| 1320 | + * |
| 1321 | + * @param string $tmpfile the full path of the temporary file to verify |
| 1322 | + * @param string $extension The filename extension that the file is to be served with |
| 1323 | + * @return mixed true of the file is verified, a WikiError object otherwise. |
| 1324 | + */ |
| 1325 | + function verify( $tmpfile, $extension ) { |
| 1326 | + #magically determine mime type |
| 1327 | + $magic = MimeMagic::singleton(); |
| 1328 | + $mime = $magic->guessMimeType($tmpfile,false); |
| 1329 | + |
| 1330 | + #check mime type, if desired |
| 1331 | + global $wgVerifyMimeType; |
| 1332 | + if ($wgVerifyMimeType) { |
| 1333 | + |
| 1334 | + wfDebug ( "\n\nmime: <$mime> extension: <$extension>\n\n"); |
| 1335 | + #check mime type against file extension |
| 1336 | + if( !self::verifyExtension( $mime, $extension ) ) { |
| 1337 | + return new WikiErrorMsg( 'uploadcorrupt' ); |
| 1338 | + } |
| 1339 | + |
| 1340 | + #check mime type blacklist |
| 1341 | + global $wgMimeTypeBlacklist; |
| 1342 | + if( isset($wgMimeTypeBlacklist) && !is_null($wgMimeTypeBlacklist) |
| 1343 | + && $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) { |
| 1344 | + return new WikiErrorMsg( 'filetype-badmime', htmlspecialchars( $mime ) ); |
| 1345 | + } |
| 1346 | + } |
| 1347 | + |
| 1348 | + #check for htmlish code and javascript |
| 1349 | + if( $this->detectScript ( $tmpfile, $mime, $extension ) ) { |
| 1350 | + return new WikiErrorMsg( 'uploadscripted' ); |
| 1351 | + } |
| 1352 | + |
| 1353 | + /** |
| 1354 | + * Scan the uploaded file for viruses |
| 1355 | + */ |
| 1356 | + $virus= $this->detectVirus($tmpfile); |
| 1357 | + if ( $virus ) { |
| 1358 | + return new WikiErrorMsg( 'uploadvirus', htmlspecialchars($virus) ); |
| 1359 | + } |
| 1360 | + |
| 1361 | + wfDebug( __METHOD__.": all clear; passing.\n" ); |
| 1362 | + return true; |
| 1363 | + } |
| 1364 | + |
| 1365 | + /** |
| 1366 | + * Checks if the mime type of the uploaded file matches the file extension. |
| 1367 | + * |
| 1368 | + * @param string $mime the mime type of the uploaded file |
| 1369 | + * @param string $extension The filename extension that the file is to be served with |
| 1370 | + * @return bool |
| 1371 | + */ |
| 1372 | + static function verifyExtension( $mime, $extension ) { |
| 1373 | + $magic = MimeMagic::singleton(); |
| 1374 | + |
| 1375 | + if ( ! $mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) |
| 1376 | + if ( ! $magic->isRecognizableExtension( $extension ) ) { |
| 1377 | + wfDebug( __METHOD__.": passing file with unknown detected mime type; " . |
| 1378 | + "unrecognized extension '$extension', can't verify\n" ); |
| 1379 | + return true; |
| 1380 | + } else { |
| 1381 | + wfDebug( __METHOD__.": rejecting file with unknown detected mime type; ". |
| 1382 | + "recognized extension '$extension', so probably invalid file\n" ); |
| 1383 | + return false; |
| 1384 | + } |
| 1385 | + |
| 1386 | + $match= $magic->isMatchingExtension($extension,$mime); |
| 1387 | + |
| 1388 | + if ($match===NULL) { |
| 1389 | + wfDebug( __METHOD__.": no file extension known for mime type $mime, passing file\n" ); |
| 1390 | + return true; |
| 1391 | + } elseif ($match===true) { |
| 1392 | + wfDebug( __METHOD__.": mime type $mime matches extension $extension, passing file\n" ); |
| 1393 | + |
| 1394 | + #TODO: if it's a bitmap, make sure PHP or ImageMagic resp. can handle it! |
| 1395 | + return true; |
| 1396 | + |
| 1397 | + } else { |
| 1398 | + wfDebug( __METHOD__.": mime type $mime mismatches file extension $extension, rejecting file\n" ); |
| 1399 | + return false; |
| 1400 | + } |
| 1401 | + } |
| 1402 | + |
| 1403 | + /** |
| 1404 | + * Heuristic for detecting files that *could* contain JavaScript instructions or |
| 1405 | + * things that may look like HTML to a browser and are thus |
| 1406 | + * potentially harmful. The present implementation will produce false positives in some situations. |
| 1407 | + * |
| 1408 | + * @param string $file Pathname to the temporary upload file |
| 1409 | + * @param string $mime The mime type of the file |
| 1410 | + * @param string $extension The extension of the file |
| 1411 | + * @return bool true if the file contains something looking like embedded scripts |
| 1412 | + */ |
| 1413 | + function detectScript($file, $mime, $extension) { |
| 1414 | + global $wgAllowTitlesInSVG; |
| 1415 | + |
| 1416 | + #ugly hack: for text files, always look at the entire file. |
| 1417 | + #For binarie field, just check the first K. |
| 1418 | + |
| 1419 | + if (strpos($mime,'text/')===0) $chunk = file_get_contents( $file ); |
| 1420 | + else { |
| 1421 | + $fp = fopen( $file, 'rb' ); |
| 1422 | + $chunk = fread( $fp, 1024 ); |
| 1423 | + fclose( $fp ); |
| 1424 | + } |
| 1425 | + |
| 1426 | + $chunk= strtolower( $chunk ); |
| 1427 | + |
| 1428 | + if (!$chunk) return false; |
| 1429 | + |
| 1430 | + #decode from UTF-16 if needed (could be used for obfuscation). |
| 1431 | + if (substr($chunk,0,2)=="\xfe\xff") $enc= "UTF-16BE"; |
| 1432 | + elseif (substr($chunk,0,2)=="\xff\xfe") $enc= "UTF-16LE"; |
| 1433 | + else $enc= NULL; |
| 1434 | + |
| 1435 | + if ($enc) $chunk= iconv($enc,"ASCII//IGNORE",$chunk); |
| 1436 | + |
| 1437 | + $chunk= trim($chunk); |
| 1438 | + |
| 1439 | + #FIXME: convert from UTF-16 if necessarry! |
| 1440 | + |
| 1441 | + wfDebug("SpecialUpload::detectScript: checking for embedded scripts and HTML stuff\n"); |
| 1442 | + |
| 1443 | + #check for HTML doctype |
| 1444 | + if (eregi("<!DOCTYPE *X?HTML",$chunk)) return true; |
| 1445 | + |
| 1446 | + /** |
| 1447 | + * Internet Explorer for Windows performs some really stupid file type |
| 1448 | + * autodetection which can cause it to interpret valid image files as HTML |
| 1449 | + * and potentially execute JavaScript, creating a cross-site scripting |
| 1450 | + * attack vectors. |
| 1451 | + * |
| 1452 | + * Apple's Safari browser also performs some unsafe file type autodetection |
| 1453 | + * which can cause legitimate files to be interpreted as HTML if the |
| 1454 | + * web server is not correctly configured to send the right content-type |
| 1455 | + * (or if you're really uploading plain text and octet streams!) |
| 1456 | + * |
| 1457 | + * Returns true if IE is likely to mistake the given file for HTML. |
| 1458 | + * Also returns true if Safari would mistake the given file for HTML |
| 1459 | + * when served with a generic content-type. |
| 1460 | + */ |
| 1461 | + |
| 1462 | + $tags = array( |
| 1463 | + '<body', |
| 1464 | + '<head', |
| 1465 | + '<html', #also in safari |
| 1466 | + '<img', |
| 1467 | + '<pre', |
| 1468 | + '<script', #also in safari |
| 1469 | + '<table' |
| 1470 | + ); |
| 1471 | + if( ! $wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) { |
| 1472 | + $tags[] = '<title'; |
| 1473 | + } |
| 1474 | + |
| 1475 | + foreach( $tags as $tag ) { |
| 1476 | + if( false !== strpos( $chunk, $tag ) ) { |
| 1477 | + return true; |
| 1478 | + } |
| 1479 | + } |
| 1480 | + |
| 1481 | + /* |
| 1482 | + * look for javascript |
| 1483 | + */ |
| 1484 | + |
| 1485 | + #resolve entity-refs to look at attributes. may be harsh on big files... cache result? |
| 1486 | + $chunk = Sanitizer::decodeCharReferences( $chunk ); |
| 1487 | + |
| 1488 | + #look for script-types |
| 1489 | + if (preg_match('!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim',$chunk)) return true; |
| 1490 | + |
| 1491 | + #look for html-style script-urls |
| 1492 | + if (preg_match('!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim',$chunk)) return true; |
| 1493 | + |
| 1494 | + #look for css-style script-urls |
| 1495 | + if (preg_match('!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim',$chunk)) return true; |
| 1496 | + |
| 1497 | + wfDebug("SpecialUpload::detectScript: no scripts found\n"); |
| 1498 | + return false; |
| 1499 | + } |
| 1500 | + |
| 1501 | + /** |
| 1502 | + * Generic wrapper function for a virus scanner program. |
| 1503 | + * This relies on the $wgAntivirus and $wgAntivirusSetup variables. |
| 1504 | + * $wgAntivirusRequired may be used to deny upload if the scan fails. |
| 1505 | + * |
| 1506 | + * @param string $file Pathname to the temporary upload file |
| 1507 | + * @return mixed false if not virus is found, NULL if the scan fails or is disabled, |
| 1508 | + * or a string containing feedback from the virus scanner if a virus was found. |
| 1509 | + * If textual feedback is missing but a virus was found, this function returns true. |
| 1510 | + */ |
| 1511 | + function detectVirus($file) { |
| 1512 | + global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut; |
| 1513 | + |
| 1514 | + if ( !$wgAntivirus ) { |
| 1515 | + wfDebug( __METHOD__.": virus scanner disabled\n"); |
| 1516 | + return NULL; |
| 1517 | + } |
| 1518 | + |
| 1519 | + if ( !$wgAntivirusSetup[$wgAntivirus] ) { |
| 1520 | + wfDebug( __METHOD__.": unknown virus scanner: $wgAntivirus\n" ); |
| 1521 | + $wgOut->wrapWikiMsg( '<div class="error">$1</div>', array( 'virus-badscanner', $wgAntivirus ) ); |
| 1522 | + return wfMsg('virus-unknownscanner') . " $wgAntivirus"; |
| 1523 | + } |
| 1524 | + |
| 1525 | + # look up scanner configuration |
| 1526 | + $command = $wgAntivirusSetup[$wgAntivirus]["command"]; |
| 1527 | + $exitCodeMap = $wgAntivirusSetup[$wgAntivirus]["codemap"]; |
| 1528 | + $msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]["messagepattern"] ) ? |
| 1529 | + $wgAntivirusSetup[$wgAntivirus]["messagepattern"] : null; |
| 1530 | + |
| 1531 | + if ( strpos( $command,"%f" ) === false ) { |
| 1532 | + # simple pattern: append file to scan |
| 1533 | + $command .= " " . wfEscapeShellArg( $file ); |
| 1534 | + } else { |
| 1535 | + # complex pattern: replace "%f" with file to scan |
| 1536 | + $command = str_replace( "%f", wfEscapeShellArg( $file ), $command ); |
| 1537 | + } |
| 1538 | + |
| 1539 | + wfDebug( __METHOD__.": running virus scan: $command \n" ); |
| 1540 | + |
| 1541 | + # execute virus scanner |
| 1542 | + $exitCode = false; |
| 1543 | + |
| 1544 | + #NOTE: there's a 50 line workaround to make stderr redirection work on windows, too. |
| 1545 | + # that does not seem to be worth the pain. |
| 1546 | + # Ask me (Duesentrieb) about it if it's ever needed. |
| 1547 | + $output = array(); |
| 1548 | + if ( wfIsWindows() ) { |
| 1549 | + exec( "$command", $output, $exitCode ); |
| 1550 | + } else { |
| 1551 | + exec( "$command 2>&1", $output, $exitCode ); |
| 1552 | + } |
| 1553 | + |
| 1554 | + # map exit code to AV_xxx constants. |
| 1555 | + $mappedCode = $exitCode; |
| 1556 | + if ( $exitCodeMap ) { |
| 1557 | + if ( isset( $exitCodeMap[$exitCode] ) ) { |
| 1558 | + $mappedCode = $exitCodeMap[$exitCode]; |
| 1559 | + } elseif ( isset( $exitCodeMap["*"] ) ) { |
| 1560 | + $mappedCode = $exitCodeMap["*"]; |
| 1561 | + } |
| 1562 | + } |
| 1563 | + |
| 1564 | + if ( $mappedCode === AV_SCAN_FAILED ) { |
| 1565 | + # scan failed (code was mapped to false by $exitCodeMap) |
| 1566 | + wfDebug( __METHOD__.": failed to scan $file (code $exitCode).\n" ); |
| 1567 | + |
| 1568 | + if ( $wgAntivirusRequired ) { |
| 1569 | + return wfMsg('virus-scanfailed', array( $exitCode ) ); |
| 1570 | + } else { |
| 1571 | + return NULL; |
| 1572 | + } |
| 1573 | + } else if ( $mappedCode === AV_SCAN_ABORTED ) { |
| 1574 | + # scan failed because filetype is unknown (probably imune) |
| 1575 | + wfDebug( __METHOD__.": unsupported file type $file (code $exitCode).\n" ); |
| 1576 | + return NULL; |
| 1577 | + } else if ( $mappedCode === AV_NO_VIRUS ) { |
| 1578 | + # no virus found |
| 1579 | + wfDebug( __METHOD__.": file passed virus scan.\n" ); |
| 1580 | + return false; |
| 1581 | + } else { |
| 1582 | + $output = join( "\n", $output ); |
| 1583 | + $output = trim( $output ); |
| 1584 | + |
| 1585 | + if ( !$output ) { |
| 1586 | + $output = true; #if there's no output, return true |
| 1587 | + } elseif ( $msgPattern ) { |
| 1588 | + $groups = array(); |
| 1589 | + if ( preg_match( $msgPattern, $output, $groups ) ) { |
| 1590 | + if ( $groups[1] ) { |
| 1591 | + $output = $groups[1]; |
| 1592 | + } |
| 1593 | + } |
| 1594 | + } |
| 1595 | + |
| 1596 | + wfDebug( __METHOD__.": FOUND VIRUS! scanner feedback: $output" ); |
| 1597 | + return $output; |
| 1598 | + } |
| 1599 | + } |
| 1600 | + |
| 1601 | + /** |
| 1602 | + * Check if the temporary file is MacBinary-encoded, as some uploads |
| 1603 | + * from Internet Explorer on Mac OS Classic and Mac OS X will be. |
| 1604 | + * If so, the data fork will be extracted to a second temporary file, |
| 1605 | + * which will then be checked for validity and either kept or discarded. |
| 1606 | + * |
| 1607 | + * @access private |
| 1608 | + */ |
| 1609 | + function checkMacBinary() { |
| 1610 | + $macbin = new MacBinary( $this->mTempPath ); |
| 1611 | + if( $macbin->isValid() ) { |
| 1612 | + $dataFile = tempnam( wfTempDir(), "WikiMacBinary" ); |
| 1613 | + $dataHandle = fopen( $dataFile, 'wb' ); |
| 1614 | + |
| 1615 | + wfDebug( "SpecialUpload::checkMacBinary: Extracting MacBinary data fork to $dataFile\n" ); |
| 1616 | + $macbin->extractData( $dataHandle ); |
| 1617 | + |
| 1618 | + $this->mTempPath = $dataFile; |
| 1619 | + $this->mFileSize = $macbin->dataForkLength(); |
| 1620 | + |
| 1621 | + // We'll have to manually remove the new file if it's not kept. |
| 1622 | + $this->mRemoveTempFile = true; |
| 1623 | + } |
| 1624 | + $macbin->close(); |
| 1625 | + } |
| 1626 | + |
| 1627 | + /** |
| 1628 | + * If we've modified the upload file we need to manually remove it |
| 1629 | + * on exit to clean up. |
| 1630 | + * @access private |
| 1631 | + */ |
| 1632 | + function cleanupTempFile() { |
| 1633 | + if ( $this->mRemoveTempFile && file_exists( $this->mTempPath ) ) { |
| 1634 | + wfDebug( "SpecialUpload::cleanupTempFile: Removing temporary file {$this->mTempPath}\n" ); |
| 1635 | + unlink( $this->mTempPath ); |
| 1636 | + } |
| 1637 | + } |
| 1638 | + |
| 1639 | + /** |
| 1640 | + * Check if there's an overwrite conflict and, if so, if restrictions |
| 1641 | + * forbid this user from performing the upload. |
| 1642 | + * |
| 1643 | + * @return mixed true on success, WikiError on failure |
| 1644 | + * @access private |
| 1645 | + */ |
| 1646 | + function checkOverwrite( $name ) { |
| 1647 | + $img = wfFindFile( $name ); |
| 1648 | + |
| 1649 | + $error = ''; |
| 1650 | + if( $img ) { |
| 1651 | + global $wgUser, $wgOut; |
| 1652 | + if( $img->isLocal() ) { |
| 1653 | + if( !self::userCanReUpload( $wgUser, $img->name ) ) { |
| 1654 | + $error = 'fileexists-forbidden'; |
| 1655 | + } |
| 1656 | + } else { |
| 1657 | + if( !$wgUser->isAllowed( 'reupload' ) || |
| 1658 | + !$wgUser->isAllowed( 'reupload-shared' ) ) { |
| 1659 | + $error = "fileexists-shared-forbidden"; |
| 1660 | + } |
| 1661 | + } |
| 1662 | + } |
| 1663 | + |
| 1664 | + if( $error ) { |
| 1665 | + $errorText = wfMsg( $error, wfEscapeWikiText( $img->getName() ) ); |
| 1666 | + return $errorText; |
| 1667 | + } |
| 1668 | + |
| 1669 | + // Rockin', go ahead and upload |
| 1670 | + return true; |
| 1671 | + } |
| 1672 | + |
919 | 1673 | /** |
920 | 1674 | * Check if a user is the last uploader |
921 | 1675 | * |
922 | 1676 | * @param User $user |
923 | 1677 | * @param string $img, image name |
924 | 1678 | * @return bool |
925 | | - * @deprecated Use UploadBase::userCanReUpload |
926 | 1679 | */ |
927 | 1680 | public static function userCanReUpload( User $user, $img ) { |
928 | | - wfDeprecated( __METHOD__ ); |
929 | | - |
930 | 1681 | if( $user->isAllowed( 'reupload' ) ) |
931 | 1682 | return true; // non-conditional |
932 | 1683 | if( !$user->isAllowed( 'reupload-own' ) ) |
Index: trunk/phase3/RELEASE-NOTES |
— | — | @@ -102,8 +102,6 @@ |
103 | 103 | instead of a hardcoded colon. |
104 | 104 | * Allow <gallery> to accept image names without an Image: prefix |
105 | 105 | * Add tooltips to rollback and undo links |
106 | | -* Backend upload code has been removed from SpecialUpload.php. This may cause |
107 | | - backwards incompatibility with upload extensions. |
108 | 106 | * BMP images are now displayed as PNG |
109 | 107 | * (bug 13471) Added NUMBERINGROUP magic word |
110 | 108 | * (bug 11884) Now support Flash EXIF attribute |