xref: /dokuwiki/inc/common.php (revision 71d1aa000928e9ee40ec367aeaa1eb5956340f2c)
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 custom IP header if $conf[client_ip_header] is set.
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 'client_ip_header' config value should only be set if the 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 * Saves a wikitext by calling io_writeWikiPage.
1239 * Also directs changelog and attic updates.
1240 *
1241 * @param string $id page id
1242 * @param string $text wikitext being saved
1243 * @param string $summary summary of text update
1244 * @param bool $minor mark this saved version as minor update
1245 * @author Andreas Gohr <andi@splitbrain.org>
1246 * @author Ben Coburn <btcoburn@silicodon.net>
1247 *
1248 */
1249function saveWikiText($id, $text, $summary, $minor = false)
1250{
1251
1252    // get COMMON_WIKIPAGE_SAVE event data
1253    $data = (new PageFile($id))->saveWikiText($text, $summary, $minor);
1254    if (!$data) return; // save was cancelled (for no changes or by a plugin)
1255
1256    // send notify mails
1257    ['oldRevision' => $rev, 'newRevision' => $new_rev, 'summary' => $summary] = $data;
1258    notify($id, 'admin', $rev, $summary, $minor, $new_rev);
1259    notify($id, 'subscribers', $rev, $summary, $minor, $new_rev);
1260
1261    // if useheading is enabled, purge the cache of all linking pages
1262    if (useHeading('content')) {
1263        $pages = (new MetadataSearch())->backlinks($id, true);
1264        foreach ($pages as $page) {
1265            $cache = new CacheRenderer($page, wikiFN($page), 'xhtml');
1266            $cache->removeCache();
1267        }
1268    }
1269}
1270
1271/**
1272 * moves the current version to the attic and returns its revision date
1273 *
1274 * @param string $id page id
1275 * @return int|string revision timestamp
1276 * @author Andreas Gohr <andi@splitbrain.org>
1277 *
1278 * @deprecated 2021-11-28
1279 */
1280function saveOldRevision($id)
1281{
1282    dbg_deprecated(PageFile::class . '::saveOldRevision()');
1283    return (new PageFile($id))->saveOldRevision();
1284}
1285
1286/**
1287 * Sends a notify mail on page change or registration
1288 *
1289 * @param string $id The changed page
1290 * @param string $who Who to notify (admin|subscribers|register)
1291 * @param int|string $rev Old page revision
1292 * @param string $summary What changed
1293 * @param boolean $minor Is this a minor edit?
1294 * @param string[] $replace Additional string substitutions, @KEY@ to be replaced by value
1295 * @param int|string $current_rev New page revision
1296 * @return bool
1297 *
1298 * @author Andreas Gohr <andi@splitbrain.org>
1299 */
1300function notify($id, $who, $rev = '', $summary = '', $minor = false, $replace = [], $current_rev = false)
1301{
1302    global $conf;
1303    /* @var Input $INPUT */
1304    global $INPUT;
1305
1306    // decide if there is something to do, eg. whom to mail
1307    if ($who == 'admin') {
1308        if (empty($conf['notify'])) return false; //notify enabled?
1309        $tpl = 'mailtext';
1310        $to = $conf['notify'];
1311    } elseif ($who == 'subscribers') {
1312        if (!actionOK('subscribe')) return false; //subscribers enabled?
1313        if ($conf['useacl'] && $INPUT->server->str('REMOTE_USER') && $minor) return false; //skip minors
1314        $data = ['id' => $id, 'addresslist' => '', 'self' => false, 'replacements' => $replace];
1315        Event::createAndTrigger(
1316            'COMMON_NOTIFY_ADDRESSLIST',
1317            $data,
1318            [new SubscriberManager(), 'notifyAddresses']
1319        );
1320        $to = $data['addresslist'];
1321        if (empty($to)) return false;
1322        $tpl = 'subscr_single';
1323    } else {
1324        return false; //just to be safe
1325    }
1326
1327    // prepare content
1328    $subscription = new PageSubscriptionSender();
1329    return $subscription->sendPageDiff($to, $tpl, $id, $rev, $summary, $current_rev);
1330}
1331
1332/**
1333 * extracts the query from a search engine referrer
1334 *
1335 * @return array|string
1336 * @author Todd Augsburger <todd@rollerorgans.com>
1337 *
1338 * @author Andreas Gohr <andi@splitbrain.org>
1339 */
1340function getGoogleQuery()
1341{
1342    /* @var Input $INPUT */
1343    global $INPUT;
1344
1345    if (!$INPUT->server->has('HTTP_REFERER')) {
1346        return '';
1347    }
1348    $url = parse_url($INPUT->server->str('HTTP_REFERER'));
1349
1350    // only handle common SEs
1351    if (!array_key_exists('host', $url)) return '';
1352    if (!preg_match('/(google|bing|yahoo|ask|duckduckgo|babylon|aol|yandex)/', $url['host'])) return '';
1353
1354    $query = [];
1355    if (!array_key_exists('query', $url)) return '';
1356    parse_str($url['query'], $query);
1357
1358    $q = '';
1359    if (isset($query['q'])) {
1360        $q = $query['q'];
1361    } elseif (isset($query['p'])) {
1362        $q = $query['p'];
1363    } elseif (isset($query['query'])) {
1364        $q = $query['query'];
1365    }
1366    $q = trim($q);
1367
1368    if (!$q) return '';
1369    // ignore if query includes a full URL
1370    if (str_contains($q, '//')) return '';
1371    $q = preg_split('/[\s\'"\\\\`()\]\[?:!\.{};,#+*<>\\/]+/', $q, -1, PREG_SPLIT_NO_EMPTY);
1372    return $q;
1373}
1374
1375/**
1376 * Return the human readable size of a file
1377 *
1378 * @param int $size A file size
1379 * @param int $dec A number of decimal places
1380 * @return string human readable size
1381 *
1382 * @author      Martin Benjamin <b.martin@cybernet.ch>
1383 * @author      Aidan Lister <aidan@php.net>
1384 * @version     1.0.0
1385 */
1386function filesize_h($size, $dec = 1)
1387{
1388    $sizes = ['B', 'KB', 'MB', 'GB'];
1389    $count = count($sizes);
1390    $i = 0;
1391
1392    while ($size >= 1024 && ($i < $count - 1)) {
1393        $size /= 1024;
1394        $i++;
1395    }
1396
1397    return round($size, $dec) . "\xC2\xA0" . $sizes[$i]; //non-breaking space
1398}
1399
1400/**
1401 * Return the given timestamp as human readable, fuzzy age
1402 *
1403 * @param int $dt timestamp
1404 * @return string
1405 * @author Andreas Gohr <gohr@cosmocode.de>
1406 *
1407 */
1408function datetime_h($dt)
1409{
1410    global $lang;
1411
1412    $ago = time() - $dt;
1413    if ($ago > 24 * 60 * 60 * 30 * 12 * 2) {
1414        return sprintf($lang['years'], round($ago / (24 * 60 * 60 * 30 * 12)));
1415    }
1416    if ($ago > 24 * 60 * 60 * 30 * 2) {
1417        return sprintf($lang['months'], round($ago / (24 * 60 * 60 * 30)));
1418    }
1419    if ($ago > 24 * 60 * 60 * 7 * 2) {
1420        return sprintf($lang['weeks'], round($ago / (24 * 60 * 60 * 7)));
1421    }
1422    if ($ago > 24 * 60 * 60 * 2) {
1423        return sprintf($lang['days'], round($ago / (24 * 60 * 60)));
1424    }
1425    if ($ago > 60 * 60 * 2) {
1426        return sprintf($lang['hours'], round($ago / (60 * 60)));
1427    }
1428    if ($ago > 60 * 2) {
1429        return sprintf($lang['minutes'], round($ago / (60)));
1430    }
1431    return sprintf($lang['seconds'], $ago);
1432}
1433
1434/**
1435 * Wraps around strftime but provides support for fuzzy dates
1436 *
1437 * The format default to $conf['dformat']. It is passed to
1438 * strftime - %f can be used to get the value from datetime_h()
1439 *
1440 * @param int|null $dt timestamp when given, null will take current timestamp
1441 * @param string $format empty default to $conf['dformat'], or provide format as recognized by strftime()
1442 * @return string
1443 * @author Andreas Gohr <gohr@cosmocode.de>
1444 *
1445 * @see datetime_h
1446 */
1447function dformat($dt = null, $format = '')
1448{
1449    global $conf;
1450
1451    if (is_null($dt)) $dt = time();
1452    $dt = (int)$dt;
1453    if (!$format) $format = $conf['dformat'];
1454
1455    $format = str_replace('%f', datetime_h($dt), $format);
1456    return strftime($format, $dt);
1457}
1458
1459/**
1460 * Formats a timestamp as ISO 8601 date
1461 *
1462 * @param int $int_date current date in UNIX timestamp
1463 * @return string
1464 * @author <ungu at terong dot com>
1465 * @link http://php.net/manual/en/function.date.php#54072
1466 *
1467 */
1468function date_iso8601($int_date)
1469{
1470    $date_mod = date('Y-m-d\TH:i:s', $int_date);
1471    $pre_timezone = date('O', $int_date);
1472    $time_zone = substr($pre_timezone, 0, 3) . ":" . substr($pre_timezone, 3, 2);
1473    $date_mod .= $time_zone;
1474    return $date_mod;
1475}
1476
1477/**
1478 * return an obfuscated email address in line with $conf['mailguard'] setting
1479 *
1480 * @param string $email email address
1481 * @return string
1482 * @author Harry Fuecks <hfuecks@gmail.com>
1483 * @author Christopher Smith <chris@jalakai.co.uk>
1484 *
1485 */
1486function obfuscate($email)
1487{
1488    global $conf;
1489
1490    switch ($conf['mailguard']) {
1491        case 'visible':
1492            $obfuscate = ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '];
1493            return strtr($email, $obfuscate);
1494
1495        case 'hex':
1496            return Conversion::toHtml($email, true);
1497
1498        case 'none':
1499        default:
1500            return $email;
1501    }
1502}
1503
1504/**
1505 * Removes quoting backslashes
1506 *
1507 * @param string $string
1508 * @param string $char backslashed character
1509 * @return string
1510 * @author Andreas Gohr <andi@splitbrain.org>
1511 *
1512 */
1513function unslash($string, $char = "'")
1514{
1515    return str_replace('\\' . $char, $char, $string);
1516}
1517
1518/**
1519 * Convert php.ini shorthands to byte
1520 *
1521 * On 32 bit systems values >= 2GB will fail!
1522 *
1523 * -1 (infinite size) will be reported as -1
1524 *
1525 * @link   https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
1526 * @param string $value PHP size shorthand
1527 * @return int
1528 */
1529function php_to_byte($value)
1530{
1531    $ret = match (strtoupper(substr($value, -1))) {
1532        'G' => (int)substr($value, 0, -1) * 1024 * 1024 * 1024,
1533        'M' => (int)substr($value, 0, -1) * 1024 * 1024,
1534        'K' => (int)substr($value, 0, -1) * 1024,
1535        default => (int)$value,
1536    };
1537    return $ret;
1538}
1539
1540/**
1541 * Wrapper around preg_quote adding the default delimiter
1542 *
1543 * @param string $string
1544 * @return string
1545 */
1546function preg_quote_cb($string)
1547{
1548    return preg_quote($string, '/');
1549}
1550
1551/**
1552 * Shorten a given string by removing data from the middle
1553 *
1554 * You can give the string in two parts, the first part $keep
1555 * will never be shortened. The second part $short will be cut
1556 * in the middle to shorten but only if at least $min chars are
1557 * left to display it. Otherwise it will be left off.
1558 *
1559 * @param string $keep the part to keep
1560 * @param string $short the part to shorten
1561 * @param int $max maximum chars you want for the whole string
1562 * @param int $min minimum number of chars to have left for middle shortening
1563 * @param string $char the shortening character to use
1564 * @return string
1565 */
1566function shorten($keep, $short, $max, $min = 9, $char = '…')
1567{
1568    $max -= PhpString::strlen($keep);
1569    if ($max < $min) return $keep;
1570    $len = PhpString::strlen($short);
1571    if ($len <= $max) return $keep . $short;
1572    $half = floor($max / 2);
1573    return $keep .
1574        PhpString::substr($short, 0, $half - 1) .
1575        $char .
1576        PhpString::substr($short, $len - $half);
1577}
1578
1579/**
1580 * Return the users real name or e-mail address for use
1581 * in page footer and recent changes pages
1582 *
1583 * @param string|null $username or null when currently logged-in user should be used
1584 * @param bool $textonly true returns only plain text, true allows returning html
1585 * @return string html or plain text(not escaped) of formatted user name
1586 *
1587 * @author Andy Webber <dokuwiki AT andywebber DOT com>
1588 */
1589function editorinfo($username, $textonly = false)
1590{
1591    return userlink($username, $textonly);
1592}
1593
1594/**
1595 * Returns users realname w/o link
1596 *
1597 * @param string|null $username or null when currently logged-in user should be used
1598 * @param bool $textonly true returns only plain text, true allows returning html
1599 * @return string html or plain text(not escaped) of formatted user name
1600 *
1601 * @triggers COMMON_USER_LINK
1602 */
1603function userlink($username = null, $textonly = false)
1604{
1605    global $conf, $INFO;
1606    /** @var AuthPlugin $auth */
1607    global $auth;
1608    /** @var Input $INPUT */
1609    global $INPUT;
1610
1611    // prepare initial event data
1612    $data = [
1613        'username' => $username, // the unique user name
1614        'name' => '',
1615        'link' => [
1616            //setting 'link' to false disables linking
1617            'target' => '',
1618            'pre' => '',
1619            'suf' => '',
1620            'style' => '',
1621            'more' => '',
1622            'url' => '',
1623            'title' => '',
1624            'class' => '',
1625        ],
1626        'userlink' => '', // formatted user name as will be returned
1627        'textonly' => $textonly,
1628    ];
1629    if ($username === null) {
1630        $data['username'] = $username = $INPUT->server->str('REMOTE_USER');
1631        if ($textonly) {
1632            $data['name'] = $INFO['userinfo']['name'] . ' (' . $INPUT->server->str('REMOTE_USER') . ')';
1633        } else {
1634            $data['name'] = '<bdi>' . hsc($INFO['userinfo']['name']) . '</bdi> ' .
1635                '(<bdi>' . hsc($INPUT->server->str('REMOTE_USER')) . '</bdi>)';
1636        }
1637    }
1638
1639    $evt = new Event('COMMON_USER_LINK', $data);
1640    if ($evt->advise_before(true)) {
1641        if (empty($data['name'])) {
1642            if ($auth instanceof AuthPlugin) {
1643                $info = $auth->getUserData($username);
1644            }
1645            if ($conf['showuseras'] != 'loginname' && isset($info) && $info) {
1646                switch ($conf['showuseras']) {
1647                    case 'username':
1648                    case 'username_link':
1649                        $data['name'] = $textonly ? $info['name'] : hsc($info['name']);
1650                        break;
1651                    case 'email':
1652                    case 'email_link':
1653                        $data['name'] = obfuscate($info['mail']);
1654                        break;
1655                }
1656            } else {
1657                $data['name'] = $textonly ? $data['username'] : hsc($data['username']);
1658            }
1659        }
1660
1661        /** @var Doku_Renderer_xhtml $xhtml_renderer */
1662        static $xhtml_renderer = null;
1663
1664        if (!$data['textonly'] && empty($data['link']['url'])) {
1665            if (in_array($conf['showuseras'], ['email_link', 'username_link'])) {
1666                if (!isset($info) && $auth instanceof AuthPlugin) {
1667                    $info = $auth->getUserData($username);
1668                }
1669                if (isset($info) && $info) {
1670                    if ($conf['showuseras'] == 'email_link') {
1671                        $data['link']['url'] = 'mailto:' . obfuscate($info['mail']);
1672                    } else {
1673                        if (is_null($xhtml_renderer)) {
1674                            $xhtml_renderer = p_get_renderer('xhtml');
1675                        }
1676                        if ($xhtml_renderer->interwiki === []) {
1677                            $xhtml_renderer->interwiki = getInterwiki();
1678                        }
1679                        $shortcut = 'user';
1680                        $exists = null;
1681                        $data['link']['url'] = $xhtml_renderer->_resolveInterWiki($shortcut, $username, $exists);
1682                        $data['link']['class'] .= ' interwiki iw_user';
1683                        if ($exists !== null) {
1684                            if ($exists) {
1685                                $data['link']['class'] .= ' wikilink1';
1686                            } else {
1687                                $data['link']['class'] .= ' wikilink2';
1688                                $data['link']['rel'] = 'nofollow';
1689                            }
1690                        }
1691                    }
1692                } else {
1693                    $data['textonly'] = true;
1694                }
1695            } else {
1696                $data['textonly'] = true;
1697            }
1698        }
1699
1700        if ($data['textonly']) {
1701            $data['userlink'] = $data['name'];
1702        } else {
1703            $data['link']['name'] = $data['name'];
1704            if (is_null($xhtml_renderer)) {
1705                $xhtml_renderer = p_get_renderer('xhtml');
1706            }
1707            $data['userlink'] = $xhtml_renderer->_formatLink($data['link']);
1708        }
1709    }
1710    $evt->advise_after();
1711    unset($evt);
1712
1713    return $data['userlink'];
1714}
1715
1716/**
1717 * Returns the path to a image file for the currently chosen license.
1718 * When no image exists, returns an empty string
1719 *
1720 * @param string $type - type of image 'badge' or 'button'
1721 * @return string
1722 * @author Andreas Gohr <andi@splitbrain.org>
1723 *
1724 */
1725function license_img($type)
1726{
1727    global $license;
1728    global $conf;
1729    if (!$conf['license']) return '';
1730    if (!is_array($license[$conf['license']])) return '';
1731    $try = [];
1732    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.png';
1733    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.gif';
1734    if (str_starts_with($conf['license'], 'cc-')) {
1735        $try[] = 'lib/images/license/' . $type . '/cc.png';
1736    }
1737    foreach ($try as $src) {
1738        if (file_exists(DOKU_INC . $src)) return $src;
1739    }
1740    return '';
1741}
1742
1743/**
1744 * Checks if the given amount of memory is available
1745 *
1746 * If the memory_get_usage() function is not available the
1747 * function just assumes $bytes of already allocated memory
1748 *
1749 * @param int $mem Size of memory you want to allocate in bytes
1750 * @param int $bytes already allocated memory (see above)
1751 * @return bool
1752 * @author Andreas Gohr <andi@splitbrain.org>
1753 *
1754 * @author Filip Oscadal <webmaster@illusionsoftworks.cz>
1755 */
1756function is_mem_available($mem, $bytes = 1_048_576)
1757{
1758    $limit = trim(ini_get('memory_limit'));
1759    if (empty($limit)) return true; // no limit set!
1760    if ($limit == -1) return true; // unlimited
1761
1762    // parse limit to bytes
1763    $limit = php_to_byte($limit);
1764
1765    // get used memory if possible
1766    if (function_exists('memory_get_usage')) {
1767        $used = memory_get_usage();
1768    } else {
1769        $used = $bytes;
1770    }
1771
1772    if ($used + $mem > $limit) {
1773        return false;
1774    }
1775
1776    return true;
1777}
1778
1779/**
1780 * Send a HTTP redirect to the browser
1781 *
1782 * Works arround Microsoft IIS cookie sending bug. Exits the script.
1783 *
1784 * @link   http://support.microsoft.com/kb/q176113/
1785 * @author Andreas Gohr <andi@splitbrain.org>
1786 *
1787 * @param string $url url being directed to
1788 */
1789function send_redirect($url)
1790{
1791    $url = stripctl($url); // defend against HTTP Response Splitting
1792
1793    /* @var Input $INPUT */
1794    global $INPUT;
1795
1796    //are there any undisplayed messages? keep them in session for display
1797    global $MSG;
1798    if (isset($MSG) && count($MSG) && !defined('NOSESSION')) {
1799        //reopen session, store data and close session again
1800        @session_start();
1801        $_SESSION[DOKU_COOKIE]['msg'] = $MSG;
1802    }
1803
1804    // always close the session
1805    session_write_close();
1806
1807    // check if running on IIS < 6 with CGI-PHP
1808    if (
1809        $INPUT->server->has('SERVER_SOFTWARE') && $INPUT->server->has('GATEWAY_INTERFACE') &&
1810        (str_contains($INPUT->server->str('GATEWAY_INTERFACE'), 'CGI')) &&
1811        (preg_match('|^Microsoft-IIS/(\d)\.\d$|', trim($INPUT->server->str('SERVER_SOFTWARE')), $matches)) &&
1812        $matches[1] < 6
1813    ) {
1814        header('Refresh: 0;url=' . $url);
1815    } else {
1816        header('Location: ' . $url);
1817    }
1818
1819    // no exits during unit tests
1820    if (defined('DOKU_UNITTEST')) {
1821        // pass info about the redirect back to the test suite
1822        $testRequest = TestRequest::getRunning();
1823        if ($testRequest !== null) {
1824            $testRequest->addData('send_redirect', $url);
1825        }
1826        return;
1827    }
1828
1829    exit;
1830}
1831
1832/**
1833 * Validate a value using a set of valid values
1834 *
1835 * This function checks whether a specified value is set and in the array
1836 * $valid_values. If not, the function returns a default value or, if no
1837 * default is specified, throws an exception.
1838 *
1839 * @param string $param The name of the parameter
1840 * @param array $valid_values A set of valid values; Optionally a default may
1841 *                             be marked by the key “default”.
1842 * @param array $array The array containing the value (typically $_POST
1843 *                             or $_GET)
1844 * @param string $exc The text of the raised exception
1845 *
1846 * @return mixed
1847 * @throws Exception
1848 * @author Adrian Lang <lang@cosmocode.de>
1849 */
1850function valid_input_set($param, $valid_values, $array, $exc = '')
1851{
1852    if (isset($array[$param]) && in_array($array[$param], $valid_values)) {
1853        return $array[$param];
1854    } elseif (isset($valid_values['default'])) {
1855        return $valid_values['default'];
1856    } else {
1857        throw new Exception($exc);
1858    }
1859}
1860
1861/**
1862 * Read a preference from the DokuWiki cookie
1863 *
1864 * Consider using PrefCookie directly
1865 *
1866 * @param string $pref preference key
1867 * @param mixed $default value returned when preference not found
1868 * @return mixed preference value
1869 */
1870function get_doku_pref($pref, $default)
1871{
1872    return (new PrefCookie())->get($pref, $default);
1873}
1874
1875/**
1876 * Add a preference to the DokuWiki cookie
1877 *
1878 * Remove it by setting $val to false.
1879 * Consider using PrefCookie directly
1880 *
1881 * @param string $pref preference key
1882 * @param string|false $val preference value
1883 */
1884function set_doku_pref($pref, $val)
1885{
1886    if ($val === false) {
1887        $val = null;
1888    } else {
1889        $val = (string) $val;
1890    }
1891
1892    (new PrefCookie())->set($pref, $val);
1893}
1894
1895/**
1896 * Strips source mapping declarations from given text #601
1897 *
1898 * @param string &$text reference to the CSS or JavaScript code to clean
1899 */
1900function stripsourcemaps(&$text)
1901{
1902    $text = preg_replace('/^(\/\/|\/\*)[@#]\s+sourceMappingURL=.*?(\*\/)?$/im', '\\1\\2', $text);
1903}
1904
1905/**
1906 * Returns the contents of a given SVG file for embedding
1907 *
1908 * Inlining SVGs saves on HTTP requests and more importantly allows for styling them through
1909 * CSS. However it should used with small SVGs only. The $maxsize setting ensures only small
1910 * files are embedded.
1911 *
1912 * This strips unneeded headers, comments and newline. The result is not a vaild standalone SVG!
1913 *
1914 * @param string $file full path to the SVG file
1915 * @param int $maxsize maximum allowed size for the SVG to be embedded
1916 * @return string|false the SVG content, false if the file couldn't be loaded
1917 */
1918function inlineSVG($file, $maxsize = 2048)
1919{
1920    $file = trim($file);
1921    if ($file === '') return false;
1922    if (!file_exists($file)) return false;
1923    if (filesize($file) > $maxsize) return false;
1924    if (!is_readable($file)) return false;
1925    $content = file_get_contents($file);
1926    $content = preg_replace('/<!--.*?(-->)/s', '', $content); // comments
1927    $content = preg_replace('/<\?xml .*?\?>/i', '', $content); // xml header
1928    $content = preg_replace('/<!DOCTYPE .*?>/i', '', $content); // doc type
1929    $content = preg_replace('/>\s+</s', '><', $content); // newlines between tags
1930    $content = trim($content);
1931    if (!str_starts_with($content, '<svg ')) return false;
1932    return $content;
1933}
1934
1935//Setup VIM: ex: et ts=2 :
1936