ryoakg
1/20/2017 - 2:58 PM

captcha.php

<?php
ensure_font_exists();
ensure_background_image_exists();

if (PHP_SAPI === 'cli'){
  echo 'if you want to execute the program in web app:' . PHP_EOL;
  echo "  run: php -S localhost:8080 {$argv[0]}" . PHP_EOL;
  echo '  browse http://localhost:8080/' . PHP_EOL . PHP_EOL;

  list($img, $code) = captcha();
  echo "code: {$code}" . PHP_EOL;
  display_image($img);
  // imagepng($img, 't.png');
  return;
}

// web app routing
session_start();
switch(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)){
  case '/captcha-image':
    list($img, $code) = captcha();
    $_SESSION['captcha-code'] = $code;
    header('Content-Type: image/png');
    header('Cache-Control: no-cache');
    imagepng($img);
    return;
  case '/verify':
    if (isset($_POST['captcha-code']) && isset($_SESSION['captcha-code']) &&
        ($_SESSION['captcha-code'] === $_POST['captcha-code'])) {
      $_SESSION['verified'] = true;
      /* header('Location: http://www.google.co.jp') ;*/
      header('Location: /') ;
    } else {
      unset($_SESSION['verified']);
      header('Content-Type: text/plain');
      echo "verification failed!";
    }
    return;
  case '/verify-api':
    switch($_SERVER['REQUEST_METHOD']){
      case 'GET':
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(isset($_SESSION['verified']) && $_SESSION['verified']);
        return;
      case 'POST':
        header('Content-Type: application/json; charset=utf-8');
        if (isset($_POST['captcha-code']) && isset($_SESSION['captcha-code']) &&
            ($_SESSION['captcha-code'] === $_POST['captcha-code'])) {
          $_SESSION['verified'] = true;
          echo json_encode(true);
        } else {
          unset($_SESSION['verified']);
          http_response_code(401);
          echo json_encode(false);
        }
        return;
      case 'DELETE':
        unset($_SESSION['verified']);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(false);
        return;
    }
  default:
    render_verification_html();
}

// the composed captcha generator with configurations.
function captcha() {
  // CONFIG
  static $scale = 5;
  static $alpha; if (! $alpha) $alpha = (int)(20 / 100 * 127);
  static $width = 215;
  static $height = 80;
  static $noise_level = 6; // 1 〜 10
  static $font_ratio = 0.4; // 0.1 < $font_ratio <= 1
  static $num_lines    = 5;
  static $perturbation = 0.85;
  static $background_image_file = './background-image';
  static $ttf_file = './font.ttf';

  $img = imagecreate($width, $height);

  // $bg_color        = rrggbb_color($img, 'ffffff');
  // $text_color      = rrggbb_color($img, '616161');
  // $line_color      = rrggbb_color($img, '616161');
  // $noise_color     = rrggbb_color($img, '616161');

  $bg_color        = rrggbb_alpha_color($img, 'ffffff', $alpha);
  $text_color      = rrggbb_alpha_color($img, '515151', $alpha);
  $line_color      = rrggbb_alpha_color($img, '616161', $alpha);
  $noise_color     = rrggbb_alpha_color($img, '616161', $alpha);


  // the procedure starts here.
  imagefilledrectangle($img, 0,0,imagesx($img), imagesy($img), $bg_color);
  render_background_image($img, $background_image_file);

  $word = generate_secure_random_string(5);

  try{
    $distortion_buff = imagecreate($width * $scale, $height * $scale);
    imagepalettecopy($distortion_buff, $img);
    imagefilledrectangle($distortion_buff,
                         0, 0, imagesx($distortion_buff), imagesy($distortion_buff),
                         $bg_color);
    render_noise($distortion_buff, $noise_color, $noise_level);
    render_word($distortion_buff, $text_color, $word, $ttf_file, $font_ratio);

    foreach(range(1,3) as $n)
    $poles[] = generate_random_pole($width, $height, $perturbation);
    copy_distorted_image($img, $distortion_buff, $poles, $scale, $bg_color);
  } finally{
    imagedestroy($distortion_buff);
  }

  foreach(range(0,$num_lines-1) as $n) {
    $vec = generate_random_vector_parameter($width, $height, $n, $num_lines);
    $wave = generate_random_sine_wave_parameter($vec['length']);
    $line_width = generate_random_line_width();
    render_sine_wave($img, $line_color, $vec, $wave, $line_width);
  }

  return [$img, $word];
}

