2026-07-16 21:58:14 +03:00
<? php
/**
* Name: AnonCaptcha
* Description: Adds a math captcha for anonymous commenters to prevent spam
2026-07-19 18:11:31 +03:00
* Version: 0.91
2026-07-16 21:58:14 +03:00
* Author: ivan zlax <@zlax@ussr.win>
* Maintainer: ivan zlax <@zlax@ussr.win>
*/
2026-07-19 18:11:31 +03:00
define ( 'ANONCAPTCHA_SECRET' , 'an0nc@ptch4hubzilla2026!@#$' );
define ( 'ANONCAPTCHA_LIFE' , 300 );
2026-07-16 21:58:14 +03:00
2026-07-19 18:11:31 +03:00
/**
* Register hooks and config on addon load
*/
2026-07-16 21:58:14 +03:00
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 );
}
2026-07-19 18:11:31 +03:00
/**
* Unregister hooks and config on addon unload
*/
2026-07-16 21:58:14 +03:00
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 ) {
2026-07-19 18:11:31 +03:00
// Skip preview submissions
2026-07-16 21:58:14 +03:00
if ( ! empty ( $_POST [ 'preview' ])) {
return ;
}
2026-07-19 18:11:31 +03:00
// Skip non-anonymous comments (logged-in users)
2026-07-16 21:58:14 +03:00
if ( empty ( $_POST [ 'anonname' ]) || empty ( $_POST [ 'anonmail' ])) {
return ;
}
2026-07-19 18:11:31 +03:00
// Validate captcha for anonymous submissions
2026-07-16 21:58:14 +03:00
if ( ! anoncaptcha_validate ()) {
2026-07-19 18:11:31 +03:00
if ( ! isset ( $_SESSION [ '_NOTICES_' ])) {
$_SESSION [ '_NOTICES_' ] = [];
}
2026-07-16 21:58:14 +03:00
$_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).
2026-07-19 18:11:31 +03:00
* On other pages: injects captcha into anonymous comment editors
2026-07-16 21:58:14 +03:00
* and hides moderation buttons for unauthenticated visitors.
*/
function anoncaptcha_page_end ( $content ) {
// Skip POST requests (AJAX form submissions)
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
return ;
}
2026-07-19 18:11:31 +03:00
// 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 );
2026-07-16 21:58:14 +03:00
$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 ;
}
2026-07-19 18:11:31 +03:00
// 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 = '<div class="anoncaptcha-form mb-3">'
. '<label style="display:block;margin-bottom:6px;font-size:1.1em;">'
. '<img src="' . $img_a . '" style="vertical-align:middle;" />'
. ' + '
. '<img src="' . $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="' . $token . '" />'
. '<small class="form-text text-muted">Captcha</small></div>' ;
} else {
$captcha_html = '<div class="anoncaptcha-form mb-3">'
. '<label style="display:block;margin-bottom:6px;">What is ' . $a . ' + ' . $b . '?</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="' . $token . '" />'
. '<small class="form-text text-muted">Captcha</small></div>' ;
}
2026-07-16 21:58:14 +03:00
$b64 = base64_encode ( $captcha_html );
2026-07-19 18:11:31 +03:00
$debug = get_config ( 'anoncaptcha' , 'debug' , false ) ? 'true' : 'false' ;
2026-07-16 21:58:14 +03:00
$js = <<< JSEOF
<script>
(function(){
2026-07-19 18:11:31 +03:00
var debugEnabled={$debug};
function dc(msg){if(debugEnabled)console.log('anoncaptcha: '+msg);}
2026-07-16 21:58:14 +03:00
// 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){
2026-07-19 18:11:31 +03:00
dc('ajaxComplete for item request, url='+settings.url);
2026-07-16 21:58:14 +03:00
try{
var resp = JSON.parse(xhr.responseText);
2026-07-19 18:11:31 +03:00
dc('response parsed, captcha_fail='+resp.captcha_fail);
2026-07-16 21:58:14 +03:00
if(resp && resp.captcha_fail){
alert(resp.error_msg || 'Captcha failed');
}
2026-07-19 18:11:31 +03:00
}catch(e){
dc('error parsing response: '+e.message);
}
2026-07-16 21:58:14 +03:00
}
});
}
2026-07-19 18:11:31 +03:00
// Detect whether the visitor is logged in
2026-07-16 21:58:14 +03:00
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}');
2026-07-19 18:11:31 +03:00
dc('captcha HTML loaded, length='+h.length);
2026-07-16 21:58:14 +03:00
function addC(){
var els=document.querySelectorAll('[id^="comment-edit-anon"]');
2026-07-19 18:11:31 +03:00
dc('found '+els.length+' comment-edit-anon elements');
2026-07-16 21:58:14 +03:00
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);
2026-07-19 18:11:31 +03:00
dc('injected into '+els[i].id);
2026-07-16 21:58:14 +03:00
}
}
}
2026-07-19 18:11:31 +03:00
// Try immediately
addC();
// If no elements found, retry up to 25 times with 400ms delay (10 seconds total)
// Hubzilla loads comment forms via AJAX after initial page load
if(document.querySelectorAll('[id^="comment-edit-anon"]').length===0){
var retries=0;
var retry=setInterval(function(){
retries++;
addC();
if(retries>=25 || document.querySelectorAll('[id^="comment-edit-anon"]').length>0){
clearInterval(retry);
}
},400);
}
// Observe for dynamically added comment forms
2026-07-16 21:58:14 +03:00
if(typeof MutationObserver!=="undefined"){
2026-07-19 18:11:31 +03:00
try{
var mo=new MutationObserver(function(mutations){
for(var m=0;m<mutations.length;m++){
for(var i=0;i<mutations[m].addedNodes.length;i++){
var node=mutations[m].addedNodes[i];
if(node.nodeType===1 && node.id && node.id.indexOf('comment-edit-anon')===0){
if(!node.querySelector(".anoncaptcha-form")){
var d=document.createElement("div");
d.className="anoncaptcha-form";
d.innerHTML=h;
node.appendChild(d);
}
}
}
}
});
mo.observe(document.body,{childList:true,subtree:true});
}catch(e){}
2026-07-16 21:58:14 +03:00
}
})();
</script>
JSEOF ;
2026-07-19 18:11:31 +03:00
// Inject into page content
2026-07-16 21:58:14 +03:00
App :: $page [ 'content' ] .= $js ;
}
2026-07-19 18:11:31 +03:00
/**
* 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 );
}
2026-07-16 21:58:14 +03:00
function anoncaptcha_plugin_admin ( & $a ) {
$use_image = get_config ( 'anoncaptcha' , 'use_image' , false );
2026-07-19 18:11:31 +03:00
$debug = get_config ( 'anoncaptcha' , 'debug' , false );
2026-07-16 21:58:14 +03:00
$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.' )],
2026-07-19 18:11:31 +03:00
'$debug' => [ 'debug' , t ( 'Enable debug console logging' ), $debug , t ( 'When enabled, shows detailed debug messages in browser console for troubleshooting.' )],
2026-07-16 21:58:14 +03:00
]);
}
function anoncaptcha_plugin_admin_post () {
$use_image = intval ( $_POST [ 'use_image' ] ?? 0 );
2026-07-19 18:11:31 +03:00
$debug = intval ( $_POST [ 'debug' ] ?? 0 );
2026-07-16 21:58:14 +03:00
set_config ( 'anoncaptcha' , 'use_image' , $use_image );
2026-07-19 18:11:31 +03:00
set_config ( 'anoncaptcha' , 'debug' , $debug );
2026-07-16 21:58:14 +03:00
info ( t ( 'Settings updated.' ) . EOL );
}