1<?php
2
3use dokuwiki\Extension\Plugin;
4use dokuwiki\plugin\captcha\FileCookie;
5use dokuwiki\Utf8\PhpString;
6
7/**
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Andreas Gohr <andi@splitbrain.org>
10 */
11
12/**
13 * Class helper_plugin_captcha
14 */
15class helper_plugin_captcha extends Plugin
16{
17    protected $field_in = 'plugin__captcha';
18    protected $field_sec = 'plugin__captcha_secret';
19    protected $field_hp = 'plugin__captcha_honeypot';
20
21    // region Public API
22
23    /**
24     * Constructor. Initializes field names
25     */
26    public function __construct()
27    {
28        $this->field_in = md5($this->fixedIdent() . $this->field_in);
29        $this->field_sec = md5($this->fixedIdent() . $this->field_sec);
30        $this->field_hp = md5($this->fixedIdent() . $this->field_hp);
31    }
32
33    /**
34     * Check if the CAPTCHA should be used. Always check this before using the methods below.
35     *
36     * @return bool true when the CAPTCHA should be used
37     */
38    public function isEnabled()
39    {
40        global $INPUT;
41        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) return false;
42        return true;
43    }
44
45    /**
46     * Returns the HTML to display the CAPTCHA with the chosen method
47     *
48     * @return string The HTML to display the CAPTCHA
49     */
50    public function getHTML()
51    {
52        global $ID;
53
54        $rand = (float)(random_int(0, 10000)) / 10000;
55        $cookie = new FileCookie($this->fixedIdent(), $rand);
56        $cookie->set();
57
58        if ($this->getConf('mode') == 'math') {
59            $code = $this->generateMath($this->fixedIdent(), $rand);
60            $code = $code[0];
61            $text = $this->getLang('fillmath');
62        } elseif ($this->getConf('mode') == 'question') {
63            $code = ''; // not used
64            $text = $this->getConf('question');
65        } else {
66            $code = $this->generateCaptchaCode($this->fixedIdent(), $rand);
67            $text = $this->getLang('fillcaptcha');
68        }
69        $secret = $this->encrypt($rand);
70
71        $txtlen = $this->getConf('lettercount');
72
73        $out = '';
74        $out .= '<div id="plugin__captcha_wrapper">';
75        $out .= '<input type="hidden" name="' . $this->field_sec . '" value="' . hsc($secret) . '" />';
76        $out .= '<label for="plugin__captcha">' . $text . '</label> ';
77
78        switch ($this->getConf('mode')) {
79            case 'math':
80            case 'text':
81                $out .= $this->obfuscateText($code);
82                break;
83            case 'js':
84                $out .= sprintf('<span id="plugin__captcha_code">%s</span>', $this->obfuscateText($code));
85                break;
86            case 'svg':
87                $out .= $this->htmlSvg($code);
88                break;
89            case 'svgaudio':
90                $out .= $this->htmlSvg($code);
91                $out .= $this->htmlAudioLink($secret, $ID);
92                break;
93            case 'image':
94                $out .= $this->htmlImage($ID, $secret);
95                break;
96            case 'audio':
97                $out .= $this->htmlImage($ID, $secret);
98                $out .= $this->htmlAudioLink($secret, $ID);
99                break;
100            case 'figlet':
101                $out .= $this->htmlFiglet($code);
102                break;
103        }
104        $out .= ' <input type="text" size="' . $txtlen . '" name="' . $this->field_in . '" class="edit" /> ';
105
106        // add honeypot field
107        $out .= sprintf(
108            '<label class="no">%s<input type="text" name="%s" /></label>',
109            $this->getLang('honeypot'),
110            $this->field_hp
111        );
112        $out .= '</div>';
113        return $out;
114    }
115
116    /**
117     * Checks if the CAPTCHA was solved correctly
118     *
119     * @param bool $msg when true, an error will be signalled through the msg() method
120     * @return bool true when the answer was correct, otherwise false
121     */
122    public function check($msg = true)
123    {
124        global $INPUT;
125
126        $field_sec = $INPUT->str($this->field_sec);
127        $field_in = $INPUT->str($this->field_in);
128        $field_hp = $INPUT->str($this->field_hp);
129
130        // reconstruct captcha from provided $field_sec
131        $rand = $this->decrypt($field_sec);
132
133        if ($this->getConf('mode') == 'math') {
134            $code = $this->generateMath($this->fixedIdent(), $rand);
135            $code = $code[1];
136        } elseif ($this->getConf('mode') == 'question') {
137            $code = $this->getConf('answer');
138        } else {
139            $code = $this->generateCaptchaCode($this->fixedIdent(), $rand);
140        }
141
142        // compare values
143        if (
144            !$field_sec ||
145            !$field_in ||
146            $rand === false ||
147            PhpString::strtolower($field_in) != PhpString::strtolower($code) ||
148            trim($field_hp) !== '' ||
149            !(new FileCookie($this->fixedIdent(), $rand))->check()
150        ) {
151            if ($msg) msg($this->getLang('testfailed'), -1);
152            return false;
153        }
154        return true;
155    }
156
157    // endregion
158
159    // region Captcha Generation methods
160
161    /**
162     * Build a semi-secret fixed string identifying the current page and user
163     *
164     * This string is always the same for the current user when editing the same
165     * page revision, but only for one day. Editing a page before midnight and saving
166     * after midnight will result in a failed CAPTCHA once, but makes sure it can
167     * not be reused which is especially important for the registration form where the
168     * $ID usually won't change.
169     *
170     * @return string
171     */
172    public function fixedIdent()
173    {
174        global $ID;
175        $lm = @filemtime(wikiFN($ID));
176        $td = date('Y-m-d');
177        $ip = clientIP();
178        $salt = auth_cookiesalt();
179
180        return sha1(implode("\n", [$ID, $lm, $td, $ip, $salt]));
181    }
182
183    /**
184     * Generate a magic code based on the given data
185     *
186     * This "magic" code represents the given fixed identifier (see fixedIdent()) and the given
187     * random number. It is used to generate the actual CAPTCHA code.
188     *
189     * @param $ident string the fixed part, any string
190     * @param $rand  float  some random number between 0 and 1
191     * @return string
192     */
193    protected function generateMagicCode($ident, $rand)
194    {
195        $ident = hexdec(substr(md5($ident), 5, 5)); // use part of the md5 to generate an int
196        $rand *= 0xFFFFF; // bitmask from the random number
197        $comb = (int)$rand ^ $ident; // combine both values
198        return md5($comb);
199    }
200
201    /**
202     * Generates a char string based on the given data
203     *
204     * The string is pseudo random based on a fixed identifier (see fixedIdent()) and a random number.
205     *
206     * @param $ident string the fixed part, any string
207     * @param $rand  float  some random number between 0 and 1
208     * @return string
209     */
210    public function generateCaptchaCode($ident, $rand)
211    {
212        $numbers = $this->generateMagicCode($ident, $rand);
213
214        // now create the letters
215        $code = '';
216        $lettercount = $this->getConf('lettercount') * 2;
217        if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
218        for ($i = 0; $i < $lettercount; $i += 2) {
219            $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65);
220        }
221
222        return $code;
223    }
224
225    /**
226     * Create a mathematical task and its result
227     *
228     * @param $ident string the fixed part, any string
229     * @param $rand  float  some random number between 0 and 1
230     * @return array [task, result]
231     */
232    protected function generateMath($ident, $rand)
233    {
234        $numbers = $this->generateMagicCode($ident, $rand);
235
236        // first letter is the operator (+/-)
237        $op = (hexdec($numbers[0]) > 8) ? -1 : 1;
238        $num = [hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])];
239
240        // we only want positive results
241        if (($op < 0) && ($num[0] < $num[1])) rsort($num);
242
243        // prepare result and task text
244        $res = $num[0] + ($num[1] * $op);
245        $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= ';
246
247        return [$task, $res];
248    }
249
250    // endregion
251
252    // region Output Builders
253
254    /**
255     * Create a CAPTCHA image
256     *
257     * @param string $text the letters to display
258     * @return string The image data
259     */
260    public function imageCaptcha($text)
261    {
262        $w = $this->getConf('width');
263        $h = $this->getConf('height');
264
265        $fonts = glob(__DIR__ . '/fonts/*.ttf');
266
267        // create a white image
268        $img = imagecreatetruecolor($w, $h);
269        $white = imagecolorallocate($img, 255, 255, 255);
270        imagefill($img, 0, 0, $white);
271
272        // add some lines as background noise
273        for ($i = 0; $i < 30; $i++) {
274            $color = imagecolorallocate($img, random_int(100, 250), random_int(100, 250), random_int(100, 250));
275            imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $color);
276        }
277
278        // draw the letters
279        $txtlen = strlen($text);
280        for ($i = 0; $i < $txtlen; $i++) {
281            $font = $fonts[array_rand($fonts)];
282            $color = imagecolorallocate($img, random_int(0, 100), random_int(0, 100), random_int(0, 100));
283            $size = random_int(floor($h / 1.8), floor($h * 0.7));
284            $angle = random_int(-35, 35);
285
286            $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
287            $cheight = $size + ($size * 0.5);
288            $y = floor($h / 2 + $cheight / 3.8);
289
290            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
291        }
292
293        ob_start();
294        imagepng($img);
295        $image = ob_get_clean();
296        imagedestroy($img);
297        return $image;
298    }
299
300    /**
301     * Generate an audio captcha
302     *
303     * @param string $text
304     * @return string The joined wav files
305     */
306    public function audioCaptcha($text)
307    {
308        global $conf;
309
310        $lc = __DIR__ . '/lang/' . $conf['lang'] . '/audio/';
311        $en = __DIR__ . '/lang/en/audio/';
312
313        $wavs = [];
314
315        $text = strtolower($text);
316        $txtlen = strlen($text);
317        for ($i = 0; $i < $txtlen; $i++) {
318            $char = $text[$i];
319            $file = $lc . $char . '.wav';
320            if (!@file_exists($file)) $file = $en . $char . '.wav';
321            $wavs[] = $file;
322        }
323
324        return $this->joinwavs($wavs);
325    }
326
327    /**
328     * Create an SVG of the given text
329     *
330     * @param string $text
331     * @return string
332     */
333    public function svgCaptcha($text)
334    {
335        require_once(__DIR__ . '/EasySVG.php');
336
337        $fonts = glob(__DIR__ . '/fonts/*.svg');
338
339        $x = 0; // where we start to draw
340        $y = 100; // our max height
341
342        $svg = new EasySVG();
343
344        // draw the letters
345        $txtlen = strlen($text);
346        for ($i = 0; $i < $txtlen; $i++) {
347            $char = $text[$i];
348            $size = random_int($y / 2, $y - $y * 0.1); // 50-90%
349            $svg->setFontSVG($fonts[array_rand($fonts)]);
350
351            $svg->setFontSize($size);
352            $svg->setLetterSpacing(round(random_int(1, 4) / 10, 2)); // 0.1 - 0.4
353            $svg->addText($char, $x, random_int(0, round($y - $size))); // random up and down
354
355            [$w] = $svg->textDimensions($char);
356            $x += $w;
357        }
358
359        $svg->addAttribute('width', $x . 'px');
360        $svg->addAttribute('height', $y . 'px');
361        $svg->addAttribute('viewbox', "0 0 $x $y");
362        return $svg->asXML();
363    }
364
365    /**
366     * Inline SVG showing the given code
367     *
368     * @param string $code
369     * @return string
370     */
371    protected function htmlSvg($code)
372    {
373        return sprintf(
374            '<span class="svg" style="width:%spx; height:%spx">%s</span>',
375            $this->getConf('width'),
376            $this->getConf('height'),
377            $this->svgCaptcha($code)
378        );
379    }
380
381    /**
382     * HTML for an img tag for the image captcha
383     *
384     * @param string $ID the page ID this is displayed on
385     * @param string $secret the encrypted random number
386     * @return string
387     */
388    protected function htmlImage($ID, $secret)
389    {
390        $img = DOKU_BASE . 'lib/plugins/captcha/img.php';
391        $param = buildURLparams([
392            'secret' => $secret,
393            'id' => $ID,
394        ]);
395
396        return sprintf(
397            '<img src="%s?%s" width="%d" height="%d" alt="" />',
398            $img,
399            $param,
400            $this->getConf('width'),
401            $this->getConf('height')
402        );
403    }
404
405    /**
406     * HTML for a link to the audio captcha
407     *
408     * @param string $secret the encrypted random number
409     * @param string $ID the page ID this is displayed on
410     * @return string
411     */
412    protected function htmlAudioLink($secret, $ID)
413    {
414
415        $url = DOKU_BASE . 'lib/plugins/captcha/wav.php';
416        $param = buildURLparams([
417            'secret' => $secret,
418            'id' => $ID,
419        ]);
420
421        $icon = inlineSVG(__DIR__ . '/ear-hearing.svg');
422
423        return sprintf(
424            '<a href="%s?%s" class="JSnocheck audiolink" title="%s" style="height: %spx">%s</a>',
425            $url,
426            $param,
427            $this->getLang('soundlink'),
428            $this->getConf('height'),
429            $icon
430        );
431    }
432
433    /**
434     * The HTML to show a figlet captcha
435     *
436     * @param string $code the code to display
437     * @return string
438     */
439    protected function htmlFiglet($code)
440    {
441        require_once(__DIR__ . '/figlet.php');
442        $figlet = new phpFiglet();
443        if ($figlet->loadfont(__DIR__ . '/figlet.flf')) {
444            return '<pre>' . rtrim($figlet->fetch($code)) . '</pre>';
445        } else {
446            msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
447        }
448        return 'FAIL';
449    }
450
451    // endregion
452
453    // region Utilities
454
455    /**
456     * Encrypt the given string with the cookie salt
457     *
458     * @param string $data
459     * @return string
460     */
461    public function encrypt($data)
462    {
463        $data = auth_encrypt($data, auth_cookiesalt());
464        return base64_encode($data);
465    }
466
467    /**
468     * Decrypt the given string with the cookie salt
469     *
470     * @param string $data
471     * @return string
472     */
473    public function decrypt($data)
474    {
475        $data = base64_decode($data);
476        if ($data === false || $data === '') return false;
477
478        return auth_decrypt($data, auth_cookiesalt());
479    }
480
481    /**
482     * Adds random space characters within the given text
483     *
484     * Keeps subsequent numbers without spaces (for math problem)
485     *
486     * @param $text
487     * @return string
488     */
489    protected function obfuscateText($text)
490    {
491        $new = '';
492
493        $spaces = [
494            "\r",
495            "\n",
496            "\r\n",
497            ' ',
498            "\xC2\xA0",
499            // \u00A0    NO-BREAK SPACE
500            "\xE2\x80\x80",
501            // \u2000    EN QUAD
502            "\xE2\x80\x81",
503            // \u2001    EM QUAD
504            "\xE2\x80\x82",
505            // \u2002    EN SPACE
506            //         "\xE2\x80\x83", // \u2003    EM SPACE
507            "\xE2\x80\x84",
508            // \u2004    THREE-PER-EM SPACE
509            "\xE2\x80\x85",
510            // \u2005    FOUR-PER-EM SPACE
511            "\xE2\x80\x86",
512            // \u2006    SIX-PER-EM SPACE
513            "\xE2\x80\x87",
514            // \u2007    FIGURE SPACE
515            "\xE2\x80\x88",
516            // \u2008    PUNCTUATION SPACE
517            "\xE2\x80\x89",
518            // \u2009    THIN SPACE
519            "\xE2\x80\x8A",
520            // \u200A    HAIR SPACE
521            "\xE2\x80\xAF",
522            // \u202F    NARROW NO-BREAK SPACE
523            "\xE2\x81\x9F",
524            // \u205F    MEDIUM MATHEMATICAL SPACE
525            "\xE1\xA0\x8E\r\n",
526            // \u180E    MONGOLIAN VOWEL SEPARATOR
527            "\xE2\x80\x8B\r\n",
528            // \u200B    ZERO WIDTH SPACE
529            "\xEF\xBB\xBF\r\n",
530        ];
531
532        $len = strlen($text);
533        for ($i = 0; $i < $len - 1; $i++) {
534            $new .= $text[$i];
535
536            if (!is_numeric($text[$i + 1])) {
537                $new .= $spaces[array_rand($spaces)];
538            }
539        }
540        $new .= $text[$len - 1];
541        return $new;
542    }
543
544
545    /**
546     * Join multiple wav files
547     *
548     * All wave files need to have the same format and need to be uncompressed.
549     * The headers of the last file will be used (with recalculated datasize
550     * of course)
551     *
552     * @link http://ccrma.stanford.edu/CCRMA/Courses/422/projects/WaveFormat/
553     * @link http://www.thescripts.com/forum/thread3770.html
554     */
555    protected function joinwavs($wavs)
556    {
557        $fields = implode(
558            '/',
559            [
560                'H8ChunkID',
561                'VChunkSize',
562                'H8Format',
563                'H8Subchunk1ID',
564                'VSubchunk1Size',
565                'vAudioFormat',
566                'vNumChannels',
567                'VSampleRate',
568                'VByteRate',
569                'vBlockAlign',
570                'vBitsPerSample'
571            ]
572        );
573
574        $data = '';
575        foreach ($wavs as $wav) {
576            $fp = fopen($wav, 'rb');
577            $header = fread($fp, 36);
578            $info = unpack($fields, $header);
579
580            // read optional extra stuff
581            if ($info['Subchunk1Size'] > 16) {
582                $header .= fread($fp, ($info['Subchunk1Size'] - 16));
583            }
584
585            // read SubChunk2ID
586            $header .= fread($fp, 4);
587
588            // read Subchunk2Size
589            $size = unpack('vsize', fread($fp, 4));
590            $size = $size['size'];
591
592            // read data
593            $data .= fread($fp, $size);
594        }
595
596        return $header . pack('V', strlen($data)) . $data;
597    }
598
599    // endregion
600}
601