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