1<?php 2 3/** 4 * Common DokuWiki functions 5 * 6 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 7 * @author Andreas Gohr <andi@splitbrain.org> 8 */ 9 10use dokuwiki\PassHash; 11use dokuwiki\Draft; 12use dokuwiki\Utf8\Clean; 13use dokuwiki\Utf8\PhpString; 14use dokuwiki\Utf8\Conversion; 15use dokuwiki\Cache\CacheRenderer; 16use dokuwiki\ChangeLog\PageChangeLog; 17use dokuwiki\File\PageFile; 18use dokuwiki\Subscriptions\PageSubscriptionSender; 19use dokuwiki\Subscriptions\SubscriberManager; 20use dokuwiki\Extension\AuthPlugin; 21use dokuwiki\Extension\Event; 22 23use function PHP81_BC\strftime; 24 25/** 26 * Wrapper around htmlspecialchars() 27 * 28 * @param string $string the string being converted 29 * @return string converted string 30 * @author Andreas Gohr <andi@splitbrain.org> 31 * @see htmlspecialchars() 32 * 33 */ 34function hsc($string) 35{ 36 return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8'); 37} 38 39/** 40 * A safer explode for fixed length lists 41 * 42 * This works just like explode(), but will always return the wanted number of elements. 43 * If the $input string does not contain enough elements, the missing elements will be 44 * filled up with the $default value. If the input string contains more elements, the last 45 * one will NOT be split up and will still contain $separator 46 * 47 * @param string $separator The boundary string 48 * @param string $string The input string 49 * @param int $limit The number of expected elements 50 * @param mixed $default The value to use when filling up missing elements 51 * @return array 52 * @see explode 53 */ 54function sexplode($separator, $string, $limit, $default = null) 55{ 56 return array_pad(explode($separator, $string, $limit), $limit, $default); 57} 58 59/** 60 * Checks if the given input is blank 61 * 62 * This is similar to empty() but will return false for "0". 63 * 64 * Please note: when you pass uninitialized variables, they will implicitly be created 65 * with a NULL value without warning. 66 * 67 * To avoid this it's recommended to guard the call with isset like this: 68 * 69 * (isset($foo) && !blank($foo)) 70 * (!isset($foo) || blank($foo)) 71 * 72 * @param $in 73 * @param bool $trim Consider a string of whitespace to be blank 74 * @return bool 75 */ 76function blank(&$in, $trim = false) 77{ 78 if (is_null($in)) return true; 79 if (is_array($in)) return $in === []; 80 if ($in === "\0") return true; 81 if ($trim && trim($in) === '') return true; 82 if (strlen($in) > 0) return false; 83 return empty($in); 84} 85 86/** 87 * strips control characters (<32) from the given string 88 * 89 * @param string $string being stripped 90 * @return string 91 * @author Andreas Gohr <andi@splitbrain.org> 92 * 93 */ 94function stripctl($string) 95{ 96 return preg_replace('/[\x00-\x1F]+/s', '', $string); 97} 98 99/** 100 * Return a secret token to be used for CSRF attack prevention 101 * 102 * @return string 103 * @link http://en.wikipedia.org/wiki/Cross-site_request_forgery 104 * @link http://christ1an.blogspot.com/2007/04/preventing-csrf-efficiently.html 105 * 106 * @author Andreas Gohr <andi@splitbrain.org> 107 */ 108function getSecurityToken() 109{ 110 /** @var Input $INPUT */ 111 global $INPUT; 112 113 $user = $INPUT->server->str('REMOTE_USER'); 114 $session = session_id(); 115 116 // CSRF checks are only for logged in users - do not generate for anonymous 117 if (trim($user) == '' || trim($session) == '') return ''; 118 return PassHash::hmac('md5', $session . $user, auth_cookiesalt()); 119} 120 121/** 122 * Check the secret CSRF token 123 * 124 * @param null|string $token security token or null to read it from request variable 125 * @return bool success if the token matched 126 */ 127function checkSecurityToken($token = null) 128{ 129 /** @var Input $INPUT */ 130 global $INPUT; 131 if (!$INPUT->server->str('REMOTE_USER')) return true; // no logged in user, no need for a check 132 133 if (is_null($token)) $token = $INPUT->str('sectok'); 134 if (getSecurityToken() != $token) { 135 msg('Security Token did not match. Possible CSRF attack.', -1); 136 return false; 137 } 138 return true; 139} 140 141/** 142 * Print a hidden form field with a secret CSRF token 143 * 144 * @param bool $print if true print the field, otherwise html of the field is returned 145 * @return string html of hidden form field 146 * @author Andreas Gohr <andi@splitbrain.org> 147 * 148 */ 149function formSecurityToken($print = true) 150{ 151 $ret = '<div class="no"><input type="hidden" name="sectok" value="' . getSecurityToken() . '" /></div>' . "\n"; 152 if ($print) echo $ret; 153 return $ret; 154} 155 156/** 157 * Determine basic information for a request of $id 158 * 159 * @param string $id pageid 160 * @param bool $htmlClient add info about whether is mobile browser 161 * @return array with info for a request of $id 162 * 163 * @author Chris Smith <chris@jalakai.co.uk> 164 * 165 * @author Andreas Gohr <andi@splitbrain.org> 166 */ 167function basicinfo($id, $htmlClient = true) 168{ 169 global $USERINFO; 170 /* @var Input $INPUT */ 171 global $INPUT; 172 173 // set info about manager/admin status. 174 $info = []; 175 $info['isadmin'] = false; 176 $info['ismanager'] = false; 177 if ($INPUT->server->has('REMOTE_USER')) { 178 $info['userinfo'] = $USERINFO; 179 $info['perm'] = auth_quickaclcheck($id); 180 $info['client'] = $INPUT->server->str('REMOTE_USER'); 181 182 if ($info['perm'] == AUTH_ADMIN) { 183 $info['isadmin'] = true; 184 $info['ismanager'] = true; 185 } elseif (auth_ismanager()) { 186 $info['ismanager'] = true; 187 } 188 189 // if some outside auth were used only REMOTE_USER is set 190 if (empty($info['userinfo']['name'])) { 191 $info['userinfo']['name'] = $INPUT->server->str('REMOTE_USER'); 192 } 193 } else { 194 $info['perm'] = auth_aclcheck($id, '', null); 195 $info['client'] = clientIP(true); 196 } 197 198 $info['namespace'] = getNS($id); 199 200 // mobile detection 201 if ($htmlClient) { 202 $info['ismobile'] = clientismobile(); 203 } 204 205 return $info; 206} 207 208/** 209 * Return info about the current document as associative 210 * array. 211 * 212 * @return array with info about current document 213 * @throws Exception 214 * 215 * @author Andreas Gohr <andi@splitbrain.org> 216 */ 217function pageinfo() 218{ 219 global $ID; 220 global $REV; 221 global $RANGE; 222 global $lang; 223 224 $info = basicinfo($ID); 225 226 // include ID & REV not redundant, as some parts of DokuWiki may temporarily change $ID, e.g. p_wiki_xhtml 227 // FIXME ... perhaps it would be better to ensure the temporary changes weren't necessary 228 $info['id'] = $ID; 229 $info['rev'] = $REV; 230 231 $subManager = new SubscriberManager(); 232 $info['subscribed'] = $subManager->userSubscription(); 233 234 $info['locked'] = checklock($ID); 235 $info['filepath'] = wikiFN($ID); 236 $info['exists'] = file_exists($info['filepath']); 237 $info['currentrev'] = @filemtime($info['filepath']); 238 239 if ($REV) { 240 //check if current revision was meant 241 if ($info['exists'] && ($info['currentrev'] == $REV)) { 242 $REV = ''; 243 } elseif ($RANGE) { 244 //section editing does not work with old revisions! 245 $REV = ''; 246 $RANGE = ''; 247 msg($lang['nosecedit'], 0); 248 } else { 249 //really use old revision 250 $info['filepath'] = wikiFN($ID, $REV); 251 $info['exists'] = file_exists($info['filepath']); 252 } 253 } 254 $info['rev'] = $REV; 255 if ($info['exists']) { 256 $info['writable'] = (is_writable($info['filepath']) && $info['perm'] >= AUTH_EDIT); 257 } else { 258 $info['writable'] = ($info['perm'] >= AUTH_CREATE); 259 } 260 $info['editable'] = ($info['writable'] && empty($info['locked'])); 261 $info['lastmod'] = @filemtime($info['filepath']); 262 263 //load page meta data 264 $info['meta'] = p_get_metadata($ID); 265 266 //who's the editor 267 $pagelog = new PageChangeLog($ID, 1024); 268 if ($REV) { 269 $revinfo = $pagelog->getRevisionInfo($REV); 270 } elseif (!empty($info['meta']['last_change']) && is_array($info['meta']['last_change'])) { 271 $revinfo = $info['meta']['last_change']; 272 } else { 273 $revinfo = $pagelog->getRevisionInfo($info['lastmod']); 274 // cache most recent changelog line in metadata if missing and still valid 275 if ($revinfo !== false) { 276 $info['meta']['last_change'] = $revinfo; 277 p_set_metadata($ID, ['last_change' => $revinfo]); 278 } 279 } 280 //and check for an external edit 281 if ($revinfo !== false && $revinfo['date'] != $info['lastmod']) { 282 // cached changelog line no longer valid 283 $revinfo = false; 284 $info['meta']['last_change'] = $revinfo; 285 p_set_metadata($ID, ['last_change' => $revinfo]); 286 } 287 288 if ($revinfo !== false) { 289 $info['ip'] = $revinfo['ip']; 290 $info['user'] = $revinfo['user']; 291 $info['sum'] = $revinfo['sum']; 292 // See also $INFO['meta']['last_change'] which is the most recent log line for page $ID. 293 // Use $INFO['meta']['last_change']['type']===DOKU_CHANGE_TYPE_MINOR_EDIT in place of $info['minor']. 294 295 $info['editor'] = $revinfo['user'] ?: $revinfo['ip']; 296 } else { 297 $info['ip'] = null; 298 $info['user'] = null; 299 $info['sum'] = null; 300 $info['editor'] = null; 301 } 302 303 // draft 304 $draft = new Draft($ID, $info['client']); 305 if ($draft->isDraftAvailable()) { 306 $info['draft'] = $draft->getDraftFilename(); 307 } 308 309 return $info; 310} 311 312/** 313 * Initialize and/or fill global $JSINFO with some basic info to be given to javascript 314 */ 315function jsinfo() 316{ 317 global $JSINFO, $ID, $INFO, $ACT; 318 319 if (!is_array($JSINFO)) { 320 $JSINFO = []; 321 } 322 //export minimal info to JS, plugins can add more 323 $JSINFO['id'] = $ID; 324 $JSINFO['namespace'] = isset($INFO) ? (string)$INFO['namespace'] : ''; 325 $JSINFO['ACT'] = act_clean($ACT); 326 $JSINFO['useHeadingNavigation'] = (int)useHeading('navigation'); 327 $JSINFO['useHeadingContent'] = (int)useHeading('content'); 328} 329 330/** 331 * Return information about the current media item as an associative array. 332 * 333 * @return array with info about current media item 334 */ 335function mediainfo() 336{ 337 global $NS; 338 global $IMG; 339 340 $info = basicinfo("$NS:*"); 341 $info['image'] = $IMG; 342 343 return $info; 344} 345 346/** 347 * Build an string of URL parameters 348 * 349 * @param array $params array with key-value pairs 350 * @param string $sep series of pairs are separated by this character 351 * @return string query string 352 * @author Andreas Gohr 353 * 354 */ 355function buildURLparams($params, $sep = '&') 356{ 357 $url = ''; 358 $amp = false; 359 foreach ($params as $key => $val) { 360 if ($amp) $url .= $sep; 361 362 $url .= rawurlencode($key) . '='; 363 $url .= rawurlencode((string)$val); 364 $amp = true; 365 } 366 return $url; 367} 368 369/** 370 * Build an string of html tag attributes 371 * 372 * Skips keys starting with '_', values get HTML encoded 373 * 374 * @param array $params array with (attribute name-attribute value) pairs 375 * @param bool $skipEmptyStrings skip empty string values? 376 * @return string 377 * @author Andreas Gohr 378 * 379 */ 380function buildAttributes($params, $skipEmptyStrings = false) 381{ 382 $url = ''; 383 $white = false; 384 foreach ($params as $key => $val) { 385 if ($key[0] == '_') continue; 386 if ($val === '' && $skipEmptyStrings) continue; 387 if ($white) $url .= ' '; 388 389 $url .= $key . '="'; 390 $url .= hsc($val); 391 $url .= '"'; 392 $white = true; 393 } 394 return $url; 395} 396 397/** 398 * This builds the breadcrumb trail and returns it as array 399 * 400 * @return string[] with the data: array(pageid=>name, ... ) 401 * @author Andreas Gohr <andi@splitbrain.org> 402 * 403 */ 404function breadcrumbs() 405{ 406 // we prepare the breadcrumbs early for quick session closing 407 static $crumbs = null; 408 if ($crumbs != null) return $crumbs; 409 410 global $ID; 411 global $ACT; 412 global $conf; 413 global $INFO; 414 415 //first visit? 416 $crumbs = $_SESSION[DOKU_COOKIE]['bc'] ?? []; 417 //we only save on show and existing visible readable wiki documents 418 $file = wikiFN($ID); 419 if ($ACT != 'show' || $INFO['perm'] < AUTH_READ || isHiddenPage($ID) || !file_exists($file)) { 420 $_SESSION[DOKU_COOKIE]['bc'] = $crumbs; 421 return $crumbs; 422 } 423 424 // page names 425 $name = noNSorNS($ID); 426 if (useHeading('navigation')) { 427 // get page title 428 $title = p_get_first_heading($ID, METADATA_RENDER_USING_SIMPLE_CACHE); 429 if ($title) { 430 $name = $title; 431 } 432 } 433 434 //remove ID from array 435 if (isset($crumbs[$ID])) { 436 unset($crumbs[$ID]); 437 } 438 439 //add to array 440 $crumbs[$ID] = $name; 441 //reduce size 442 while (count($crumbs) > $conf['breadcrumbs']) { 443 array_shift($crumbs); 444 } 445 //save to session 446 $_SESSION[DOKU_COOKIE]['bc'] = $crumbs; 447 return $crumbs; 448} 449 450/** 451 * Filter for page IDs 452 * 453 * This is run on a ID before it is outputted somewhere 454 * currently used to replace the colon with something else 455 * on Windows (non-IIS) systems and to have proper URL encoding 456 * 457 * See discussions at https://github.com/dokuwiki/dokuwiki/pull/84 and 458 * https://github.com/dokuwiki/dokuwiki/pull/173 why we use a whitelist of 459 * unaffected servers instead of blacklisting affected servers here. 460 * 461 * Urlencoding is ommitted when the second parameter is false 462 * 463 * @param string $id pageid being filtered 464 * @param bool $ue apply urlencoding? 465 * @return string 466 * @author Andreas Gohr <andi@splitbrain.org> 467 * 468 */ 469function idfilter($id, $ue = true) 470{ 471 global $conf; 472 /* @var Input $INPUT */ 473 global $INPUT; 474 475 $id = (string)$id; 476 477 if ($conf['useslash'] && $conf['userewrite']) { 478 $id = strtr($id, ':', '/'); 479 } elseif ( 480 str_starts_with(strtoupper(PHP_OS), 'WIN') && 481 $conf['userewrite'] && 482 strpos($INPUT->server->str('SERVER_SOFTWARE'), 'Microsoft-IIS') === false 483 ) { 484 $id = strtr($id, ':', ';'); 485 } 486 if ($ue) { 487 $id = rawurlencode($id); 488 $id = str_replace('%3A', ':', $id); //keep as colon 489 $id = str_replace('%3B', ';', $id); //keep as semicolon 490 $id = str_replace('%2F', '/', $id); //keep as slash 491 } 492 return $id; 493} 494 495/** 496 * This builds a link to a wikipage 497 * 498 * It handles URL rewriting and adds additional parameters 499 * 500 * @param string $id page id, defaults to start page 501 * @param string|array $urlParameters URL parameters, associative array recommended 502 * @param bool $absolute request an absolute URL instead of relative 503 * @param string $separator parameter separator 504 * @return string 505 * @author Andreas Gohr <andi@splitbrain.org> 506 * 507 */ 508function wl($id = '', $urlParameters = '', $absolute = false, $separator = '&') 509{ 510 global $conf; 511 if (is_array($urlParameters)) { 512 if (isset($urlParameters['rev']) && !$urlParameters['rev']) unset($urlParameters['rev']); 513 if (isset($urlParameters['at']) && $conf['date_at_format']) { 514 $urlParameters['at'] = date($conf['date_at_format'], $urlParameters['at']); 515 } 516 $urlParameters = buildURLparams($urlParameters, $separator); 517 } else { 518 $urlParameters = str_replace(',', $separator, $urlParameters); 519 } 520 if ($id === '') { 521 $id = $conf['start']; 522 } 523 $id = idfilter($id); 524 if ($absolute) { 525 $xlink = DOKU_URL; 526 } else { 527 $xlink = DOKU_BASE; 528 } 529 530 if ($conf['userewrite'] == 2) { 531 $xlink .= DOKU_SCRIPT . '/' . $id; 532 if ($urlParameters) $xlink .= '?' . $urlParameters; 533 } elseif ($conf['userewrite']) { 534 $xlink .= $id; 535 if ($urlParameters) $xlink .= '?' . $urlParameters; 536 } elseif ($id !== '') { 537 $xlink .= DOKU_SCRIPT . '?id=' . $id; 538 if ($urlParameters) $xlink .= $separator . $urlParameters; 539 } else { 540 $xlink .= DOKU_SCRIPT; 541 if ($urlParameters) $xlink .= '?' . $urlParameters; 542 } 543 544 return $xlink; 545} 546 547/** 548 * This builds a link to an alternate page format 549 * 550 * Handles URL rewriting if enabled. Follows the style of wl(). 551 * 552 * @param string $id page id, defaults to start page 553 * @param string $format the export renderer to use 554 * @param string|array $urlParameters URL parameters, associative array recommended 555 * @param bool $abs request an absolute URL instead of relative 556 * @param string $sep parameter separator 557 * @return string 558 * @author Ben Coburn <btcoburn@silicodon.net> 559 */ 560function exportlink($id = '', $format = 'raw', $urlParameters = '', $abs = false, $sep = '&') 561{ 562 global $conf; 563 if (is_array($urlParameters)) { 564 $urlParameters = buildURLparams($urlParameters, $sep); 565 } else { 566 $urlParameters = str_replace(',', $sep, $urlParameters); 567 } 568 569 $format = rawurlencode($format); 570 $id = idfilter($id); 571 if ($abs) { 572 $xlink = DOKU_URL; 573 } else { 574 $xlink = DOKU_BASE; 575 } 576 577 if ($conf['userewrite'] == 2) { 578 $xlink .= DOKU_SCRIPT . '/' . $id . '?do=export_' . $format; 579 if ($urlParameters) $xlink .= $sep . $urlParameters; 580 } elseif ($conf['userewrite'] == 1) { 581 $xlink .= '_export/' . $format . '/' . $id; 582 if ($urlParameters) $xlink .= '?' . $urlParameters; 583 } else { 584 $xlink .= DOKU_SCRIPT . '?do=export_' . $format . $sep . 'id=' . $id; 585 if ($urlParameters) $xlink .= $sep . $urlParameters; 586 } 587 588 return $xlink; 589} 590 591/** 592 * Build a link to a media file 593 * 594 * Will return a link to the detail page if $direct is false 595 * 596 * The $more parameter should always be given as array, the function then 597 * will strip default parameters to produce even cleaner URLs 598 * 599 * @param string $id the media file id or URL 600 * @param mixed $more string or array with additional parameters 601 * @param bool $direct link to detail page if false 602 * @param string $sep URL parameter separator 603 * @param bool $abs Create an absolute URL 604 * @return string 605 */ 606function ml($id = '', $more = '', $direct = true, $sep = '&', $abs = false) 607{ 608 global $conf; 609 $isexternalimage = media_isexternal($id); 610 if (!$isexternalimage) { 611 $id = cleanID($id); 612 } 613 614 if (is_array($more)) { 615 // add token for resized images 616 $w = $more['w'] ?? null; 617 $h = $more['h'] ?? null; 618 if ($w || $h || $isexternalimage) { 619 $more['tok'] = media_get_token($id, $w, $h); 620 } 621 // strip defaults for shorter URLs 622 if (isset($more['cache']) && $more['cache'] == 'cache') unset($more['cache']); 623 if (empty($more['w'])) unset($more['w']); 624 if (empty($more['h'])) unset($more['h']); 625 if (isset($more['id']) && $direct) unset($more['id']); 626 if (isset($more['rev']) && !$more['rev']) unset($more['rev']); 627 $more = buildURLparams($more, $sep); 628 } else { 629 $matches = []; 630 if (preg_match_all('/\b(w|h)=(\d*)\b/', $more, $matches, PREG_SET_ORDER) || $isexternalimage) { 631 $resize = ['w' => 0, 'h' => 0]; 632 foreach ($matches as $match) { 633 $resize[$match[1]] = $match[2]; 634 } 635 $more .= $more === '' ? '' : $sep; 636 $more .= 'tok=' . media_get_token($id, $resize['w'], $resize['h']); 637 } 638 $more = str_replace('cache=cache', '', $more); //skip default 639 $more = str_replace(',,', ',', $more); 640 $more = str_replace(',', $sep, $more); 641 } 642 643 if ($abs) { 644 $xlink = DOKU_URL; 645 } else { 646 $xlink = DOKU_BASE; 647 } 648 649 // external URLs are always direct without rewriting 650 if ($isexternalimage) { 651 $xlink .= 'lib/exe/fetch.php'; 652 $xlink .= '?' . $more; 653 $xlink .= $sep . 'media=' . rawurlencode($id); 654 return $xlink; 655 } 656 657 $id = idfilter($id); 658 659 // decide on scriptname 660 if ($direct) { 661 if ($conf['userewrite'] == 1) { 662 $script = '_media'; 663 } else { 664 $script = 'lib/exe/fetch.php'; 665 } 666 } elseif ($conf['userewrite'] == 1) { 667 $script = '_detail'; 668 } else { 669 $script = 'lib/exe/detail.php'; 670 } 671 672 // build URL based on rewrite mode 673 if ($conf['userewrite']) { 674 $xlink .= $script . '/' . $id; 675 if ($more) $xlink .= '?' . $more; 676 } elseif ($more) { 677 $xlink .= $script . '?' . $more; 678 $xlink .= $sep . 'media=' . $id; 679 } else { 680 $xlink .= $script . '?media=' . $id; 681 } 682 683 return $xlink; 684} 685 686/** 687 * Returns the URL to the DokuWiki base script 688 * 689 * Consider using wl() instead, unless you absoutely need the doku.php endpoint 690 * 691 * @return string 692 * @author Andreas Gohr <andi@splitbrain.org> 693 * 694 */ 695function script() 696{ 697 return DOKU_BASE . DOKU_SCRIPT; 698} 699 700/** 701 * Spamcheck against wordlist 702 * 703 * Checks the wikitext against a list of blocked expressions 704 * returns true if the text contains any bad words 705 * 706 * Triggers COMMON_WORDBLOCK_BLOCKED 707 * 708 * Action Plugins can use this event to inspect the blocked data 709 * and gain information about the user who was blocked. 710 * 711 * Event data: 712 * data['matches'] - array of matches 713 * data['userinfo'] - information about the blocked user 714 * [ip] - ip address 715 * [user] - username (if logged in) 716 * [mail] - mail address (if logged in) 717 * [name] - real name (if logged in) 718 * 719 * @param string $text - optional text to check, if not given the globals are used 720 * @return bool - true if a spam word was found 721 * @author Andreas Gohr <andi@splitbrain.org> 722 * @author Michael Klier <chi@chimeric.de> 723 * 724 */ 725function checkwordblock($text = '') 726{ 727 global $TEXT; 728 global $PRE; 729 global $SUF; 730 global $SUM; 731 global $conf; 732 global $INFO; 733 /* @var Input $INPUT */ 734 global $INPUT; 735 736 if (!$conf['usewordblock']) return false; 737 738 if (!$text) $text = "$PRE $TEXT $SUF $SUM"; 739 740 // we prepare the text a tiny bit to prevent spammers circumventing URL checks 741 // phpcs:disable Generic.Files.LineLength.TooLong 742 $text = preg_replace( 743 '!(\b)(www\.[\w.:?\-;,]+?\.[\w.:?\-;,]+?[\w/\#~:.?+=&%@\!\-.:?\-;,]+?)([.:?\-;,]*[^\w/\#~:.?+=&%@\!\-.:?\-;,])!i', 744 '\1http://\2 \2\3', 745 $text 746 ); 747 // phpcs:enable 748 749 $wordblocks = getWordblocks(); 750 // read file in chunks of 200 - this should work around the 751 // MAX_PATTERN_SIZE in modern PCRE 752 $chunksize = 200; 753 754 while ($blocks = array_splice($wordblocks, 0, $chunksize)) { 755 $re = []; 756 // build regexp from blocks 757 foreach ($blocks as $block) { 758 $block = preg_replace('/#.*$/', '', $block); 759 $block = trim($block); 760 if (empty($block)) continue; 761 $re[] = $block; 762 } 763 if (count($re) && preg_match('#(' . implode('|', $re) . ')#si', $text, $matches)) { 764 // prepare event data 765 $data = []; 766 $data['matches'] = $matches; 767 $data['userinfo']['ip'] = $INPUT->server->str('REMOTE_ADDR'); 768 if ($INPUT->server->str('REMOTE_USER')) { 769 $data['userinfo']['user'] = $INPUT->server->str('REMOTE_USER'); 770 $data['userinfo']['name'] = $INFO['userinfo']['name']; 771 $data['userinfo']['mail'] = $INFO['userinfo']['mail']; 772 } 773 $callback = static fn() => true; 774 return Event::createAndTrigger('COMMON_WORDBLOCK_BLOCKED', $data, $callback, true); 775 } 776 } 777 return false; 778} 779 780/** 781 * Return the IP of the client 782 * 783 * Honours X-Forwarded-For and X-Real-IP Proxy Headers 784 * 785 * It returns a comma separated list of IPs if the above mentioned 786 * headers are set. If the single parameter is set, it tries to return 787 * a routable public address, prefering the ones suplied in the X 788 * headers 789 * 790 * @param boolean $single If set only a single IP is returned 791 * @return string 792 * @author Andreas Gohr <andi@splitbrain.org> 793 * 794 */ 795function clientIP($single = false) 796{ 797 /* @var Input $INPUT */ 798 global $INPUT, $conf; 799 800 $ip = []; 801 $ip[] = $INPUT->server->str('REMOTE_ADDR'); 802 if ($INPUT->server->str('HTTP_X_FORWARDED_FOR')) { 803 $ip = array_merge($ip, explode(',', str_replace(' ', '', $INPUT->server->str('HTTP_X_FORWARDED_FOR')))); 804 } 805 if ($INPUT->server->str('HTTP_X_REAL_IP')) { 806 $ip = array_merge($ip, explode(',', str_replace(' ', '', $INPUT->server->str('HTTP_X_REAL_IP')))); 807 } 808 809 // remove any non-IP stuff 810 $cnt = count($ip); 811 for ($i = 0; $i < $cnt; $i++) { 812 if (filter_var($ip[$i], FILTER_VALIDATE_IP) === false) { 813 unset($ip[$i]); 814 } 815 } 816 $ip = array_values(array_unique($ip)); 817 if ($ip === [] || !$ip[0]) $ip[0] = '0.0.0.0'; // for some strange reason we don't have a IP 818 819 if (!$single) return implode(',', $ip); 820 821 // skip trusted local addresses 822 foreach ($ip as $i) { 823 if (!empty($conf['trustedproxy']) && preg_match('/' . $conf['trustedproxy'] . '/', $i)) { 824 continue; 825 } else { 826 return $i; 827 } 828 } 829 830 // still here? just use the last address 831 // this case all ips in the list are trusted 832 return $ip[count($ip) - 1]; 833} 834 835/** 836 * Check if the browser is on a mobile device 837 * 838 * Adapted from the example code at url below 839 * 840 * @link http://www.brainhandles.com/2007/10/15/detecting-mobile-browsers/#code 841 * 842 * @deprecated 2018-04-27 you probably want media queries instead anyway 843 * @return bool if true, client is mobile browser; otherwise false 844 */ 845function clientismobile() 846{ 847 /* @var Input $INPUT */ 848 global $INPUT; 849 850 if ($INPUT->server->has('HTTP_X_WAP_PROFILE')) return true; 851 852 if (preg_match('/wap\.|\.wap/i', $INPUT->server->str('HTTP_ACCEPT'))) return true; 853 854 if (!$INPUT->server->has('HTTP_USER_AGENT')) return false; 855 856 $uamatches = implode( 857 '|', 858 [ 859 'midp', 'j2me', 'avantg', 'docomo', 'novarra', 'palmos', 'palmsource', '240x320', 'opwv', 860 'chtml', 'pda', 'windows ce', 'mmp\/', 'blackberry', 'mib\/', 'symbian', 'wireless', 'nokia', 861 'hand', 'mobi', 'phone', 'cdm', 'up\.b', 'audio', 'SIE\-', 'SEC\-', 'samsung', 'HTC', 'mot\-', 862 'mitsu', 'sagem', 'sony', 'alcatel', 'lg', 'erics', 'vx', 'NEC', 'philips', 'mmm', 'xx', 863 'panasonic', 'sharp', 'wap', 'sch', 'rover', 'pocket', 'benq', 'java', 'pt', 'pg', 'vox', 864 'amoi', 'bird', 'compal', 'kg', 'voda', 'sany', 'kdd', 'dbt', 'sendo', 'sgh', 'gradi', 'jb', 865 '\d\d\di', 'moto' 866 ] 867 ); 868 869 if (preg_match("/$uamatches/i", $INPUT->server->str('HTTP_USER_AGENT'))) return true; 870 871 return false; 872} 873 874/** 875 * check if a given link is interwiki link 876 * 877 * @param string $link the link, e.g. "wiki>page" 878 * @return bool 879 */ 880function link_isinterwiki($link) 881{ 882 if (preg_match('/^[a-zA-Z0-9\.]+>/u', $link)) return true; 883 return false; 884} 885 886/** 887 * Convert one or more comma separated IPs to hostnames 888 * 889 * If $conf['dnslookups'] is disabled it simply returns the input string 890 * 891 * @param string $ips comma separated list of IP addresses 892 * @return string a comma separated list of hostnames 893 * @author Glen Harris <astfgl@iamnota.org> 894 * 895 */ 896function gethostsbyaddrs($ips) 897{ 898 global $conf; 899 if (!$conf['dnslookups']) return $ips; 900 901 $hosts = []; 902 $ips = explode(',', $ips); 903 904 if (is_array($ips)) { 905 foreach ($ips as $ip) { 906 $hosts[] = gethostbyaddr(trim($ip)); 907 } 908 return implode(',', $hosts); 909 } else { 910 return gethostbyaddr(trim($ips)); 911 } 912} 913 914/** 915 * Checks if a given page is currently locked. 916 * 917 * removes stale lockfiles 918 * 919 * @param string $id page id 920 * @return bool page is locked? 921 * @author Andreas Gohr <andi@splitbrain.org> 922 * 923 */ 924function checklock($id) 925{ 926 global $conf; 927 /* @var Input $INPUT */ 928 global $INPUT; 929 930 $lock = wikiLockFN($id); 931 932 //no lockfile 933 if (!file_exists($lock)) return false; 934 935 //lockfile expired 936 if ((time() - filemtime($lock)) > $conf['locktime']) { 937 @unlink($lock); 938 return false; 939 } 940 941 //my own lock 942 [$ip, $session] = sexplode("\n", io_readFile($lock), 2); 943 if ($ip == $INPUT->server->str('REMOTE_USER') || (session_id() && $session === session_id())) { 944 return false; 945 } 946 947 return $ip; 948} 949 950/** 951 * Lock a page for editing 952 * 953 * @param string $id page id to lock 954 * @author Andreas Gohr <andi@splitbrain.org> 955 * 956 */ 957function lock($id) 958{ 959 global $conf; 960 /* @var Input $INPUT */ 961 global $INPUT; 962 963 if ($conf['locktime'] == 0) { 964 return; 965 } 966 967 $lock = wikiLockFN($id); 968 if ($INPUT->server->str('REMOTE_USER')) { 969 io_saveFile($lock, $INPUT->server->str('REMOTE_USER')); 970 } else { 971 io_saveFile($lock, clientIP() . "\n" . session_id()); 972 } 973} 974 975/** 976 * Unlock a page if it was locked by the user 977 * 978 * @param string $id page id to unlock 979 * @return bool true if a lock was removed 980 * @author Andreas Gohr <andi@splitbrain.org> 981 * 982 */ 983function unlock($id) 984{ 985 /* @var Input $INPUT */ 986 global $INPUT; 987 988 $lock = wikiLockFN($id); 989 if (file_exists($lock)) { 990 @[$ip, $session] = explode("\n", io_readFile($lock)); 991 if ($ip == $INPUT->server->str('REMOTE_USER') || $session == session_id()) { 992 @unlink($lock); 993 return true; 994 } 995 } 996 return false; 997} 998 999/** 1000 * convert line ending to unix format 1001 * 1002 * also makes sure the given text is valid UTF-8 1003 * 1004 * @param string $text 1005 * @return string 1006 * @see formText() for 2crlf conversion 1007 * @author Andreas Gohr <andi@splitbrain.org> 1008 * 1009 */ 1010function cleanText($text) 1011{ 1012 $text = preg_replace("/(\015\012)|(\015)/", "\012", $text); 1013 1014 // if the text is not valid UTF-8 we simply assume latin1 1015 // this won't break any worse than it breaks with the wrong encoding 1016 // but might actually fix the problem in many cases 1017 if (!Clean::isUtf8($text)) $text = utf8_encode($text); 1018 1019 return $text; 1020} 1021 1022/** 1023 * Prepares text for print in Webforms by encoding special chars. 1024 * It also converts line endings to Windows format which is 1025 * pseudo standard for webforms. 1026 * 1027 * @param string $text 1028 * @return string 1029 * @see cleanText() for 2unix conversion 1030 * @author Andreas Gohr <andi@splitbrain.org> 1031 * 1032 */ 1033function formText($text) 1034{ 1035 $text = str_replace("\012", "\015\012", $text ?? ''); 1036 return htmlspecialchars($text); 1037} 1038 1039/** 1040 * Returns the specified local text in raw format 1041 * 1042 * @param string $id page id 1043 * @param string $ext extension of file being read, default 'txt' 1044 * @return string 1045 * @author Andreas Gohr <andi@splitbrain.org> 1046 * 1047 */ 1048function rawLocale($id, $ext = 'txt') 1049{ 1050 return io_readFile(localeFN($id, $ext)); 1051} 1052 1053/** 1054 * Returns the raw WikiText 1055 * 1056 * @param string $id page id 1057 * @param string|int $rev timestamp when a revision of wikitext is desired 1058 * @return string 1059 * @author Andreas Gohr <andi@splitbrain.org> 1060 * 1061 */ 1062function rawWiki($id, $rev = '') 1063{ 1064 return io_readWikiPage(wikiFN($id, $rev), $id, $rev); 1065} 1066 1067/** 1068 * Returns the pagetemplate contents for the ID's namespace 1069 * 1070 * @triggers COMMON_PAGETPL_LOAD 1071 * @param string $id the id of the page to be created 1072 * @return string parsed pagetemplate content 1073 * @author Andreas Gohr <andi@splitbrain.org> 1074 * 1075 */ 1076function pageTemplate($id) 1077{ 1078 global $conf; 1079 1080 if (is_array($id)) $id = $id[0]; 1081 1082 // prepare initial event data 1083 $data = [ 1084 'id' => $id, // the id of the page to be created 1085 'tpl' => '', // the text used as template 1086 'tplfile' => '', // the file above text was/should be loaded from 1087 'doreplace' => true, 1088 ]; 1089 1090 $evt = new Event('COMMON_PAGETPL_LOAD', $data); 1091 if ($evt->advise_before(true)) { 1092 // the before event might have loaded the content already 1093 if (empty($data['tpl'])) { 1094 // if the before event did not set a template file, try to find one 1095 if (empty($data['tplfile'])) { 1096 $path = dirname(wikiFN($id)); 1097 if (file_exists($path . '/_template.txt')) { 1098 $data['tplfile'] = $path . '/_template.txt'; 1099 } else { 1100 // search upper namespaces for templates 1101 $len = strlen(rtrim($conf['datadir'], '/')); 1102 while (strlen($path) >= $len) { 1103 if (file_exists($path . '/__template.txt')) { 1104 $data['tplfile'] = $path . '/__template.txt'; 1105 break; 1106 } 1107 $path = substr($path, 0, strrpos($path, '/')); 1108 } 1109 } 1110 } 1111 // load the content 1112 $data['tpl'] = io_readFile($data['tplfile']); 1113 } 1114 if ($data['doreplace']) parsePageTemplate($data); 1115 } 1116 $evt->advise_after(); 1117 unset($evt); 1118 1119 return $data['tpl']; 1120} 1121 1122/** 1123 * Performs common page template replacements 1124 * This works on data from COMMON_PAGETPL_LOAD 1125 * 1126 * @param array $data array with event data 1127 * @return string 1128 * @author Andreas Gohr <andi@splitbrain.org> 1129 * 1130 */ 1131function parsePageTemplate(&$data) 1132{ 1133 /** 1134 * @var string $id the id of the page to be created 1135 * @var string $tpl the text used as template 1136 * @var string $tplfile the file above text was/should be loaded from 1137 * @var bool $doreplace should wildcard replacements be done on the text? 1138 */ 1139 extract($data); 1140 1141 global $USERINFO; 1142 global $conf; 1143 /* @var Input $INPUT */ 1144 global $INPUT; 1145 1146 // replace placeholders 1147 $file = noNS($id); 1148 $page = strtr($file, $conf['sepchar'], ' '); 1149 1150 $tpl = str_replace( 1151 [ 1152 '@ID@', 1153 '@NS@', 1154 '@CURNS@', 1155 '@!CURNS@', 1156 '@!!CURNS@', 1157 '@!CURNS!@', 1158 '@FILE@', 1159 '@!FILE@', 1160 '@!FILE!@', 1161 '@PAGE@', 1162 '@!PAGE@', 1163 '@!!PAGE@', 1164 '@!PAGE!@', 1165 '@USER@', 1166 '@NAME@', 1167 '@MAIL@', 1168 '@DATE@' 1169 ], 1170 [ 1171 $id, 1172 getNS($id), 1173 curNS($id), 1174 PhpString::ucfirst(curNS($id)), 1175 PhpString::ucwords(curNS($id)), 1176 PhpString::strtoupper(curNS($id)), 1177 $file, 1178 PhpString::ucfirst($file), 1179 PhpString::strtoupper($file), 1180 $page, 1181 PhpString::ucfirst($page), 1182 PhpString::ucwords($page), 1183 PhpString::strtoupper($page), 1184 $INPUT->server->str('REMOTE_USER'), 1185 $USERINFO ? $USERINFO['name'] : '', 1186 $USERINFO ? $USERINFO['mail'] : '', 1187 $conf['dformat'] 1188 ], 1189 $tpl 1190 ); 1191 1192 // we need the callback to work around strftime's char limit 1193 $tpl = preg_replace_callback( 1194 '/%./', 1195 static fn($m) => dformat(null, $m[0]), 1196 $tpl 1197 ); 1198 $data['tpl'] = $tpl; 1199 return $tpl; 1200} 1201 1202/** 1203 * Returns the raw Wiki Text in three slices. 1204 * 1205 * The range parameter needs to have the form "from-to" 1206 * and gives the range of the section in bytes - no 1207 * UTF-8 awareness is needed. 1208 * The returned order is prefix, section and suffix. 1209 * 1210 * @param string $range in form "from-to" 1211 * @param string $id page id 1212 * @param string $rev optional, the revision timestamp 1213 * @return string[] with three slices 1214 * @author Andreas Gohr <andi@splitbrain.org> 1215 * 1216 */ 1217function rawWikiSlices($range, $id, $rev = '') 1218{ 1219 $text = io_readWikiPage(wikiFN($id, $rev), $id, $rev); 1220 1221 // Parse range 1222 [$from, $to] = sexplode('-', $range, 2); 1223 // Make range zero-based, use defaults if marker is missing 1224 $from = $from ? $from - 1 : (0); 1225 $to = $to ? $to - 1 : (strlen($text)); 1226 1227 $slices = []; 1228 $slices[0] = substr($text, 0, $from); 1229 $slices[1] = substr($text, $from, $to - $from); 1230 $slices[2] = substr($text, $to); 1231 return $slices; 1232} 1233 1234/** 1235 * Joins wiki text slices 1236 * 1237 * function to join the text slices. 1238 * When the pretty parameter is set to true it adds additional empty 1239 * lines between sections if needed (used on saving). 1240 * 1241 * @param string $pre prefix 1242 * @param string $text text in the middle 1243 * @param string $suf suffix 1244 * @param bool $pretty add additional empty lines between sections 1245 * @return string 1246 * @author Andreas Gohr <andi@splitbrain.org> 1247 * 1248 */ 1249function con($pre, $text, $suf, $pretty = false) 1250{ 1251 if ($pretty) { 1252 if ( 1253 $pre !== '' && !str_ends_with($pre, "\n") && 1254 !str_starts_with($text, "\n") 1255 ) { 1256 $pre .= "\n"; 1257 } 1258 if ( 1259 $suf !== '' && !str_ends_with($text, "\n") && 1260 !str_starts_with($suf, "\n") 1261 ) { 1262 $text .= "\n"; 1263 } 1264 } 1265 1266 return $pre . $text . $suf; 1267} 1268 1269/** 1270 * Checks if the current page version is newer than the last entry in the page's 1271 * changelog. If so, we assume it has been an external edit and we create an 1272 * attic copy and add a proper changelog line. 1273 * 1274 * This check is only executed when the page is about to be saved again from the 1275 * wiki, triggered in @param string $id the page ID 1276 * @see saveWikiText() 1277 * 1278 * @deprecated 2021-11-28 1279 */ 1280function detectExternalEdit($id) 1281{ 1282 dbg_deprecated(PageFile::class . '::detectExternalEdit()'); 1283 (new PageFile($id))->detectExternalEdit(); 1284} 1285 1286/** 1287 * Saves a wikitext by calling io_writeWikiPage. 1288 * Also directs changelog and attic updates. 1289 * 1290 * @param string $id page id 1291 * @param string $text wikitext being saved 1292 * @param string $summary summary of text update 1293 * @param bool $minor mark this saved version as minor update 1294 * @author Andreas Gohr <andi@splitbrain.org> 1295 * @author Ben Coburn <btcoburn@silicodon.net> 1296 * 1297 */ 1298function saveWikiText($id, $text, $summary, $minor = false) 1299{ 1300 1301 // get COMMON_WIKIPAGE_SAVE event data 1302 $data = (new PageFile($id))->saveWikiText($text, $summary, $minor); 1303 if (!$data) return; // save was cancelled (for no changes or by a plugin) 1304 1305 // send notify mails 1306 ['oldRevision' => $rev, 'newRevision' => $new_rev, 'summary' => $summary] = $data; 1307 notify($id, 'admin', $rev, $summary, $minor, $new_rev); 1308 notify($id, 'subscribers', $rev, $summary, $minor, $new_rev); 1309 1310 // if useheading is enabled, purge the cache of all linking pages 1311 if (useHeading('content')) { 1312 $pages = ft_backlinks($id, true); 1313 foreach ($pages as $page) { 1314 $cache = new CacheRenderer($page, wikiFN($page), 'xhtml'); 1315 $cache->removeCache(); 1316 } 1317 } 1318} 1319 1320/** 1321 * moves the current version to the attic and returns its revision date 1322 * 1323 * @param string $id page id 1324 * @return int|string revision timestamp 1325 * @author Andreas Gohr <andi@splitbrain.org> 1326 * 1327 * @deprecated 2021-11-28 1328 */ 1329function saveOldRevision($id) 1330{ 1331 dbg_deprecated(PageFile::class . '::saveOldRevision()'); 1332 return (new PageFile($id))->saveOldRevision(); 1333} 1334 1335/** 1336 * Sends a notify mail on page change or registration 1337 * 1338 * @param string $id The changed page 1339 * @param string $who Who to notify (admin|subscribers|register) 1340 * @param int|string $rev Old page revision 1341 * @param string $summary What changed 1342 * @param boolean $minor Is this a minor edit? 1343 * @param string[] $replace Additional string substitutions, @KEY@ to be replaced by value 1344 * @param int|string $current_rev New page revision 1345 * @return bool 1346 * 1347 * @author Andreas Gohr <andi@splitbrain.org> 1348 */ 1349function notify($id, $who, $rev = '', $summary = '', $minor = false, $replace = [], $current_rev = false) 1350{ 1351 global $conf; 1352 /* @var Input $INPUT */ 1353 global $INPUT; 1354 1355 // decide if there is something to do, eg. whom to mail 1356 if ($who == 'admin') { 1357 if (empty($conf['notify'])) return false; //notify enabled? 1358 $tpl = 'mailtext'; 1359 $to = $conf['notify']; 1360 } elseif ($who == 'subscribers') { 1361 if (!actionOK('subscribe')) return false; //subscribers enabled? 1362 if ($conf['useacl'] && $INPUT->server->str('REMOTE_USER') && $minor) return false; //skip minors 1363 $data = ['id' => $id, 'addresslist' => '', 'self' => false, 'replacements' => $replace]; 1364 Event::createAndTrigger( 1365 'COMMON_NOTIFY_ADDRESSLIST', 1366 $data, 1367 [new SubscriberManager(), 'notifyAddresses'] 1368 ); 1369 $to = $data['addresslist']; 1370 if (empty($to)) return false; 1371 $tpl = 'subscr_single'; 1372 } else { 1373 return false; //just to be safe 1374 } 1375 1376 // prepare content 1377 $subscription = new PageSubscriptionSender(); 1378 return $subscription->sendPageDiff($to, $tpl, $id, $rev, $summary, $current_rev); 1379} 1380 1381/** 1382 * extracts the query from a search engine referrer 1383 * 1384 * @return array|string 1385 * @author Todd Augsburger <todd@rollerorgans.com> 1386 * 1387 * @author Andreas Gohr <andi@splitbrain.org> 1388 */ 1389function getGoogleQuery() 1390{ 1391 /* @var Input $INPUT */ 1392 global $INPUT; 1393 1394 if (!$INPUT->server->has('HTTP_REFERER')) { 1395 return ''; 1396 } 1397 $url = parse_url($INPUT->server->str('HTTP_REFERER')); 1398 1399 // only handle common SEs 1400 if (!array_key_exists('host', $url)) return ''; 1401 if (!preg_match('/(google|bing|yahoo|ask|duckduckgo|babylon|aol|yandex)/', $url['host'])) return ''; 1402 1403 $query = []; 1404 if (!array_key_exists('query', $url)) return ''; 1405 parse_str($url['query'], $query); 1406 1407 $q = ''; 1408 if (isset($query['q'])) { 1409 $q = $query['q']; 1410 } elseif (isset($query['p'])) { 1411 $q = $query['p']; 1412 } elseif (isset($query['query'])) { 1413 $q = $query['query']; 1414 } 1415 $q = trim($q); 1416 1417 if (!$q) return ''; 1418 // ignore if query includes a full URL 1419 if (strpos($q, '//') !== false) return ''; 1420 $q = preg_split('/[\s\'"\\\\`()\]\[?:!\.{};,#+*<>\\/]+/', $q, -1, PREG_SPLIT_NO_EMPTY); 1421 return $q; 1422} 1423 1424/** 1425 * Return the human readable size of a file 1426 * 1427 * @param int $size A file size 1428 * @param int $dec A number of decimal places 1429 * @return string human readable size 1430 * 1431 * @author Martin Benjamin <b.martin@cybernet.ch> 1432 * @author Aidan Lister <aidan@php.net> 1433 * @version 1.0.0 1434 */ 1435function filesize_h($size, $dec = 1) 1436{ 1437 $sizes = ['B', 'KB', 'MB', 'GB']; 1438 $count = count($sizes); 1439 $i = 0; 1440 1441 while ($size >= 1024 && ($i < $count - 1)) { 1442 $size /= 1024; 1443 $i++; 1444 } 1445 1446 return round($size, $dec) . "\xC2\xA0" . $sizes[$i]; //non-breaking space 1447} 1448 1449/** 1450 * Return the given timestamp as human readable, fuzzy age 1451 * 1452 * @param int $dt timestamp 1453 * @return string 1454 * @author Andreas Gohr <gohr@cosmocode.de> 1455 * 1456 */ 1457function datetime_h($dt) 1458{ 1459 global $lang; 1460 1461 $ago = time() - $dt; 1462 if ($ago > 24 * 60 * 60 * 30 * 12 * 2) { 1463 return sprintf($lang['years'], round($ago / (24 * 60 * 60 * 30 * 12))); 1464 } 1465 if ($ago > 24 * 60 * 60 * 30 * 2) { 1466 return sprintf($lang['months'], round($ago / (24 * 60 * 60 * 30))); 1467 } 1468 if ($ago > 24 * 60 * 60 * 7 * 2) { 1469 return sprintf($lang['weeks'], round($ago / (24 * 60 * 60 * 7))); 1470 } 1471 if ($ago > 24 * 60 * 60 * 2) { 1472 return sprintf($lang['days'], round($ago / (24 * 60 * 60))); 1473 } 1474 if ($ago > 60 * 60 * 2) { 1475 return sprintf($lang['hours'], round($ago / (60 * 60))); 1476 } 1477 if ($ago > 60 * 2) { 1478 return sprintf($lang['minutes'], round($ago / (60))); 1479 } 1480 return sprintf($lang['seconds'], $ago); 1481} 1482 1483/** 1484 * Wraps around strftime but provides support for fuzzy dates 1485 * 1486 * The format default to $conf['dformat']. It is passed to 1487 * strftime - %f can be used to get the value from datetime_h() 1488 * 1489 * @param int|null $dt timestamp when given, null will take current timestamp 1490 * @param string $format empty default to $conf['dformat'], or provide format as recognized by strftime() 1491 * @return string 1492 * @author Andreas Gohr <gohr@cosmocode.de> 1493 * 1494 * @see datetime_h 1495 */ 1496function dformat($dt = null, $format = '') 1497{ 1498 global $conf; 1499 1500 if (is_null($dt)) $dt = time(); 1501 $dt = (int)$dt; 1502 if (!$format) $format = $conf['dformat']; 1503 1504 $format = str_replace('%f', datetime_h($dt), $format); 1505 return strftime($format, $dt); 1506} 1507 1508/** 1509 * Formats a timestamp as ISO 8601 date 1510 * 1511 * @param int $int_date current date in UNIX timestamp 1512 * @return string 1513 * @author <ungu at terong dot com> 1514 * @link http://php.net/manual/en/function.date.php#54072 1515 * 1516 */ 1517function date_iso8601($int_date) 1518{ 1519 $date_mod = date('Y-m-d\TH:i:s', $int_date); 1520 $pre_timezone = date('O', $int_date); 1521 $time_zone = substr($pre_timezone, 0, 3) . ":" . substr($pre_timezone, 3, 2); 1522 $date_mod .= $time_zone; 1523 return $date_mod; 1524} 1525 1526/** 1527 * return an obfuscated email address in line with $conf['mailguard'] setting 1528 * 1529 * @param string $email email address 1530 * @return string 1531 * @author Harry Fuecks <hfuecks@gmail.com> 1532 * @author Christopher Smith <chris@jalakai.co.uk> 1533 * 1534 */ 1535function obfuscate($email) 1536{ 1537 global $conf; 1538 1539 switch ($conf['mailguard']) { 1540 case 'visible': 1541 $obfuscate = ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ']; 1542 return strtr($email, $obfuscate); 1543 1544 case 'hex': 1545 return Conversion::toHtml($email, true); 1546 1547 case 'none': 1548 default: 1549 return $email; 1550 } 1551} 1552 1553/** 1554 * Removes quoting backslashes 1555 * 1556 * @param string $string 1557 * @param string $char backslashed character 1558 * @return string 1559 * @author Andreas Gohr <andi@splitbrain.org> 1560 * 1561 */ 1562function unslash($string, $char = "'") 1563{ 1564 return str_replace('\\' . $char, $char, $string); 1565} 1566 1567/** 1568 * Convert php.ini shorthands to byte 1569 * 1570 * On 32 bit systems values >= 2GB will fail! 1571 * 1572 * -1 (infinite size) will be reported as -1 1573 * 1574 * @link https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes 1575 * @param string $value PHP size shorthand 1576 * @return int 1577 */ 1578function php_to_byte($value) 1579{ 1580 switch (strtoupper(substr($value, -1))) { 1581 case 'G': 1582 $ret = (int)substr($value, 0, -1) * 1024 * 1024 * 1024; 1583 break; 1584 case 'M': 1585 $ret = (int)substr($value, 0, -1) * 1024 * 1024; 1586 break; 1587 case 'K': 1588 $ret = (int)substr($value, 0, -1) * 1024; 1589 break; 1590 default: 1591 $ret = (int)$value; 1592 break; 1593 } 1594 return $ret; 1595} 1596 1597/** 1598 * Wrapper around preg_quote adding the default delimiter 1599 * 1600 * @param string $string 1601 * @return string 1602 */ 1603function preg_quote_cb($string) 1604{ 1605 return preg_quote($string, '/'); 1606} 1607 1608/** 1609 * Shorten a given string by removing data from the middle 1610 * 1611 * You can give the string in two parts, the first part $keep 1612 * will never be shortened. The second part $short will be cut 1613 * in the middle to shorten but only if at least $min chars are 1614 * left to display it. Otherwise it will be left off. 1615 * 1616 * @param string $keep the part to keep 1617 * @param string $short the part to shorten 1618 * @param int $max maximum chars you want for the whole string 1619 * @param int $min minimum number of chars to have left for middle shortening 1620 * @param string $char the shortening character to use 1621 * @return string 1622 */ 1623function shorten($keep, $short, $max, $min = 9, $char = '…') 1624{ 1625 $max -= PhpString::strlen($keep); 1626 if ($max < $min) return $keep; 1627 $len = PhpString::strlen($short); 1628 if ($len <= $max) return $keep . $short; 1629 $half = floor($max / 2); 1630 return $keep . 1631 PhpString::substr($short, 0, $half - 1) . 1632 $char . 1633 PhpString::substr($short, $len - $half); 1634} 1635 1636/** 1637 * Return the users real name or e-mail address for use 1638 * in page footer and recent changes pages 1639 * 1640 * @param string|null $username or null when currently logged-in user should be used 1641 * @param bool $textonly true returns only plain text, true allows returning html 1642 * @return string html or plain text(not escaped) of formatted user name 1643 * 1644 * @author Andy Webber <dokuwiki AT andywebber DOT com> 1645 */ 1646function editorinfo($username, $textonly = false) 1647{ 1648 return userlink($username, $textonly); 1649} 1650 1651/** 1652 * Returns users realname w/o link 1653 * 1654 * @param string|null $username or null when currently logged-in user should be used 1655 * @param bool $textonly true returns only plain text, true allows returning html 1656 * @return string html or plain text(not escaped) of formatted user name 1657 * 1658 * @triggers COMMON_USER_LINK 1659 */ 1660function userlink($username = null, $textonly = false) 1661{ 1662 global $conf, $INFO; 1663 /** @var AuthPlugin $auth */ 1664 global $auth; 1665 /** @var Input $INPUT */ 1666 global $INPUT; 1667 1668 // prepare initial event data 1669 $data = [ 1670 'username' => $username, // the unique user name 1671 'name' => '', 1672 'link' => [ 1673 //setting 'link' to false disables linking 1674 'target' => '', 1675 'pre' => '', 1676 'suf' => '', 1677 'style' => '', 1678 'more' => '', 1679 'url' => '', 1680 'title' => '', 1681 'class' => '', 1682 ], 1683 'userlink' => '', // formatted user name as will be returned 1684 'textonly' => $textonly, 1685 ]; 1686 if ($username === null) { 1687 $data['username'] = $username = $INPUT->server->str('REMOTE_USER'); 1688 if ($textonly) { 1689 $data['name'] = $INFO['userinfo']['name'] . ' (' . $INPUT->server->str('REMOTE_USER') . ')'; 1690 } else { 1691 $data['name'] = '<bdi>' . hsc($INFO['userinfo']['name']) . '</bdi> ' . 1692 '(<bdi>' . hsc($INPUT->server->str('REMOTE_USER')) . '</bdi>)'; 1693 } 1694 } 1695 1696 $evt = new Event('COMMON_USER_LINK', $data); 1697 if ($evt->advise_before(true)) { 1698 if (empty($data['name'])) { 1699 if ($auth instanceof AuthPlugin) { 1700 $info = $auth->getUserData($username); 1701 } 1702 if ($conf['showuseras'] != 'loginname' && isset($info) && $info) { 1703 switch ($conf['showuseras']) { 1704 case 'username': 1705 case 'username_link': 1706 $data['name'] = $textonly ? $info['name'] : hsc($info['name']); 1707 break; 1708 case 'email': 1709 case 'email_link': 1710 $data['name'] = obfuscate($info['mail']); 1711 break; 1712 } 1713 } else { 1714 $data['name'] = $textonly ? $data['username'] : hsc($data['username']); 1715 } 1716 } 1717 1718 /** @var Doku_Renderer_xhtml $xhtml_renderer */ 1719 static $xhtml_renderer = null; 1720 1721 if (!$data['textonly'] && empty($data['link']['url'])) { 1722 if (in_array($conf['showuseras'], ['email_link', 'username_link'])) { 1723 if (!isset($info) && $auth instanceof AuthPlugin) { 1724 $info = $auth->getUserData($username); 1725 } 1726 if (isset($info) && $info) { 1727 if ($conf['showuseras'] == 'email_link') { 1728 $data['link']['url'] = 'mailto:' . obfuscate($info['mail']); 1729 } else { 1730 if (is_null($xhtml_renderer)) { 1731 $xhtml_renderer = p_get_renderer('xhtml'); 1732 } 1733 if (empty($xhtml_renderer->interwiki)) { 1734 $xhtml_renderer->interwiki = getInterwiki(); 1735 } 1736 $shortcut = 'user'; 1737 $exists = null; 1738 $data['link']['url'] = $xhtml_renderer->_resolveInterWiki($shortcut, $username, $exists); 1739 $data['link']['class'] .= ' interwiki iw_user'; 1740 if ($exists !== null) { 1741 if ($exists) { 1742 $data['link']['class'] .= ' wikilink1'; 1743 } else { 1744 $data['link']['class'] .= ' wikilink2'; 1745 $data['link']['rel'] = 'nofollow'; 1746 } 1747 } 1748 } 1749 } else { 1750 $data['textonly'] = true; 1751 } 1752 } else { 1753 $data['textonly'] = true; 1754 } 1755 } 1756 1757 if ($data['textonly']) { 1758 $data['userlink'] = $data['name']; 1759 } else { 1760 $data['link']['name'] = $data['name']; 1761 if (is_null($xhtml_renderer)) { 1762 $xhtml_renderer = p_get_renderer('xhtml'); 1763 } 1764 $data['userlink'] = $xhtml_renderer->_formatLink($data['link']); 1765 } 1766 } 1767 $evt->advise_after(); 1768 unset($evt); 1769 1770 return $data['userlink']; 1771} 1772 1773/** 1774 * Returns the path to a image file for the currently chosen license. 1775 * When no image exists, returns an empty string 1776 * 1777 * @param string $type - type of image 'badge' or 'button' 1778 * @return string 1779 * @author Andreas Gohr <andi@splitbrain.org> 1780 * 1781 */ 1782function license_img($type) 1783{ 1784 global $license; 1785 global $conf; 1786 if (!$conf['license']) return ''; 1787 if (!is_array($license[$conf['license']])) return ''; 1788 $try = []; 1789 $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.png'; 1790 $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.gif'; 1791 if (str_starts_with($conf['license'], 'cc-')) { 1792 $try[] = 'lib/images/license/' . $type . '/cc.png'; 1793 } 1794 foreach ($try as $src) { 1795 if (file_exists(DOKU_INC . $src)) return $src; 1796 } 1797 return ''; 1798} 1799 1800/** 1801 * Checks if the given amount of memory is available 1802 * 1803 * If the memory_get_usage() function is not available the 1804 * function just assumes $bytes of already allocated memory 1805 * 1806 * @param int $mem Size of memory you want to allocate in bytes 1807 * @param int $bytes already allocated memory (see above) 1808 * @return bool 1809 * @author Andreas Gohr <andi@splitbrain.org> 1810 * 1811 * @author Filip Oscadal <webmaster@illusionsoftworks.cz> 1812 */ 1813function is_mem_available($mem, $bytes = 1_048_576) 1814{ 1815 $limit = trim(ini_get('memory_limit')); 1816 if (empty($limit)) return true; // no limit set! 1817 if ($limit == -1) return true; // unlimited 1818 1819 // parse limit to bytes 1820 $limit = php_to_byte($limit); 1821 1822 // get used memory if possible 1823 if (function_exists('memory_get_usage')) { 1824 $used = memory_get_usage(); 1825 } else { 1826 $used = $bytes; 1827 } 1828 1829 if ($used + $mem > $limit) { 1830 return false; 1831 } 1832 1833 return true; 1834} 1835 1836/** 1837 * Send a HTTP redirect to the browser 1838 * 1839 * Works arround Microsoft IIS cookie sending bug. Exits the script. 1840 * 1841 * @link http://support.microsoft.com/kb/q176113/ 1842 * @author Andreas Gohr <andi@splitbrain.org> 1843 * 1844 * @param string $url url being directed to 1845 */ 1846function send_redirect($url) 1847{ 1848 $url = stripctl($url); // defend against HTTP Response Splitting 1849 1850 /* @var Input $INPUT */ 1851 global $INPUT; 1852 1853 //are there any undisplayed messages? keep them in session for display 1854 global $MSG; 1855 if (isset($MSG) && count($MSG) && !defined('NOSESSION')) { 1856 //reopen session, store data and close session again 1857 @session_start(); 1858 $_SESSION[DOKU_COOKIE]['msg'] = $MSG; 1859 } 1860 1861 // always close the session 1862 session_write_close(); 1863 1864 // check if running on IIS < 6 with CGI-PHP 1865 if ( 1866 $INPUT->server->has('SERVER_SOFTWARE') && $INPUT->server->has('GATEWAY_INTERFACE') && 1867 (strpos($INPUT->server->str('GATEWAY_INTERFACE'), 'CGI') !== false) && 1868 (preg_match('|^Microsoft-IIS/(\d)\.\d$|', trim($INPUT->server->str('SERVER_SOFTWARE')), $matches)) && 1869 $matches[1] < 6 1870 ) { 1871 header('Refresh: 0;url=' . $url); 1872 } else { 1873 header('Location: ' . $url); 1874 } 1875 1876 // no exits during unit tests 1877 if (defined('DOKU_UNITTEST')) { 1878 // pass info about the redirect back to the test suite 1879 $testRequest = TestRequest::getRunning(); 1880 if ($testRequest !== null) { 1881 $testRequest->addData('send_redirect', $url); 1882 } 1883 return; 1884 } 1885 1886 exit; 1887} 1888 1889/** 1890 * Validate a value using a set of valid values 1891 * 1892 * This function checks whether a specified value is set and in the array 1893 * $valid_values. If not, the function returns a default value or, if no 1894 * default is specified, throws an exception. 1895 * 1896 * @param string $param The name of the parameter 1897 * @param array $valid_values A set of valid values; Optionally a default may 1898 * be marked by the key “default”. 1899 * @param array $array The array containing the value (typically $_POST 1900 * or $_GET) 1901 * @param string $exc The text of the raised exception 1902 * 1903 * @return mixed 1904 * @throws Exception 1905 * @author Adrian Lang <lang@cosmocode.de> 1906 */ 1907function valid_input_set($param, $valid_values, $array, $exc = '') 1908{ 1909 if (isset($array[$param]) && in_array($array[$param], $valid_values)) { 1910 return $array[$param]; 1911 } elseif (isset($valid_values['default'])) { 1912 return $valid_values['default']; 1913 } else { 1914 throw new Exception($exc); 1915 } 1916} 1917 1918/** 1919 * Read a preference from the DokuWiki cookie 1920 * (remembering both keys & values are urlencoded) 1921 * 1922 * @param string $pref preference key 1923 * @param mixed $default value returned when preference not found 1924 * @return string preference value 1925 */ 1926function get_doku_pref($pref, $default) 1927{ 1928 $enc_pref = urlencode($pref); 1929 if (isset($_COOKIE['DOKU_PREFS']) && strpos($_COOKIE['DOKU_PREFS'], $enc_pref) !== false) { 1930 $parts = explode('#', $_COOKIE['DOKU_PREFS']); 1931 $cnt = count($parts); 1932 1933 // due to #2721 there might be duplicate entries, 1934 // so we read from the end 1935 for ($i = $cnt - 2; $i >= 0; $i -= 2) { 1936 if ($parts[$i] === $enc_pref) { 1937 return urldecode($parts[$i + 1]); 1938 } 1939 } 1940 } 1941 return $default; 1942} 1943 1944/** 1945 * Add a preference to the DokuWiki cookie 1946 * (remembering $_COOKIE['DOKU_PREFS'] is urlencoded) 1947 * Remove it by setting $val to false 1948 * 1949 * @param string $pref preference key 1950 * @param string $val preference value 1951 */ 1952function set_doku_pref($pref, $val) 1953{ 1954 global $conf; 1955 $orig = get_doku_pref($pref, false); 1956 $cookieVal = ''; 1957 1958 if ($orig !== false && ($orig !== $val)) { 1959 $parts = explode('#', $_COOKIE['DOKU_PREFS']); 1960 $cnt = count($parts); 1961 // urlencode $pref for the comparison 1962 $enc_pref = rawurlencode($pref); 1963 $seen = false; 1964 for ($i = 0; $i < $cnt; $i += 2) { 1965 if ($parts[$i] === $enc_pref) { 1966 if (!$seen) { 1967 if ($val !== false) { 1968 $parts[$i + 1] = rawurlencode($val ?? ''); 1969 } else { 1970 unset($parts[$i]); 1971 unset($parts[$i + 1]); 1972 } 1973 $seen = true; 1974 } else { 1975 // no break because we want to remove duplicate entries 1976 unset($parts[$i]); 1977 unset($parts[$i + 1]); 1978 } 1979 } 1980 } 1981 $cookieVal = implode('#', $parts); 1982 } elseif ($orig === false && $val !== false) { 1983 $cookieVal = (isset($_COOKIE['DOKU_PREFS']) ? $_COOKIE['DOKU_PREFS'] . '#' : '') . 1984 rawurlencode($pref) . '#' . rawurlencode($val); 1985 } 1986 1987 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 1988 if (defined('DOKU_UNITTEST')) { 1989 $_COOKIE['DOKU_PREFS'] = $cookieVal; 1990 } else { 1991 setcookie('DOKU_PREFS', $cookieVal, [ 1992 'expires' => time() + 365 * 24 * 3600, 1993 'path' => $cookieDir, 1994 'secure' => ($conf['securecookie'] && is_ssl()), 1995 'samesite' => 'Lax' 1996 ]); 1997 } 1998} 1999 2000/** 2001 * Strips source mapping declarations from given text #601 2002 * 2003 * @param string &$text reference to the CSS or JavaScript code to clean 2004 */ 2005function stripsourcemaps(&$text) 2006{ 2007 $text = preg_replace('/^(\/\/|\/\*)[@#]\s+sourceMappingURL=.*?(\*\/)?$/im', '\\1\\2', $text); 2008} 2009 2010/** 2011 * Returns the contents of a given SVG file for embedding 2012 * 2013 * Inlining SVGs saves on HTTP requests and more importantly allows for styling them through 2014 * CSS. However it should used with small SVGs only. The $maxsize setting ensures only small 2015 * files are embedded. 2016 * 2017 * This strips unneeded headers, comments and newline. The result is not a vaild standalone SVG! 2018 * 2019 * @param string $file full path to the SVG file 2020 * @param int $maxsize maximum allowed size for the SVG to be embedded 2021 * @return string|false the SVG content, false if the file couldn't be loaded 2022 */ 2023function inlineSVG($file, $maxsize = 2048) 2024{ 2025 $file = trim($file); 2026 if ($file === '') return false; 2027 if (!file_exists($file)) return false; 2028 if (filesize($file) > $maxsize) return false; 2029 if (!is_readable($file)) return false; 2030 $content = file_get_contents($file); 2031 $content = preg_replace('/<!--.*?(-->)/s', '', $content); // comments 2032 $content = preg_replace('/<\?xml .*?\?>/i', '', $content); // xml header 2033 $content = preg_replace('/<!DOCTYPE .*?>/i', '', $content); // doc type 2034 $content = preg_replace('/>\s+</s', '><', $content); // newlines between tags 2035 $content = trim($content); 2036 if (!str_starts_with($content, '<svg ')) return false; 2037 return $content; 2038} 2039 2040//Setup VIM: ex: et ts=2 : 2041