diff --git a/README.md b/README.md index e0fd0df..215ea5a 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,5 @@ Installing: List of addons: -- link - Using short links on whole hub \ No newline at end of file +- anoncaptcha - Adds a math captcha for anonymous commenters to prevent spam +- link - Using short links on whole hub diff --git a/anoncaptcha/README.md b/anoncaptcha/README.md new file mode 100644 index 0000000..322d66a --- /dev/null +++ b/anoncaptcha/README.md @@ -0,0 +1,12 @@ +AnonCaptcha Addon +=============================== + +Adds a math-based captcha for anonymous commenters to prevent spam. + +When enabled, anonymous visitors who wish to leave a comment must solve a simple addition problem (e.g. "What is 12 + 37?"). The captcha is generated server-side and signed with an HMAC token, so answers are never exposed in URLs or client-side code. + +Based on Zotlabs hook system. + +Adapted for Hubzilla 11 + +Generated with Qwen 3.6 35B APEX Quality [VLM] v5090 diff --git a/anoncaptcha/anoncaptcha.php b/anoncaptcha/anoncaptcha.php new file mode 100644 index 0000000..9e4d8b5 --- /dev/null +++ b/anoncaptcha/anoncaptcha.php @@ -0,0 +1,351 @@ + + * 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); +} diff --git a/anoncaptcha/lang/de/strings.php b/anoncaptcha/lang/de/strings.php new file mode 100644 index 0000000..66f5528 --- /dev/null +++ b/anoncaptcha/lang/de/strings.php @@ -0,0 +1,17 @@ + "AnonCaptcha aktivieren", + "When enabled, anonymous users must solve a math captcha before submitting comments." => "Wenn aktiviert, müssen anonyme Benutzer eine mathematische Aufgabe lösen, bevor sie einen Kommentar absenden.", + "Submit" => "Absenden", + "Captcha verification failed. Please try again." => "Captcha-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "What is %d + %d?" => "Wie viel ist %d + %d?", + "Draw digital symbols using graphics" => "Ziffern als Grafik darstellen", + "When enabled, captcha numbers will be drawn as small images instead of text." => "Wenn aktiviert, werden die Captcha-Ziffern als kleine Bilder statt als Text dargestellt.", + "Settings updated." => "Einstellungen aktualisiert.", + "Captcha" => "Captcha", + "anti-spam" => "Antispam", +]; diff --git a/anoncaptcha/lang/en/strings.php b/anoncaptcha/lang/en/strings.php new file mode 100644 index 0000000..4cfa4bb --- /dev/null +++ b/anoncaptcha/lang/en/strings.php @@ -0,0 +1,17 @@ + "Enable AnonCaptcha", + "When enabled, anonymous users must solve a math captcha before submitting comments." => "When enabled, anonymous users must solve a math captcha before submitting comments.", + "Submit" => "Submit", + "Captcha verification failed. Please try again." => "Captcha verification failed. Please try again.", + "What is %d + %d?" => "What is %d + %d?", + "Draw digital symbols using graphics" => "Draw digital symbols using graphics", + "When enabled, captcha numbers will be drawn as small images instead of text." => "When enabled, captcha numbers will be drawn as small images instead of text.", + "Settings updated." => "Settings updated.", + "Captcha" => "Captcha", + "anti-spam" => "anti-spam", +]; diff --git a/anoncaptcha/lang/es/strings.php b/anoncaptcha/lang/es/strings.php new file mode 100644 index 0000000..9a4a5f5 --- /dev/null +++ b/anoncaptcha/lang/es/strings.php @@ -0,0 +1,17 @@ + "Habilitar AnonCaptcha", + "When enabled, anonymous users must solve a math captcha before submitting comments." => "Cuando está habilitado, los usuarios anónimos deben resolver un captcha matemático antes de enviar un comentario.", + "Submit" => "Enviar", + "Captcha verification failed. Please try again." => "Verificación del captcha fallida. Por favor, inténtelo de nuevo.", + "What is %d + %d?" => "¿Cuánto es %d + %d?", + "Draw digital symbols using graphics" => "Mostrar dígitos como imagen", + "When enabled, captcha numbers will be drawn as small images instead of text." => "Cuando está habilitado, los números del captcha se mostrarán como imágenes en lugar de texto.", + "Settings updated." => "Configuración actualizada.", + "Captcha" => "Captcha", + "anti-spam" => "antispam", +]; diff --git a/anoncaptcha/lang/fr/strings.php b/anoncaptcha/lang/fr/strings.php new file mode 100644 index 0000000..d1b43da --- /dev/null +++ b/anoncaptcha/lang/fr/strings.php @@ -0,0 +1,17 @@ + "Activer AnonCaptcha", + "When enabled, anonymous users must solve a math captcha before submitting comments." => "Lorsqu'activé, les utilisateurs anonymes doivent résoudre un captcha mathématique avant de soumettre un commentaire.", + "Submit" => "Soumettre", + "Captcha verification failed. Please try again." => "Échec de la vérification du captcha. Veuillez réessayer.", + "What is %d + %d?" => "Combien font %d + %d ?", + "Draw digital symbols using graphics" => "Afficher les chiffres sous forme d'images", + "When enabled, captcha numbers will be drawn as small images instead of text." => "Lorsqu'activé, les chiffres du captcha seront affichés sous forme d'images plutôt qu'en texte.", + "Settings updated." => "Paramètres mis à jour.", + "Captcha" => "Captcha", + "anti-spam" => "anti-spam", +]; diff --git a/anoncaptcha/lang/ru/strings.php b/anoncaptcha/lang/ru/strings.php new file mode 100644 index 0000000..1bccdb0 --- /dev/null +++ b/anoncaptcha/lang/ru/strings.php @@ -0,0 +1,17 @@ + "Включить AnonCaptcha", + "When enabled, anonymous users must solve a math captcha before submitting comments." => "При включении анонимные пользователи должны решить математическую капчу перед отправкой комментария.", + "Submit" => "Отправить", + "Captcha verification failed. Please try again." => "Ошибка проверки капчи. Попробуйте снова.", + "What is %d + %d?" => "Чему равно %d + %d?", + "Draw digital symbols using graphics" => "Отрисовывать символы графически", + "When enabled, captcha numbers will be drawn as small images instead of text." => "При включении капча отображается как изображения вместо текста.", + "Settings updated." => "Настройки обновлены.", + "Captcha" => "Капча", + "anti-spam" => "от спама", +]; diff --git a/anoncaptcha/view/tpl/admin.tpl b/anoncaptcha/view/tpl/admin.tpl new file mode 100644 index 0000000..df4eeed --- /dev/null +++ b/anoncaptcha/view/tpl/admin.tpl @@ -0,0 +1,2 @@ +{{include file="field_checkbox.tpl" field=$use_image}} +
diff --git a/anoncaptcha/view/tpl/captcha_form.tpl b/anoncaptcha/view/tpl/captcha_form.tpl new file mode 100644 index 0000000..0a616db --- /dev/null +++ b/anoncaptcha/view/tpl/captcha_form.tpl @@ -0,0 +1,5 @@ +
+ + + (anti-spam) +