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