Files
ussr-hubzilla-addons/anoncaptcha/anoncaptcha.php
T
2026-07-19 20:18:38 +03:00

378 lines
14 KiB
PHP

<?php
/**
* Name: AnonCaptcha
* Description: Adds a math captcha for anonymous commenters to prevent spam
* Version: 0.92
* Author: ivan zlax <@zlax@ussr.win>
* 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 = <<<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;
}
// 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>';
}
$b64 = base64_encode($captcha_html);
$debug = get_config('anoncaptcha', 'debug', false) ? 'true' : 'false';
$js = <<<JSEOF
<script>
(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<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}');
dc('captcha HTML loaded, length='+h.length);
function addC(){
var els=document.querySelectorAll('[id^="comment-edit-anon"]');
dc('found '+els.length+' comment-edit-anon elements');
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);
dc('injected into '+els[i].id);
}
}
}
// Try immediately
addC();
// If no elements found, retry up to 30 times with 300ms delay (9 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>=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<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){}
}
})();
</script>
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);
}