// COLOR
function extract_color_from_rrggbb($rrggbb){
  return [
    'r'=> hexdec(substr($rrggbb, 0, 2)),
    'g'=> hexdec(substr($rrggbb, 2, 2)),
    'b'=> hexdec(substr($rrggbb, 4, 2)),
  ];
}

function rrggbb_color($img, $rrggbb){
  $color = extract_color_from_rrggbb($rrggbb);
  return imagecolorallocate($img, $color['r'], $color['g'], $color['b']);
}

function rrggbb_alpha_color($img, $rrggbb, $alpha){
  if ($alpha < 0 || 127 < $alpha) throw new Exception('alpha range over: '. $alpha);
  $color = extract_color_from_rrggbb($rrggbb);
  return imagecolorallocatealpha($img, $color['r'], $color['g'], $color['b'], $alpha);
}

// RANDOM
function generate_random_float() {
  return 0.0001 * mt_rand(0,9999);
}

function generate_secure_random_string($length){
  static $alphabet;
  static $mask;
  static $bit_width;
  if (! $alphabet){
    // 見分けが付きやすい 32文字をピックアップ
    // 速度と予測困難性を重視して、出てくる文字のバランスは軽視
    // 難しくする場合は、$length を増やす方向で
    $alphabet = array_merge(['a','m','4','5','7','9'], range('A', 'Z'));
    $mask = count($alphabet) - 1;
    // http://stackoverflow.com/questions/16848931/how-to-fastest-count-the-number-of-set-bits-in-php?answertab=active#tab-top
    // みたいの実装してもいいけど、32なので 5bitという事
    $bit_width = 5;
  }

  $shifted = 1 << ($length * $bit_width);
  if ($shifted === 0) throw new Exception('length is too long.');
  $n = random_int(0, $shifted - 1);
  $r = '';
  foreach (range(1,$length) as $_x){
    $r .= $alphabet[$mask & $n];
    $n >>= $bit_width;
  }
  return $r;
}

// various renderers
function render_noise($img, $color, $noise_level){
  $width  = imagesx($img);
  $height = imagesy($img);
  $noise_level *= 125; // an arbitrary number that works well on a 1-10 scale
  for ($i = 0; $i < $noise_level; ++$i) {
    $x = mt_rand(10, $width);
    $y = mt_rand(10, $height);
    $size = mt_rand(7, 10);
    if ($x - $size <= 0 && $y - $size <= 0) continue; // dont cover 0,0 since it is used by imagedistortedcopy
    imagefilledarc($img, $x, $y, $size, $size, 0, 360, $color, IMG_ARC_PIE);
  }
}

function render_word($img, $color, $word, $ttf_file, $ratio){
  $width  = imagesx($img);
  $height = imagesy($img);
  $font_size = $height * $ratio;

  list($left_bottom_x,  $left_bottom_y,
       $right_bottom_x, $right_bottom_y,
       $right_top_x,    $right_top_y) = imageftbbox($font_size, 0, $ttf_file, $word);
  $x = floor($width  / 2 - ($right_top_x - $left_bottom_x) / 2 - $left_bottom_x);
  $y = round($height / 2 - ($right_top_y - $left_bottom_y) / 2 - $left_bottom_y);

  imagettftext($img, $font_size, 0, (int)$x, (int)$y, $color, $ttf_file, $word);
}

function create_image_from_file($file){
  static $fs = [IMAGETYPE_GIF   => 'imagecreatefromgif',
                IMAGETYPE_JPEG  => 'imagecreatefromjpeg',
                IMAGETYPE_PNG   => 'imagecreatefrompng',
                IMAGETYPE_WBMP  => 'imagecreatefromwbmp',
                IMG_XPM         => 'imagecreatefromxpm',
                // IMAGETYPE_WEBP  => 'imagecreatefromwebp',
                IMAGETYPE_BMP   => 'imagecreatefrombmp'];
  $a = getimagesize($file);
  if (isset($fs[$a[2]])) return $fs[$a[2]]($file);
  throw new Exception('unsupported image type: '. $a[2]);
}

