|
|
|
@@ -2,95 +2,32 @@
|
|
|
|
|
/**
|
|
|
|
|
* Name: AnonCaptcha
|
|
|
|
|
* Description: Adds a math captcha for anonymous commenters to prevent spam
|
|
|
|
|
* Version: 0.9
|
|
|
|
|
* Version: 0.91
|
|
|
|
|
* Author: ivan zlax <@zlax@ussr.win>
|
|
|
|
|
* Maintainer: ivan zlax <@zlax@ussr.win>
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
define('ANONCAPTCHA_SECRET', 'heresecretsalt');
|
|
|
|
|
define('ANONCAPTCHA_LIFE', 900);
|
|
|
|
|
define('ANONCAPTCHA_SECRET', 'an0nc@ptch4hubzilla2026!@#$');
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
@@ -148,54 +85,25 @@ function anoncaptcha_validate_token($token) {
|
|
|
|
|
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) {
|
|
|
|
|
// 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()) {
|
|
|
|
|
$_SESSION['_NOTICES_'] ??= [];
|
|
|
|
|
if (!isset($_SESSION['_NOTICES_'])) {
|
|
|
|
|
$_SESSION['_NOTICES_'] = [];
|
|
|
|
|
}
|
|
|
|
|
$_SESSION['_NOTICES_'][] = t('Captcha verification failed. Please try again.');
|
|
|
|
|
|
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
@@ -208,7 +116,7 @@ function anoncaptcha_post_local_start(&$data) {
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* On other pages: injects captcha into anonymous comment editors
|
|
|
|
|
* and hides moderation buttons for unauthenticated visitors.
|
|
|
|
|
*/
|
|
|
|
|
function anoncaptcha_page_end($content) {
|
|
|
|
@@ -217,6 +125,23 @@ function anoncaptcha_page_end($content) {
|
|
|
|
|
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
|
|
|
|
@@ -258,29 +183,55 @@ JSEOF;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Encode captcha HTML as base64 to safely embed in JavaScript
|
|
|
|
|
$captcha_html = anoncaptcha_render_captcha();
|
|
|
|
|
// 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);
|
|
|
|
|
try{
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}catch(e){}
|
|
|
|
|
}catch(e){
|
|
|
|
|
dc('error parsing response: '+e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detect whether the visitor is logged in by checking for
|
|
|
|
|
// elements that only appear for authenticated users.
|
|
|
|
|
// Detect whether the visitor is logged in
|
|
|
|
|
function isLoggedIn(){
|
|
|
|
|
if(document.querySelector('#my_channels')) return true;
|
|
|
|
|
if(document.querySelector('#user-menu')) return true;
|
|
|
|
@@ -313,39 +264,108 @@ if(typeof MutationObserver!=="undefined"){
|
|
|
|
|
|
|
|
|
|
// 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{addC();}catch(e){}
|
|
|
|
|
// 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
|
|
|
|
|
if(typeof MutationObserver!=="undefined"){
|
|
|
|
|
try{var m=new MutationObserver(addC);m.observe(document.body,{childList:true,subtree:true});}catch(e){}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|