1<?php 2 3use dokuwiki\Extension\EventHandler; 4use dokuwiki\Extension\Event; 5use dokuwiki\Logger; 6 7/** 8 * Action Component for the Bot Monitoring Plugin 9 * 10 * @license GPL 3 (http://www.gnu.org/licenses/gpl.html) 11 * @author Sascha Leib <sascha.leib(at)kolmio.com> 12 */ 13 14class action_plugin_botmon extends DokuWiki_Action_Plugin { 15 16 /** 17 * Registers a callback functions 18 * 19 * @param EventHandler $controller DokuWiki's event controller object 20 * @return void 21 */ 22 public function register(EventHandler $controller) { 23 24 global $ACT; 25 26 // populate the session id and type: 27 $this->setSessionInfo(); 28 29 // insert header data into the page: 30 if ($ACT == 'show' || $ACT == 'edit' || $ACT == 'media') { 31 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'insertHeader'); 32 33 // Override the page rendering, if a captcha needs to be displayed: 34 $controller->register_hook('TPL_ACT_RENDER', 'BEFORE', $this, 'showCaptcha'); 35 36 } else if ($ACT == 'admin' && isset($_REQUEST['page']) && $_REQUEST['page'] == 'botmon') { 37 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'insertAdminHeader'); 38 } 39 40 // also show a captcha before the image preview 41 $controller->register_hook('TPL_IMG_DISPLAY', 'BEFORE', $this, 'showImageCaptcha'); 42 43 // write to the log after the page content was displayed: 44 $controller->register_hook('TPL_CONTENT_DISPLAY', 'AFTER', $this, 'writeServerLog'); 45 46 } 47 48 /* session information */ 49 private $sessionId = null; 50 private $sessionType = ''; 51 private $showCaptcha = '-'; 52 53 /** 54 * Inserts tracking code to the page header 55 * (only called on 'show' actions) 56 * 57 * @param Event $event event object by reference 58 * @return void 59 */ 60 public function insertHeader(Event $event, $param) { 61 62 global $INFO; 63 64 65 // build the tracker code: 66 $code = $this->getBMHeader(); 67 68 // add the deferred script loader:: 69 $code .= DOKU_TAB . DOKU_TAB . "addEventListener('DOMContentLoaded', function(){" . NL; 70 $code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "const e=document.createElement('script');" . NL; 71 $code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "e.async=true;e.defer=true;" . NL; 72 $code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "e.src='".DOKU_BASE."lib/plugins/botmon/client.js';" . NL; 73 $code .= DOKU_TAB . DOKU_TAB . DOKU_TAB . "document.getElementsByTagName('head')[0].appendChild(e);" . NL; 74 $code .= DOKU_TAB . DOKU_TAB . "});"; 75 $event->data['script'][] = ['_data' => $code]; 76 } 77 78 /* create the BM object code for insertion into a script element: */ 79 private function getBMHeader() { 80 81 // build the tracker code: 82 $code = DOKU_TAB . DOKU_TAB . "document._botmon = {t0: Date.now(), session: " . json_encode($this->sessionId) . ", seed: " . json_encode($this->getConf('captchaSeed')) . ", ip: " . json_encode($_SERVER['REMOTE_ADDR']) . "};" . NL; 83 84 // is there a user logged in? 85 $username = ( !empty($INFO['userinfo']) && !empty($INFO['userinfo']['name']) ? $INFO['userinfo']['name'] : ''); 86 if ($username) { 87 $code .= DOKU_TAB . DOKU_TAB . 'document._botmon.user = "' . $username . '";'. NL; 88 } 89 90 return $code; 91 92 } 93 94 /** 95 * Inserts tracking code to the page header 96 * (only called on 'show' actions) 97 * 98 * @param Event $event event object by reference 99 * @return void 100 */ 101 public function insertAdminHeader(Event $event, $param) { 102 103 $event->data['link'][] = ['rel' => 'stylesheet', 'href' => DOKU_BASE.'lib/plugins/botmon/admin.css', 'defer' => 'defer']; 104 $event->data['script'][] = ['src' => DOKU_BASE.'lib/plugins/botmon/admin.js', 'defer' => 'defer', '_data' => '']; 105 } 106 107 /** 108 * Writes data to the server log. 109 * 110 * @return void 111 */ 112 public function writeServerLog(Event $event, $param) { 113 114 global $conf; 115 global $INFO; 116 117 // is there a user logged in? 118 $username = ( !empty($INFO['userinfo']) && !empty($INFO['userinfo']['name']) 119 ? $INFO['userinfo']['name'] : ''); 120 121 // clean the page ID 122 $pageId = preg_replace('/[\x00-\x1F]/', "\u{FFFD}", $INFO['id'] ?? ''); 123 124 // create the log array: 125 $logArr = Array( 126 $_SERVER['REMOTE_ADDR'], /* remote IP */ 127 $pageId, /* page ID */ 128 $this->sessionId, /* Session ID */ 129 $this->sessionType, /* session ID type */ 130 $username, /* user name */ 131 $_SERVER['HTTP_USER_AGENT'] ?? '', /* User agent */ 132 $_SERVER['HTTP_REFERER'] ?? '', /* HTTP Referrer */ 133 substr($conf['lang'],0,2), /* page language */ 134 implode(',', array_unique(array_map( function($it) { return substr(trim($it),0,2); }, explode(',',trim($_SERVER['HTTP_ACCEPT_LANGUAGE'], " \t;,*"))))), /* accepted client languages */ 135 $this->getCountryCode(), /* GeoIP country code */ 136 $this->showCaptcha /* show captcha? */ 137 ); 138 139 //* create the log line */ 140 $filename = __DIR__ .'/logs/' . gmdate('Y-m-d') . '.srv.txt'; /* use GMT date for filename */ 141 $logline = gmdate('Y-m-d H:i:s'); /* use GMT time for log entries */ 142 foreach ($logArr as $tab) { 143 $logline .= "\t" . $tab; 144 }; 145 146 /* write the log line to the file */ 147 $logfile = fopen($filename, 'a'); 148 if (!$logfile) die(); 149 if (fwrite($logfile, $logline . "\n") === false) { 150 fclose($logfile); 151 die(); 152 } 153 154 /* Done */ 155 fclose($logfile); 156 } 157 158 private function getCountryCode() { 159 160 $country = ( $_SERVER['REMOTE_ADDR'] == '127.0.0.1' ? 'local' : 'ZZ' ); // default if no geoip is available! 161 162 $lib = $this->getConf('geoiplib'); /* which library to use? (can only be phpgeoip or disabled) */ 163 164 try { 165 166 // use GeoIP module? 167 if ($lib == 'phpgeoip' && extension_loaded('geoip') && geoip_db_avail(GEOIP_COUNTRY_EDITION)) { // Use PHP GeoIP module 168 $result = geoip_country_code_by_name($_SERVER['REMOTE_ADDR']); 169 $country = ($result ? $result : $country); 170 } 171 } catch (Exception $e) { 172 Logger::error('BotMon Plugin: GeoIP Error', $e->getMessage()); 173 } 174 175 return $country; 176 } 177 178 private function setSessionInfo() { 179 180 // what is the session identifier? 181 if (isset($_SESSION)) { 182 $sesKeys = array_keys($_SESSION); /* DokuWiki Session ID preferred */ 183 foreach ($sesKeys as $key) { 184 if (substr($key, 0, 2) == 'DW') { 185 $this->sessionId = $key; 186 $this->sessionType = 'dw'; 187 return; 188 } 189 } 190 } 191 if (!$this->sessionId) { /* no DokuWiki Session ID, try PHP session ID */ 192 $this->sessionId = session_id(); 193 $this->sessionType = 'php'; 194 } 195 if (!$this->sessionId) { /* no PHP session ID, try IP address */ 196 $this->sessionId = $_SERVER['REMOTE_ADDR']; 197 $this->sessionType = 'ip'; 198 } 199 200 if (!$this->sessionId) { /* if all fails, use random data */ 201 $this->sessionId = rand(100000000, 999999999); 202 $this->sessionType = 'rnd'; 203 } 204 205 } 206 207 public function showCaptcha(Event $event) { 208 209 $useCaptcha = $this->getConf('useCaptcha'); 210 211 $cCode = '-'; 212 if ($useCaptcha !== 'disabled') { 213 if ($this->captchaWhitelisted()) { 214 $cCode = 'W'; // whitelisted 215 } elseif ($this->hasCaptchaCookie()) { 216 $cCode = 'N'; // user already has a cookie 217 } else { 218 $cCode = 'Y'; // show the captcha 219 220 221 echo '<h1 class="sectionedit1">'; tpl_pagetitle(); echo "</h1>\n"; // always show the original page title 222 $event->preventDefault(); // don't show normal content 223 switch ($useCaptcha) { 224 case 'blank': 225 $this->insertBlankBox(); // show dada filler instead of text 226 break; 227 case 'dada': 228 $this->insertDadaFiller(); // show dada filler instead of text 229 break; 230 } 231 $this->insertCaptchaLoader(); // and load the captcha 232 } 233 } 234 $this->showCaptcha = $cCode; // store the captcha code for the logfile 235 236 } 237 238 public function showImageCaptcha(Event $event, $param) { 239 240 $useCaptcha = $this->getConf('useCaptcha'); 241 242 echo '<script>' . $this->getBMHeader($event, $param) . '</script>'; 243 244 $cCode = '-'; 245 if ($useCaptcha !== 'disabled') { 246 if ($this->captchaWhitelisted()) { 247 $cCode = 'W'; // whitelisted 248 } 249 elseif ($this->hasCaptchaCookie()) { 250 $cCode = 'N'; // user already has a cookie 251 } 252 else { 253 $cCode = 'Y'; // show the captcha 254 255 echo '<svg width="100%" height="100%" viewBox="0 0 800 400" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M1,1l798,398" style="fill:none;stroke:#f00;stroke-width:1px;"/><path d="M1,399l798,-398" style="fill:none;stroke:#f00;stroke-width:1px;"/><rect x="1" y="1" width="798" height="398" style="fill:none;stroke:#000;stroke-width:1px;"/></svg>'; // placeholder image 256 $event->preventDefault(); // don't show normal content 257 258 // TODO Insert dummy image 259 $this->insertCaptchaLoader(); // and load the captcha 260 } 261 }; 262 263 $this->showCaptcha = $cCode; // store the captcha code for the logfile 264 } 265 266 private function hasCaptchaCookie() { 267 268 $cookieVal = isset($_COOKIE['DWConfirm']) ? $_COOKIE['DWConfirm'] : null; 269 270 $today = substr((new DateTime())->format('c'), 0, 10); 271 272 $raw = $this->getConf('captchaSeed') . '|' . $_SERVER['SERVER_NAME'] . '|' . $_SERVER['REMOTE_ADDR'] . '|' . $today; 273 $expected = hash('sha256', $raw); 274 275 //echo '<ul><li>cookie: ' . $cookieVal . '</li><li>expected: ' . $expected . '</li><li>matches: ' .($cookieVal == $expected ? 'true' : 'false') . '</li></ul>'; 276 277 return $cookieVal == $expected; 278 } 279 280 // check if the visitor's IP is on a whitelist: 281 private function captchaWhitelisted() { 282 283 // normalise IP address: 284 $ip = inet_pton($_SERVER['REMOTE_ADDR']); 285 286 // find which file to open: 287 $prefixes = ['user', 'default']; 288 foreach ($prefixes as $pre) { 289 $filename = __DIR__ .'/config/' . $pre . '-whitelist.txt'; 290 if (file_exists($filename)) { 291 break; 292 } 293 } 294 295 if (file_exists($filename)) { 296 $lines = file($filename, FILE_SKIP_EMPTY_LINES); 297 foreach ($lines as $line) { 298 if (trim($line) !== '' && !str_starts_with($line, '#')) { 299 $col = explode("\t", $line); 300 if (count($col) >= 2) { 301 $from = inet_pton($col[0]); 302 $to = inet_pton($col[1]); 303 304 if ($ip >= $from && $ip <= $to) { 305 return true; /* IP whitelisted */ 306 } 307 } 308 } 309 } 310 } 311 return false; /* IP not found in whitelist */ 312 } 313 314 private function insertCaptchaLoader() { 315 316 echo '<script>' . NL; 317 318 // add the deferred script loader:: 319 echo DOKU_TAB . "addEventListener('DOMContentLoaded', function(){" . NL; 320 echo DOKU_TAB . DOKU_TAB . "const cj=document.createElement('script');" . NL; 321 echo DOKU_TAB . DOKU_TAB . "cj.async=true;cj.defer=true;cj.type='text/javascript';" . NL; 322 echo DOKU_TAB . DOKU_TAB . "cj.src='".DOKU_BASE."lib/plugins/botmon/captcha.js';" . NL; 323 echo DOKU_TAB . DOKU_TAB . "document.getElementsByTagName('head')[0].appendChild(cj);" . NL; 324 echo DOKU_TAB . "});"; 325 326 // add the locales for the captcha: 327 echo DOKU_TAB . '$BMLocales = {' . NL; 328 echo DOKU_TAB . DOKU_TAB . '"dlgTitle": ' . json_encode($this->getLang('bm_dlgTitle')) . ',' . NL; 329 echo DOKU_TAB . DOKU_TAB . '"dlgSubtitle": ' . json_encode($this->getLang('bm_dlgSubtitle')) . ',' . NL; 330 echo DOKU_TAB . DOKU_TAB . '"dlgConfirm": ' . json_encode($this->getLang('bm_dlgConfirm')) . ',' . NL; 331 echo DOKU_TAB . DOKU_TAB . '"dlgChecking": ' . json_encode($this->getLang('bm_dlgChecking')) . ',' . NL; 332 echo DOKU_TAB . DOKU_TAB . '"dlgLoading": ' . json_encode($this->getLang('bm_dlgLoading')) . ',' . NL; 333 echo DOKU_TAB . DOKU_TAB . '"dlgError": ' . json_encode($this->getLang('bm_dlgError')) . ',' . NL; 334 echo DOKU_TAB . '};' . NL; 335 336 echo '</script>' . NL; 337 338 } 339 340 // inserts a blank box to ensure there is enough space for the captcha: 341 private function insertBlankBox() { 342 343 echo '<p style="min-height: 100px;"> </p>'; 344 } 345 346 /* Generates a few paragraphs of Dada text to show instead of the article content */ 347 private function insertDadaFiller() { 348 349 global $conf; 350 global $TOC; 351 global $ID; 352 353 // list of languages to search for the wordlist 354 $langs = array_unique([$conf['lang'], 'la']); 355 356 // find path to the first available wordlist: 357 foreach ($langs as $lang) { 358 $filename = __DIR__ .'/lang/' . $lang . '/wordlist.txt'; /* language-specific wordlist */ 359 if (file_exists($filename)) { 360 break; 361 } 362 } 363 364 // load the wordlist file: 365 if (file_exists($filename)) { 366 $words = array(); 367 $totalWeight = 0; 368 $lines = file($filename, FILE_SKIP_EMPTY_LINES); 369 foreach ($lines as $line) { 370 $arr = explode("\t", $line); 371 $arr[1] = ( count($arr) > 1 ? (int) trim($arr[1]) : 1 ); 372 $totalWeight += (int) $arr[1]; 373 array_push($words, $arr); 374 } 375 } else { 376 echo '<script> console.log("Can’t generate filler text: wordlist file not found!"); </script>'; 377 return; 378 } 379 380 // If a TOC exists, use it for the headlines: 381 if(is_array($TOC)) { 382 $toc = $TOC; 383 } else { 384 $meta = p_get_metadata($ID, '', METADATA_RENDER_USING_CACHE); 385 //$tocok = (isset($meta['internal']['toc']) ? $meta['internal']['toc'] : $tocok = true); 386 $toc = isset($meta['description']['tableofcontents']) ? $meta['description']['tableofcontents'] : null; 387 } 388 if (!$toc) { // no TOC, generate my own: 389 $hlCount = mt_rand(0, (int) $conf['tocminheads']); 390 $toc = array(); 391 for ($i=0; $i<$hlCount; $i++) { 392 array_push($toc, $this->dadaMakeHeadline($words, $totalWeight)); // $toc 393 } 394 } 395 396 // if H1 heading is not in the TOC, add a chappeau section: 397 $chapeauCount = mt_rand(1, 3); 398 if ((int) $conf['toptoclevel'] > 1) { 399 echo "<div class=\"level1\">\n"; 400 for ($i=0; $i<$chapeauCount; $i++) { 401 echo $this->dadaMakeParagraph($words, $totalWeight); 402 } 403 echo "</div>\n"; 404 } 405 406 // text sections for each sub-headline: 407 foreach ($toc as $hl) { 408 echo $this->dadaMakeSection($words, $totalWeight, $hl); 409 } 410 } 411 412 private function dadaMakeSection($words, $totalWeight, $hl) { 413 414 global $conf; 415 416 // how many paragraphs? 417 $paragraphCount = mt_rand(1, 4); 418 419 // section level 420 $topTocLevel = (int) $conf['toptoclevel']; 421 $secLevel = $hl['level'] + 1;; 422 423 // return value: 424 $sec = ""; 425 426 // make a headline: 427 if ($topTocLevel > 1 || $secLevel > 1) { 428 $sec .= "<h{$secLevel} id=\"{$hl['hid']}\">{$hl['title']}</h{$secLevel}>\n"; 429 } 430 431 // add the paragraphs: 432 $sec .= "<div class=\"level{$secLevel}\">\n"; 433 for ($i=0; $i<$paragraphCount; $i++) { 434 $sec .= $this->dadaMakeParagraph($words, $totalWeight); 435 } 436 $sec .= "</div>\n"; 437 438 return $sec; 439 } 440 441 private function dadaMakeHeadline($words, $totalWeight) { 442 443 // how many words to generate? 444 $wordCount = mt_rand(2, 5); 445 446 // function returns an array: 447 $r = Array(); 448 449 // generate the headline: 450 $hlArr = array(); 451 for ($i=0; $i<$wordCount; $i++) { 452 array_push($hlArr, $this->dadaSelectRandomWord($words, $totalWeight)); 453 } 454 455 $r['title'] = ucfirst(implode(' ', $hlArr)); 456 457 $r['hid'] = preg_replace('/[^\w\d\-]+/i', '_', strtolower($r['title'])); 458 $r['type'] = 'ul'; // always ul! 459 $r['level'] = 1; // always level 1 for now 460 461 return $r; 462 } 463 464 private function dadaMakeParagraph($words, $totalWeight) { 465 466 // how many words to generate? 467 $sentenceCount = mt_rand(2, 5); 468 469 $paragraph = array(); 470 for ($i=0; $i<$sentenceCount; $i++) { 471 array_push($paragraph, $this->dadaMakeSentence($words, $totalWeight)); 472 } 473 474 return "<p>\n" . implode(' ', $paragraph) . "\n</p>\n"; 475 476 } 477 478 private function dadaMakeSentence($words, $totalWeight) { 479 480 // how many words to generate? 481 $wordCount = mt_rand(4, 20); 482 483 // generate the sentence: 484 $sentence = array(); 485 for ($i=0; $i<$wordCount; $i++) { 486 array_push($sentence, $this->dadaSelectRandomWord($words, $totalWeight)); 487 } 488 489 return ucfirst(implode(' ', $sentence)) . '.'; 490 491 } 492 493 private function dadaSelectRandomWord($list, $totalWeight) { 494 495 // get a random selection: 496 $rand = mt_rand(0, $totalWeight); 497 498 // match the selection to the weighted list: 499 $cumulativeWeight = 0; 500 for ($i=0; $i<count($list); $i++) { 501 $cumulativeWeight += $list[$i][1]; 502 if ($cumulativeWeight >= $rand) { 503 return $list[$i][0]; 504 } 505 } 506 return '***'; 507 } 508 509}