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 return md5($rand ^ $ident); // combine both values 198 } 199 200 /** 201 * Generates a char string based on the given data 202 * 203 * The string is pseudo random based on a fixed identifier (see fixedIdent()) and a random number. 204 * 205 * @param $ident string the fixed part, any string 206 * @param $rand float some random number between 0 and 1 207 * @return string 208 */ 209 public function generateCaptchaCode($ident, $rand) 210 { 211 $numbers = $this->generateMagicCode($ident, $rand); 212 213 // now create the letters 214 $code = ''; 215 $lettercount = $this->getConf('lettercount') * 2; 216 if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers); 217 for ($i = 0; $i < $lettercount; $i += 2) { 218 $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65); 219 } 220 221 return $code; 222 } 223 224 /** 225 * Create a mathematical task and its result 226 * 227 * @param $ident string the fixed part, any string 228 * @param $rand float some random number between 0 and 1 229 * @return array [task, result] 230 */ 231 protected function generateMath($ident, $rand) 232 { 233 $numbers = $this->generateMagicCode($ident, $rand); 234 235 // first letter is the operator (+/-) 236 $op = (hexdec($numbers[0]) > 8) ? -1 : 1; 237 $num = [hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])]; 238 239 // we only want positive results 240 if (($op < 0) && ($num[0] < $num[1])) rsort($num); 241 242 // prepare result and task text 243 $res = $num[0] + ($num[1] * $op); 244 $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= '; 245 246 return [$task, $res]; 247 } 248 249 // endregion 250 251 // region Output Builders 252 253 /** 254 * Create a CAPTCHA image 255 * 256 * @param string $text the letters to display 257 * @return string The image data 258 */ 259 public function imageCaptcha($text) 260 { 261 $w = $this->getConf('width'); 262 $h = $this->getConf('height'); 263 264 $fonts = glob(__DIR__ . '/fonts/*.ttf'); 265 266 // create a white image 267 $img = imagecreatetruecolor($w, $h); 268 $white = imagecolorallocate($img, 255, 255, 255); 269 imagefill($img, 0, 0, $white); 270 271 // add some lines as background noise 272 for ($i = 0; $i < 30; $i++) { 273 $color = imagecolorallocate($img, random_int(100, 250), random_int(100, 250), random_int(100, 250)); 274 imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $color); 275 } 276 277 // draw the letters 278 $txtlen = strlen($text); 279 for ($i = 0; $i < $txtlen; $i++) { 280 $font = $fonts[array_rand($fonts)]; 281 $color = imagecolorallocate($img, random_int(0, 100), random_int(0, 100), random_int(0, 100)); 282 $size = random_int(floor($h / 1.8), floor($h * 0.7)); 283 $angle = random_int(-35, 35); 284 285 $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen); 286 $cheight = $size + ($size * 0.5); 287 $y = floor($h / 2 + $cheight / 3.8); 288 289 imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]); 290 } 291 292 ob_start(); 293 imagepng($img); 294 $image = ob_get_clean(); 295 imagedestroy($img); 296 return $image; 297 } 298 299 /** 300 * Generate an audio captcha 301 * 302 * @param string $text 303 * @return string The joined wav files 304 */ 305 public function audioCaptcha($text) 306 { 307 global $conf; 308 309 $lc = __DIR__ . '/lang/' . $conf['lang'] . '/audio/'; 310 $en = __DIR__ . '/lang/en/audio/'; 311 312 $wavs = []; 313 314 $text = strtolower($text); 315 $txtlen = strlen($text); 316 for ($i = 0; $i < $txtlen; $i++) { 317 $char = $text[$i]; 318 $file = $lc . $char . '.wav'; 319 if (!@file_exists($file)) $file = $en . $char . '.wav'; 320 $wavs[] = $file; 321 } 322 323 return $this->joinwavs($wavs); 324 } 325 326 /** 327 * Create an SVG of the given text 328 * 329 * @param string $text 330 * @return string 331 */ 332 public function svgCaptcha($text) 333 { 334 require_once(__DIR__ . '/EasySVG.php'); 335 336 $fonts = glob(__DIR__ . '/fonts/*.svg'); 337 338 $x = 0; // where we start to draw 339 $y = 100; // our max height 340 341 $svg = new EasySVG(); 342 343 // draw the letters 344 $txtlen = strlen($text); 345 for ($i = 0; $i < $txtlen; $i++) { 346 $char = $text[$i]; 347 $size = random_int($y / 2, $y - $y * 0.1); // 50-90% 348 $svg->setFontSVG($fonts[array_rand($fonts)]); 349 350 $svg->setFontSize($size); 351 $svg->setLetterSpacing(round(random_int(1, 4) / 10, 2)); // 0.1 - 0.4 352 $svg->addText($char, $x, random_int(0, round($y - $size))); // random up and down 353 354 [$w] = $svg->textDimensions($char); 355 $x += $w; 356 } 357 358 $svg->addAttribute('width', $x . 'px'); 359 $svg->addAttribute('height', $y . 'px'); 360 $svg->addAttribute('viewbox', "0 0 $x $y"); 361 return $svg->asXML(); 362 } 363 364 /** 365 * Inline SVG showing the given code 366 * 367 * @param string $code 368 * @return string 369 */ 370 protected function htmlSvg($code) 371 { 372 return sprintf( 373 '<span class="svg" style="width:%spx; height:%spx">%s</span>', 374 $this->getConf('width'), 375 $this->getConf('height'), 376 $this->svgCaptcha($code) 377 ); 378 } 379 380 /** 381 * HTML for an img tag for the image captcha 382 * 383 * @param string $ID the page ID this is displayed on 384 * @param string $secret the encrypted random number 385 * @return string 386 */ 387 protected function htmlImage($ID, $secret) 388 { 389 $img = DOKU_BASE . 'lib/plugins/captcha/img.php'; 390 $param = buildURLparams([ 391 'secret' => $secret, 392 'id' => $ID, 393 ]); 394 395 return sprintf( 396 '<img src="%s?%s" width="%d" height="%d" alt="" />', 397 $img, 398 $param, 399 $this->getConf('width'), 400 $this->getConf('height') 401 ); 402 } 403 404 /** 405 * HTML for a link to the audio captcha 406 * 407 * @param string $secret the encrypted random number 408 * @param string $ID the page ID this is displayed on 409 * @return string 410 */ 411 protected function htmlAudioLink($secret, $ID) 412 { 413 $img = DOKU_BASE . 'lib/plugins/captcha/sound.png'; // FIXME use svg icon 414 $url = DOKU_BASE . 'lib/plugins/captcha/wav.php'; 415 $param = buildURLparams([ 416 'secret' => $secret, 417 'id' => $ID, 418 ]); 419 420 $icon = sprintf( 421 '<img src="%s" width="16" height="16" alt="%s" /></a>', 422 $img, 423 $this->getLang('soundlink') 424 ); 425 426 return sprintf( 427 '<a href="%s?%s" class="JSnocheck audiolink" title="%s">%s</a>', 428 $url, 429 $param, 430 $this->getLang('soundlink'), 431 $icon 432 ); 433 } 434 435 /** 436 * The HTML to show a figlet captcha 437 * 438 * @param string $code the code to display 439 * @return string 440 */ 441 protected function htmlFiglet($code) 442 { 443 require_once(__DIR__ . '/figlet.php'); 444 $figlet = new phpFiglet(); 445 if ($figlet->loadfont(__DIR__ . '/figlet.flf')) { 446 return '<pre>' . rtrim($figlet->fetch($code)) . '</pre>'; 447 } else { 448 msg('Failed to load figlet.flf font file. CAPTCHA broken', -1); 449 } 450 return 'FAIL'; 451 } 452 453 // endregion 454 455 // region Utilities 456 457 /** 458 * Encrypt the given string with the cookie salt 459 * 460 * @param string $data 461 * @return string 462 */ 463 public function encrypt($data) 464 { 465 $data = auth_encrypt($data, auth_cookiesalt()); 466 return base64_encode($data); 467 } 468 469 /** 470 * Decrypt the given string with the cookie salt 471 * 472 * @param string $data 473 * @return string 474 */ 475 public function decrypt($data) 476 { 477 $data = base64_decode($data); 478 if ($data === false || $data === '') return false; 479 480 return auth_decrypt($data, auth_cookiesalt()); 481 } 482 483 /** 484 * Adds random space characters within the given text 485 * 486 * Keeps subsequent numbers without spaces (for math problem) 487 * 488 * @param $text 489 * @return string 490 */ 491 protected function obfuscateText($text) 492 { 493 $new = ''; 494 495 $spaces = [ 496 "\r", 497 "\n", 498 "\r\n", 499 ' ', 500 "\xC2\xA0", 501 // \u00A0 NO-BREAK SPACE 502 "\xE2\x80\x80", 503 // \u2000 EN QUAD 504 "\xE2\x80\x81", 505 // \u2001 EM QUAD 506 "\xE2\x80\x82", 507 // \u2002 EN SPACE 508 // "\xE2\x80\x83", // \u2003 EM SPACE 509 "\xE2\x80\x84", 510 // \u2004 THREE-PER-EM SPACE 511 "\xE2\x80\x85", 512 // \u2005 FOUR-PER-EM SPACE 513 "\xE2\x80\x86", 514 // \u2006 SIX-PER-EM SPACE 515 "\xE2\x80\x87", 516 // \u2007 FIGURE SPACE 517 "\xE2\x80\x88", 518 // \u2008 PUNCTUATION SPACE 519 "\xE2\x80\x89", 520 // \u2009 THIN SPACE 521 "\xE2\x80\x8A", 522 // \u200A HAIR SPACE 523 "\xE2\x80\xAF", 524 // \u202F NARROW NO-BREAK SPACE 525 "\xE2\x81\x9F", 526 // \u205F MEDIUM MATHEMATICAL SPACE 527 "\xE1\xA0\x8E\r\n", 528 // \u180E MONGOLIAN VOWEL SEPARATOR 529 "\xE2\x80\x8B\r\n", 530 // \u200B ZERO WIDTH SPACE 531 "\xEF\xBB\xBF\r\n", 532 ]; 533 534 $len = strlen($text); 535 for ($i = 0; $i < $len - 1; $i++) { 536 $new .= $text[$i]; 537 538 if (!is_numeric($text[$i + 1])) { 539 $new .= $spaces[array_rand($spaces)]; 540 } 541 } 542 $new .= $text[$len - 1]; 543 return $new; 544 } 545 546 547 /** 548 * Join multiple wav files 549 * 550 * All wave files need to have the same format and need to be uncompressed. 551 * The headers of the last file will be used (with recalculated datasize 552 * of course) 553 * 554 * @link http://ccrma.stanford.edu/CCRMA/Courses/422/projects/WaveFormat/ 555 * @link http://www.thescripts.com/forum/thread3770.html 556 */ 557 protected function joinwavs($wavs) 558 { 559 $fields = implode( 560 '/', 561 [ 562 'H8ChunkID', 563 'VChunkSize', 564 'H8Format', 565 'H8Subchunk1ID', 566 'VSubchunk1Size', 567 'vAudioFormat', 568 'vNumChannels', 569 'VSampleRate', 570 'VByteRate', 571 'vBlockAlign', 572 'vBitsPerSample' 573 ] 574 ); 575 576 $data = ''; 577 foreach ($wavs as $wav) { 578 $fp = fopen($wav, 'rb'); 579 $header = fread($fp, 36); 580 $info = unpack($fields, $header); 581 582 // read optional extra stuff 583 if ($info['Subchunk1Size'] > 16) { 584 $header .= fread($fp, ($info['Subchunk1Size'] - 16)); 585 } 586 587 // read SubChunk2ID 588 $header .= fread($fp, 4); 589 590 // read Subchunk2Size 591 $size = unpack('vsize', fread($fp, 4)); 592 $size = $size['size']; 593 594 // read data 595 $data .= fread($fp, $size); 596 } 597 598 return $header . pack('V', strlen($data)) . $data; 599 } 600 601 // endregion 602} 603