Captcha implementation

Also Dada generator is OK
This commit is contained in:
Sascha Leib
2025-10-23 15:01:36 +02:00
parent f5f4ca13af
commit 12993035b5
8 changed files with 11097 additions and 5995 deletions

View File

@@ -62,7 +62,7 @@ class action_plugin_botmon extends DokuWiki_Action_Plugin {
$username = ( !empty($INFO['userinfo']) && !empty($INFO['userinfo']['name']) ? $INFO['userinfo']['name'] : '');
// build the tracker code:
$code = "document._botmon = {'t0': Date.now(), 'session': '" . json_encode($this->sessionId) . "'};" . NL;
$code = "document._botmon = {t0: Date.now(), session: " . json_encode($this->sessionId) . ", seed: " . json_encode($this->getConf('captchaSeed')) . ", ip: " . json_encode($_SERVER['REMOTE_ADDR']) . "};" . NL;
if ($username) {
$code .= DOKU_TAB . DOKU_TAB . 'document._botmon.user = "' . $username . '";'. NL;
}
@@ -74,7 +74,6 @@ class action_plugin_botmon extends DokuWiki_Action_Plugin {
$code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "e.src='".DOKU_BASE."lib/plugins/botmon/client.js';" . NL;
$code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "document.getElementsByTagName('head')[0].appendChild(e);" . NL;
$code .= DOKU_TAB . DOKU_TAB . "});";
$event->data['script'][] = ['_data' => $code];
}
@@ -91,7 +90,6 @@ class action_plugin_botmon extends DokuWiki_Action_Plugin {
$event->data['script'][] = ['src' => DOKU_BASE.'lib/plugins/botmon/admin.js', 'defer' => 'defer', '_data' => ''];
}
/**
* Writes data to the server log.
*
@@ -191,39 +189,216 @@ class action_plugin_botmon extends DokuWiki_Action_Plugin {
public function showCaptcha(Event $event) {
if ($this->getConf('useCaptcha') && $this->checkCaptchaCookie()) {
$useCaptcha = $this->getConf('useCaptcha');
if ($useCaptcha !== 'disabled' && $this->checkCaptchaCookie()) {
echo '<h1 class="sectionedit1">'; tpl_pagetitle(); echo "</h1>\n"; // always show the original page title
$event->preventDefault(); // don't show normal content
$this->insertDadaFiller(); // show dada filler instead!
switch ($useCaptcha) {
case 'blank':
$this->insertBlankBox(); // show dada filler instead of text
break;
case 'dada':
$this->insertDadaFiller(); // show dada filler instead of text
break;
}
$this->insertCaptchaLoader(); // and load the captcha
} else {
echo '<p>Normal page.</p>';
}
}
private function checkCaptchaCookie() {
$cookieVal = isset($_COOKIE['_c_']) ? $_COOKIE['_c_'] : '';
$seed = $this->getConf('captchaSeed');
$cookieVal = isset($_COOKIE['captcha']) ? $_COOKIE['captcha'] : null;
return ($cookieVal == $seed ? 0 : 1); // #TODO: encrypt with other data
$today = new DateTime();
$isodate = substr((new DateTime())->format('c'), 0, 10);
$raw = $this->getConf('captchaSeed') . '|' . $_SERVER['SERVER_NAME'] . '|' . $_SERVER['REMOTE_ADDR'] . '|' . $isodate;
return $cookieVal !== hash('sha256', $raw);
}
private function insertCaptchaLoader() {
echo '<script>' . NL;
// add the deferred script loader::
echo DOKU_TAB . "addEventListener('DOMContentLoaded', function(){" . NL;
echo DOKU_TAB . DOKU_TAB . "const cj=document.createElement('script');" . NL;
echo DOKU_TAB . DOKU_TAB . "cj.async=true;cj.defer=true;cj.type='text/javascript';" . NL;
echo DOKU_TAB . DOKU_TAB . "cj.src='".DOKU_BASE."lib/plugins/botmon/captcha.js';" . NL;
echo DOKU_TAB . DOKU_TAB . "document.getElementsByTagName('head')[0].appendChild(cj);" . NL;
echo DOKU_TAB . "});";
echo '</script>' . NL;
}
// inserts a blank box to ensure there is enough space for the captcha:
private function insertBlankBox() {
echo '<p style="min-height: 100px;">&nbsp;</p>';
}
/* Generates a few paragraphs of Dada text to show instead of the article content */
private function insertDadaFiller() {
// #TODO: make a dada filler
echo '<h1>'; tpl_pagetitle(); echo "</h1>\n";
global $conf;
global $TOC;
global $ID;
echo '<script> alert("Hello world!"); </script>';
// list of languages to search for the wordlist
$langs = array_unique([$conf['lang'], 'la']);
echo "<p>Placeholder text while the captcha is being displayed.</p>\n";
// find path to the first available wordlist:
foreach ($langs as $lang) {
$filename = __DIR__ .'/lang/' . $lang . '/wordlist.txt'; /* language-specific wordlist */
if (file_exists($filename)) {
break;
}
}
// load the wordlist file:
if (file_exists($filename)) {
$words = array();
$totalWeight = 0;
$lines = file($filename, FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$arr = explode("\t", $line);
$arr[1] = ( count($arr) > 1 ? (int) trim($arr[1]) : 1 );
$totalWeight += (int) $arr[1];
array_push($words, $arr);
}
} else {
echo '<script> console.log("Cant generate filler text: wordlist file not found!"); </script>';
return;
}
// If a TOC exists, use it for the headlines:
if(is_array($TOC)) {
$toc = $TOC;
} else {
$meta = p_get_metadata($ID, '', METADATA_RENDER_USING_CACHE);
//$tocok = (isset($meta['internal']['toc']) ? $meta['internal']['toc'] : $tocok = true);
$toc = isset($meta['description']['tableofcontents']) ? $meta['description']['tableofcontents'] : null;
}
if (!$toc) { // no TOC, generate my own:
$hlCount = mt_rand(0, (int) $conf['tocminheads']);
$toc = array();
for ($i=0; $i<$hlCount; $i++) {
array_push($toc, $this->dadaMakeHeadline($words, $totalWeight)); // $toc
}
}
// if H1 heading is not in the TOC, add a chappeau section:
$chapeauCount = mt_rand(1, 3);
if ((int) $conf['toptoclevel'] > 1) {
echo "<div class=\"level1\">\n";
for ($i=0; $i<$chapeauCount; $i++) {
echo $this->dadaMakeParagraph($words, $totalWeight);
}
echo "</div>\n";
}
// text sections for each sub-headline:
foreach ($toc as $hl) {
echo $this->dadaMakeSection($words, $totalWeight, $hl);
}
}
private function dadaMakeSection($words, $totalWeight, $hl) {
global $conf;
// how many paragraphs?
$paragraphCount = mt_rand(1, 4);
// section level
$topTocLevel = (int) $conf['toptoclevel'];
$secLevel = $hl['level'] + 1;;
// return value:
$sec = "";
// make a headline:
if ($topTocLevel > 1 || $secLevel > 1) {
$sec .= "<h{$secLevel} id=\"{$hl['hid']}\">{$hl['title']}</h{$secLevel}>\n";
}
// add the paragraphs:
$sec .= "<div class=\"level{$secLevel}\">\n";
for ($i=0; $i<$paragraphCount; $i++) {
$sec .= $this->dadaMakeParagraph($words, $totalWeight);
}
$sec .= "</div>\n";
return $sec;
}
private function dadaMakeHeadline($words, $totalWeight) {
// how many words to generate?
$wordCount = mt_rand(2, 5);
// function returns an array:
$r = Array();
// generate the headline:
$hlArr = array();
for ($i=0; $i<$wordCount; $i++) {
array_push($hlArr, $this->dadaSelectRandomWord($words, $totalWeight));
}
$r['title'] = ucfirst(implode(' ', $hlArr));
$r['hid'] = preg_replace('/[^\w\d\-]+/i', '_', strtolower($r['title']));
$r['type'] = 'ul'; // always ul!
$r['level'] = 1; // always level 1 for now
return $r;
}
private function dadaMakeParagraph($words, $totalWeight) {
// how many words to generate?
$sentenceCount = mt_rand(2, 5);
$paragraph = array();
for ($i=0; $i<$sentenceCount; $i++) {
array_push($paragraph, $this->dadaMakeSentence($words, $totalWeight));
}
return "<p>\n" . implode(' ', $paragraph) . "\n</p>\n";
}
private function dadaMakeSentence($words, $totalWeight) {
// how many words to generate?
$wordCount = mt_rand(4, 20);
// generate the sentence:
$sentence = array();
for ($i=0; $i<$wordCount; $i++) {
array_push($sentence, $this->dadaSelectRandomWord($words, $totalWeight));
}
return ucfirst(implode(' ', $sentence)) . '.';
}
private function dadaSelectRandomWord($list, $totalWeight) {
// get a random selection:
$rand = mt_rand(0, $totalWeight);
// match the selection to the weighted list:
$cumulativeWeight = 0;
for ($i=0; $i<count($list); $i++) {
$cumulativeWeight += $list[$i][1];
if ($cumulativeWeight >= $rand) {
return $list[$i][0];
}
}
return '***';
}
}

