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