[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/ -> common.php (source)

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