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