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