166
captcha.js Normal file
View File

@@ -0,0 +1,166 @@
"use strict";
/* DokuWiki BotMon Captcha JavaScript */
/* 22.10.2025 - 0.1.0 - pre-release */
/* Author: Sascha Leib <ad@hominem.info> */
const $BMCaptcha = {
init: function() {
/* mark the page to contain the captcha styles */
document.getElementsByTagName('body')[0].classList.add('botmon_captcha');
$BMCaptcha.install()
},
install: function() {
// find the parent element:
let bm_parent = document.getElementsByTagName('body')[0];
// create the dialog:
const dlg = document.createElement('dialog');
dlg.setAttribute('closedby', 'none');
dlg.setAttribute('open', 'open');
dlg.id = 'botmon_captcha_box';
dlg.innerHTML = '<h2>Captcha box</h2><p>Checking if you are a human …</p><p></p>';
// Checkbox:
const lbl = document.createElement('label');
const cb = document.createElement('input');
cb.setAttribute('type', 'checkbox');
cb.addEventListener('click', $BMCaptcha._cbCallback);
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode('I am a human.'));
dlg.appendChild(lbl);
bm_parent.appendChild(dlg);
},
/* creates a digest hash for the cookie function */
digest: {
/* simple SHA hash function - adapted from https://geraintluff.github.io/sha256/ */
hash: function(ascii) {
// shortcut:
const sha256 = $BMCaptcha.digest.hash;
// helper function
const rightRotate = function(v, a) {
return (v>>>a) | (v<<(32 - a));
};
var mathPow = Math.pow;
var maxWord = mathPow(2, 32);
var lengthProperty = 'length'
var i, j;
var result = ''
var words = [];
var asciiBitLength = ascii[lengthProperty]*8;
//* caching results is optional - remove/add slash from front of this line to toggle
// Initial hash value: first 32 bits of the fractional parts of the square roots of the first 8 primes
// (we actually calculate the first 64, but extra values are just ignored)
var hash = sha256.h = sha256.h || [];
// Round constants: first 32 bits of the fractional parts of the cube roots of the first 64 primes
var k = sha256.k = sha256.k || [];
var primeCounter = k[lengthProperty];
/*/
var hash = [], k = [];
var primeCounter = 0;
//*/
var isComposite = {};
for (var candidate = 2; primeCounter < 64; candidate++) {
if (!isComposite[candidate]) {
for (i = 0; i < 313; i += candidate) {
isComposite[i] = candidate;
}
hash[primeCounter] = (mathPow(candidate, .5)*maxWord)|0;
k[primeCounter++] = (mathPow(candidate, 1/3)*maxWord)|0;
}
}
ascii += '\x80' // Append Ƈ' bit (plus zero padding)
while (ascii[lengthProperty]%64 - 56) ascii += '\x00' // More zero padding
for (i = 0; i < ascii[lengthProperty]; i++) {
j = ascii.charCodeAt(i);
if (j>>8) return; // ASCII check: only accept characters in range 0-255
words[i>>2] |= j << ((3 - i)%4)*8;
}
words[words[lengthProperty]] = ((asciiBitLength/maxWord)|0);
words[words[lengthProperty]] = (asciiBitLength)
// process each chunk
for (j = 0; j < words[lengthProperty];) {
var w = words.slice(j, j += 16); // The message is expanded into 64 words as part of the iteration
var oldHash = hash;
// This is now the undefinedworking hash", often labelled as variables a...g
// (we have to truncate as well, otherwise extra entries at the end accumulate
hash = hash.slice(0, 8);
for (i = 0; i < 64; i++) {
var i2 = i + j;
// Expand the message into 64 words
// Used below if
var w15 = w[i - 15], w2 = w[i - 2];
// Iterate
var a = hash[0], e = hash[4];
var temp1 = hash[7]
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) // S1
+ ((e&hash[5])^((~e)&hash[6])) // ch
+ k[i]
// Expand the message schedule if needed
+ (w[i] = (i < 16) ? w[i] : (
w[i - 16]
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15>>>3)) // s0
+ w[i - 7]
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2>>>10)) // s1
)|0
);
// This is only used once, so *could* be moved below, but it only saves 4 bytes and makes things unreadble
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) // S0
+ ((a&hash[1])^(a&hash[2])^(hash[1]&hash[2])); // maj
hash = [(temp1 + temp2)|0].concat(hash); // We don't bother trimming off the extra ones, they're harmless as long as we're truncating when we do the slice()
hash[4] = (hash[4] + temp1)|0;
}
for (i = 0; i < 8; i++) {
hash[i] = (hash[i] + oldHash[i])|0;
}
}
for (i = 0; i < 8; i++) {
for (j = 3; j + 1; j--) {
var b = (hash[i]>>(j*8))&255;
result += ((b < 16) ? 0 : '') + b.toString(16);
}
}
return result;
}
},
_cbCallback: function(e) {
if (e.target.checked) {
//document.getElementById('botmon_captcha_box').close();
// make a hash for the cookie:
const seed = document._botmon.seed || '';
const extIp = document._botmon.ip || '0.0.0.0';
const d = new Date(document._botmon.t0);
const raw = seed + '|' + location.hostname + '|' + extIp + '|' + d.toISOString().substring(0, 10);
const hash = $BMCaptcha.digest.hash(raw);
console.log('Setting cookie to:', raw, ' --> ', hash);
document.cookie = "captcha=" + hash + ';';
window.location.reload(true);
}
}
}
// initialise the captcha module:
$BMCaptcha.init();

