anoncaptcha init
This commit is contained in:
@@ -6,4 +6,5 @@ Installing:
|
||||
|
||||
List of addons:
|
||||
|
||||
- link - Using short links on whole hub
|
||||
- anoncaptcha - Adds a math captcha for anonymous commenters to prevent spam
|
||||
- link - Using short links on whole hub
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
/**
|
||||
* Name: AnonCaptcha
|
||||
* Description: Adds a math captcha for anonymous commenters to prevent spam
|
||||
* Version: 0.9
|
||||
* Author: ivan zlax <@zlax@ussr.win>
|
||||
* 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 '<div class="anoncaptcha-form mb-3">'
|
||||
. '<label style="display:block;margin-bottom:6px;font-size:1.1em;">'
|
||||
. '<img src="' . $gen['img_a'] . '" style="vertical-align:middle;" />'
|
||||
. ' + '
|
||||
. '<img src="' . $gen['img_b'] . '" style="vertical-align:middle;" />'
|
||||
. ' = ?'
|
||||
. '</label>'
|
||||
. '<input type="text" name="anoncaptcha_answer" required class="form-control" style="width:auto;max-width:200px;" autocomplete="off" />'
|
||||
. '<input type="hidden" name="anoncaptcha_token" value="' . htmlspecialchars($gen['token']) . '" />'
|
||||
. '<small class="form-text text-muted">' . t('Captcha') . '</small>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
return '<div class="anoncaptcha-form mb-3">'
|
||||
. '<label style="display:block;margin-bottom:6px;">'
|
||||
. htmlspecialchars($question)
|
||||
. '</label>'
|
||||
. '<input type="text" name="anoncaptcha_answer" required class="form-control" style="width:auto;max-width:200px;" autocomplete="off" />'
|
||||
. '<input type="hidden" name="anoncaptcha_token" value="' . htmlspecialchars($gen['token']) . '" />'
|
||||
. '<small class="form-text text-muted">' . t('Captcha') . '</small>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = <<<JSEOF
|
||||
<script>
|
||||
(function(){
|
||||
var added=new Set();
|
||||
function addModerButtons(){
|
||||
var items = document.querySelectorAll('[id*="wall-item-content-wrapper-"]');
|
||||
for(var i=0;i<items.length;i++){
|
||||
var item = items[i];
|
||||
var id = item.id;
|
||||
if(added.has(id)) continue;
|
||||
var match = id.match(/(\d+)/);
|
||||
if(!match) continue;
|
||||
var num = match[1];
|
||||
added.add(id);
|
||||
var btns = document.createElement('div');
|
||||
btns.className = 'moderate-buttons';
|
||||
btns.style.cssText = 'margin:10px 0;display:flex;gap:8px;clear:both;';
|
||||
btns.innerHTML = '<a href="moderate/'+num+'/approve" class="btn btn-sm btn-outline-success"><i class="bi bi-check-lg"></i> Approve</a>'
|
||||
+ '<a href="moderate/'+num+'/drop" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Delete</a>';
|
||||
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){}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
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 = <<<JSEOF
|
||||
<script>
|
||||
(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<els.length;i++){
|
||||
var h=els[i].href||els[i].getAttribute('href')||'';
|
||||
if(/\\/moderate\\/\\d+\\/(approve|drop)/i.test(h) || /\\/moderate\\/\\d+$/i.test(h)){
|
||||
els[i].style.display='none';
|
||||
els[i].parentNode.style.display='none';
|
||||
}
|
||||
}
|
||||
}
|
||||
try{hideModButtons();}catch(e){}
|
||||
if(typeof MutationObserver!=="undefined"){
|
||||
try{
|
||||
var mo=new MutationObserver(function(){
|
||||
if(!isLoggedIn()) hideModButtons();
|
||||
});
|
||||
mo.observe(document.body,{childList:true,subtree:true});
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// Inject captcha form into anonymous comment editors.
|
||||
var h=atob('{$b64}');
|
||||
function addC(){
|
||||
var els=document.querySelectorAll('[id^="comment-edit-anon"]');
|
||||
for(var i=0;i<els.length;i++){
|
||||
if(!els[i].querySelector(".anoncaptcha-form")){
|
||||
var d=document.createElement("div");
|
||||
d.className="anoncaptcha-form";
|
||||
d.innerHTML=h;
|
||||
els[i].appendChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
try{addC();}catch(e){}
|
||||
if(typeof MutationObserver!=="undefined"){
|
||||
try{var m=new MutationObserver(addC);m.observe(document.body,{childList:true,subtree:true});}catch(e){}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* German strings for AnonCaptcha addon
|
||||
*/
|
||||
|
||||
App::$strings = [
|
||||
"Enable AnonCaptcha" => "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",
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* English strings for AnonCaptcha addon
|
||||
*/
|
||||
|
||||
App::$strings = [
|
||||
"Enable AnonCaptcha" => "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",
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Spanish strings for AnonCaptcha addon
|
||||
*/
|
||||
|
||||
App::$strings = [
|
||||
"Enable AnonCaptcha" => "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",
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* French strings for AnonCaptcha addon
|
||||
*/
|
||||
|
||||
App::$strings = [
|
||||
"Enable AnonCaptcha" => "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",
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Russian strings for AnonCaptcha addon
|
||||
*/
|
||||
|
||||
App::$strings = [
|
||||
"Enable AnonCaptcha" => "Включить 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" => "от спама",
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
{{include file="field_checkbox.tpl" field=$use_image}}
|
||||
<div class="submit"><input type="submit" name="page_site" value="{{$submit}}" /></div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="anoncaptcha-field" style="margin: 8px 0; padding: 10px; background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<label for="anoncaptcha_answer" style="display: block; margin-bottom: 6px; font-weight: bold;">{{ $captcha_question }}:</label>
|
||||
<input type="text" id="anoncaptcha_answer" name="anoncaptcha_answer" required style="width: 100px; padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px;" autocomplete="off" />
|
||||
<span style="margin-left: 8px; color: #666; font-size: 0.9em;">(anti-spam)</span>
|
||||
</div>
|
||||
Reference in New Issue
Block a user