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