View File

@@ -8,5 +8,7 @@
$meta['geoiplib'] = array('multichoice',
'_choices' => array ('disabled', 'phpgeoip'));
$meta['useCaptcha'] = array('onoff');
$meta['captchaSeed'] = array('string', '_pattern' => '/[\da-fA-F]{16,32}/');
//$meta['useCaptcha'] = array('onoff');
$meta['useCaptcha'] = array('multichoice',
'_choices' => array ('disabled', 'blank', 'dada'));
$meta['captchaSeed'] = array('string');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1577
lang/la/wordlist.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,37 @@
/* This file is no longer in use */
body.botmon_captcha {
main {
h1 {
color: transparent !important;
text-shadow: 0 0 .25em rgba(0,.0,0,.8);
}
p, h2, h3, h4, h5, h6 {
color: transparent !important;
text-shadow: 0 0 .35em rgba(0,0,0,.5);
user-select: none;
}
}
#botmon_captcha_box {
border: red dotted 2pt;
position: fixed;
width: 400px;
height: 400px;
top: ~"calc(50vh - 200px)";
left: ~"calc(50vw - 200px)";
border-radius: .5rem;
margin: 0; padding: .5rem;
}
}
/* dark mode overrides */
@media (prefers-color-scheme: dark) {
body.darkmode.botmon_captcha {
main {
h1 {
text-shadow: 0 0 .25em rgba(170,170,170,.75);
}
p, h2, h3, h4, h5, h6 {
text-shadow: 0 0 .35em rgba(170,170,170,.75);
}
}
}
}