xref: /plugin/botmon/action.php (revision 393de67c2219bde8f8a5693bcbdc0755b6d8e697)
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}