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 '
';
+ }
+
+ 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)
+