<?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 } ?>