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