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