MediaWiki  1.34.0
FancyCaptcha.php
Go to the documentation of this file.
1 <?php
2 
5 
9 class FancyCaptcha extends SimpleCaptcha {
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 }
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:32
FancyCaptcha\onAuthChangeFormFields
onAuthChangeFormFields(array $requests, array $fieldInfo, array &$formDescriptor, $action)
Definition: FancyCaptcha.php:467
FancyCaptcha\getCaptchaCount
getCaptchaCount()
Definition: FancyCaptcha.php:51
FancyCaptcha\pickImageFromDir
pickImageFromDir( $directory, &$lockouts)
Definition: FancyCaptcha.php:265
FancyCaptcha\pickImageFromList
pickImageFromList( $directory, array $files, &$lockouts)
Definition: FancyCaptcha.php:312
FancyCaptcha\imagePath
imagePath( $salt, $hash)
Definition: FancyCaptcha.php:392
$file
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1264
SpecialPage\getTitleFor
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Definition: SpecialPage.php:83
FancyCaptcha\getCaptchaInfo
getCaptchaInfo( $captchaData, $id)
Definition: FancyCaptcha.php:456
SimpleCaptcha\$action
string $action
Used to select the right message.
Definition: SimpleCaptcha.php:20
FancyCaptcha\pickImage
pickImage()
Select a previously generated captcha image from the queue.
Definition: FancyCaptcha.php:189
FileBackendGroup\singleton
static singleton()
Definition: FileBackendGroup.php:46
$wgMemc
$wgMemc
Definition: Setup.php:791
NullLockManager
Simple version of LockManager that only does lock reference counting.
Definition: NullLockManager.php:28
wfDeprecated
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
Definition: GlobalFunctions.php:1044
FancyCaptcha\getBackend
getBackend()
Definition: FancyCaptcha.php:17
$matches
$matches
Definition: NoLocalSettings.php:24
FancyCaptcha\addCaptchaAPI
addCaptchaAPI(&$resultArr)
Definition: FancyCaptcha.php:86
$modules
$modules
Definition: HTMLFormElement.php:13
FancyCaptcha\pickImageDir
pickImageDir( $directory, $levels, &$lockouts)
Definition: FancyCaptcha.php:204
$title
$title
Definition: testCompression.php:34
$dirs
$dirs
Definition: mergeMessageFileList.php:192
FancyCaptcha\showImage
showImage()
Definition: FancyCaptcha.php:362
wfDebug
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
Definition: GlobalFunctions.php:913
FancyCaptcha\getCaptcha
getCaptcha()
Returns an array with 'salt' and 'hash' keys.
Definition: FancyCaptcha.php:443
FancyCaptcha\$messagePrefix
static $messagePrefix
Definition: FancyCaptcha.php:12
wfWikiID
wfWikiID()
Get an ASCII string identifying this wiki This is used as a prefix in memcached keys.
Definition: GlobalFunctions.php:2541
FancyCaptcha\keyMatch
keyMatch( $answer, $info)
Check if the submitted form matches the captcha session data provided by the plugin when the form was...
Definition: FancyCaptcha.php:68
SimpleCaptcha\retrieveCaptcha
retrieveCaptcha( $index)
Fetch this session's captcha info.
Definition: SimpleCaptcha.php:1068
FancyCaptcha
FancyCaptcha for displaying captchas precomputed by captcha.py.
Definition: FancyCaptcha.php:9
FancyCaptcha\describeCaptchaType
describeCaptchaType()
Definition: FancyCaptcha.php:102
SimpleCaptcha
Demo CAPTCHA (not for production usage) and base class for real CAPTCHAs.
Definition: SimpleCaptcha.php:9
SimpleCaptcha\storeCaptcha
storeCaptcha( $info)
Generate a captcha session ID and save the info in PHP's session storage.
Definition: SimpleCaptcha.php:1054
FancyCaptcha\hashFromImageName
hashFromImageName( $basename)
Definition: FancyCaptcha.php:409
SimpleCaptcha\getMessage
getMessage( $action)
Show a message asking the user to enter a captcha on edit The result will be treated as wiki text.
Definition: SimpleCaptcha.php:241
MediaWiki\Auth\AuthManager
This serves as the entry point to the authentication system.
Definition: AuthManager.php:85
FancyCaptcha\passCaptcha
passCaptcha( $index, $word)
Delete a solved captcha image, if $wgCaptchaDeleteOnSolve is true.
Definition: FancyCaptcha.php:421
wfHttpError
wfHttpError( $code, $label, $desc)
Provide a simple HTTP error.
Definition: GlobalFunctions.php:1659
FSFileBackend
Class for a file system (FS) based file backend.
Definition: FSFileBackend.php:62
FancyCaptcha\getFormInformation
getFormInformation( $tabIndex=1)
Definition: FancyCaptcha.php:113
FancyCaptcha\estimateCaptchaCount
estimateCaptchaCount()
Definition: FancyCaptcha.php:43
$wgRequest
if(! $wgDBerrorLogTZ) $wgRequest
Definition: Setup.php:752
$wgOut
$wgOut
Definition: Setup.php:886
MediaWiki\Auth\AuthenticationRequest
This is a value object for authentication requests.
Definition: AuthenticationRequest.php:37