Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.00% covered (warning)
84.00%
210 / 250
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
FancyCaptcha
84.00% covered (warning)
84.00%
210 / 250
77.78% covered (warning)
77.78%
14 / 18
68.85
0.00% covered (danger)
0.00%
0 / 1
 getBackend
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getCaptchaCount
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getStorageDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keyMatch
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 addCaptchaAPI
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 describeCaptchaType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getFormInformation
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
2
 pickImage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 pickImageDir
6.45% covered (danger)
6.45%
2 / 31
0.00% covered (danger)
0.00%
0 / 1
110.06
 pickImageFromDir
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
8.23
 pickImageFromList
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
9.80
 showImage
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 imagePath
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 hashFromImageName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 passCaptcha
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getCaptcha
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCaptchaInfo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onAuthChangeFormFields
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\ConfirmEdit\FancyCaptcha;
4
5use InvalidArgumentException;
6use MediaWiki\Auth\AuthenticationRequest;
7use MediaWiki\Auth\AuthManager;
8use MediaWiki\Context\IContextSource;
9use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest;
10use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
11use MediaWiki\Html\Html;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Output\OutputPage;
15use MediaWiki\Output\StreamFile;
16use MediaWiki\SpecialPage\SpecialPage;
17use MediaWiki\Utils\MWTimestamp;
18use MediaWiki\WikiMap\WikiMap;
19use NullLockManager;
20use UnderflowException;
21use Wikimedia\FileBackend\FileBackend;
22use Wikimedia\FileBackend\FSFileBackend;
23
24/**
25 * FancyCaptcha for displaying captcha images precomputed by captcha.py
26 */
27class FancyCaptcha extends SimpleCaptcha {
28    /**
29     * @var string used for fancycaptcha-edit, fancycaptcha-addurl, fancycaptcha-badlogin,
30     * fancycaptcha-accountcreate, fancycaptcha-create, fancycaptcha-sendemail via getMessage()
31     */
32    protected static $messagePrefix = 'fancycaptcha';
33
34    private ?FSFileBackend $backend = null;
35
36    /**
37     * @return FileBackend
38     */
39    public function getBackend() {
40        global $wgCaptchaFileBackend, $wgCaptchaDirectory;
41
42        if ( $wgCaptchaFileBackend ) {
43            return MediaWikiServices::getInstance()->getFileBackendGroup()
44                ->get( $wgCaptchaFileBackend );
45        }
46
47        if ( !$this->backend ) {
48            $this->backend = new FSFileBackend( [
49                'name'           => 'captcha-backend',
50                'wikiId'         => WikiMap::getCurrentWikiId(),
51                'lockManager'    => new NullLockManager( [] ),
52                'containerPaths' => [ $this->getStorageDir() => $wgCaptchaDirectory ],
53                'fileMode'       => 777,
54                'obResetFunc'    => wfResetOutputBuffers( ... ),
55                'streamMimeFunc' => StreamFile::contentTypeFromPath( ... ),
56            ] );
57        }
58
59        return $this->backend;
60    }
61
62    /**
63     * @return int Number of captcha files
64     */
65    public function getCaptchaCount() {
66        $backend = $this->getBackend();
67        $files = $backend->getFileList(
68            [ 'dir' => $backend->getRootStoragePath() . '/' . $this->getStorageDir() ]
69        );
70
71        return iterator_count( $files );
72    }
73
74    /**
75     * @return string
76     */
77    public function getStorageDir() {
78        global $wgCaptchaStorageDirectory;
79        return $wgCaptchaStorageDirectory;
80    }
81
82    /**
83     * Check if the submitted form matches the captcha session data provided
84     * by the plugin when the form was generated.
85     *
86     * @param string $answer
87     * @param array $info
88     * @return bool
89     */
90    protected function keyMatch( $answer, $info ) {
91        global $wgCaptchaSecret;
92
93        $digest = $wgCaptchaSecret . $info['salt'] . $answer . $wgCaptchaSecret . $info['salt'];
94        $answerHash = substr( md5( $digest ), 0, 16 );
95
96        $logger = LoggerFactory::getInstance( 'captcha' );
97        if ( $answerHash == $info['hash'] ) {
98            $logger->debug(
99                'FancyCaptcha: answer hash matches expected {expected_hash}', [ 'expected_hash' => $info['hash'] ]
100            );
101            return true;
102        } else {
103            $logger->debug(
104                'FancyCaptcha: answer hashes to {answer_hash}, expected {expected_hash}',
105                [ 'answer_hash' => $answerHash, 'expected_hash' => $info['hash'] ]
106            );
107            return false;
108        }
109    }
110
111    /**
112     * @param array &$resultArr
113     */
114    protected function addCaptchaAPI( &$resultArr ) {
115        $info = $this->pickImage();
116        if ( !$info ) {
117            $resultArr['captcha']['error'] = 'Out of images';
118            return;
119        }
120        $index = $this->storeCaptcha( $info );
121        $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
122        $resultArr['captcha'] = $this->describeCaptchaType( $this->action );
123        $resultArr['captcha']['id'] = $index;
124        $resultArr['captcha']['url'] = $title->getLocalURL( 'wpCaptchaId=' . urlencode( $index ) );
125    }
126
127    /** @inheritDoc */
128    public function describeCaptchaType( ?string $action = null ) {
129        return [
130            'type' => 'image',
131            'mime' => 'image/png',
132        ];
133    }
134
135    /** @inheritDoc */
136    public function getFormInformation( $tabIndex = 1, ?OutputPage $out = null ) {
137        $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
138        $info = $this->getCaptcha();
139        $index = $this->storeCaptcha( $info );
140
141        $captchaReload = Html::element(
142            'small',
143            [
144                'class' => 'confirmedit-captcha-reload fancycaptcha-reload'
145            ],
146            wfMessage( 'fancycaptcha-reload-text' )->text()
147        );
148
149        $form = Html::openElement( 'div' ) .
150            Html::element( 'label', [
151                    'for' => 'wpCaptchaWord',
152                ],
153                wfMessage( 'captcha-label' )->text() . ' ' . wfMessage( 'fancycaptcha-captcha' )->text()
154            ) .
155            Html::openElement( 'div', [ 'class' => 'fancycaptcha-captcha-container' ] ) .
156            Html::openElement( 'div', [ 'class' => 'fancycaptcha-captcha-and-reload' ] ) .
157            Html::openElement( 'div', [ 'class' => 'fancycaptcha-image-container' ] ) .
158            Html::element( 'img', [
159                    'class'  => 'fancycaptcha-image',
160                    'src'    => $title->getLocalURL( 'wpCaptchaId=' . urlencode( $index ) ),
161                    'alt'    => ''
162                ]
163            ) . $captchaReload . Html::closeElement( 'div' ) . Html::closeElement( 'div' ) . "\n" .
164            // FIXME: This should use CodexHTMLForm rather than Html::element
165            Html::openElement( 'div', [ 'class' => 'cdx-text-input' ] ) .
166            Html::element( 'input', [
167                    'name' => 'wpCaptchaWord',
168                    'class' => 'cdx-text-input__input',
169                    'id'   => 'wpCaptchaWord',
170                    'type' => 'text',
171                    // max_length in captcha.py plus some fudge factor
172                    'size' => '12',
173                    'autocomplete' => 'off',
174                    'autocorrect' => 'off',
175                    'autocapitalize' => 'off',
176                    'required' => 'required',
177                    // tab in before the edit textarea
178                    'tabindex' => $tabIndex,
179                    'placeholder' => wfMessage( 'fancycaptcha-imgcaptcha-ph' )->text()
180                ]
181            ) . Html::closeElement( 'div' );
182        if ( $this->action == 'createaccount' ) {
183            // use a raw element, because the message can contain links or some other html
184            $form .= Html::rawElement( 'small', [
185                    'class' => 'mw-createacct-captcha-assisted'
186                ], wfMessage( 'createacct-imgcaptcha-help' )->parse()
187            );
188        }
189        $form .= Html::element( 'input', [
190                'type'  => 'hidden',
191                'name'  => 'wpCaptchaId',
192                'id'    => 'wpCaptchaId',
193                'value' => $index
194            ]
195        ) . Html::closeElement( 'div' ) . Html::closeElement( 'div' ) . "\n";
196
197        return [
198            'html' => $form,
199            'modules' => [ 'ext.confirmEdit.fancyCaptcha' ],
200            // Uses addModuleStyles so it is loaded when JS is disabled.
201            'modulestyles' => [ 'codex-styles', 'ext.confirmEdit.fancyCaptcha.styles' ],
202        ];
203    }
204
205    /**
206     * Select a previously generated captcha image from the queue.
207     * @return mixed tuple of (salt key, text hash) or false if no image to find
208     */
209    protected function pickImage() {
210        global $wgCaptchaDirectoryLevels;
211
212        // number of times another process claimed a file before this one
213        $lockouts = 0;
214        $baseDir = $this->getBackend()->getRootStoragePath() . '/' . $this->getStorageDir();
215        return $this->pickImageDir( $baseDir, $wgCaptchaDirectoryLevels, $lockouts );
216    }
217
218    /**
219     * @param string $directory
220     * @param int $levels
221     * @param int &$lockouts
222     * @return array|bool
223     */
224    protected function pickImageDir( $directory, $levels, &$lockouts ) {
225        if ( $levels <= 0 ) {
226            // $directory has regular files
227            return $this->pickImageFromDir( $directory, $lockouts );
228        }
229
230        $backend = $this->getBackend();
231        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
232
233        $key = $cache->makeGlobalKey(
234            'fancycaptcha-dirlist',
235            $backend->getDomainId(),
236            sha1( $directory )
237        );
238
239        // check cache
240        $dirs = $cache->get( $key );
241        if ( !is_array( $dirs ) || !count( $dirs ) ) {
242            // cache miss
243            $dirs = [];
244            // subdirs are actually present...
245            foreach ( $backend->getTopDirectoryList( [ 'dir' => $directory ] ) as $entry ) {
246                if ( ctype_xdigit( $entry ) && strlen( $entry ) == 1 ) {
247                    $dirs[] = $entry;
248                }
249            }
250            wfDebug( "Cache miss for $directory subdirectory listing.\n" );
251            if ( count( $dirs ) ) {
252                $cache->set( $key, $dirs, 86400 );
253            }
254        }
255
256        if ( !count( $dirs ) ) {
257            // Remove this directory if empty, so callers don't keep looking here
258            $backend->clean( [ 'dir' => $directory ] );
259            // none found
260            return false;
261        }
262
263        // pick a random subdir
264        $place = random_int( 0, count( $dirs ) - 1 );
265        // In case all dirs are not filled, cycle through the next digits...
266        $fancyCount = count( $dirs );
267        for ( $j = 0; $j < $fancyCount; $j++ ) {
268            $char = $dirs[( $place + $j ) % count( $dirs )];
269            $info = $this->pickImageDir( "$directory/$char", $levels - 1, $lockouts );
270            if ( $info ) {
271                // found a captcha
272                return $info;
273            } else {
274                wfDebug( "Could not find captcha in $directory.\n" );
275                // files changed on disk?
276                $cache->delete( $key );
277            }
278        }
279
280        // didn't find any images in this directory... empty?
281        return false;
282    }
283
284    /**
285     * @param string $directory
286     * @param int &$lockouts
287     * @return array|bool
288     */
289    protected function pickImageFromDir( $directory, &$lockouts ) {
290        $backend = $this->getBackend();
291        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
292
293        $key = $cache->makeGlobalKey(
294            'fancycaptcha-filelist',
295            $backend->getDomainId(),
296            sha1( $directory )
297        );
298
299        // check cache
300        $files = $cache->get( $key );
301        if ( !is_array( $files ) || !count( $files ) ) {
302            // cache miss
303            $files = [];
304            foreach ( $backend->getTopFileList( [ 'dir' => $directory ] ) as $entry ) {
305                $files[] = $entry;
306                if ( count( $files ) >= 500 ) {
307                    // sanity
308                    wfDebug( 'Skipping some captchas; $wgCaptchaDirectoryLevels set too low?.' );
309                    break;
310                }
311            }
312            if ( count( $files ) ) {
313                $cache->set( $key, $files, 86400 );
314            }
315            wfDebug( "Cache miss for $directory captcha listing.\n" );
316        }
317
318        if ( !count( $files ) ) {
319            // Remove this directory if empty, so callers don't keep looking here
320            $backend->clean( [ 'dir' => $directory ] );
321            return false;
322        }
323
324        $info = $this->pickImageFromList( $directory, $files, $lockouts );
325        if ( !$info ) {
326            wfDebug( "Could not find captcha in $directory.\n" );
327            // files changed on disk?
328            $cache->delete( $key );
329        }
330
331        return $info;
332    }
333
334    /**
335     * @param string $directory
336     * @param array $files
337     * @param int &$lockouts
338     * @return array|bool
339     */
340    protected function pickImageFromList( $directory, array $files, &$lockouts ) {
341        global $wgCaptchaDeleteOnSolve;
342
343        if ( !count( $files ) ) {
344            // none found
345            return false;
346        }
347
348        $backend = $this->getBackend();
349        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
350
351        // pick a random file
352        $place = random_int( 0, count( $files ) - 1 );
353        // number of files in listing that don't actually exist
354        $misses = 0;
355        $fancyImageCount = count( $files );
356        for ( $j = 0; $j < $fancyImageCount; $j++ ) {
357            $entry = $files[( $place + $j ) % count( $files )];
358            if ( preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $entry, $matches ) ) {
359                if ( $wgCaptchaDeleteOnSolve ) {
360                    // captcha will be deleted when solved
361                    $key = $cache->makeGlobalKey(
362                        'fancycaptcha-filelock',
363                        $backend->getDomainId(),
364                        sha1( $entry )
365                    );
366                    // Try to claim this captcha for 10 minutes (for the user to solve)...
367                    if ( ++$lockouts <= 10 && !$cache->add( $key, '1', 600 ) ) {
368                        // could not acquire (skip it to avoid race conditions)
369                        continue;
370                    }
371                }
372                if ( !$backend->fileExists( [ 'src' => "$directory/$entry" ] ) ) {
373                    if ( ++$misses >= 5 ) {
374                        // too many files in the listing don't exist
375                        // listing cache too stale? break out so it will be cleared
376                        break;
377                    }
378                    // try the next file
379                    continue;
380                }
381                return [
382                    'salt'   => $matches[1],
383                    'hash'   => $matches[2],
384                    'viewed' => false,
385                ];
386            }
387        }
388
389        // none found
390        return false;
391    }
392
393    /**
394     * Shows the image associated with the given captcha index for the user
395     */
396    public function showImage( IContextSource $context ): bool {
397        $context->getOutput()->disable();
398
399        $index = $context->getRequest()->getVal( 'wpCaptchaId' );
400        $info = $this->retrieveCaptcha( $index );
401        if ( $info ) {
402            $timestamp = new MWTimestamp();
403            $info['viewed'] = $timestamp->getTimestamp();
404            $this->storeCaptcha( $info );
405
406            $salt = $info['salt'];
407            $hash = $info['hash'];
408
409            return $this->getBackend()->streamFile( [
410                'src'     => $this->imagePath( $salt, $hash ),
411                'headers' => [ "Cache-Control: private, s-maxage=0, max-age=3600" ]
412            ] )->isOK();
413        }
414
415        wfHttpError( 400, 'Request Error', 'Requested bogus captcha image' );
416        return false;
417    }
418
419    /**
420     * @param string $salt
421     * @param string $hash
422     * @return string
423     */
424    public function imagePath( $salt, $hash ) {
425        global $wgCaptchaDirectoryLevels;
426
427        $file = $this->getBackend()->getRootStoragePath() . '/' . $this->getStorageDir() . '/';
428        for ( $i = 0; $i < $wgCaptchaDirectoryLevels; $i++ ) {
429            $file .= $hash[ $i ] . '/';
430        }
431        $file .= "image_{$salt}_{$hash}.png";
432
433        return $file;
434    }
435
436    /**
437     * @param string $basename
438     * @return array (salt, hash)
439     */
440    public function hashFromImageName( $basename ) {
441        if ( !preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $basename, $matches ) ) {
442            throw new InvalidArgumentException( "Invalid filename '$basename'.\n" );
443        }
444
445        return [
446            $matches[1],
447            $matches[2]
448        ];
449    }
450
451    /**
452     * Delete a solved captcha image, if $wgCaptchaDeleteOnSolve is true.
453     * @inheritDoc
454     */
455    protected function passCaptcha( $index, $word, $user ) {
456        global $wgCaptchaDeleteOnSolve;
457
458        if ( !$wgCaptchaDeleteOnSolve || $index === null ) {
459            return parent::passCaptcha( $index, $word, $user );
460        }
461
462        // get the captcha info before it gets deleted
463        $info = $this->retrieveCaptcha( $index );
464        $pass = parent::passCaptcha( $index, $word, $user );
465
466        if ( $pass ) {
467            $this->getBackend()->quickDelete( [
468                'src' => $this->imagePath( $info['salt'], $info['hash'] )
469            ] );
470        }
471
472        return $pass;
473    }
474
475    /**
476     * Returns an array with 'salt' and 'hash' keys. Hash is
477     * md5( $wgCaptchaSecret . $salt . $answer . $wgCaptchaSecret . $salt )[0..15]
478     * @return array
479     * @throws UnderflowException When a captcha image cannot be produced.
480     */
481    public function getCaptcha() {
482        $info = $this->pickImage();
483        if ( !$info ) {
484            throw new UnderflowException( 'Ran out of captcha images' );
485        }
486        return $info;
487    }
488
489    /**
490     * @param array $captchaData
491     * @param string $id
492     * @return string
493     */
494    public function getCaptchaInfo( $captchaData, $id ) {
495        return SpecialPage::getTitleFor( 'Captcha', 'image' )
496            ->getLocalURL( 'wpCaptchaId=' . urlencode( $id ) );
497    }
498
499    /**
500     * @param array $requests
501     * @param array $fieldInfo
502     * @param array &$formDescriptor
503     * @param string $action
504     */
505    public function onAuthChangeFormFields(
506        array $requests, array $fieldInfo, array &$formDescriptor, $action
507    ) {
508        /** @var CaptchaAuthenticationRequest $req */
509        $req =
510            AuthenticationRequest::getRequestByClass(
511                $requests,
512                CaptchaAuthenticationRequest::class,
513                true
514            );
515        if ( !$req ) {
516            return;
517        }
518
519        // HTMLFancyCaptchaField will include this
520        unset( $formDescriptor['captchaInfo' ] );
521
522        $formDescriptor['captchaWord'] = [
523            'class' => HTMLFancyCaptchaField::class,
524            'imageUrl' => $this->getCaptchaInfo( $req->captchaData, $req->captchaId ),
525            'label-message' => $this->getMessage( $this->action ),
526            'showCreateHelp' => in_array( $action, [
527                AuthManager::ACTION_CREATE,
528                AuthManager::ACTION_CREATE_CONTINUE
529            ], true ),
530        ] + $formDescriptor['captchaWord'];
531    }
532}