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