* Maintainer: ivan zlax <@zlax@ussr.win> */ define('ANONCAPTCHA_SECRET', 'heresecretsalt'); define('ANONCAPTCHA_LIFE', 900); function anoncaptcha_load() { register_hook('post_local_start', 'addon/anoncaptcha/anoncaptcha.php', 'anoncaptcha_post_local_start'); register_hook('page_end', 'addon/anoncaptcha/anoncaptcha.php', 'anoncaptcha_page_end'); set_config('anoncaptcha', 'enabled', 1); } function anoncaptcha_unload() { unregister_hook('post_local_start', 'addon/anoncaptcha/anoncaptcha.php', 'anoncaptcha_post_local_start'); unregister_hook('page_end', 'addon/anoncaptcha/anoncaptcha.php', 'anoncaptcha_page_end'); del_config('anoncaptcha', 'enabled'); } /** * Generate a PNG image with a number and return as base64 data URI. * Uses random background colors and noise to deter bot recognition. */ function anoncaptcha_generate_image($answer) { $width = 64; $height = 32; $img = imagecreatetruecolor($width, $height); // Random background $bgR = rand(180, 230); $bgG = rand(180, 230); $bgB = rand(180, 230); $bg = imagecolorallocate($img, $bgR, $bgG, $bgB); imagefill($img, 0, 0, $bg); // Noise lines for ($i = 0; $i < 3; $i++) { $color = imagecolorallocate($img, rand(100, 200), rand(100, 200), rand(100, 200)); imageline($img, rand(0, $width), rand(0, $height), rand(0, $width), rand(0, $height), $color); } // Noise dots for ($i = 0; $i < 10; $i++) { $color = imagecolorallocate($img, rand(100, 200), rand(100, 200), rand(100, 200)); imagesetpixel($img, rand(0, $width), rand(0, $height), $color); } // High contrast text (complementary to background) imagestring($img, 5, ($width - 40) / 2, 4, (string)$answer, imagecolorallocate($img, 255 - $bgR, 255 - $bgG, 255 - $bgB)); // Encode as base64 data URI ob_start(); imagepng($img); $data = ob_get_contents(); ob_end_clean(); imagedestroy($img); return 'data:image/png;base64,' . base64_encode($data); } /** * Generate a captcha token containing a random sum and an HMAC-signed payload. * If use_image is enabled, embeds base64 images for each operand. */ function anoncaptcha_generate_token() { $use_image = get_config('anoncaptcha', 'use_image', false); $a = rand(5, 49); $b = rand(5, 49); $answer = $a + $b; $expires = time() + ANONCAPTCHA_LIFE; $data = $answer . '|' . $expires; $hmac = hash_hmac('sha256', $data, ANONCAPTCHA_SECRET); $token = [ 'token' => base64_encode($hmac . '|' . $data), 'a' => $a, 'b' => $b, 'use_image' => $use_image, ]; if ($use_image) { $token['img_a'] = anoncaptcha_generate_image($a); $token['img_b'] = anoncaptcha_generate_image($b); } return $token; } /** * Validate the captcha answer submitted via POST. * Checks that both token and answer are present, the answer is numeric, * and that the HMAC token is valid and not expired. */ function anoncaptcha_validate() { $token = $_POST['anoncaptcha_token'] ?? ''; $submitted = trim($_POST['anoncaptcha_answer'] ?? ''); if ($token === '' || $submitted === '') { return false; } if (!is_numeric($submitted)) { return false; } $correct = anoncaptcha_validate_token($token); if ($correct === false) { return false; } return (int)$submitted === $correct; } /** * Decode and verify an HMAC captcha token. * Returns the correct answer on success, or false if the token is * malformed, expired, or has an invalid signature. */ function anoncaptcha_validate_token($token) { $decoded = @base64_decode($token); if (!$decoded || !str_contains($decoded, '|')) { return false; } $parts = explode('|', $decoded, 3); if (count($parts) !== 3) { return false; } $hmac = $parts[0]; $answer = $parts[1]; $expires = (int)$parts[2]; // Reject expired tokens if ($expires < time()) { return false; } // Verify HMAC signature (constant-time comparison) $data = $answer . '|' . $expires; $expected_hmac = hash_hmac('sha256', $data, ANONCAPTCHA_SECRET); if (!hash_equals($expected_hmac, $hmac)) { return false; } return (int)$answer; } /** * Render the captcha form as HTML. * Shows image-based operands when use_image is enabled, otherwise text. */ function anoncaptcha_render_captcha() { $gen = anoncaptcha_generate_token(); $use_image = $gen['use_image']; $question = sprintf(t('What is %d + %d?'), $gen['a'], $gen['b']); if ($use_image) { return '
' . '' . '' . '' . '' . t('Captcha') . '' . '
'; } return '
' . '' . '' . '' . '' . t('Captcha') . '' . '
'; } /** * Hook: post_local_start — validate captcha before a comment is persisted. * Skips preview submissions and logged-in users (non-anonymous). */ function anoncaptcha_post_local_start(&$data) { if (!empty($_POST['preview'])) { return; } if (empty($_POST['anonname']) || empty($_POST['anonmail'])) { return; } if (!anoncaptcha_validate()) { $_SESSION['_NOTICES_'] ??= []; $_SESSION['_NOTICES_'][] = t('Captcha verification failed. Please try again.'); header('Content-Type: application/json'); $msg = addslashes(t('Captcha verification failed. Please try again.')); echo json_encode(['captcha_fail' => true, 'error_msg' => $msg, 'success' => false]); exit; } } /** * Hook: page_end — inject JavaScript into the page footer. * On /moderate pages: adds Approve/Delete buttons (not rendered by default). * On other pages: injects the captcha form into anonymous comment editors * and hides moderation buttons for unauthenticated visitors. */ function anoncaptcha_page_end($content) { // Skip POST requests (AJAX form submissions) if ($_SERVER['REQUEST_METHOD'] === 'POST') { return; } $path = $_SERVER['REQUEST_URI'] ?? ''; if (strpos($path, '/moderate') !== false) { // Inject Approve/Delete buttons on /moderate pages $mod_js = << (function(){ var added=new Set(); function addModerButtons(){ var items = document.querySelectorAll('[id*="wall-item-content-wrapper-"]'); for(var i=0;i Approve' + ' Delete'; var tools = item.querySelector('#wall-item-tools-right-'+num); if(tools){ tools.parentNode.insertBefore(btns, tools.nextSibling); } else { item.parentNode.appendChild(btns); } } } try{addModerButtons();}catch(e){} if(typeof MutationObserver!=="undefined"){ try{var mo=new MutationObserver(addModerButtons);mo.observe(document.body,{childList:true,subtree:true});}catch(e){} } })(); JSEOF; App::$page['content'] .= $mod_js; return; } // Encode captcha HTML as base64 to safely embed in JavaScript $captcha_html = anoncaptcha_render_captcha(); $b64 = base64_encode($captcha_html); $js = << (function(){ // Show alert when captcha validation fails on item submission if(typeof jQuery!=='undefined'){ jQuery(document).ajaxComplete(function(event, xhr, settings){ if(settings && settings.url && settings.url.indexOf('item')!==-1){ try{ var resp = JSON.parse(xhr.responseText); if(resp && resp.captcha_fail){ alert(resp.error_msg || 'Captcha failed'); } }catch(e){} } }); } // Detect whether the visitor is logged in by checking for // elements that only appear for authenticated users. function isLoggedIn(){ if(document.querySelector('#my_channels')) return true; if(document.querySelector('#user-menu')) return true; if(document.querySelector('#profile-link')) return true; if(document.querySelector('#settings-link')) return true; return false; } // Hide moderation buttons for anonymous visitors only. function hideModButtons(){ if(isLoggedIn()) return; var els = document.querySelectorAll('a[href*="moderate"]'); for(var i=0;i JSEOF; App::$page['content'] .= $js; } function anoncaptcha_plugin_admin(&$a) { $use_image = get_config('anoncaptcha', 'use_image', false); $t = get_markup_template('admin.tpl', 'addon/anoncaptcha/'); $a = replace_macros($t, [ '$submit' => t('Submit'), '$use_image' => ['use_image', t('Draw digital symbols using graphics'), $use_image, t('When enabled, captcha numbers will be drawn as small images instead of text.')], ]); } function anoncaptcha_plugin_admin_post() { $use_image = intval($_POST['use_image'] ?? 0); set_config('anoncaptcha', 'use_image', $use_image); info(t('Settings updated.') . EOL); }