<?php
// captcha_form_fixed.php — single-file CAPTCHA + AJAX verify + final post-create + form
declare(strict_types=1);


// ---------------- CONFIG ----------------
$width = 160; $height = 50;
$length = 6;
$font = __DIR__ . '/fonts/DejaVuSans-Bold.ttf';
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';

$MAX_FAILURES_BLOCK = 10;
$COOLDOWN_SECONDS = 60;
$CAPTCHA_TTL = 300; // 5 minutes
$TRUSTED_PROXIES = []; // e.g. ['127.0.0.1', '::1']
$WHITELISTED_IPS = ['127.0.0.1']; // e.g. ['1.2.3.4', '2001:db8::1']

// --- Whitelist early-return (place right after config) ---
function _get_client_ip(array $trusted_proxies = []) {
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $xff = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
        foreach ($xff as $candidate) {
            if (!in_array($candidate, $trusted_proxies, true)) { $ip = $candidate; break; }
        }
    }
    return $ip;
}

function _ip_in_cidr($ip, $cidr) {
    if (strpos($cidr, '/') === false) return $ip === $cidr;
    list($subnet, $mask) = explode('/', $cidr);
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        $ip_long  = ip2long($ip);
        $sub_long = ip2long($subnet);
        $mask_long = -1 << (32 - (int)$mask);
        return ($ip_long & $mask_long) === ($sub_long & $mask_long);
    }
    $ipn = @inet_pton($ip); $subn = @inet_pton($subnet);
    if ($ipn === false || $subn === false) return false;
    $bin_ip  = unpack('H*', $ipn)[1];
    $bin_sub = unpack('H*', $subn)[1];
    $bip = ''; $bsub = '';
    for ($i = 0; $i < strlen($bin_ip); $i += 2) {
        $bip  .= str_pad(base_convert(substr($bin_ip, $i, 2), 16, 2), 8, '0', STR_PAD_LEFT);
        $bsub .= str_pad(base_convert(substr($bin_sub, $i, 2), 16, 2), 8, '0', STR_PAD_LEFT);
    }
    return substr($bip, 0, (int)$mask) === substr($bsub, 0, (int)$mask);
}

$_client_ip = _get_client_ip($TRUSTED_PROXIES ?? []);
foreach ($WHITELISTED_IPS as $entry) {
    if (strpos($entry, '/') !== false) {
        if (_ip_in_cidr($_client_ip, $entry)) { echo '<p>You do not have to verify this CAPTCHA.</p>'; exit; }
    } else {
        if ($_client_ip === $entry) { echo '<p>You do not have to verify this CAPTCHA.</p>'; exit; }
    }
}
// --- end whitelist early-return ---


// ---------------- ENV / SESSIONS ----------------
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$remote_addr = $_SERVER['REMOTE_ADDR'] ?? '';

