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