/ captcha.php
captcha.php
   1  <?php
   2  
   3  require_once 'lib/userpwd.php';
   4  require_once 'lib/twister_captcha.php';
   5  
   6  define('TWISTER_FONT_PATH', '/usr/local/share/captcha/PCss_8x16_LE.gdf');
   7  
   8  define('TWISTER_USE_TICKET_CAPTCHA', true);
   9  define('TWISTER_HCAPTCHA_SITEKEY', '49d294fa-f15c-41fc-80ba-c2544c52ec2a');
  10  
  11  // Validity period for captcha in seconds
  12  define('TWISTER_TTL', 120);
  13  // Delay in seconds before requesting a new captcha
  14  define('TWISTER_COOLDOWN', 5);
  15  // Long cooldown for unsolved captchas
  16  define('TWISTER_COOLDOWN_LONG', 8);
  17  // Duration of unsolved sessions
  18  define('TWISTER_TTL_UNSOLVED', 600);
  19  // Pre-cooldowns
  20  define('TWISTER_HMAC_SECRET', 'y474c22ugpVtJD5gveDxcmMcL7DQFP+/w1FhVP9CYVM=');
  21  define('TWISTER_TICKET_TTL', 3600); // 60 minutes
  22  define('TWISTER_PRE_CD_THREAD', 300); // 5 minutes
  23  define('TWISTER_PRE_CD_REPLY', 60);
  24  define('TWISTER_PRE_CD_REPORT', 60);
  25  
  26  define('TWISTER_PRE_CD_PENALITY', 60);
  27  
  28  // Default number of characters for captchas
  29  define('TWISTER_CHARS', 5);
  30  define('TWISTER_CHARS_MIN', 4);
  31  define('TWISTER_CHARS_MAX', 6);
  32  
  33  // Userpwd reputation checks:
  34  // Number of posts to get static captchas
  35  define('TWISTER_PWD_STATIC_POSTS', 5);
  36  // Known time in minutes to get static captchas
  37  define('TWISTER_PWD_STATIC_KNOWN', 4320); // 3 days
  38  // Idle time above which slider captchas will be served
  39  define('TWISTER_PWD_SLIDER_IDLE', 86400); // 24 hours
  40  
  41  // Whether or not to check for bypassing credits
  42  define('TWISTER_ALLOW_NOOP', true);
  43  // Number of posts to be eligible for captcha bypassing
  44  define('TWISTER_PWD_NOOP_POSTS', 5);
  45  // Known time in minutes to be eligible for captcha bypassing
  46  define('TWISTER_PWD_NOOP_KNOWN', 4320); // 3 days
  47  
  48  // List of boards where bypassing credits are disabled.
  49  // Corresponds to the CAPTCHA_ALLOW_BYPASS board setting.
  50  const TWISTER_NO_NOOP_BOARDS = [
  51    'bant', 'pol', 'trash'
  52  ];
  53  
  54  // Covers 272814 (84.81 %) unique IPs and 10863 (79.15 %) unique bans
  55  // IPs %: 0.05 | Bans %: 0 | GR1 all: 0 | Any EU: false
  56  const TWISTER_WHITELIST_COUNTRIES = [
  57    'US', 'CA', 'GB', 'DE', 'AU', 'PL', 'IT', 'FR', 'FI', 'SE', 'ID', 'ES', 'NL',
  58    'PH', 'JP', 'MY', 'NO', 'IE', 'VN', 'NZ', 'HR', 'HU', 'AT', 'SG', 'PT', 'BE',
  59    'GR', 'DK', 'RS', 'CZ', 'TH', 'BG', 'CH', 'EE', 'LT', 'SK', 'SI', 'LV', 'TW',
  60    'IS',
  61  ];
  62  
  63  // Covers 73407 (29.95 %) unique IPs and 2501 (16.7 %) unique bans
  64  // IPs %: 2 | Bans %: 0 | GR1 all: 0 | Any EU: false
  65  const TWISTER_WHITELIST_ASNS = [
  66    21928, 6167,
  67  ];
  68  
  69  // Mobile ISPs
  70  const TWISTER_MOBILE_ASNS = [
  71    21928,6167,20057,26599,35228,206067,4775,22085,8359,10139,30689,52876,27895,
  72    31615,11315,28403,6614,25135,10030,45143,4230,20365,6306,12929,21450,264731,
  73    203995,29465,38466,4818,15480,13280,13335,132618,25106,4657,10631,29247,12716,
  74    262210,138384,8953,9146,31213,2497,5578,21575,28036,28469,132061,29975
  75  ];
  76  
  77  define('TWISTER_BAD_UA', '/headless|node-fetch|python-|java\/|jakarta|-perl|http-?client|-resty-|awesomium\//i');
  78  
  79  // Memcached server
  80  define('TWISTER_MEMCACHED_HOST', '127.0.0.1');
  81  define('TWISTER_MEMCACHED_PORT', 11211);
  82  
  83  // ---
  84  
  85  define('TWISTER_DOMAIN', ($_SERVER['HTTP_HOST'] === 'sys.4chan.org') ? '4chan.org' : '4channel.org');
  86  
  87  define('TWISTER_ERR_GENERIC', 'Internal Server Error');
  88  define('TWISTER_ERR_COOLDOWN', 'You have to wait a while before doing this again');
  89  define('TWISTER_ERR_PCD_THREAD', 'Please wait a while before making a thread');
  90  define('TWISTER_ERR_PCD_REPLY', 'Please wait a while before making a post');
  91  define('TWISTER_ERR_PCD_SIGNIN', 'Please wait for the timer<br>or verify your email address before making a post.<br><br><a href="https://sys.4chan.org/signin">Click here</a> for more information<br>or to verify your email.');
  92  
  93  // ---
  94  
  95  function twister_captcha_output_data($data) {
  96    if (isset($_GET['framed'])) {
  97      twister_captcha_output_html($data);
  98    }
  99    else {
 100      header('Content-Type: application/json');
 101      echo json_encode($data);
 102    }
 103  }
 104  
 105  function twister_captcha_output_html($data) {
 106    header('Content-Security-Policy: frame-ancestors https://*.' . TWISTER_DOMAIN . ';');
 107    $now = $_SERVER['REQUEST_TIME'];
 108  ?><!DOCTYPE html><html><head><meta charset="utf-8"><title></title>
 109  <script>window.parent.postMessage(<?php echo json_encode(['twister' => $data]) ?>, '*');</script>
 110  <script>document.cookie = `_tcs=${0|(Date.now()/1000)}.${new window.Intl.DateTimeFormat().resolvedOptions().timeZone}.<?php echo $now ?>.${window.eval.toString().length}; path=/; domain=sys.<?php echo TWISTER_DOMAIN ?>`;</script>
 111  </head><body></body></html><?php
 112  }
 113  
 114  function twister_captcha_output_dummy() {
 115  ?><!DOCTYPE html><html><head><meta charset="utf-8">
 116  <meta name="viewport" content="width=device-width, initial-scale=1.0"><title></title></head><body>
 117  <h3 style="text-align:center">You can now close this page and try getting a captcha again.<h3>
 118  </body></html><?php
 119  }
 120  
 121  function twister_captcha_output_ticket_captcha() {
 122    twister_captcha_output_data([ 'mpcd' => true, 'ticket' => false ]);
 123  }
 124  
 125  function twister_captcha_get_ticket_captcha_response() {
 126    if (isset($_GET['ticket_resp'])) {
 127      return $_GET['ticket_resp'];
 128    }
 129    
 130    return false;
 131  }
 132  
 133  function twister_captcha_get_hcaptcha_private_key() {
 134    $path = '/www/global/yotsuba/config/captcha_config.ini';
 135    
 136    $cfg = file_get_contents($path);
 137    
 138    if (!$cfg) {
 139      return false;
 140    }
 141    
 142    $res = preg_match('/^HCAPTCHA_API_KEY_PRIVATE ?= ?([^\s]+)$/m', $cfg, $m);
 143    
 144    if (!$res || empty($m) || !$m[1]) {
 145      return false;
 146    }
 147    
 148    return $m[1];
 149  }
 150  
 151  function twister_captcha_verify_ticket_captcha() {
 152    $response = twister_captcha_get_ticket_captcha_response();
 153    
 154    if (!$response || strlen($response) > 4096) {
 155      return false;
 156    }
 157    
 158    $captcha_private_key = twister_captcha_get_hcaptcha_private_key();
 159    
 160    if (!$captcha_private_key) {
 161      // Don't block in case of misconfiguration on our end
 162      return true;
 163    }
 164    
 165    $url = 'https://hcaptcha.com/siteverify';
 166    
 167    $post = array(
 168      'secret' => $captcha_private_key,
 169      'response' => $response,
 170      'remoteip' => $_SERVER['REMOTE_ADDR'],
 171      'sitekey' => TWISTER_HCAPTCHA_SITEKEY,
 172    );
 173    
 174    $curl = curl_init();
 175    
 176    curl_setopt($curl, CURLOPT_URL, $url);
 177    curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
 178    curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 2);
 179    curl_setopt($curl, CURLOPT_TIMEOUT, 4);
 180    curl_setopt($curl, CURLOPT_USERAGENT, '4chan');
 181    curl_setopt($curl, CURLOPT_POSTFIELDS, $post);
 182    
 183    $resp = curl_exec($curl);
 184    
 185    if ($resp === false) {
 186      curl_close($curl);
 187      return false;
 188    }
 189    
 190    $resp_status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
 191    
 192    if ($resp_status >= 300) {
 193      curl_close($curl);
 194      return false;
 195    }
 196    
 197    curl_close($curl);
 198    
 199    $json = json_decode($resp, true);
 200    
 201    // BAD
 202    if (json_last_error() !== JSON_ERROR_NONE) {
 203      return false;
 204    }
 205    
 206    // GOOD
 207    if ($json && isset($json['success']) && $json['success']) {
 208      return true;
 209    }
 210    
 211    // BAD
 212    return false;
 213  }
 214  
 215  function twister_captcha_process_ticket_captcha($pcd, $now, $long_ip, $board, $msg = null, $bypassable = false) {
 216    // Captcha response was sent, verify it
 217    if (twister_captcha_get_ticket_captcha_response()) {
 218      if (twister_captcha_verify_ticket_captcha() === true) {
 219        // Captcha is valid, return a new ticket
 220        twister_captcha_output_new_ticket($pcd, $now, $long_ip, $board, $msg, $bypassable);
 221      }
 222      else {
 223        // Wrong captcha or captcha malfunction
 224        twister_captcha_error(TWISTER_ERR_GENERIC . ' (tcr0)');
 225      }
 226    }
 227    // No captcha reponse provided, tell the frontend to show a ticket captcha
 228    else {
 229      twister_captcha_output_ticket_captcha();
 230    }
 231  }
 232  
 233  function twister_captcha_output_new_ticket($pcd, $now, $long_ip, $board, $msg = null, $bypassable = false) {
 234    $ticket = twister_captcha_generate_ticket($now, $long_ip, $board);
 235    
 236    if (!$ticket) {
 237      twister_captcha_error(TWISTER_ERR_GENERIC . ' (gt1)');
 238    }
 239    
 240    twister_captcha_output_ticket_pcd($ticket, $pcd, $msg, $bypassable);
 241  }
 242  
 243  function twister_captcha_output_ticket_pcd($ticket, $pcd, $msg, $bypassable) {
 244    $data = [];
 245    
 246    if ($ticket) {
 247      $data['ticket'] = $ticket;
 248    }
 249    
 250    $data['pcd'] = $pcd;
 251    
 252    if ($msg) {
 253      $data['pcd_msg'] = $msg;
 254    }
 255    
 256    if ($bypassable) {
 257      $data['bpcd'] = true;
 258    }
 259    
 260    twister_captcha_output_data($data);
 261  }
 262  
 263  function twister_captcha_error($msg, $extra = null) {
 264    //http_response_code(500);
 265    $data = [ 'error' => $msg ];
 266    
 267    if ($extra) {
 268      $data = array_merge($data, $extra);
 269    }
 270    
 271    twister_captcha_output_data($data);
 272    
 273    die();
 274  }
 275  
 276  function twister_captcha_is_req_suspicious() {
 277    /*
 278    if (isset($_SERVER['HTTP_X_HTTP_VERSION'])) {
 279      if (strpos($_SERVER['HTTP_X_HTTP_VERSION'], 'HTTP/1') === 0) {
 280        return true;
 281      }
 282    }
 283    */
 284    $no_lang = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) === false;
 285    $no_accept = isset($_SERVER['HTTP_ACCEPT']) === false;
 286    
 287    if ($no_lang && $no_accept) {
 288      return true;
 289    }
 290    
 291    if ($no_lang && strpos($_SERVER['HTTP_USER_AGENT'], '; wv)') !== false) {
 292      return true;
 293    }
 294    
 295    if ($no_accept && strpos($_SERVER['HTTP_USER_AGENT'], 'iPhone') !== false && strpos($_SERVER['HTTP_USER_AGENT'], 'Mobile') === false) {
 296      return true;
 297    }
 298    
 299    if ($no_lang && isset($_SERVER['HTTP_REFERER'])) {
 300      $ref = $_SERVER['HTTP_REFERER'];
 301      
 302      if (strpos($ref, 'sys.4chan.org') !== false || strpos($ref, '/thread/') !== false) {
 303        return true;
 304      }
 305    }
 306    
 307    return false;
 308  }
 309  
 310  function twister_captcha_need_hcaptcha() {
 311    if (!isset($_SERVER['HTTP_X_BOT_SCORE'])) {
 312      return true;
 313    }
 314    
 315    $ua = $_SERVER['HTTP_USER_AGENT'];
 316    
 317    $score = (int)$_SERVER['HTTP_X_BOT_SCORE'];
 318    
 319    // Skip Android Webviews
 320    if ($score == 1 && strpos($ua, '; wv)') !== false) {
 321      return false;
 322    }
 323    
 324    return $score < 99;
 325  }
 326  
 327  function twister_captcha_check_likely_automated($memcached, $now, $threshold = 29) {
 328    if (!isset($_SERVER['HTTP_X_BOT_SCORE'])) {
 329      return false;
 330    }
 331    
 332    $ua = $_SERVER['HTTP_USER_AGENT'];
 333    
 334    // Skip Android Webviews
 335    if (strpos($ua, '; wv)') !== false) {
 336      return false;
 337    }
 338    
 339    // Skip iPhone Webviews
 340    if (preg_match('/iPhone|iPad/', $ua) && !preg_match('/Mobile|Safari/', $ua)) {
 341      return false;
 342    }
 343    
 344    $score = (int)$_SERVER['HTTP_X_BOT_SCORE'];
 345    
 346    if ($score > 1 && $score <= $threshold) {
 347      $key = 'bmbot' . $_SERVER['REMOTE_ADDR'];
 348      $memcached->set($key, 1, $now + 43200);
 349      return true;
 350    }
 351    
 352    return false;
 353  }
 354  
 355  function twister_captcha_get_pcd_penalty($board, $thread_id) {
 356    $count = 0;
 357    
 358    return 0; // FIXME
 359    
 360    // Reports
 361    if ($thread_id === 1 && $board !== '!') {
 362      return 0;
 363    }
 364    
 365    if (isset($_SERVER['HTTP_X_GEO_ASN'])) {
 366      $asn = (int)$_SERVER['HTTP_X_GEO_ASN'];
 367    }
 368    else {
 369      $asn = 0;
 370    }
 371    
 372    if (isset($_SERVER['HTTP_X_GEO_COUNTRY'])) {
 373      $country = $_SERVER['HTTP_X_GEO_COUNTRY'];
 374    }
 375    else {
 376      $country = null;
 377    }
 378    
 379    // Mobile clients
 380    if ($asn > 0 && in_array($asn, TWISTER_MOBILE_ASNS)) {
 381      $count++;
 382    }
 383    else if (isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/Mobile|iPhone|; wv/', $_SERVER['HTTP_USER_AGENT'])) {
 384      $count++;
 385    }
 386    
 387    // Rare countries
 388    if ($country && !in_array($country, TWISTER_WHITELIST_COUNTRIES)) {
 389      $count++;
 390    }
 391    
 392    // Rare ISPs
 393    if ($asn > 0 && !in_array($asn, TWISTER_WHITELIST_ASNS)) {
 394      $count++;
 395    }
 396    
 397    return $count * TWISTER_PRE_CD_PENALITY;
 398  }
 399  
 400  function twister_captcha_store_challenge($memcached, $challenge_uid, $long_ip, $expiration) {
 401    if (!$challenge_uid || !$long_ip || !$expiration) {
 402      return false;
 403    }
 404    
 405    return $memcached->set("ch$long_ip", $challenge_uid, $expiration);
 406  }
 407  
 408  // Cooldowns
 409  function twister_captcha_store_cooldown($memcached, $long_ip, $expiration) {
 410    if (!$long_ip || !$expiration) {
 411      return false;
 412    }
 413    
 414    return $memcached->set("cd$long_ip", $expiration, $expiration);
 415  }
 416  
 417  function twister_captcha_get_cooldown($memcached, $long_ip) {
 418    if (!$long_ip) {
 419      return false;
 420    }
 421    
 422    $val = $memcached->get("cd$long_ip");
 423    
 424    if ($val === false && $memcached->getResultCode() === Memcached::RES_NOTFOUND) {
 425      return 0;
 426    }
 427    
 428    return $val;
 429  }
 430  
 431  function twister_captcha_generate_ticket($now, $long_ip, $board) {
 432    if (!$long_ip || !$now) {
 433      return false;
 434    }
 435    
 436    $hmac_secret = base64_decode(TWISTER_HMAC_SECRET);
 437    
 438    if (!$hmac_secret) {
 439      return false;
 440    }
 441    
 442    $hash = hash_hmac('sha256', "$now.$long_ip.$board", $hmac_secret);
 443    
 444    if (!$hash) {
 445      return false;
 446    }
 447    
 448    return "$now.$hash";
 449  }
 450  
 451  function twister_captcha_decode_ticket($ticket, $long_ip, $board) {
 452    if (!$long_ip || !$ticket) {
 453      return false;
 454    }
 455    
 456    $hmac_secret = base64_decode(TWISTER_HMAC_SECRET);
 457    
 458    if (!$hmac_secret) {
 459      return false;
 460    }
 461    
 462    list($ts, $hash) = explode('.', $ticket);
 463    
 464    $ts = (int)$ts;
 465    
 466    if (!$ts || !$hash) {
 467      return false;
 468    }
 469    
 470    $this_hash = hash_hmac('sha256', "$ts.$long_ip.$board", $hmac_secret);
 471    
 472    if ($this_hash === $hash) {
 473      return $ts;
 474    }
 475    
 476    return false;
 477  }
 478  
 479  function twister_captcha_should_purge_ticket($ticket, $now) {
 480    if (!$ticket || !$now) {
 481      return false;
 482    }
 483    
 484    list($ts, $hash) = explode('.', $ticket);
 485    
 486    if (!$ts) {
 487      return false;
 488    }
 489    
 490    return $ts + TWISTER_TICKET_TTL <= $now;
 491  }
 492  
 493  function twister_captcha_store_session($memcached, $long_ip, $count, $expiration) {
 494    if (!$long_ip || !$expiration || $count < 1) {
 495      return false;
 496    }
 497    
 498    $key = "us$long_ip";
 499    
 500    $res = $memcached->replace($key, $count, $expiration);
 501    
 502    if ($res === false) {
 503      if ($memcached->getResultCode() === Memcached::RES_NOTSTORED) {
 504        return $memcached->set($key, $count, $expiration);
 505      }
 506      else {
 507        return false;
 508      }
 509    }
 510    
 511    return true;
 512  }
 513  
 514  function twister_captcha_get_session($memcached, $long_ip) {
 515    if (!$long_ip) {
 516      return false;
 517    }
 518    
 519    $val = $memcached->get("us$long_ip");
 520    
 521    if ($val === false) {
 522      if ($memcached->getResultCode() === Memcached::RES_NOTFOUND) {
 523        return 0;
 524      }
 525      else {
 526        return false;
 527      }
 528    }
 529    
 530    return $val;
 531  }
 532  
 533  function twister_captcha_get_credits($memcached, $pwd) {
 534    if (!$pwd) {
 535      return false;
 536    }
 537    
 538    $key = "cr-$pwd";
 539    $val = $memcached->get($key);
 540    
 541    if ($val === false) {
 542      return false;
 543    }
 544    
 545    $val = explode('.', $val);
 546    
 547    $count = (int)$val[0];
 548    $ts = (int)$val[1];
 549    
 550    if ($count <= 0 || $ts <= 0) {
 551      $memcached->delete($key);
 552      return false;
 553    }
 554    
 555    return [ $count, $ts ];
 556  }
 557  
 558  function twister_captcha_get_userpwd($user_ip) {
 559    if (isset($_COOKIE['4chan_pass'])) {
 560      $_c = $_COOKIE['4chan_pass'];
 561    }
 562    else {
 563      $_c = null;
 564    }
 565    
 566    return new UserPwd($user_ip, TWISTER_DOMAIN, $_COOKIE['4chan_pass']);
 567  }
 568  
 569  // ---
 570  
 571  // Dummy page for cloudflare challenges
 572  if (isset($_GET['opened'])) {
 573    twister_captcha_output_dummy();
 574    die();
 575  }
 576  
 577  // ---
 578  
 579  // Block TOR immediately
 580  if ($_SERVER['HTTP_X_GEO_CONTINENT'] === 'T1') {
 581    twister_captcha_error(TWISTER_ERR_GENERIC . ' (T1)');
 582  }
 583  
 584  // Check parameters
 585  if (isset($_GET['board']) && preg_match('/^[!0-9a-z]{1,10}$/', $_GET['board'])) {
 586    $param_board = $_GET['board'];
 587  }
 588  else {
 589    $param_board = '!';
 590  }
 591  
 592  if (isset($_GET['thread_id']) && $_GET['thread_id']) {
 593    $param_thread_id = (int)$_GET['thread_id'];
 594  }
 595  else {
 596    $param_thread_id = 0;
 597  }
 598  
 599  $now = $_SERVER['REQUEST_TIME'];
 600  $user_ip = $_SERVER['REMOTE_ADDR'];
 601  $user_long_ip = ip2long($user_ip);
 602  
 603  if (!isset($_SERVER['HTTP_USER_AGENT'])) {
 604    $user_agent = '!';
 605  }
 606  else {
 607    $user_agent = md5($_SERVER['HTTP_USER_AGENT']);
 608  }
 609  
 610  $userpwd = twister_captcha_get_userpwd($user_ip);
 611  
 612  if (!$userpwd) {
 613    twister_captcha_error(TWISTER_ERR_GENERIC . ' (GU1)');
 614  }
 615  
 616  if (!$userpwd->isNew() && $param_board !== '!signin') {
 617    $password = $userpwd->getPwd();
 618  }
 619  else {
 620    $password = '!';
 621  }
 622  
 623  // ---
 624  
 625  $use_static = false;
 626  $difficulty = TwisterCaptcha::LEVEL_NORMAL;
 627  $char_count = TWISTER_CHARS_MAX;
 628  
 629  // ---
 630  
 631  $m = new Memcached();
 632  
 633  // Only call the following once (when getServerList() is empty) if using persistent connections
 634  //$m->setOption(Memcached::OPT_TCP_NODELAY, true);
 635  $m->setOption(Memcached::OPT_SERVER_FAILURE_LIMIT, 1);
 636  $m->setOption(Memcached::OPT_SEND_TIMEOUT, 500000); // 500ms
 637  $m->setOption(Memcached::OPT_RECV_TIMEOUT, 500000); // 500ms
 638  
 639  // Only use one server. Having multiple servers will break the captcha
 640  // as "set" is used instead of "replace + add"
 641  if ($m->addServer(TWISTER_MEMCACHED_HOST, TWISTER_MEMCACHED_PORT) === false) {
 642    twister_captcha_error(TWISTER_ERR_GENERIC . ' (c0)');
 643  }
 644  
 645  // ---
 646  
 647  // If CF's bot score is low, store the information for 1h and use it during posting
 648  twister_captcha_check_likely_automated($m, $now);
 649  
 650  /**
 651   * Pre-cooldowns
 652   */
 653  $pcd = 0;
 654  
 655  // Posting a thread
 656  if ($param_thread_id === 0) {
 657    if ($userpwd->threadCount() < 1 || ($userpwd->ipChanged() && $userpwd->postCount() < 2)) {
 658      $pcd = TWISTER_PRE_CD_THREAD;
 659    }
 660  }
 661  // Posting a reply (reporting uses a thread id of 1)
 662  else if ($param_thread_id !== 1) {
 663    if ($userpwd->postCount() < 1 || ($userpwd->ipChanged() && $userpwd->postCount() < 2)) {
 664      $pcd = TWISTER_PRE_CD_REPLY;
 665    }
 666  }
 667  // Reporting a post
 668  else if ($param_board !== '!') {
 669    if ($userpwd->reportCount() < 1 && $userpwd->postCount() < 1) {
 670      $pcd = TWISTER_PRE_CD_REPORT;
 671    }
 672  }
 673  
 674  if ($param_thread_id != 1 && !$userpwd->verifiedLevel()) {
 675    // Initial cooldown, bypassable by verifying your email
 676    if ($userpwd->postCount() < 1 || !$userpwd->isUserKnown(15)) {
 677      $pcd = 900;
 678    }
 679    // Cooldown for when the user is still new and the mask changes
 680    else if ($userpwd->postCount() > 0 && $userpwd->pwdLifetime() < 86400 && $userpwd->maskChanged()) { // 24h
 681      $pcd = 180;
 682    }
 683  }
 684  
 685  // Pre-cooldown needed
 686  if ($pcd > 0 && $param_board !== '!signin') {
 687    // Extra pre-cooldown for unverified users
 688    if (!$userpwd->verifiedLevel()) {
 689      $pcd += twister_captcha_get_pcd_penalty($param_board, $param_thread_id);
 690    }
 691    
 692    $ticket_ts = twister_captcha_decode_ticket($_GET['ticket'], $user_long_ip, $param_board);
 693    
 694    $bypassable = false;
 695    
 696    if ($param_thread_id === 0) {
 697      $pcd_msg = TWISTER_ERR_PCD_THREAD;
 698    }
 699    else if ($param_thread_id !== 1 && $param_board !== '!') {
 700      $pcd_msg = TWISTER_ERR_PCD_REPLY;
 701    }
 702    
 703    if ($param_thread_id !== 1 && $pcd >= 900) {
 704      $pcd_msg = TWISTER_ERR_PCD_SIGNIN;
 705      $bypassable = true;
 706    }
 707    
 708    if (!$ticket_ts) {
 709      //if (TWISTER_USE_TICKET_CAPTCHA && !in_array((int)$_SERVER['HTTP_X_GEO_ASN'], TWISTER_WHITELIST_ASNS)) {
 710      if (TWISTER_USE_TICKET_CAPTCHA && twister_captcha_need_hcaptcha()) {
 711        twister_captcha_process_ticket_captcha($pcd, $now, $user_long_ip, $param_board, $pcd_msg, $bypassable);
 712      }
 713      else {
 714        twister_captcha_output_new_ticket($pcd, $now, $user_long_ip, $param_board, $pcd_msg, $bypassable);
 715      }
 716      
 717      die();
 718    }
 719    
 720    $ticket_lifetime = $now - $ticket_ts;
 721    
 722    if ($ticket_lifetime < $pcd) {
 723      $pcd = $pcd - $ticket_lifetime;
 724      twister_captcha_output_ticket_pcd(null, $pcd, $pcd_msg, $bypassable);
 725      die();
 726    }
 727    
 728    // Ticket expired
 729    if ($ticket_lifetime >= TWISTER_TICKET_TTL) {
 730      if (TWISTER_USE_TICKET_CAPTCHA && twister_captcha_need_hcaptcha()) {
 731        twister_captcha_process_ticket_captcha($pcd, $now, $user_long_ip, $param_board, $pcd_msg, $bypassable);
 732      }
 733      else {
 734        twister_captcha_output_new_ticket($pcd, $now, $user_long_ip, $param_board, $pcd_msg, $bypassable);
 735      }
 736      
 737      die();
 738    }
 739  }
 740  
 741  /**
 742   * Adjust difficulty
 743   */
 744  
 745  $ip_ttl_static = TWISTER_PWD_TTL_IP_STATIC;
 746  $mask_ttl_static = TWISTER_PWD_TTL_MASK_STATIC;
 747  
 748  $ip_ttl_min = TWISTER_PWD_TTL_IP_MIN;
 749  $idle_ttl = TWISTER_PWD_TTL_IDLE;
 750  
 751  // Serve max len twister to bad actors
 752  $bad_actor = false;
 753  
 754  if (!isset($_SERVER['HTTP_USER_AGENT']) || !$_SERVER['HTTP_USER_AGENT']) {
 755    $bad_actor = true;
 756  }
 757  else if (preg_match(TWISTER_BAD_UA, $_SERVER['HTTP_USER_AGENT'])) {
 758    $bad_actor = true;
 759  }
 760  else if (twister_captcha_is_req_suspicious()) {
 761    $bad_actor = true;
 762  }
 763  
 764  // Serve static captcha to known users.
 765  // Serve max length captchas for unknown users.
 766  // Only applies to replies. Theads always use slider captchas.
 767  if ($param_thread_id !== 0 && !$bad_actor) {
 768    // Known and post count check
 769    if ($userpwd->isUserKnown(TWISTER_PWD_STATIC_KNOWN) && $userpwd->postCount() >= TWISTER_PWD_STATIC_POSTS) {
 770      // Inactivity and IP change checks for unverified users
 771      if ($userpwd->verifiedLevel()) {
 772        $use_static = true;
 773        $char_count = TWISTER_CHARS_MIN;
 774      }
 775      else if ($userpwd->idleLifetime() <= TWISTER_PWD_SLIDER_IDLE && !$userpwd->ipChanged()) {
 776        $use_static = true;
 777        $char_count = TWISTER_CHARS;
 778      }
 779    }
 780  }
 781  
 782  // Check captcha bypassing credits
 783  if (TWISTER_ALLOW_NOOP && !in_array($param_board, TWISTER_NO_NOOP_BOARDS) && !$bad_actor && $param_thread_id !== 0) {
 784    if ($userpwd->isUserKnown(TWISTER_PWD_NOOP_KNOWN) && $userpwd->postCount() >= TWISTER_PWD_NOOP_POSTS) {
 785      $credits = twister_captcha_get_credits($m, $userpwd->getPwd());
 786      
 787      if ($credits !== false && $credits[0] > 0) {
 788        $data = [
 789          'challenge' => 'noop',
 790          'ttl' => min(TWISTER_TTL, $credits[1] - $now),
 791          'cd' => TWISTER_COOLDOWN
 792        ];
 793        
 794        twister_captcha_output_data($data);
 795        
 796        die();
 797      }
 798    }
 799  }
 800  
 801  // Check cooldown
 802  $should_cd_until = twister_captcha_get_cooldown($m, $user_long_ip);
 803  
 804  if ($should_cd_until === false) {
 805    twister_captcha_error(TWISTER_ERR_GENERIC . ' (scdu)');
 806  }
 807  
 808  if ($should_cd_until > 1) {
 809    twister_captcha_error(TWISTER_ERR_COOLDOWN, ['cd' => $should_cd_until - $now]);
 810  }
 811  
 812  // Number of unsolved captchas requested recently
 813  $unsolved_count = twister_captcha_get_session($m, $user_long_ip);
 814  
 815  if ($unsolved_count === false) {
 816    twister_captcha_error(TWISTER_ERR_GENERIC . ' (gus1)');
 817  }
 818  
 819  if ($unsolved_count > 2) {
 820    $cooldown = TWISTER_COOLDOWN_LONG * min($unsolved_count, 20);
 821    
 822    if ($unsolved_count > 10) {
 823      $cooldown = 300;
 824    }
 825  }
 826  else {
 827    $cooldown = TWISTER_COOLDOWN;
 828  }
 829  
 830  if (twister_captcha_store_cooldown($m, $user_long_ip, $now + $cooldown) === false) {
 831    twister_captcha_error(TWISTER_ERR_GENERIC . ' (sc0)');
 832  }
 833  
 834  // Generate images
 835  $c = new TwisterCaptcha(TWISTER_FONT_PATH);
 836  $c->setDifficulty($difficulty);
 837  
 838  // Adjust features
 839  // User is suspicious
 840  $should_harden = $bad_actor && ($userpwd->ipChanged() || !$userpwd->isUserKnownOrVerified(7200) || mt_rand(0, 9) === 9);
 841  
 842  $should_fog = false;
 843  
 844  if ($should_harden) {
 845    $use_static = false;
 846    
 847    if (mt_rand(0, 1)) {
 848      $c->useInkBlot(true);
 849      
 850      if (mt_rand(0, 1)) {
 851        $c->useNegateBlotFilter(true);
 852      }
 853    }
 854    else {
 855      $c->useEdgeBlock(true);
 856    }
 857    
 858    $c->useFakeCharPadding(true);
 859    $c->useJumpyMode(true);
 860    
 861    if (mt_rand(0, 9) === 9) {
 862      $c->setDifficulty(TwisterCaptcha::LEVEL_LUNATIC);
 863    }
 864    else {
 865      $c->setDifficulty(TwisterCaptcha::LEVEL_HARD);
 866    }
 867    
 868    if (mt_rand(0, 9) === 9) {
 869      $char_count = 8;
 870    }
 871    
 872    if (mt_rand(0, 1)) {
 873      $c->useGridLines(true);
 874    }
 875    else {
 876      $c->useScoreLines(true);
 877    }
 878  }
 879  // Other new users
 880  else if ($userpwd->postCount() < 5 || $userpwd->maskLifetime() < 3600) {
 881    if (!$use_static) {
 882      $_boards = [ 'a', 'v', 'vg', 'co', 'vp', 'g', 'biz', 'b', 'vt', 'mu', 'pol', 'tv', 'sp', 'int', 'soc', 'test' ];
 883      
 884      if (true || in_array($param_board, $_boards) || $param_thread_id == 0) {
 885        $should_fog = true;
 886      }
 887      else {
 888        $c->useInkBlot(true);
 889        $c->useScoreLines(true);
 890      }
 891    }
 892    else {
 893      $c->useInkBlot(true);
 894      //$c->useFakeCharPadding(true);
 895      
 896      if (mt_rand(0, 1)) {
 897        $c->useJumpyMode(true);
 898      }
 899    }
 900    
 901    $char_count = mt_rand(TWISTER_CHARS, TWISTER_CHARS_MAX);
 902  }
 903  
 904  if ($param_board === '!signin') {
 905    $should_fog = true;
 906    $use_static = false;
 907  }
 908  
 909  if ($use_static) {
 910    if (mt_rand(0, 9) === 9) {
 911      $c->useScoreLines(true);
 912    }
 913    
 914    list($challenge_str, $img, $img_width, $img_height) = $c->generateStatic($char_count);
 915    $img_bg = null;
 916  }
 917  else {
 918    if ($should_fog) {
 919      if (false && $param_board == 'co' && $param_thread_id == 0) {
 920        $c->useEdgeDetect(true);
 921        $c->useSpecialRot(true);
 922        //$c->useOverlayId(5, true);
 923        $c->useInvert(true);
 924        //$c->useAltBlackWhite(true);
 925        $c->useGridLines(true);
 926        $c->useSimplexBg(true);
 927        //$c->useEmboss(true);
 928        //list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHFogNew($char_count);
 929        list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHFogNew(7, 28, 28);
 930        //list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterV(3);
 931      }
 932      else {
 933        $_olid = mt_rand(0, 6);
 934  
 935        if ($_olid) {
 936          $c->useOverlayId($_olid, (bool)mt_rand(0, 1));
 937        }
 938        
 939        if (mt_rand(0, 1)) {
 940          $c->useInvert(true);
 941        }
 942        
 943        if (mt_rand(0, 3) === 3) {
 944          $c->useFakeCharPadding(true);
 945        }
 946        
 947        if ($param_thread_id == 0) {
 948          $_olid = 2;
 949        }
 950        else {
 951          $_olid = mt_rand(1, 2);
 952        }
 953        
 954        $_olid = 2;
 955        
 956        // Simplex
 957        if ($_olid === 1) {
 958          if (mt_rand(0, 3) === 3) {
 959            $c->useScoreLines(true);
 960          }
 961          
 962          if (mt_rand(0, 1)) {
 963            $c->useInvert(true);
 964          }
 965          
 966          list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHSimplex($char_count);
 967        }
 968        // Fog New
 969        else if ($_olid === 2) {
 970          if (mt_rand(0, 1)) {
 971            $c->useSimplexBg(true);
 972          }
 973          
 974          if (mt_rand(0, 1)) {
 975            $c->useEmboss(true);
 976          }
 977          else {
 978            $c->useEdgeDetect(true);
 979          }
 980          
 981          if (mt_rand(0, 1)) {
 982            $c->useAltBlackWhite(true);
 983          }
 984          
 985          if (mt_rand(0, 1)) {
 986            $c->useInvert(true);
 987          }
 988          
 989          //if (mt_rand(0, 3) === 3) {
 990            //$c->useSpecialRot(true);
 991            //$char_count = 5;
 992          //}
 993          
 994          if (isset($_SERVER['HTTP_X_BOT_SCORE'])) {
 995            $_bot_score = (int)$_SERVER['HTTP_X_BOT_SCORE'];
 996          }
 997          else {
 998            $_bot_score = 100;
 999          }
1000          
1001          if ($param_board === '!signin') {
1002            if ($_bot_score > 95) {
1003              list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHFogNew(5, 30, 50);
1004            }
1005            else {
1006              list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateSimpleTask(2);
1007            }
1008          }
1009          else {
1010            //$c->useSpecialRot(true);
1011            
1012            if ($_bot_score <= 80 && mt_rand(0, 1) === 0) {
1013              list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateSimpleTask(2);
1014            }
1015            else {
1016              list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHFogNew(5, 30, 50);
1017            }
1018          }
1019        }
1020        // Default
1021        else {
1022          list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterHFogNew($char_count);
1023        }
1024      }
1025    }
1026    else {
1027      list($challenge_str, $img, $img_width, $img_height, $img_bg, $bg_width) = $c->generateTwisterH($char_count);
1028    }
1029  }
1030  
1031  if (!$challenge_str) {
1032    twister_captcha_error(TWISTER_ERR_GENERIC . ' (ncs1)');
1033  }
1034  
1035  list($challenge_uid, $challenge_hash) = TwisterCaptcha::getChallengeHash(
1036    $challenge_str,
1037    [$user_ip, $password, $user_agent, $param_board, $param_thread_id]
1038  );
1039  
1040  if (!$challenge_uid || !$challenge_hash) {
1041    twister_captcha_error(TWISTER_ERR_GENERIC . ' (gch1)');
1042  }
1043  
1044  // Register challenge
1045  if (twister_captcha_store_challenge($m, $challenge_uid, $user_long_ip, $now + TWISTER_TTL) === false) {
1046    twister_captcha_error(TWISTER_ERR_GENERIC . ' (sch1)');
1047  }
1048  
1049  // Register unsolved session
1050  if (twister_captcha_store_session($m, $user_long_ip, $unsolved_count + 1, $now + TWISTER_TTL_UNSOLVED) === false) {
1051    twister_captcha_error(TWISTER_ERR_GENERIC . ' (sus1)');
1052  }
1053  
1054  // Generate base 64 urls of images
1055  list($img_b64, $img_bg_b64) = TwisterCaptcha::getBase64Images($img, $img_bg);
1056  
1057  $data = [
1058    'challenge' => "$challenge_uid.$challenge_hash",
1059    'ttl' => TWISTER_TTL,
1060    'cd' => $cooldown,
1061    'img' => $img_b64,
1062    'img_width' => $img_width,
1063    'img_height' => $img_height
1064  ];
1065  
1066  if ($img_bg) {
1067    $data['bg'] = $img_bg_b64;
1068    $data['bg_width'] = $bg_width;
1069  }
1070  
1071  if (twister_captcha_should_purge_ticket($_GET['ticket'], $now)) {
1072    $data['ticket'] = false;
1073  }
1074  
1075  twister_captcha_output_data($data);