function render_background_image($dest, $file){
  try{
    $bg_img = create_image_from_file($file);
    imagecopyresized($dest, $bg_img,
                     0, 0, 0, 0,
                     imagesx($dest), imagesy($dest),
                     imagesx($bg_img), imagesy($bg_img));
  }finally{
    imagedestroy($bg_img);
  }
}

// SINE WAVE
function generate_random_vector_parameter($width, $height, $nth, $num_lines){
  return [
    'x'      => $width * (1 + $nth) / ($num_lines + 1) +
              (0.5 - generate_random_float()) * $width / $num_lines,
    'y'      => mt_rand($height * 0.1, $height * 0.9),
    'length' => mt_rand($width * 0.4, $width * 0.7),
    'theta'  => (generate_random_float() - 0.5) * M_PI * 0.7,
  ];
}

function generate_random_sine_wave_parameter($length){
  $omega0 = generate_random_float() * 0.6 + 0.2;
  $omega = $omega0 * $omega0 * 0.5;
  return [
    'phi'       => generate_random_float() * 6.28,      // 6.28 ≒ π x 2
    'omega'     => $omega,              // angular rate
    'amplitude' => 1.5 * generate_random_float() / ($omega + 5.0 / $length),
  ];
}

function generate_random_line_width(){
  return mt_rand(0, 2);
}

function render_sine_wave($img, $color, $vector, $sine_wave, $line_width){
  static $step = 0.5;
  $theta = $vector['theta'];
  $length = $vector['length'];
  $amplitude = $sine_wave['amplitude'];
  $phi = $sine_wave['phi'];

  $x0 = $vector['x'] - 0.5 * $length * cos($theta);
  $y0 = $vector['y'] - 0.5 * $length * sin($theta);
  $dx = $step * cos($theta);
  $dy = $step * sin($theta);
  $do = $step * $sine_wave['omega'];

  $n = $length / $step;
  for ($i = 0; $i < $n; ++ $i) {
    $x = $x0 + $dx * $i + $amplitude * $dy * sin($do * $i + $phi);
    $y = $y0 + $dy * $i - $amplitude * $dx * sin($do * $i + $phi);
    imagefilledrectangle($img, $x, $y, $x + $line_width, $y + $line_width, $color);
  }
}

// DISTORTION
function generate_random_pole($width, $height, $perturbation){
  return [
    'x' => mt_rand($width  * 0.2, $width  * 0.8),
    'y' => mt_rand($height * 0.2, $height * 0.8),
    'rad' => mt_rand($height * 0.2, $height * 0.8),
    'amp' => $perturbation * ((generate_random_float() * -0.15) - .15),
  ];
}

function transform_point_with_poles($x,$y,$poles){
  $ix = $x;
  $iy = $y;
  foreach($poles as $pole) {
    $dx = $ix - $pole['x'];
    $dy = $iy - $pole['y'];
    if ($dx === 0 && $dy === 0) continue;

    $r = sqrt($dx * $dx + $dy * $dy);
    if ($r > $pole['rad']) continue;

    $rscale = $pole['amp'] * sin(M_PI * $r / $pole['rad']);
    $x += $dx * $rscale;
    $y += $dy * $rscale;
  }
  return [$x, $y];
}

function copy_distorted_image($dest, $src, $poles, $scale, $bg_color){
  $src_w = imagesx($src);
  $src_h = imagesy($src);

  foreach(range(0, imagesx($dest)-1) as $dest_x)
  foreach(range(0, imagesy($dest)-1) as $dest_y) {
    list($x, $y) = transform_point_with_poles($dest_x, $dest_y, $poles);
    $x *= $scale; $y *= $scale;
    if ((0 <= $x && $x < $src_w) && (0 <= $y && $y < $src_h) &&
        ($c = imagecolorat($src, $x, $y)) !== $bg_color)
    imagesetpixel($dest, $dest_x, $dest_y, $c);
  }
}

