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 207 } else { 208 $info['perm'] = auth_aclcheck($id, '', null); 209 $info['client'] = clientIP(true); 210 } 211 212 $info['namespace'] = getNS($id); 213 214 // mobile detection 215 if ($htmlClient) { 216 $info['ismobile'] = clientismobile(); 217 } 218 219 return $info; 220 } 221 222/** 223 * Return info about the current document as associative 224 * array. 225 * 226 * @author Andreas Gohr <andi@splitbrain.org> 227 * 228 * @return array with info about current document 229 */ 230function pageinfo() 231{ 232 global $ID; 233 global $REV; 234 global $RANGE; 235 global $lang; 236 /* @var Input $INPUT */ 237 global $INPUT; 238 239 $info = basicinfo($ID); 240 241 // include ID & REV not redundant, as some parts of DokuWiki may temporarily change $ID, e.g. p_wiki_xhtml 242 // FIXME ... perhaps it would be better to ensure the temporary changes weren't necessary 243 $info['id'] = $ID; 244 $info['rev'] = $REV; 245 246 $subManager = new SubscriberManager(); 247 $info['subscribed'] = $subManager->userSubscription(); 248 249 $info['locked'] = checklock($ID); 250 $info['filepath'] = wikiFN($ID); 251 $info['exists'] = file_exists($info['filepath']); 252 $info['currentrev'] = @filemtime($info['filepath']); 253 254 if ($REV) { 255 //check if current revision was meant 256 if ($info['exists'] && ($info['currentrev'] == $REV)) { 257 $REV = ''; 258 } elseif ($RANGE) { 259 //section editing does not work with old revisions! 260 $REV = ''; 261 $RANGE = ''; 262 msg($lang['nosecedit'], 0); 263 } else { 264 //really use old revision 265 $info['filepath'] = wikiFN($ID, $REV); 266 $info['exists'] = file_exists($info['filepath']); 267 } 268 } 269 $info['rev'] = $REV; 270 if ($info['exists']) { 271 $info['writable'] = (is_writable($info['filepath']) && $info['perm'] >= AUTH_EDIT); 272 } else { 273 $info['writable'] = ($info['perm'] >= AUTH_CREATE); 274 } 275 $info['editable'] = ($info['writable'] && empty($info['locked'])); 276 $info['lastmod'] = @filemtime($info['filepath']); 277 278 //load page meta data 279 $info['meta'] = p_get_metadata($ID); 280 281 //who's the editor 282 $pagelog = new PageChangeLog($ID, 1024); 283 if ($REV) { 284 $revinfo = $pagelog->getRevisionInfo($REV); 285 } elseif (!empty($info['meta']['last_change']) && is_array($info['meta']['last_change'])) { 286 $revinfo = $info['meta']['last_change']; 287 } else { 288 $revinfo = $pagelog->getRevisionInfo($info['lastmod']); 289 // cache most recent changelog line in metadata if missing and still valid 290 if ($revinfo !== false) { 291 $info['meta']['last_change'] = $revinfo; 292 p_set_metadata($ID, ['last_change' => $revinfo]); 293 } 294 } 295 //and check for an external edit 296 if ($revinfo !== false && $revinfo['date'] != $info['lastmod']) { 297 // cached changelog line no longer valid 298 $revinfo = false; 299 $info['meta']['last_change'] = $revinfo; 300 p_set_metadata($ID, ['last_change' => $revinfo]); 301 } 302 303 if ($revinfo !== false) { 304 $info['ip'] = $revinfo['ip']; 305 $info['user'] = $revinfo['user']; 306 $info['sum'] = $revinfo['sum']; 307 // See also $INFO['meta']['last_change'] which is the most recent log line for page $ID. 308 // Use $INFO['meta']['last_change']['type']===DOKU_CHANGE_TYPE_MINOR_EDIT in place of $info['minor']. 309 310 $info['editor'] = $revinfo['user'] ?: $revinfo['ip']; 311 } else { 312 $info['ip'] = null; 313 $info['user'] = null; 314 $info['sum'] = null; 315 $info['editor'] = null; 316 } 317 318 // draft 319 $draft = new Draft($ID, $info['client']); 320 if ($draft->isDraftAvailable()) { 321 $info['draft'] = $draft->getDraftFilename(); 322 } 323 324 return $info; 325} 326 327/** 328 * Initialize and/or fill global $JSINFO with some basic info to be given to javascript 329 */ 330function jsinfo() 331{ 332 global $JSINFO, $ID, $INFO, $ACT; 333 334 if (!is_array($JSINFO)) { 335 $JSINFO = []; 336 } 337 //export minimal info to JS, plugins can add more 338 $JSINFO['id'] = $ID; 339 $JSINFO['namespace'] = isset($INFO) ? (string) $INFO['namespace'] : ''; 340 $JSINFO['ACT'] = act_clean($ACT); 341 $JSINFO['useHeadingNavigation'] = (int) useHeading('navigation'); 342 $JSINFO['useHeadingContent'] = (int) useHeading('content'); 343} 344 345/** 346 * Return information about the current media item as an associative array. 347 * 348 * @return array with info about current media item 349 */ 350function mediainfo() 351{ 352 global $NS; 353 global $IMG; 354 355 $info = basicinfo("$NS:*"); 356 $info['image'] = $IMG; 357 358 return $info; 359} 360 361/** 362 * Build an string of URL parameters 363 * 364 * @author Andreas Gohr 365 * 366 * @param array $params array with key-value pairs 367 * @param string $sep series of pairs are separated by this character 368 * @return string query string 369 */ 370function buildURLparams($params, $sep = '&') 371{ 372 $url = ''; 373 $amp = false; 374 foreach($params as $key => $val) { 375 if($amp) $url .= $sep; 376 377 $url .= rawurlencode($key).'='; 378 $url .= rawurlencode((string) $val); 379 $amp = true; 380 } 381 return $url; 382} 383 384/** 385 * Build an string of html tag attributes 386 * 387 * Skips keys starting with '_', values get HTML encoded 388 * 389 * @author Andreas Gohr 390 * 391 * @param array $params array with (attribute name-attribute value) pairs 392 * @param bool $skipEmptyStrings skip empty string values? 393 * @return string 394 */ 395function buildAttributes($params, $skipEmptyStrings = false) 396{ 397 $url = ''; 398 $white = false; 399 foreach($params as $key => $val) { 400 if($key[0] == '_') continue; 401 if($val === '' && $skipEmptyStrings) continue; 402 if($white) $url .= ' '; 403 404 $url .= $key.'="'; 405 $url .= hsc($val); 406 $url .= '"'; 407 $white = true; 408 } 409 return $url; 410} 411 412/** 413 * This builds the breadcrumb trail and returns it as array 414 * 415 * @author Andreas Gohr <andi@splitbrain.org> 416 * 417 * @return string[] with the data: array(pageid=>name, ... ) 418 */ 419function breadcrumbs() 420{ 421 // we prepare the breadcrumbs early for quick session closing 422 static $crumbs = null; 423 if($crumbs != null) return $crumbs; 424 425 global $ID; 426 global $ACT; 427 global $conf; 428 global $INFO; 429 430 //first visit? 431 $crumbs = $_SESSION[DOKU_COOKIE]['bc'] ?? []; 432 //we only save on show and existing visible readable wiki documents 433 $file = wikiFN($ID); 434 if($ACT != 'show' || $INFO['perm'] < AUTH_READ || isHiddenPage($ID) || !file_exists($file)) { 435 $_SESSION[DOKU_COOKIE]['bc'] = $crumbs; 436 return $crumbs; 437 } 438 439 // page names 440 $name = noNSorNS($ID); 441 if(useHeading('navigation')) { 442 // get page title 443 $title = p_get_first_heading($ID, METADATA_RENDER_USING_SIMPLE_CACHE); 444 if($title) { 445 $name = $title; 446 } 447 } 448 449 //remove ID from array 450 if(isset($crumbs[$ID])) { 451 unset($crumbs[$ID]); 452 } 453 454 //add to array 455 $crumbs[$ID] = $name; 456 //reduce size 457 while(count($crumbs) > $conf['breadcrumbs']) { 458 array_shift($crumbs); 459 } 460 //save to session 461 $_SESSION[DOKU_COOKIE]['bc'] = $crumbs; 462 return $crumbs; 463} 464 465/** 466 * Filter for page IDs 467 * 468 * This is run on a ID before it is outputted somewhere 469 * currently used to replace the colon with something else 470 * on Windows (non-IIS) systems and to have proper URL encoding 471 * 472 * See discussions at https://github.com/dokuwiki/dokuwiki/pull/84 and 473 * https://github.com/dokuwiki/dokuwiki/pull/173 why we use a whitelist of 474 * unaffected servers instead of blacklisting affected servers here. 475 * 476 * Urlencoding is ommitted when the second parameter is false 477 * 478 * @author Andreas Gohr <andi@splitbrain.org> 479 * 480 * @param string $id pageid being filtered 481 * @param bool $ue apply urlencoding? 482 * @return string 483 */ 484function idfilter($id, $ue = true) 485{ 486 global $conf; 487 /* @var Input $INPUT */ 488 global $INPUT; 489 490 $id = (string) $id; 491 492 if($conf['useslash'] && $conf['userewrite']) { 493 $id = strtr($id, ':', '/'); 494 } elseif(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($pre !== '' && substr($pre, -1) !== "\n" && 1267 substr($text, 0, 1) !== "\n" 1268 ) { 1269 $pre .= "\n"; 1270 } 1271 if($suf !== '' && substr($text, -1) !== "\n" && 1272 substr($suf, 0, 1) !== "\n" 1273 ) { 1274 $text .= "\n"; 1275 } 1276 } 1277 1278 return $pre.$text.$suf; 1279} 1280 1281/** 1282 * Checks if the current page version is newer than the last entry in the page's 1283 * changelog. If so, we assume it has been an external edit and we create an 1284 * attic copy and add a proper changelog line. 1285 * 1286 * This check is only executed when the page is about to be saved again from the 1287 * wiki, triggered in @see saveWikiText() 1288 * 1289 * @param string $id the page ID 1290 * @deprecated 2021-11-28 1291 */ 1292function detectExternalEdit($id) 1293{ 1294 dbg_deprecated(PageFile::class .'::detectExternalEdit()'); 1295 (new PageFile($id))->detectExternalEdit(); 1296} 1297 1298/** 1299 * Saves a wikitext by calling io_writeWikiPage. 1300 * Also directs changelog and attic updates. 1301 * 1302 * @author Andreas Gohr <andi@splitbrain.org> 1303 * @author Ben Coburn <btcoburn@silicodon.net> 1304 * 1305 * @param string $id page id 1306 * @param string $text wikitext being saved 1307 * @param string $summary summary of text update 1308 * @param bool $minor mark this saved version as minor update 1309 */ 1310function saveWikiText($id, $text, $summary, $minor = false) 1311{ 1312 1313 // get COMMON_WIKIPAGE_SAVE event data 1314 $data = (new PageFile($id))->saveWikiText($text, $summary, $minor); 1315 if(!$data) return; // save was cancelled (for no changes or by a plugin) 1316 1317 // send notify mails 1318 ['oldRevision' => $rev, 'newRevision' => $new_rev, 'summary' => $summary] = $data; 1319 notify($id, 'admin', $rev, $summary, $minor, $new_rev); 1320 notify($id, 'subscribers', $rev, $summary, $minor, $new_rev); 1321 1322 // if useheading is enabled, purge the cache of all linking pages 1323 if (useHeading('content')) { 1324 $pages = ft_backlinks($id, true); 1325 foreach ($pages as $page) { 1326 $cache = new CacheRenderer($page, wikiFN($page), 'xhtml'); 1327 $cache->removeCache(); 1328 } 1329 } 1330} 1331 1332/** 1333 * moves the current version to the attic and returns its revision date 1334 * 1335 * @author Andreas Gohr <andi@splitbrain.org> 1336 * 1337 * @param string $id page id 1338 * @return int|string revision timestamp 1339 * @deprecated 2021-11-28 1340 */ 1341function saveOldRevision($id) 1342{ 1343 dbg_deprecated(PageFile::class .'::saveOldRevision()'); 1344 return (new PageFile($id))->saveOldRevision(); 1345} 1346 1347/** 1348 * Sends a notify mail on page change or registration 1349 * 1350 * @param string $id The changed page 1351 * @param string $who Who to notify (admin|subscribers|register) 1352 * @param int|string $rev Old page revision 1353 * @param string $summary What changed 1354 * @param boolean $minor Is this a minor edit? 1355 * @param string[] $replace Additional string substitutions, @KEY@ to be replaced by value 1356 * @param int|string $current_rev New page revision 1357 * @return bool 1358 * 1359 * @author Andreas Gohr <andi@splitbrain.org> 1360 */ 1361function notify($id, $who, $rev = '', $summary = '', $minor = false, $replace = [], $current_rev = false) 1362{ 1363 global $conf; 1364 /* @var Input $INPUT */ 1365 global $INPUT; 1366 1367 // decide if there is something to do, eg. whom to mail 1368 if ($who == 'admin') { 1369 if (empty($conf['notify'])) return false; //notify enabled? 1370 $tpl = 'mailtext'; 1371 $to = $conf['notify']; 1372 } elseif ($who == 'subscribers') { 1373 if (!actionOK('subscribe')) return false; //subscribers enabled? 1374 if ($conf['useacl'] && $INPUT->server->str('REMOTE_USER') && $minor) return false; //skip minors 1375 $data = ['id' => $id, 'addresslist' => '', 'self' => false, 'replacements' => $replace]; 1376 Event::createAndTrigger( 1377 'COMMON_NOTIFY_ADDRESSLIST', 1378 $data, 1379 [new SubscriberManager(), 'notifyAddresses'] 1380 ); 1381 $to = $data['addresslist']; 1382 if (empty($to)) return false; 1383 $tpl = 'subscr_single'; 1384 } else { 1385 return false; //just to be safe 1386 } 1387 1388 // prepare content 1389 $subscription = new PageSubscriptionSender(); 1390 return $subscription->sendPageDiff($to, $tpl, $id, $rev, $summary, $current_rev); 1391} 1392 1393/** 1394 * extracts the query from a search engine referrer 1395 * 1396 * @author Andreas Gohr <andi@splitbrain.org> 1397 * @author Todd Augsburger <todd@rollerorgans.com> 1398 * 1399 * @return array|string 1400 */ 1401function getGoogleQuery() 1402{ 1403 /* @var Input $INPUT */ 1404 global $INPUT; 1405 1406 if(!$INPUT->server->has('HTTP_REFERER')) { 1407 return ''; 1408 } 1409 $url = parse_url($INPUT->server->str('HTTP_REFERER')); 1410 1411 // only handle common SEs 1412 if(!array_key_exists('host', $url)) return ''; 1413 if(!preg_match('/(google|bing|yahoo|ask|duckduckgo|babylon|aol|yandex)/', $url['host'])) return ''; 1414 1415 $query = []; 1416 if(!array_key_exists('query', $url)) return ''; 1417 parse_str($url['query'], $query); 1418 1419 $q = ''; 1420 if(isset($query['q'])){ 1421 $q = $query['q']; 1422 }elseif(isset($query['p'])){ 1423 $q = $query['p']; 1424 }elseif(isset($query['query'])){ 1425 $q = $query['query']; 1426 } 1427 $q = trim($q); 1428 1429 if(!$q) return ''; 1430 // ignore if query includes a full URL 1431 if(strpos($q, '//') !== false) return ''; 1432 $q = preg_split('/[\s\'"\\\\`()\]\[?:!\.{};,#+*<>\\/]+/', $q, -1, PREG_SPLIT_NO_EMPTY); 1433 return $q; 1434} 1435 1436/** 1437 * Return the human readable size of a file 1438 * 1439 * @param int $size A file size 1440 * @param int $dec A number of decimal places 1441 * @return string human readable size 1442 * 1443 * @author Martin Benjamin <b.martin@cybernet.ch> 1444 * @author Aidan Lister <aidan@php.net> 1445 * @version 1.0.0 1446 */ 1447function filesize_h($size, $dec = 1) 1448{ 1449 $sizes = ['B', 'KB', 'MB', 'GB']; 1450 $count = count($sizes); 1451 $i = 0; 1452 1453 while($size >= 1024 && ($i < $count - 1)) { 1454 $size /= 1024; 1455 $i++; 1456 } 1457 1458 return round($size, $dec)."\xC2\xA0".$sizes[$i]; //non-breaking space 1459} 1460 1461/** 1462 * Return the given timestamp as human readable, fuzzy age 1463 * 1464 * @author Andreas Gohr <gohr@cosmocode.de> 1465 * 1466 * @param int $dt timestamp 1467 * @return string 1468 */ 1469function datetime_h($dt) 1470{ 1471 global $lang; 1472 1473 $ago = time() - $dt; 1474 if($ago > 24 * 60 * 60 * 30 * 12 * 2) { 1475 return sprintf($lang['years'], round($ago / (24 * 60 * 60 * 30 * 12))); 1476 } 1477 if($ago > 24 * 60 * 60 * 30 * 2) { 1478 return sprintf($lang['months'], round($ago / (24 * 60 * 60 * 30))); 1479 } 1480 if($ago > 24 * 60 * 60 * 7 * 2) { 1481 return sprintf($lang['weeks'], round($ago / (24 * 60 * 60 * 7))); 1482 } 1483 if($ago > 24 * 60 * 60 * 2) { 1484 return sprintf($lang['days'], round($ago / (24 * 60 * 60))); 1485 } 1486 if($ago > 60 * 60 * 2) { 1487 return sprintf($lang['hours'], round($ago / (60 * 60))); 1488 } 1489 if($ago > 60 * 2) { 1490 return sprintf($lang['minutes'], round($ago / (60))); 1491 } 1492 return sprintf($lang['seconds'], $ago); 1493} 1494 1495/** 1496 * Wraps around strftime but provides support for fuzzy dates 1497 * 1498 * The format default to $conf['dformat']. It is passed to 1499 * strftime - %f can be used to get the value from datetime_h() 1500 * 1501 * @see datetime_h 1502 * @author Andreas Gohr <gohr@cosmocode.de> 1503 * 1504 * @param int|null $dt timestamp when given, null will take current timestamp 1505 * @param string $format empty default to $conf['dformat'], or provide format as recognized by strftime() 1506 * @return string 1507 */ 1508function dformat($dt = null, $format = '') 1509{ 1510 global $conf; 1511 1512 if(is_null($dt)) $dt = time(); 1513 $dt = (int) $dt; 1514 if(!$format) $format = $conf['dformat']; 1515 1516 $format = str_replace('%f', datetime_h($dt), $format); 1517 return strftime($format, $dt); 1518} 1519 1520/** 1521 * Formats a timestamp as ISO 8601 date 1522 * 1523 * @author <ungu at terong dot com> 1524 * @link http://php.net/manual/en/function.date.php#54072 1525 * 1526 * @param int $int_date current date in UNIX timestamp 1527 * @return string 1528 */ 1529function date_iso8601($int_date) 1530{ 1531 $date_mod = date('Y-m-d\TH:i:s', $int_date); 1532 $pre_timezone = date('O', $int_date); 1533 $time_zone = substr($pre_timezone, 0, 3).":".substr($pre_timezone, 3, 2); 1534 $date_mod .= $time_zone; 1535 return $date_mod; 1536} 1537 1538/** 1539 * return an obfuscated email address in line with $conf['mailguard'] setting 1540 * 1541 * @author Harry Fuecks <hfuecks@gmail.com> 1542 * @author Christopher Smith <chris@jalakai.co.uk> 1543 * 1544 * @param string $email email address 1545 * @return string 1546 */ 1547function obfuscate($email) 1548{ 1549 global $conf; 1550 1551 switch($conf['mailguard']) { 1552 case 'visible' : 1553 $obfuscate = ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] ']; 1554 return strtr($email, $obfuscate); 1555 1556 case 'hex' : 1557 return Conversion::toHtml($email, true); 1558 1559 case 'none' : 1560 default : 1561 return $email; 1562 } 1563} 1564 1565/** 1566 * Removes quoting backslashes 1567 * 1568 * @author Andreas Gohr <andi@splitbrain.org> 1569 * 1570 * @param string $string 1571 * @param string $char backslashed character 1572 * @return string 1573 */ 1574function unslash($string, $char = "'") 1575{ 1576 return str_replace('\\'.$char, $char, $string); 1577} 1578 1579/** 1580 * Convert php.ini shorthands to byte 1581 * 1582 * On 32 bit systems values >= 2GB will fail! 1583 * 1584 * -1 (infinite size) will be reported as -1 1585 * 1586 * @link https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes 1587 * @param string $value PHP size shorthand 1588 * @return int 1589 */ 1590function php_to_byte($value) 1591{ 1592 switch (strtoupper(substr($value, -1))) { 1593 case 'G': 1594 $ret = (int) substr($value, 0, -1) * 1024 * 1024 * 1024; 1595 break; 1596 case 'M': 1597 $ret = (int) substr($value, 0, -1) * 1024 * 1024; 1598 break; 1599 case 'K': 1600 $ret = (int) substr($value, 0, -1) * 1024; 1601 break; 1602 default: 1603 $ret = (int) $value; 1604 break; 1605 } 1606 return $ret; 1607} 1608 1609/** 1610 * Wrapper around preg_quote adding the default delimiter 1611 * 1612 * @param string $string 1613 * @return string 1614 */ 1615function preg_quote_cb($string) 1616{ 1617 return preg_quote($string, '/'); 1618} 1619 1620/** 1621 * Shorten a given string by removing data from the middle 1622 * 1623 * You can give the string in two parts, the first part $keep 1624 * will never be shortened. The second part $short will be cut 1625 * in the middle to shorten but only if at least $min chars are 1626 * left to display it. Otherwise it will be left off. 1627 * 1628 * @param string $keep the part to keep 1629 * @param string $short the part to shorten 1630 * @param int $max maximum chars you want for the whole string 1631 * @param int $min minimum number of chars to have left for middle shortening 1632 * @param string $char the shortening character to use 1633 * @return string 1634 */ 1635function shorten($keep, $short, $max, $min = 9, $char = '…') 1636{ 1637 $max -= PhpString::strlen($keep); 1638 if($max < $min) return $keep; 1639 $len = PhpString::strlen($short); 1640 if($len <= $max) return $keep.$short; 1641 $half = floor($max / 2); 1642 return $keep . 1643 PhpString::substr($short, 0, $half - 1) . 1644 $char . 1645 PhpString::substr($short, $len - $half); 1646} 1647 1648/** 1649 * Return the users real name or e-mail address for use 1650 * in page footer and recent changes pages 1651 * 1652 * @param string|null $username or null when currently logged-in user should be used 1653 * @param bool $textonly true returns only plain text, true allows returning html 1654 * @return string html or plain text(not escaped) of formatted user name 1655 * 1656 * @author Andy Webber <dokuwiki AT andywebber DOT com> 1657 */ 1658function editorinfo($username, $textonly = false) 1659{ 1660 return userlink($username, $textonly); 1661} 1662 1663/** 1664 * Returns users realname w/o link 1665 * 1666 * @param string|null $username or null when currently logged-in user should be used 1667 * @param bool $textonly true returns only plain text, true allows returning html 1668 * @return string html or plain text(not escaped) of formatted user name 1669 * 1670 * @triggers COMMON_USER_LINK 1671 */ 1672function userlink($username = null, $textonly = false) 1673{ 1674 global $conf, $INFO; 1675 /** @var AuthPlugin $auth */ 1676 global $auth; 1677 /** @var Input $INPUT */ 1678 global $INPUT; 1679 1680 // prepare initial event data 1681 $data = [ 1682 'username' => $username, // the unique user name 1683 'name' => '', 1684 'link' => [ 1685 //setting 'link' to false disables linking 1686 'target' => '', 1687 'pre' => '', 1688 'suf' => '', 1689 'style' => '', 1690 'more' => '', 1691 'url' => '', 1692 'title' => '', 1693 'class' => '', 1694 ], 1695 'userlink' => '', // formatted user name as will be returned 1696 'textonly' => $textonly, 1697 ]; 1698 if($username === null) { 1699 $data['username'] = $username = $INPUT->server->str('REMOTE_USER'); 1700 if($textonly){ 1701 $data['name'] = $INFO['userinfo']['name']. ' (' . $INPUT->server->str('REMOTE_USER') . ')'; 1702 }else { 1703 $data['name'] = '<bdi>' . hsc($INFO['userinfo']['name']) . '</bdi> '. 1704 '(<bdi>' . hsc($INPUT->server->str('REMOTE_USER')) . '</bdi>)'; 1705 } 1706 } 1707 1708 $evt = new Event('COMMON_USER_LINK', $data); 1709 if($evt->advise_before(true)) { 1710 if(empty($data['name'])) { 1711 if($auth) $info = $auth->getUserData($username); 1712 if($conf['showuseras'] != 'loginname' && isset($info) && $info) { 1713 switch($conf['showuseras']) { 1714 case 'username': 1715 case 'username_link': 1716 $data['name'] = $textonly ? $info['name'] : hsc($info['name']); 1717 break; 1718 case 'email': 1719 case 'email_link': 1720 $data['name'] = obfuscate($info['mail']); 1721 break; 1722 } 1723 } else { 1724 $data['name'] = $textonly ? $data['username'] : hsc($data['username']); 1725 } 1726 } 1727 1728 /** @var Doku_Renderer_xhtml $xhtml_renderer */ 1729 static $xhtml_renderer = null; 1730 1731 if(!$data['textonly'] && empty($data['link']['url'])) { 1732 1733 if(in_array($conf['showuseras'], ['email_link', 'username_link'])) { 1734 if(!isset($info)) { 1735 if($auth) $info = $auth->getUserData($username); 1736 } 1737 if(isset($info) && $info) { 1738 if($conf['showuseras'] == 'email_link') { 1739 $data['link']['url'] = 'mailto:' . obfuscate($info['mail']); 1740 } else { 1741 if(is_null($xhtml_renderer)) { 1742 $xhtml_renderer = p_get_renderer('xhtml'); 1743 } 1744 if(empty($xhtml_renderer->interwiki)) { 1745 $xhtml_renderer->interwiki = getInterwiki(); 1746 } 1747 $shortcut = 'user'; 1748 $exists = null; 1749 $data['link']['url'] = $xhtml_renderer->_resolveInterWiki($shortcut, $username, $exists); 1750 $data['link']['class'] .= ' interwiki iw_user'; 1751 if($exists !== null) { 1752 if($exists) { 1753 $data['link']['class'] .= ' wikilink1'; 1754 } else { 1755 $data['link']['class'] .= ' wikilink2'; 1756 $data['link']['rel'] = 'nofollow'; 1757 } 1758 } 1759 } 1760 } else { 1761 $data['textonly'] = true; 1762 } 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($INPUT->server->has('SERVER_SOFTWARE') && $INPUT->server->has('GATEWAY_INTERFACE') && 1878 (strpos($INPUT->server->str('GATEWAY_INTERFACE'), 'CGI') !== false) && 1879 (preg_match('|^Microsoft-IIS/(\d)\.\d$|', trim($INPUT->server->str('SERVER_SOFTWARE')), $matches)) && 1880 $matches[1] < 6 1881 ) { 1882 header('Refresh: 0;url='.$url); 1883 } else { 1884 header('Location: '.$url); 1885 } 1886 1887 // no exits during unit tests 1888 if(defined('DOKU_UNITTEST')) { 1889 // pass info about the redirect back to the test suite 1890 $testRequest = TestRequest::getRunning(); 1891 if($testRequest !== null) { 1892 $testRequest->addData('send_redirect', $url); 1893 } 1894 return; 1895 } 1896 1897 exit; 1898} 1899 1900/** 1901 * Validate a value using a set of valid values 1902 * 1903 * This function checks whether a specified value is set and in the array 1904 * $valid_values. If not, the function returns a default value or, if no 1905 * default is specified, throws an exception. 1906 * 1907 * @param string $param The name of the parameter 1908 * @param array $valid_values A set of valid values; Optionally a default may 1909 * be marked by the key “default”. 1910 * @param array $array The array containing the value (typically $_POST 1911 * or $_GET) 1912 * @param string $exc The text of the raised exception 1913 * 1914 * @throws Exception 1915 * @return mixed 1916 * @author Adrian Lang <lang@cosmocode.de> 1917 */ 1918function valid_input_set($param, $valid_values, $array, $exc = '') 1919{ 1920 if(isset($array[$param]) && in_array($array[$param], $valid_values)) { 1921 return $array[$param]; 1922 } elseif(isset($valid_values['default'])) { 1923 return $valid_values['default']; 1924 } else { 1925 throw new Exception($exc); 1926 } 1927} 1928 1929/** 1930 * Read a preference from the DokuWiki cookie 1931 * (remembering both keys & values are urlencoded) 1932 * 1933 * @param string $pref preference key 1934 * @param mixed $default value returned when preference not found 1935 * @return string preference value 1936 */ 1937function get_doku_pref($pref, $default) 1938{ 1939 $enc_pref = urlencode($pref); 1940 if(isset($_COOKIE['DOKU_PREFS']) && strpos($_COOKIE['DOKU_PREFS'], $enc_pref) !== false) { 1941 $parts = explode('#', $_COOKIE['DOKU_PREFS']); 1942 $cnt = count($parts); 1943 1944 // due to #2721 there might be duplicate entries, 1945 // so we read from the end 1946 for($i = $cnt-2; $i >= 0; $i -= 2) { 1947 if($parts[$i] === $enc_pref) { 1948 return urldecode($parts[$i + 1]); 1949 } 1950 } 1951 } 1952 return $default; 1953} 1954 1955/** 1956 * Add a preference to the DokuWiki cookie 1957 * (remembering $_COOKIE['DOKU_PREFS'] is urlencoded) 1958 * Remove it by setting $val to false 1959 * 1960 * @param string $pref preference key 1961 * @param string $val preference value 1962 */ 1963function set_doku_pref($pref, $val) 1964{ 1965 global $conf; 1966 $orig = get_doku_pref($pref, false); 1967 $cookieVal = ''; 1968 1969 if ($orig !== false && ($orig !== $val)) { 1970 $parts = explode('#', $_COOKIE['DOKU_PREFS']); 1971 $cnt = count($parts); 1972 // urlencode $pref for the comparison 1973 $enc_pref = rawurlencode($pref); 1974 $seen = false; 1975 for ($i = 0; $i < $cnt; $i += 2) { 1976 if ($parts[$i] === $enc_pref) { 1977 if (!$seen){ 1978 if ($val !== false) { 1979 $parts[$i + 1] = rawurlencode($val ?? ''); 1980 } else { 1981 unset($parts[$i]); 1982 unset($parts[$i + 1]); 1983 } 1984 $seen = true; 1985 } else { 1986 // no break because we want to remove duplicate entries 1987 unset($parts[$i]); 1988 unset($parts[$i + 1]); 1989 } 1990 } 1991 } 1992 $cookieVal = implode('#', $parts); 1993 } elseif ($orig === false && $val !== false) { 1994 $cookieVal = (isset($_COOKIE['DOKU_PREFS']) ? $_COOKIE['DOKU_PREFS'] . '#' : '') . 1995 rawurlencode($pref) . '#' . rawurlencode($val); 1996 } 1997 1998 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 1999 if(defined('DOKU_UNITTEST')) { 2000 $_COOKIE['DOKU_PREFS'] = $cookieVal; 2001 }else{ 2002 setcookie('DOKU_PREFS', $cookieVal, [ 2003 'expires' => time() + 365 * 24 * 3600, 2004 'path' => $cookieDir, 2005 'secure' => ($conf['securecookie'] && is_ssl()), 2006 'samesite' => 'Lax' 2007 ]); 2008 } 2009} 2010 2011/** 2012 * Strips source mapping declarations from given text #601 2013 * 2014 * @param string &$text reference to the CSS or JavaScript code to clean 2015 */ 2016function stripsourcemaps(&$text) 2017{ 2018 $text = preg_replace('/^(\/\/|\/\*)[@#]\s+sourceMappingURL=.*?(\*\/)?$/im', '\\1\\2', $text); 2019} 2020 2021/** 2022 * Returns the contents of a given SVG file for embedding 2023 * 2024 * Inlining SVGs saves on HTTP requests and more importantly allows for styling them through 2025 * CSS. However it should used with small SVGs only. The $maxsize setting ensures only small 2026 * files are embedded. 2027 * 2028 * This strips unneeded headers, comments and newline. The result is not a vaild standalone SVG! 2029 * 2030 * @param string $file full path to the SVG file 2031 * @param int $maxsize maximum allowed size for the SVG to be embedded 2032 * @return string|false the SVG content, false if the file couldn't be loaded 2033 */ 2034function inlineSVG($file, $maxsize = 2048) 2035{ 2036 $file = trim($file); 2037 if($file === '') return false; 2038 if(!file_exists($file)) return false; 2039 if(filesize($file) > $maxsize) return false; 2040 if(!is_readable($file)) return false; 2041 $content = file_get_contents($file); 2042 $content = preg_replace('/<!--.*?(-->)/s', '', $content); // comments 2043 $content = preg_replace('/<\?xml .*?\?>/i', '', $content); // xml header 2044 $content = preg_replace('/<!DOCTYPE .*?>/i', '', $content); // doc type 2045 $content = preg_replace('/>\s+</s', '><', $content); // newlines between tags 2046 $content = trim($content); 2047 if(substr($content, 0, 5) !== '<svg ') return false; 2048 return $content; 2049} 2050 2051//Setup VIM: ex: et ts=2 : 2052