1<?php 2 3namespace dokuwiki\plugin\statistics; 4 5use DeviceDetector\DeviceDetector; 6use DeviceDetector\Parser\Client\Browser; 7use DeviceDetector\Parser\Device\AbstractDeviceParser; 8use DeviceDetector\Parser\OperatingSystem; 9use dokuwiki\HTTP\DokuHTTPClient; 10use dokuwiki\plugin\sqlite\SQLiteDB; 11use dokuwiki\Utf8\Clean; 12use helper_plugin_popularity; 13use helper_plugin_statistics; 14 15 16class Logger 17{ 18 /** @var helper_plugin_statistics The statistics helper plugin instance */ 19 protected helper_plugin_statistics $hlp; 20 21 /** @var SQLiteDB The SQLite database instance */ 22 protected SQLiteDB $db; 23 24 /** @var string The full user agent string */ 25 protected string $uaAgent; 26 27 /** @var string The type of user agent (browser, robot, feedreader) */ 28 protected string $uaType = 'browser'; 29 30 /** @var string The browser/client name */ 31 protected string $uaName; 32 33 /** @var string The browser/client version */ 34 protected string $uaVersion; 35 36 /** @var string The operating system/platform */ 37 protected string $uaPlatform; 38 39 /** @var string The unique user identifier */ 40 protected string $uid; 41 42 /** @var DokuHTTPClient|null The HTTP client instance for testing */ 43 protected ?DokuHTTPClient $httpClient = null; 44 45 46 /** 47 * Constructor 48 * 49 * Parses browser info and set internal vars 50 */ 51 public function __construct(helper_plugin_statistics $hlp, ?DokuHTTPClient $httpClient = null) 52 { 53 global $INPUT; 54 55 $this->hlp = $hlp; 56 $this->db = $this->hlp->getDB(); 57 $this->httpClient = $httpClient; 58 59 $ua = trim($INPUT->server->str('HTTP_USER_AGENT')); 60 61 AbstractDeviceParser::setVersionTruncation(AbstractDeviceParser::VERSION_TRUNCATION_MAJOR); 62 $dd = new DeviceDetector($ua); // FIXME we could use client hints, but need to add headers 63 $dd->discardBotInformation(); 64 $dd->parse(); 65 66 if ($dd->isFeedReader()) { 67 $this->uaType = 'feedreader'; 68 } else if ($dd->isBot()) { 69 $this->uaType = 'robot'; 70 71 // for now ignore bots 72 throw new \RuntimeException('Bot detected, not logging'); 73 } 74 75 $this->uaAgent = $ua; 76 $this->uaName = Browser::getBrowserFamily($dd->getClient('name')) ?: 'Unknown'; 77 $this->uaVersion = $dd->getClient('version') ?: '0'; 78 $this->uaPlatform = OperatingSystem::getOsFamily($dd->getOs('name')) ?: 'Unknown'; 79 $this->uid = $this->getUID(); 80 81 82 $this->logLastseen(); 83 } 84 85 /** 86 * Should be called before logging 87 * 88 * This starts a transaction, so all logging is done in one go 89 */ 90 public function begin(): void 91 { 92 $this->hlp->getDB()->getPdo()->beginTransaction(); 93 } 94 95 /** 96 * Should be called after logging 97 * 98 * This commits the transaction started in begin() 99 */ 100 public function end(): void 101 { 102 $this->hlp->getDB()->getPdo()->commit(); 103 } 104 105 /** 106 * Get the unique user ID 107 * 108 * @return string The unique user identifier 109 */ 110 protected function getUID(): string 111 { 112 global $INPUT; 113 114 $uid = $INPUT->str('uid'); 115 if (!$uid) $uid = get_doku_pref('plgstats', false); 116 if (!$uid) $uid = session_id(); 117 set_doku_pref('plgstats', $uid); 118 return $uid; 119 } 120 121 /** 122 * Return the user's session ID 123 * 124 * This is usually our own managed session, not a PHP session (only in fallback) 125 * 126 * @return string The session identifier 127 */ 128 protected function getSession(): string 129 { 130 global $INPUT; 131 132 $ses = $INPUT->str('ses'); 133 if (!$ses) $ses = get_doku_pref('plgstatsses', false); 134 if (!$ses) $ses = session_id(); 135 set_doku_pref('plgstatsses', $ses); 136 return $ses; 137 } 138 139 /** 140 * Log that we've seen the user (authenticated only) 141 */ 142 public function logLastseen(): void 143 { 144 global $INPUT; 145 146 if (empty($INPUT->server->str('REMOTE_USER'))) return; 147 148 $this->db->exec( 149 'REPLACE INTO lastseen (user, dt) VALUES (?, CURRENT_TIMESTAMP)', 150 $INPUT->server->str('REMOTE_USER'), 151 ); 152 } 153 154 /** 155 * Log actions by groups 156 * 157 * @param string $type The type of access to log ('view','edit') 158 * @param array $groups The groups to log 159 */ 160 public function logGroups(string $type, array $groups): void 161 { 162 if (!$groups) return; 163 164 $toLog = (array)$this->hlp->getConf('loggroups'); 165 166 // if specific groups are configured, limit logging to them only 167 $groups = !empty(array_filter($toLog)) ? array_intersect($groups, $toLog) : $groups; 168 if (!$groups) return; 169 170 $placeholders = join(',', array_fill(0, count($groups), '(?, ?)')); 171 $params = []; 172 $sql = "INSERT INTO groups (`type`, `group`) VALUES $placeholders"; 173 foreach ($groups as $group) { 174 $params[] = $type; 175 $params[] = $group; 176 } 177 $sql = rtrim($sql, ','); 178 $this->db->exec($sql, $params); 179 } 180 181 /** 182 * Log external search queries 183 * 184 * Will not write anything if the referer isn't a search engine 185 * 186 * @param string $referer The HTTP referer URL 187 * @param string $type Reference to the type variable that will be modified 188 */ 189 public function logExternalSearch(string $referer, string &$type): void 190 { 191 global $INPUT; 192 193 $searchEngine = new SearchEngines($referer); 194 195 if (!$searchEngine->isSearchEngine()) { 196 return; // not a search engine 197 } 198 199 $type = 'search'; 200 $query = $searchEngine->getQuery(); 201 202 // log it! 203 $words = explode(' ', Clean::stripspecials($query, ' ', '\._\-:\*')); 204 $this->logSearch($INPUT->str('p'), $query, $words, $searchEngine->getEngine()); 205 } 206 207 /** 208 * Log search data to the search related tables 209 * 210 * @param string $page The page being searched from 211 * @param string $query The search query 212 * @param array $words Array of search words 213 * @param string $engine The search engine name 214 */ 215 public function logSearch(string $page, string $query, array $words, string $engine): void 216 { 217 $sid = $this->db->exec( 218 'INSERT INTO search (dt, page, query, engine) VALUES (CURRENT_TIMESTAMP, ?, ?, ?)', 219 $page, $query, $engine 220 ); 221 if (!$sid) return; 222 223 foreach ($words as $word) { 224 if (!$word) continue; 225 $this->db->exec( 226 'INSERT INTO searchwords (sid, word) VALUES (?, ?)', 227 $sid, $word 228 ); 229 } 230 } 231 232 /** 233 * Log that the session was seen 234 * 235 * This is used to calculate the time people spend on the whole site 236 * during their session 237 * 238 * Viewcounts are used for bounce calculation 239 * 240 * @param int $addview set to 1 to count a view 241 */ 242 public function logSession(int $addview = 0): void 243 { 244 // only log browser sessions 245 if ($this->uaType != 'browser') return; 246 247 $session = $this->getSession(); 248 $this->db->exec( 249 'INSERT OR REPLACE INTO session ( 250 session, dt, end, views, uid 251 ) VALUES ( 252 ?, 253 CURRENT_TIMESTAMP, 254 CURRENT_TIMESTAMP, 255 COALESCE((SELECT views FROM session WHERE session = ?) + ?, ?), 256 ? 257 )', 258 $session, $session, $addview, $addview, $this->uid 259 ); 260 } 261 262 /** 263 * Resolve IP to country/city and store in database 264 * 265 * @param string $ip The IP address to resolve 266 */ 267 public function logIp(string $ip): void 268 { 269 // check if IP already known and up-to-date 270 $result = $this->db->queryValue( 271 "SELECT ip 272 FROM iplocation 273 WHERE ip = ? 274 AND lastupd > date('now', '-30 days')", 275 $ip 276 ); 277 if ($result) return; 278 279 $http = $this->httpClient ?: new DokuHTTPClient(); 280 $http->timeout = 10; 281 $json = $http->get('http://ip-api.com/json/' . $ip); // yes, it's HTTP only 282 283 if (!$json) return; // FIXME log error 284 try { 285 $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 286 } catch (\JsonException $e) { 287 return; // FIXME log error 288 } 289 if (!isset($data['status']) || $data['status'] !== 'success') { 290 return; // FIXME log error 291 } 292 293 $host = gethostbyaddr($ip); 294 $this->db->exec( 295 'INSERT OR REPLACE INTO iplocation ( 296 ip, country, code, city, host, lastupd 297 ) VALUES ( 298 ?, ?, ?, ?, ?, CURRENT_TIMESTAMP 299 )', 300 $ip, $data['country'], $data['countryCode'], $data['city'], $host 301 ); 302 } 303 304 /** 305 * Log a click on an external link 306 * 307 * Called from log.php 308 */ 309 public function logOutgoing(): void 310 { 311 global $INPUT; 312 313 if (!$INPUT->str('ol')) return; 314 315 $link = $INPUT->str('ol'); 316 $link_md5 = md5($link); 317 $session = $this->getSession(); 318 $page = $INPUT->str('p'); 319 320 $this->db->exec( 321 'INSERT INTO outlinks ( 322 dt, session, page, link_md5, link 323 ) VALUES ( 324 CURRENT_TIMESTAMP, ?, ?, ?, ? 325 )', 326 $session, $page, $link_md5, $link 327 ); 328 } 329 330 /** 331 * Log a page access 332 * 333 * Called from log.php 334 */ 335 public function logAccess(): void 336 { 337 global $INPUT, $USERINFO; 338 339 if (!$INPUT->str('p')) return; 340 341 # FIXME check referer against blacklist and drop logging for bad boys 342 343 // handle referer 344 $referer = trim($INPUT->str('r')); 345 if ($referer) { 346 $ref = $referer; 347 $ref_md5 = md5($referer); 348 if (str_starts_with($referer, DOKU_URL)) { 349 $ref_type = 'internal'; 350 } else { 351 $ref_type = 'external'; 352 $this->logExternalSearch($referer, $ref_type); 353 } 354 } else { 355 $ref = ''; 356 $ref_md5 = ''; 357 $ref_type = ''; 358 } 359 360 $page = $INPUT->str('p'); 361 $ip = clientIP(true); 362 $sx = $INPUT->int('sx'); 363 $sy = $INPUT->int('sy'); 364 $vx = $INPUT->int('vx'); 365 $vy = $INPUT->int('vy'); 366 $js = $INPUT->int('js'); 367 $user = $INPUT->server->str('REMOTE_USER'); 368 $session = $this->getSession(); 369 370 $this->db->exec( 371 'INSERT INTO access ( 372 dt, page, ip, ua, ua_info, ua_type, ua_ver, os, ref, ref_md5, ref_type, 373 screen_x, screen_y, view_x, view_y, js, user, session, uid 374 ) VALUES ( 375 CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 376 ?, ?, ?, ?, ?, ?, ?, ? 377 )', 378 $page, $ip, $this->uaAgent, $this->uaName, $this->uaType, $this->uaVersion, $this->uaPlatform, 379 $ref, $ref_md5, $ref_type, $sx, $sy, $vx, $vy, $js, $user, $session, $this->uid 380 ); 381 382 if ($ref_md5) { 383 $this->db->exec( 384 'INSERT OR IGNORE INTO refseen ( 385 ref_md5, dt 386 ) VALUES ( 387 ?, CURRENT_TIMESTAMP 388 )', 389 $ref_md5 390 ); 391 } 392 393 // log group access 394 if (isset($USERINFO['grps'])) { 395 $this->logGroups('view', $USERINFO['grps']); 396 } 397 398 // resolve the IP 399 $this->logIp(clientIP(true)); 400 } 401 402 /** 403 * Log access to a media file 404 * 405 * Called from action.php 406 * 407 * @param string $media The media ID 408 * @param string $mime The media's mime type 409 * @param bool $inline Is this displayed inline? 410 * @param int $size Size of the media file 411 */ 412 public function logMedia(string $media, string $mime, bool $inline, int $size): void 413 { 414 global $INPUT; 415 416 [$mime1, $mime2] = explode('/', strtolower($mime)); 417 $inline = $inline ? 1 : 0; 418 $size = (int)$size; 419 420 $ip = clientIP(true); 421 $user = $INPUT->server->str('REMOTE_USER'); 422 $session = $this->getSession(); 423 424 $this->db->exec( 425 'INSERT INTO media ( 426 dt, media, ip, ua, ua_info, ua_type, ua_ver, os, user, session, uid, 427 size, mime1, mime2, inline 428 ) VALUES ( 429 CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 430 ?, ?, ?, ? 431 )', 432 $media, $ip, $this->uaAgent, $this->uaName, $this->uaType, $this->uaVersion, $this->uaPlatform, 433 $user, $session, $this->uid, $size, $mime1, $mime2, $inline 434 ); 435 } 436 437 /** 438 * Log page edits 439 * 440 * @param string $page The page that was edited 441 * @param string $type The type of edit (create, edit, etc.) 442 */ 443 public function logEdit(string $page, string $type): void 444 { 445 global $INPUT, $USERINFO; 446 447 $ip = clientIP(true); 448 $user = $INPUT->server->str('REMOTE_USER'); 449 $session = $this->getSession(); 450 451 $this->db->exec( 452 'INSERT INTO edits ( 453 dt, page, type, ip, user, session, uid 454 ) VALUES ( 455 CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ? 456 )', 457 $page, $type, $ip, $user, $session, $this->uid 458 ); 459 460 // log group access 461 if (isset($USERINFO['grps'])) { 462 $this->logGroups('edit', $USERINFO['grps']); 463 } 464 } 465 466 /** 467 * Log login/logoffs and user creations 468 * 469 * @param string $type The type of login event (login, logout, create) 470 * @param string $user The username (optional, will use current user if empty) 471 */ 472 public function logLogin(string $type, string $user = ''): void 473 { 474 global $INPUT; 475 476 if (!$user) $user = $INPUT->server->str('REMOTE_USER'); 477 478 $ip = clientIP(true); 479 $session = $this->getSession(); 480 481 $this->db->exec( 482 'INSERT INTO logins ( 483 dt, type, ip, user, session, uid 484 ) VALUES ( 485 CURRENT_TIMESTAMP, ?, ?, ?, ?, ? 486 )', 487 $type, $ip, $user, $session, $this->uid 488 ); 489 } 490 491 /** 492 * Log the current page count and size as today's history entry 493 */ 494 public function logHistoryPages(): void 495 { 496 global $conf; 497 498 // use the popularity plugin's search method to find the wanted data 499 /** @var helper_plugin_popularity $pop */ 500 $pop = plugin_load('helper', 'popularity'); 501 $list = $this->initEmptySearchList(); 502 search($list, $conf['datadir'], [$pop, 'searchCountCallback'], ['all' => false], ''); 503 $page_count = $list['file_count']; 504 $page_size = $list['file_size']; 505 506 $this->db->exec( 507 'INSERT OR REPLACE INTO history ( 508 info, value, dt 509 ) VALUES ( 510 ?, ?, CURRENT_TIMESTAMP 511 )', 512 'page_count', $page_count 513 ); 514 $this->db->exec( 515 'INSERT OR REPLACE INTO history ( 516 info, value, dt 517 ) VALUES ( 518 ?, ?, CURRENT_TIMESTAMP 519 )', 520 'page_size', $page_size 521 ); 522 } 523 524 /** 525 * Log the current media count and size as today's history entry 526 */ 527 public function logHistoryMedia(): void 528 { 529 global $conf; 530 531 // use the popularity plugin's search method to find the wanted data 532 /** @var helper_plugin_popularity $pop */ 533 $pop = plugin_load('helper', 'popularity'); 534 $list = $this->initEmptySearchList(); 535 search($list, $conf['mediadir'], [$pop, 'searchCountCallback'], ['all' => true], ''); 536 $media_count = $list['file_count']; 537 $media_size = $list['file_size']; 538 539 $this->db->exec( 540 'INSERT OR REPLACE INTO history ( 541 info, value, dt 542 ) VALUES ( 543 ?, ?, CURRENT_TIMESTAMP 544 )', 545 'media_count', $media_count 546 ); 547 $this->db->exec( 548 'INSERT OR REPLACE INTO history ( 549 info, value, dt 550 ) VALUES ( 551 ?, ?, CURRENT_TIMESTAMP 552 )', 553 'media_size', $media_size 554 ); 555 } 556 557 /** 558 * @todo can be dropped in favor of helper_plugin_popularity::initEmptySearchList() once it's public 559 * @return array 560 */ 561 protected function initEmptySearchList() 562 { 563 return array_fill_keys([ 564 'file_count', 565 'file_size', 566 'file_max', 567 'file_min', 568 'dir_count', 569 'dir_nest', 570 'file_oldest' 571 ], 0); 572 } 573} 574