// DEBUG
function display_image($img){
  echo "press `q` to quit `display`." . PHP_EOL;
  try{
    // `display` command from ImageMagick
    $fp = popen('display', 'w');
    imagepng($img, $fp);
  } finally {
    pclose($fp);
  }
}

// utils
function ensure_font_exists(){
  // You can find fonts on https://github.com/google/fonts
  // You can preview fonts on https://fonts.google.com/
  static $ttf_src = 'https://github.com/google/fonts/raw/master/apache/chewy/Chewy.ttf';
  if (! file_exists('font.ttf'))
    file_put_contents('font.ttf', file_get_contents($ttf_src));
}
function ensure_background_image_exists(){
  static $img_src = 'https://upload.wikimedia.org/wikipedia/commons/f/f6/White-noise-mv255-240x180.png';
  if (! file_exists('background-image'))
    file_put_contents('background-image', file_get_contents($img_src));
}

// web app
function render_verification_html(){
?>
  <html>
    <head>
      <title>CAPTCHA</title>
      <style>
       @import url(https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css);
      </style>
      <script>
       // ajax
       function delete_remote_verification_status(cont){
         var xhr = new XMLHttpRequest();
         xhr.onload = function(ev){
           if (200 <= xhr.status && xhr.status < 300) cont(xhr.response);
           else console.log('remote verification status deletion failed.');
         };
         xhr.timeout = function(ev){
           console.log('remote verification status deletion timeout.');
           xhr.abort();
         }
         xhr.open('DELETE', '/verify-api', true);
         xhr.responseType = 'json';
         xhr.send();
       }
       function get_remote_verification_status(cont){
         var xhr = new XMLHttpRequest();
         xhr.onload = function(ev){
           if (200 <= xhr.status && xhr.status < 300) cont(xhr.response);
           else console.log('getting remote verification status failed.');
         };
         xhr.timeout = function(ev){
           console.log('getting remote verification status timeout.');
           xhr.abort();
         }
         xhr.open('GET', '/verify-api', true);
         xhr.responseType = 'json';
         xhr.send();
       }
       function verify_with_ajax(){
         var code = document.getElementById('captcha-code').value;
         var xhr = new XMLHttpRequest();
         xhr.onload = function(ev){
           var r = (200 <= xhr.status && xhr.status < 300);
           alert('verification ' + (r ? 'succeeded!' : 'failed!'));
           set_verification_status(r);
           refresh_captcha_image();
           clear_captcha_code();
         };
         xhr.timeout = function(ev){
           alert('timeout!');
           refresh_captcha_image();
           clear_captcha_code();
           xhr.abort();
         }
         xhr.open('POST', '/verify-api', true);
         xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
         xhr.responseType = 'json';
         xhr.send('captcha-code='+code);
       }

       // UI
       function refresh_captcha_image(){
         document.getElementById('captcha').src = '/captcha-image?' + Math.random();
       }
       function clear_captcha_code(){
         document.getElementById('captcha-code').value = '';
       }
       function set_verification_status(status){
         document.getElementById('verification-status').className = status ? 'fa fa-check-square-o' : 'fa fa-square-o';
       }
      </script>
    </head>
    <body>
      <div>
        <form action="/verify" method="post">
          <img id="captcha" src="/captcha-image" alt="CAPTCHA Image" /><br />
          <input type="text" name="captcha-code" id="captcha-code" size="10" maxlength="6" />
          <a href="#" onclick="refresh_captcha_image()"><i class="fa fa-refresh" aria-hidden="true"></i></a>
          <input type="submit" value="verify">
        </form>
        <div><i id="verification-status" class="fa fa-square-o"></i> verified</div>
        <button type="button" onclick="verify_with_ajax()">verify with ajax</button>
        <button type="button" onclick="delete_remote_verification_status(set_verification_status);">delete verification status</button>
      </div>
      <script>
       get_remote_verification_status(set_verification_status);
      </script>
    </body>
  </html>
<?php } ?>