// Get client IP taking trusted proxies into account (first non-empty entry in X-Forwarded-For)
function get_client_ip(string $remote_addr, array $trusted_proxies): string {
    if (!empty($trusted_proxies) && in_array($remote_addr, $trusted_proxies, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
        foreach ($xff as $ip) {
            $ip = trim($ip);
            if ($ip !== '') return $ip;
        }
    }
    return $remote_addr;
}

$client_ip = get_client_ip($remote_addr, $TRUSTED_PROXIES);

if (!$secure && !empty($TRUSTED_PROXIES) && in_array($remote_addr, $TRUSTED_PROXIES, true)) {
    if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') $secure = true;
}

session_name('vichan_sess');
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '',
    'secure' => $secure,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

if (!isset($_SESSION['captcha_meta'])) $_SESSION['captcha_meta'] = ['failures' => 0, 'last_failure' => 0, 'last_generated' => 0];
$meta =& $_SESSION['captcha_meta'];

if (!isset($_SESSION['ip_meta'])) $_SESSION['ip_meta'] = ['addr' => $client_ip, 'failures' => 0, 'last_failure' => 0];
$ip_meta =& $_SESSION['ip_meta'];

if (!isset($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(16));

// If client is whitelisted, mark session as whitelisted
function is_whitelisted(string $client_ip, array $whitelist): bool {
    if (empty($whitelist)) return false;
    foreach ($whitelist as $w) {
        if ($w === $client_ip) return true;
    }
    return false;
}
$client_is_whitelisted = is_whitelisted($client_ip, $WHITELISTED_IPS);
if ($client_is_whitelisted) {
    // set a deterministic-but-random-looking token to indicate verified
    if (empty($_SESSION['captcha_verified_token'])) {
        $_SESSION['captcha_verified_token'] = 'whitelist:' . bin2hex(hash('sha256', $client_ip . ($_SESSION['csrf_token'] ?? '')));
    }
}

// ---------------- HELPERS ----------------
function json_out($arr) {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($arr);
    exit;
}
function safe($s){ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function is_cooldown($meta, $max, $cooldown) {
    return ($meta['failures'] >= $max) && ((time() - $meta['last_failure']) < $cooldown);
}
function is_ip_cooldown($ip_meta, $max, $cooldown) {
    return ($ip_meta['failures'] >= $max) && ((time() - $ip_meta['last_failure']) < $cooldown);
}
function captcha_is_expired(array $meta, int $ttl): bool {
    if (empty($meta['last_generated'])) return true;
    return (time() - (int)$meta['last_generated']) > $ttl;
}

// ---------------- ROUTING ----------------
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

// ---------- IMAGE GENERATION ----------
if (isset($_GET['img'])) {
// If whitelisted, we can either serve no image or still serve one; here we serve a tiny transparent image to avoid wasted generation
if ($client_is_whitelisted) {
    while (ob_get_level()) ob_end_clean();
    header('Content-Type: image/png');
    $tmp = imagecreatetruecolor(1, 1);
    imagesavealpha($tmp, true);
    $transparent = imagecolorallocatealpha($tmp, 0, 0, 0, 127);
    imagefilledrectangle($tmp, 0, 0, 1, 1, $transparent);
    imagepng($tmp);
    imagedestroy($tmp);
    exit;
}

    if (is_cooldown($meta, $MAX_FAILURES_BLOCK, $COOLDOWN_SECONDS) || is_ip_cooldown($ip_meta, $MAX_FAILURES_BLOCK * 2, $COOLDOWN_SECONDS)) {
        while (ob_get_level()) ob_end_clean();
        header('Content-Type: image/png');
        $tmp = imagecreatetruecolor($width, $height);
        imagesavealpha($tmp, true);
        $transparent = imagecolorallocatealpha($tmp, 0, 0, 0, 127);
        imagefilledrectangle($tmp, 0, 0, $width, $height, $transparent);
        imagepng($tmp);
        imagedestroy($tmp);
        exit;
    }

    // generate code and persist
    $code = '';
    $maxc = strlen($chars) - 1;
    for ($i = 0; $i < $length; $i++) $code .= $chars[random_int(0, $maxc)];
    $_SESSION['vichan_captcha'] = strtolower($code);
    $_SESSION['captcha_meta']['last_generated'] = time();
    unset($_SESSION['captcha_verified_token']);
    session_write_close();

    // base image
    $image = imagecreatetruecolor($width, $height);
    imagesavealpha($image, true);
    imagealphablending($image, true);
    $bg = imagecolorallocate($image, 240, 240, 240);
    $fg = imagecolorallocate($image, 30, 30, 30);
    $noise1 = imagecolorallocate($image, 180, 180, 180);
    $noise2 = imagecolorallocate($image, 200, 200, 255);
    imagefilledrectangle($image, 0, 0, $width, $height, $bg);
    for ($i = 0; $i < 150; $i++) imagesetpixel($image, random_int(0, $width - 1), random_int(0, $height - 1), $noise1);
    for ($i = 0; $i < 6; $i++) imageline($image, random_int(0, $width - 1), random_int(0, $height - 1),
        random_int(0, $width - 1), random_int(0, $height - 1), $noise2);

    $boxSize = (int)($width / $length);
    for ($i = 0; $i < $length; $i++) {
        $angle = random_int(-25, 25);
        $size = random_int(20, 28);
        $x = 8 + $i * $boxSize + random_int(-3, 3);
        $y = (int)(($height / 2) + random_int(8, 12));
        if (file_exists($font)) {
            imagettftext($image, $size, $angle, $x, $y, $fg, $font, $code[$i]);
        } else {
            $ys = $y - 18;
            $ys = max(0, min($height - imagefontheight(5), $ys));
            imagestring($image, 5, $x, $ys, $code[$i], $fg);
        }
    }

    // distorted destination with preserved alpha and cached allocations
    $distorted = imagecreatetruecolor($width, $height);
    imagesavealpha($distorted, true);
    imagealphablending($distorted, false);
    $bg2 = imagecolorallocatealpha($distorted, 240, 240, 240, 0);
    imagefilledrectangle($distorted, 0, 0, $width, $height, $bg2);

    $t = microtime(true);
    $colorCache = [];
    for ($x = 0; $x < $width; $x++) {
        for ($y = 0; $y < $height; $y++) {
            $offsetX = (int)(sin($y / 8 + $t) * 2);
            $offsetY = (int)(cos($x / 8 + $t) * 2);
            $sx = $x + $offsetX; $sy = $y + $offsetY;
            if ($sx >= 0 && $sx < $width && $sy >= 0 && $sy < $height) {
                $colIndex = imagecolorat($image, $sx, $sy);
                if (!isset($colorCache[$colIndex])) {
                    $r = ($colIndex >> 16) & 0xFF;
                    $g = ($colIndex >> 8) & 0xFF;
                    $b = $colIndex & 0xFF;
                    $colorCache[$colIndex] = imagecolorallocate($distorted, $r, $g, $b);
                }
                imagesetpixel($distorted, $x, $y, $colorCache[$colIndex]);
            }
        }
    }

    while (ob_get_level()) ob_end_clean();
    header('Content-Type: image/png');
    header('Cache-Control: no-cache, no-store, must-revalidate');
    header('Pragma: no-cache');
    header('Expires: 0');
    imagepng($distorted);
    imagedestroy($image);
    imagedestroy($distorted);
    exit;
}

// ---------- AJAX VERIFY ----------
if ($method === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify') {
    $csrf = $_POST['csrf_token'] ?? '';
    if (!hash_equals((string)($_SESSION['csrf_token'] ?? ''), (string)$csrf)) json_out(['ok' => false, 'reason' => 'csrf']);

    // If whitelisted, short-circuit with ok and existing verified token
    if ($client_is_whitelisted && !empty($_SESSION['captcha_verified_token'])) {
        json_out(['ok' => true, 'token' => $_SESSION['captcha_verified_token']]);
    }

    if (is_cooldown($meta, $MAX_FAILURES_BLOCK, $COOLDOWN_SECONDS) || is_ip_cooldown($ip_meta, $MAX_FAILURES_BLOCK * 2, $COOLDOWN_SECONDS)) json_out(['ok' => false, 'reason' => 'cooldown']);

    $provided = isset($_POST['captcha']) ? strtolower(trim((string)$_POST['captcha'])) : '';
    $expected = isset($_SESSION['vichan_captcha']) ? (string)$_SESSION['vichan_captcha'] : '';

    if ($expected === '' || strlen($expected) !== $length || captcha_is_expired($_SESSION['captcha_meta'], $CAPTCHA_TTL)) {
        $meta['failures']++; $meta['last_failure'] = time();
        $ip_meta['failures']++; $ip_meta['last_failure'] = time();
        json_out(['ok' => false, 'reason' => 'expired']);
    }

    $ok = ($provided !== '' && strlen($provided) === $length && hash_equals($expected, $provided));
    if ($ok) {
        $token = bin2hex(random_bytes(16));
        $_SESSION['captcha_verified_token'] = $token;
        unset($_SESSION['vichan_captcha']);
        json_out(['ok' => true, 'token' => $token]);
    }

    $meta['failures']++; $meta['last_failure'] = time();
    $ip_meta['failures']++; $ip_meta['last_failure'] = time();
    json_out(['ok' => false]);
}

// ---------- FINAL POST ----------
$err = ''; $success = '';
if ($method === 'POST' && (!isset($_POST['action']) || $_POST['action'] !== 'verify')) {
    $csrf = $_POST['csrf_token'] ?? '';
    if (!hash_equals((string)($_SESSION['csrf_token'] ?? ''), (string)$csrf)) {
        http_response_code(400); $err = 'Bad request.';
    } elseif (is_cooldown($meta, $MAX_FAILURES_BLOCK, $COOLDOWN_SECONDS) || is_ip_cooldown($ip_meta, $MAX_FAILURES_BLOCK * 2, $COOLDOWN_SECONDS)) {
        http_response_code(429); $err = 'Too many attempts. Try again later.';
    } else {
        $posted_token = $_POST['captcha_token'] ?? '';
        $session_token = $_SESSION['captcha_verified_token'] ?? '';
        $provided = isset($_POST['captcha']) ? strtolower(trim((string)$_POST['captcha'])) : '';
        $expected  = isset($_SESSION['vichan_captcha']) ? (string)$_SESSION['vichan_captcha'] : null;

        // If client is whitelisted, treat as verified regardless of provided captcha
        if ($client_is_whitelisted && !empty($session_token)) {
            // clear ephemeral captcha state but keep success
            unset($_SESSION['vichan_captcha']);
            $meta['failures'] = 0; $meta['last_failure'] = 0;
            $ip_meta['failures'] = 0; $ip_meta['last_failure'] = 0;
            session_regenerate_id(true);
            $success = 'Post created (simulated).';
        } else {
            // short/long immediate reject
            if ($provided === '' || strlen($provided) !== $length) {
                $meta['failures']++; $meta['last_failure'] = time();
                $ip_meta['failures']++; $ip_meta['last_failure'] = time();
                error_log("CAPTCHA debug: provided=\"{$provided}\" len=" . strlen($provided) . " expected=\"" . ($expected ?? 'null') . "\" last_gen=" . ($_SESSION['captcha_meta']['last_generated'] ?? 'none'));
                $err = 'Captcha did not match.';
            } else {
                $verified_ok = ($posted_token !== '' && $session_token !== '' && hash_equals($session_token, $posted_token));
                $direct_ok = false;

                if (!$verified_ok) {
                    if (is_string($expected) && $expected !== '' && strlen($expected) === $length && !captcha_is_expired($_SESSION['captcha_meta'], $CAPTCHA_TTL)) {
                        if (hash_equals($expected, $provided)) $direct_ok = true;
                    }
                }

                if (!$verified_ok && !$direct_ok) {
                    $meta['failures']++; $meta['last_failure'] = time();
                    $ip_meta['failures']++; $ip_meta['last_failure'] = time();
                    $err = 'Captcha did not match.';
                } else {
                    if ($verified_ok) unset($_SESSION['captcha_verified_token']);
                    unset($_SESSION['vichan_captcha']);
                    $meta['failures'] = 0; $meta['last_failure'] = 0;
                    $ip_meta['failures'] = 0; $ip_meta['last_failure'] = 0;
                    session_regenerate_id(true);

                    // TODO: actual post creation logic goes here.
                    $success = 'Post created (simulated).';
                }
            }
        }
    }
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Captcha Post (fixed)</title>
<meta http-equiv="Expires" content="0">
<style>
  .msg { margin: .5em 0; font-weight: bold; }
  .msg.err { color: #b00; }
  .msg.ok  { color: #080; }
</style>
</head>
<body>
<form id="captcha_form" method="post" action="">
    <p><input type="text" name="subject" maxlength="255" placeholder="Subject" required></p>
    <p><textarea name="body" placeholder="Body" required></textarea></p>

    <p>
<?php if (!empty($client_is_whitelisted)): ?>
        <span id="captcha_message">You do not have to verify this CAPTCHA.</span>
<?php else: ?>
        <img id="captcha_img" src="?img=1" alt="captcha" style="vertical-align:middle">
        <button type="button" id="refresh_btn">Refresh</button>
<?php endif; ?>
    </p>

    <input type="hidden" name="csrf_token" value="<?=safe($_SESSION['csrf_token'])?>">

<?php if (!empty($client_is_whitelisted)): ?>
    <!-- server must set $_SESSION['captcha_verified_token'] for whitelisted clients -->
    <input type="hidden" name="captcha_token" id="captcha_token_field" value="<?= safe($_SESSION['captcha_verified_token'] ?? '') ?>">
<?php else: ?>
    <p><input type="text" name="captcha" id="captcha_input" maxlength="<?= (int)$length ?>" placeholder="Enter characters" required autocomplete="off" aria-label="Captcha input"></p>
<?php endif; ?>

    <p><button type="submit">Submit</button></p>
</form>

<script>
(function(){
  var clientIsWhitelisted = <?= json_encode(!empty($client_is_whitelisted)) ?>;
  var csrfToken = <?= json_encode($_SESSION['csrf_token']) ?>;

  if (clientIsWhitelisted) {
    // no client-side captcha verification needed; form will submit with prefilled captcha_token
    return;
  }

  var form = document.getElementById('captcha_form');
  var input = document.getElementById('captcha_input');
  var img = document.getElementById('captcha_img');
  var refresh = document.getElementById('refresh_btn');

  function reloadCaptcha(){ img.src = '?img=1&_=' + Date.now(); if (input) input.value = ''; }
  refresh.addEventListener('click', reloadCaptcha);

  function setCaptchaToken(token){
    var existing = document.getElementById('captcha_token_field');
    if (!existing) {
      var inp = document.createElement('input');
      inp.type = 'hidden';
      inp.name = 'captcha_token';
      inp.id = 'captcha_token_field';
      form.appendChild(inp);
      existing = inp;
    }
    existing.value = token;
  }

  form.addEventListener('submit', function(e){
    e.preventDefault();
    var provided = input.value || '';
    if (provided.length === 0) { alert('Enter the captcha.'); return; }
    var fd = new FormData();
    fd.append('action', 'verify');
    fd.append('captcha', provided);
    fd.append('csrf_token', csrfToken);

    fetch(location.pathname + '?', { method: 'POST', body: fd, credentials: 'same-origin' })
      .then(function(resp){ return resp.json(); })
      .then(function(json){
        if (json && json.ok) {
          setCaptchaToken(json.token || '');
          form.submit();
        } else {
          alert('Captcha did not match. Try again.');
          reloadCaptcha();
        }
      }).catch(function(){
        alert('Network error while verifying captcha. Please try again.');
        reloadCaptcha();
      });
  });
})();
</script>

</body>
</html>
