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