MediaWiki REL1_34
FancyCaptcha.php
Go to the documentation of this file.
1<?php
2
5
10 // used for fancycaptcha-edit, fancycaptcha-addurl, fancycaptcha-badlogin,
11 // fancycaptcha-accountcreate, fancycaptcha-create, fancycaptcha-sendemail via getMessage()
12 protected static $messagePrefix = 'fancycaptcha-';
13
17 public function getBackend() {
18 global $wgCaptchaFileBackend, $wgCaptchaDirectory;
19
20 if ( $wgCaptchaFileBackend ) {
21 return FileBackendGroup::singleton()->get( $wgCaptchaFileBackend );
22 } else {
23 static $backend = null;
24 if ( !$backend ) {
25 $backend = new FSFileBackend( [
26 'name' => 'captcha-backend',
27 'wikiId' => wfWikiID(),
28 'lockManager' => new NullLockManager( [] ),
29 'containerPaths' => [ 'captcha-render' => $wgCaptchaDirectory ],
30 'fileMode' => 777,
31 'obResetFunc' => 'wfResetOutputBuffers',
32 'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ]
33 ] );
34 }
35 return $backend;
36 }
37 }
38
43 public function estimateCaptchaCount() {
44 wfDeprecated( __METHOD__ );
45 return $this->getCaptchaCount();
46 }
47
51 public function getCaptchaCount() {
52 $backend = $this->getBackend();
53 $files = $backend->getFileList(
54 [ 'dir' => $backend->getRootStoragePath() . '/captcha-render' ]
55 );
56
57 return iterator_count( $files );
58 }
59
68 protected function keyMatch( $answer, $info ) {
69 global $wgCaptchaSecret;
70
71 $digest = $wgCaptchaSecret . $info['salt'] . $answer . $wgCaptchaSecret . $info['salt'];
72 $answerHash = substr( md5( $digest ), 0, 16 );
73
74 if ( $answerHash == $info['hash'] ) {
75 wfDebug( "FancyCaptcha: answer hash matches expected {$info['hash']}\n" );
76 return true;
77 } else {
78 wfDebug( "FancyCaptcha: answer hashes to $answerHash, expected {$info['hash']}\n" );
79 return false;
80 }
81 }
82
86 protected function addCaptchaAPI( &$resultArr ) {
87 $info = $this->pickImage();
88 if ( !$info ) {
89 $resultArr['captcha']['error'] = 'Out of images';
90 return;
91 }
92 $index = $this->storeCaptcha( $info );
93 $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
94 $resultArr['captcha'] = $this->describeCaptchaType();
95 $resultArr['captcha']['id'] = $index;
96 $resultArr['captcha']['url'] = $title->getLocalURL( 'wpCaptchaId=' . urlencode( $index ) );
97 }
98
102 public function describeCaptchaType() {
103 return [
104 'type' => 'image',
105 'mime' => 'image/png',
106 ];
107 }
108
113 public function getFormInformation( $tabIndex = 1 ) {
114 $modules = [];
115
116 $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
117 $info = $this->getCaptcha();
118 $index = $this->storeCaptcha( $info );
119
120 // Loaded only for clients with JS enabled
121 $modules[] = 'ext.confirmEdit.fancyCaptcha';
122
123 $captchaReload = Html::element(
124 'small',
125 [
126 'class' => 'confirmedit-captcha-reload fancycaptcha-reload'
127 ],
128 wfMessage( 'fancycaptcha-reload-text' )->text()
129 );
130
131 $form = Html::openElement( 'div' ) .
132 Html::element( 'label', [
133 'for' => 'wpCaptchaWord',
134 ],
135 wfMessage( 'captcha-label' )->text() . ' ' . wfMessage( 'fancycaptcha-captcha' )->text()
136 ) .
137 Html::openElement( 'div', [ 'class' => 'fancycaptcha-captcha-container' ] ) .
138 Html::openElement( 'div', [ 'class' => 'fancycaptcha-captcha-and-reload' ] ) .
139 Html::openElement( 'div', [ 'class' => 'fancycaptcha-image-container' ] ) .
140 Html::element( 'img', [
141 'class' => 'fancycaptcha-image',
142 'src' => $title->getLocalURL( 'wpCaptchaId=' . urlencode( $index ) ),
143 'alt' => ''
144 ]
145 ) . $captchaReload . Html::closeElement( 'div' ) . Html::closeElement( 'div' ) . "\n" .
146 Html::element( 'input', [
147 'name' => 'wpCaptchaWord',
148 'class' => 'mw-ui-input',
149 'id' => 'wpCaptchaWord',
150 'type' => 'text',
151 // max_length in captcha.py plus fudge factor
152 'size' => '12',
153 'autocomplete' => 'off',
154 'autocorrect' => 'off',
155 'autocapitalize' => 'off',
156 'required' => 'required',
157 // tab in before the edit textarea
158 'tabindex' => $tabIndex,
159 'placeholder' => wfMessage( 'fancycaptcha-imgcaptcha-ph' )->text()
160 ]
161 );
162 if ( $this->action == 'createaccount' ) {
163 // use raw element, because the message can contain links or some other html
164 $form .= Html::rawElement( 'small', [
165 'class' => 'mw-createacct-captcha-assisted'
166 ], wfMessage( 'createacct-imgcaptcha-help' )->parse()
167 );
168 }
169 $form .= Html::element( 'input', [
170 'type' => 'hidden',
171 'name' => 'wpCaptchaId',
172 'id' => 'wpCaptchaId',
173 'value' => $index
174 ]
175 ) . Html::closeElement( 'div' ) . Html::closeElement( 'div' ) . "\n";
176
177 return [
178 'html' => $form,
179 'modules' => $modules,
180 // Uses addModuleStyles so it is loaded when JS is disabled.
181 'modulestyles' => [ 'mediawiki.ui.input', 'ext.confirmEdit.fancyCaptcha.styles' ],
182 ];
183 }
184
189 protected function pickImage() {
190 global $wgCaptchaDirectoryLevels;
191
192 // number of times another process claimed a file before this one
193 $lockouts = 0;
194 $baseDir = $this->getBackend()->getRootStoragePath() . '/captcha-render';
195 return $this->pickImageDir( $baseDir, $wgCaptchaDirectoryLevels, $lockouts );
196 }
197
204 protected function pickImageDir( $directory, $levels, &$lockouts ) {
205 global $wgMemc;
206
207 if ( $levels <= 0 ) {
208 // $directory has regular files
209 return $this->pickImageFromDir( $directory, $lockouts );
210 }
211
212 $backend = $this->getBackend();
213
214 $key = "fancycaptcha:dirlist:{$backend->getWikiId()}:" . sha1( $directory );
215 // check cache
216 $dirs = $wgMemc->get( $key );
217 if ( !is_array( $dirs ) || !count( $dirs ) ) {
218 // cache miss
219 $dirs = [];
220 // subdirs actually present...
221 foreach ( $backend->getTopDirectoryList( [ 'dir' => $directory ] ) as $entry ) {
222 if ( ctype_xdigit( $entry ) && strlen( $entry ) == 1 ) {
223 $dirs[] = $entry;
224 }
225 }
226 wfDebug( "Cache miss for $directory subdirectory listing.\n" );
227 if ( count( $dirs ) ) {
228 $wgMemc->set( $key, $dirs, 86400 );
229 }
230 }
231
232 if ( !count( $dirs ) ) {
233 // Remove this directory if empty so callers don't keep looking here
234 $backend->clean( [ 'dir' => $directory ] );
235 // none found
236 return false;
237 }
238
239 // pick a random subdir
240 $place = mt_rand( 0, count( $dirs ) - 1 );
241 // In case all dirs are not filled, cycle through next digits...
242 $fancyCount = count( $dirs );
243 for ( $j = 0; $j < $fancyCount; $j++ ) {
244 $char = $dirs[( $place + $j ) % count( $dirs )];
245 $info = $this->pickImageDir( "$directory/$char", $levels - 1, $lockouts );
246 if ( $info ) {
247 // found a captcha
248 return $info;
249 } else {
250 wfDebug( "Could not find captcha in $directory.\n" );
251 // files changed on disk?
252 $wgMemc->delete( $key );
253 }
254 }
255
256 // didn't find any images in this directory... empty?
257 return false;
258 }
259
265 protected function pickImageFromDir( $directory, &$lockouts ) {
266 global $wgMemc;
267
268 $backend = $this->getBackend();
269
270 $key = "fancycaptcha:filelist:{$backend->getWikiId()}:" . sha1( $directory );
271 // check cache
272 $files = $wgMemc->get( $key );
273 if ( !is_array( $files ) || !count( $files ) ) {
274 // cache miss
275 $files = [];
276 foreach ( $backend->getTopFileList( [ 'dir' => $directory ] ) as $entry ) {
277 $files[] = $entry;
278 if ( count( $files ) >= 500 ) {
279 // sanity
280 wfDebug( 'Skipping some captchas; $wgCaptchaDirectoryLevels set too low?.' );
281 break;
282 }
283 }
284 if ( count( $files ) ) {
285 $wgMemc->set( $key, $files, 86400 );
286 }
287 wfDebug( "Cache miss for $directory captcha listing.\n" );
288 }
289
290 if ( !count( $files ) ) {
291 // Remove this directory if empty so callers don't keep looking here
292 $backend->clean( [ 'dir' => $directory ] );
293 return false;
294 }
295
296 $info = $this->pickImageFromList( $directory, $files, $lockouts );
297 if ( !$info ) {
298 wfDebug( "Could not find captcha in $directory.\n" );
299 // files changed on disk?
300 $wgMemc->delete( $key );
301 }
302
303 return $info;
304 }
305
312 protected function pickImageFromList( $directory, array $files, &$lockouts ) {
313 global $wgMemc, $wgCaptchaDeleteOnSolve;
314
315 if ( !count( $files ) ) {
316 // none found
317 return false;
318 }
319
320 $backend = $this->getBackend();
321 // pick a random file
322 $place = mt_rand( 0, count( $files ) - 1 );
323 // number of files in listing that don't actually exist
324 $misses = 0;
325 $fancyImageCount = count( $files );
326 for ( $j = 0; $j < $fancyImageCount; $j++ ) {
327 $entry = $files[( $place + $j ) % count( $files )];
328 if ( preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $entry, $matches ) ) {
329 if ( $wgCaptchaDeleteOnSolve ) {
330 // captcha will be deleted when solved
331 $key = "fancycaptcha:filelock:{$backend->getWikiId()}:" . sha1( $entry );
332 // Try to claim this captcha for 10 minutes (for the user to solve)...
333 if ( ++$lockouts <= 10 && !$wgMemc->add( $key, '1', 600 ) ) {
334 // could not acquire (skip it to avoid race conditions)
335 continue;
336 }
337 }
338 if ( !$backend->fileExists( [ 'src' => "$directory/$entry" ] ) ) {
339 if ( ++$misses >= 5 ) {
340 // too many files in the listing don't exist
341 // listing cache too stale? break out so it will be cleared
342 break;
343 }
344 // try next file
345 continue;
346 }
347 return [
348 'salt' => $matches[1],
349 'hash' => $matches[2],
350 'viewed' => false,
351 ];
352 }
353 }
354
355 // none found
356 return false;
357 }
358
362 public function showImage() {
363 global $wgOut, $wgRequest;
364
365 $wgOut->disable();
366
367 $index = $wgRequest->getVal( 'wpCaptchaId' );
368 $info = $this->retrieveCaptcha( $index );
369 if ( $info ) {
370 $timestamp = new MWTimestamp();
371 $info['viewed'] = $timestamp->getTimestamp();
372 $this->storeCaptcha( $info );
373
374 $salt = $info['salt'];
375 $hash = $info['hash'];
376
377 return $this->getBackend()->streamFile( [
378 'src' => $this->imagePath( $salt, $hash ),
379 'headers' => [ "Cache-Control: private, s-maxage=0, max-age=3600" ]
380 ] )->isOK();
381 }
382
383 wfHttpError( 400, 'Request Error', 'Requested bogus captcha image' );
384 return false;
385 }
386
392 public function imagePath( $salt, $hash ) {
393 global $wgCaptchaDirectoryLevels;
394
395 $file = $this->getBackend()->getRootStoragePath() . '/captcha-render/';
396 for ( $i = 0; $i < $wgCaptchaDirectoryLevels; $i++ ) {
397 $file .= $hash{ $i } . '/';
398 }
399 $file .= "image_{$salt}_{$hash}.png";
400
401 return $file;
402 }
403
409 public function hashFromImageName( $basename ) {
410 if ( preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $basename, $matches ) ) {
411 return [ $matches[1], $matches[2] ];
412 } else {
413 throw new Exception( "Invalid filename '$basename'.\n" );
414 }
415 }
416
421 protected function passCaptcha( $index, $word ) {
422 global $wgCaptchaDeleteOnSolve;
423
424 // get the captcha info before it gets deleted
425 $info = $this->retrieveCaptcha( $index );
426 $pass = parent::passCaptcha( $index, $word );
427
428 if ( $pass && $wgCaptchaDeleteOnSolve ) {
429 $this->getBackend()->quickDelete( [
430 'src' => $this->imagePath( $info['salt'], $info['hash'] )
431 ] );
432 }
433
434 return $pass;
435 }
436
443 public function getCaptcha() {
444 $info = $this->pickImage();
445 if ( !$info ) {
446 throw new UnderflowException( 'Ran out of captcha images' );
447 }
448 return $info;
449 }
450
456 public function getCaptchaInfo( $captchaData, $id ) {
457 $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
458 return $title->getLocalURL( 'wpCaptchaId=' . urlencode( $id ) );
459 }
460
467 public function onAuthChangeFormFields(
468 array $requests, array $fieldInfo, array &$formDescriptor, $action
469 ) {
471 $req =
472 AuthenticationRequest::getRequestByClass( $requests,
473 CaptchaAuthenticationRequest::class, true );
474 if ( !$req ) {
475 return;
476 }
477
478 // HTMLFancyCaptchaField will include this
479 unset( $formDescriptor['captchaInfo' ] );
480
481 $formDescriptor['captchaWord'] = [
482 'class' => HTMLFancyCaptchaField::class,
483 'imageUrl' => $this->getCaptchaInfo( $req->captchaData, $req->captchaId ),
484 'label-message' => $this->getMessage( $this->action ),
485 'showCreateHelp' => in_array( $action, [
486 AuthManager::ACTION_CREATE,
487 AuthManager::ACTION_CREATE_CONTINUE
488 ], true ),
489 ] + $formDescriptor['captchaWord'];
490 }
491}
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfHttpError( $code, $label, $desc)
Provide a simple HTTP error.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
wfWikiID()
Get an ASCII string identifying this wiki This is used as a prefix in memcached keys.
$wgOut
Definition Setup.php:885
$wgMemc
Definition Setup.php:790
if(! $wgDBerrorLogTZ) $wgRequest
Definition Setup.php:751
Class for a file system (FS) based file backend.
FancyCaptcha for displaying captchas precomputed by captcha.py.
imagePath( $salt, $hash)
pickImage()
Select a previously generated captcha image from the queue.
passCaptcha( $index, $word)
Delete a solved captcha image, if $wgCaptchaDeleteOnSolve is true.
addCaptchaAPI(&$resultArr)
getFormInformation( $tabIndex=1)
getCaptcha()
Returns an array with 'salt' and 'hash' keys.
pickImageFromList( $directory, array $files, &$lockouts)
pickImageDir( $directory, $levels, &$lockouts)
static $messagePrefix
getCaptchaInfo( $captchaData, $id)
hashFromImageName( $basename)
keyMatch( $answer, $info)
Check if the submitted form matches the captcha session data provided by the plugin when the form was...
onAuthChangeFormFields(array $requests, array $fieldInfo, array &$formDescriptor, $action)
pickImageFromDir( $directory, &$lockouts)
Library for creating and parsing MW-style timestamps.
This serves as the entry point to the authentication system.
This is a value object for authentication requests.
Simple version of LockManager that only does lock reference counting.
Demo CAPTCHA (not for production usage) and base class for real CAPTCHAs.
getMessage( $action)
Show a message asking the user to enter a captcha on edit The result will be treated as wiki text.
retrieveCaptcha( $index)
Fetch this session's captcha info.
string $action
Used to select the right message.
storeCaptcha( $info)
Generate a captcha session ID and save the info in PHP's session storage.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition router.php:42