* Maintainer: ivan zlax <@zlax@ussr.win> */ define('ANONCAPTCHA_SECRET', 'heresecretsalt'); define('ANONCAPTCHA_LIFE', 300); /** * Register hooks and config on addon load */ 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); } /** * Unregister hooks and config on addon unload */ 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'); } /** * 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; } /** * 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) { // Skip preview submissions if (!empty($_POST['preview'])) { return; } // Skip non-anonymous comments (logged-in users) if (empty($_POST['anonname']) || empty($_POST['anonmail'])) { return; } // Validate captcha for anonymous submissions if (!anoncaptcha_validate()) { if (!isset($_SESSION['_NOTICES_'])) { $_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 captcha 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; } // Prevent page caching if (!headers_sent()) { header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); } // Generate captcha token server-side (fresh on every request) $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 = base64_encode($hmac . '|' . $data); $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; } // Build captcha HTML with server-generated token if ($use_image && function_exists('imagecreatetruecolor')) { // Generate images inline $img_a = anoncaptcha_gen_img($a); $img_b = anoncaptcha_gen_img($b); $captcha_html = '
' . '' . '' . '' . 'Captcha
'; } else { $captcha_html = '
' . '' . '' . '' . 'Captcha
'; } $b64 = base64_encode($captcha_html); $debug = get_config('anoncaptcha', 'debug', false) ? 'true' : 'false'; $js = << (function(){ var debugEnabled={$debug}; function dc(msg){if(debugEnabled)console.log('anoncaptcha: '+msg);} // 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){ dc('ajaxComplete for item request, url='+settings.url); dc('responseStatus='+xhr.status+' responseLength='+xhr.responseText.length); try{ if(xhr.responseText){ var resp = JSON.parse(xhr.responseText); dc('response parsed, captcha_fail='+resp.captcha_fail); if(resp && resp.captcha_fail){ alert(resp.error_msg || 'Captcha failed'); } } else { dc('empty response from server'); dc('check if anoncaptcha_token is in form data'); } }catch(e){ dc('error parsing response: '+e.message); } } }); } // Detect whether the visitor is logged in 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=30 || document.querySelectorAll('[id^="comment-edit-anon"]').length>0){ clearInterval(retry); } },300); } // Observe for dynamically added comment forms if(typeof MutationObserver!=="undefined"){ try{ var mo=new MutationObserver(function(mutations){ for(var m=0;m JSEOF; // Inject into page content App::$page['content'] .= $js; } /** * Generate a PNG image with a number and return as base64 data URI. */ function anoncaptcha_gen_img($answer) { $width = 64; $height = 32; $img = imagecreatetruecolor($width, $height); $bgR = rand(180, 230); $bgG = rand(180, 230); $bgB = rand(180, 230); $bg = imagecolorallocate($img, $bgR, $bgG, $bgB); imagefill($img, 0, 0, $bg); 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); } 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); } imagestring($img, 5, ($width - 40) / 2, 4, (string)$answer, imagecolorallocate($img, 255 - $bgR, 255 - $bgG, 255 - $bgB)); ob_start(); imagepng($img); $data = ob_get_contents(); ob_end_clean(); imagedestroy($img); return 'data:image/png;base64,' . base64_encode($data); } function anoncaptcha_plugin_admin(&$a) { $use_image = get_config('anoncaptcha', 'use_image', false); $debug = get_config('anoncaptcha', 'debug', 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.')], '$debug' => ['debug', t('Enable debug console logging'), $debug, t('When enabled, shows detailed debug messages in browser console for troubleshooting.')], ]); } function anoncaptcha_plugin_admin_post() { $use_image = intval($_POST['use_image'] ?? 0); $debug = intval($_POST['debug'] ?? 0); set_config('anoncaptcha', 'use_image', $use_image); set_config('anoncaptcha', 'debug', $debug); info(t('Settings updated.') . EOL); }