xref: /dokuwiki/inc/common.php (revision 19d5ba27782e35d8c5d7e3db41e9834c96868bbb)
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 * @author Zebra North <mrzebra@mrzebra.co.uk>
798 *
799 * @param  bool $single If set only a single IP is returned.
800 *
801 * @return string Returns an IP address if 'single' is true, or a comma-separated list
802 *                of IP addresses otherwise.
803 */
804function clientIP($single = false) {
805    // Return the first IP in single mode, or all the IPs.
806    return $single ? Ip::clientIp() : join(',', Ip::clientIps());
807}
808
809/**
810 * Check if the browser is on a mobile device
811 *
812 * Adapted from the example code at url below
813 *
814 * @link http://www.brainhandles.com/2007/10/15/detecting-mobile-browsers/#code
815 *
816 * @deprecated 2018-04-27 you probably want media queries instead anyway
817 * @return bool if true, client is mobile browser; otherwise false
818 */
819function clientismobile()
820{
821    /* @var Input $INPUT */
822    global $INPUT;
823
824    if ($INPUT->server->has('HTTP_X_WAP_PROFILE')) return true;
825
826    if (preg_match('/wap\.|\.wap/i', $INPUT->server->str('HTTP_ACCEPT'))) return true;
827
828    if (!$INPUT->server->has('HTTP_USER_AGENT')) return false;
829
830    $uamatches = implode(
831        '|',
832        [
833            'midp', 'j2me', 'avantg', 'docomo', 'novarra', 'palmos', 'palmsource', '240x320', 'opwv',
834            'chtml', 'pda', 'windows ce', 'mmp\/', 'blackberry', 'mib\/', 'symbian', 'wireless', 'nokia',
835            'hand', 'mobi', 'phone', 'cdm', 'up\.b', 'audio', 'SIE\-', 'SEC\-', 'samsung', 'HTC', 'mot\-',
836            'mitsu', 'sagem', 'sony', 'alcatel', 'lg', 'erics', 'vx', 'NEC', 'philips', 'mmm', 'xx',
837            'panasonic', 'sharp', 'wap', 'sch', 'rover', 'pocket', 'benq', 'java', 'pt', 'pg', 'vox',
838            'amoi', 'bird', 'compal', 'kg', 'voda', 'sany', 'kdd', 'dbt', 'sendo', 'sgh', 'gradi', 'jb',
839            '\d\d\di', 'moto'
840        ]
841    );
842
843    if (preg_match("/$uamatches/i", $INPUT->server->str('HTTP_USER_AGENT'))) return true;
844
845    return false;
846}
847
848/**
849 * check if a given link is interwiki link
850 *
851 * @param string $link the link, e.g. "wiki>page"
852 * @return bool
853 */
854function link_isinterwiki($link)
855{
856    if (preg_match('/^[a-zA-Z0-9\.]+>/u', $link)) return true;
857    return false;
858}
859
860/**
861 * Convert one or more comma separated IPs to hostnames
862 *
863 * If $conf['dnslookups'] is disabled it simply returns the input string
864 *
865 * @param string $ips comma separated list of IP addresses
866 * @return string a comma separated list of hostnames
867 * @author Glen Harris <astfgl@iamnota.org>
868 *
869 */
870function gethostsbyaddrs($ips)
871{
872    global $conf;
873    if (!$conf['dnslookups']) return $ips;
874
875    $hosts = [];
876    $ips = explode(',', $ips);
877
878    if (is_array($ips)) {
879        foreach ($ips as $ip) {
880            $hosts[] = gethostbyaddr(trim($ip));
881        }
882        return implode(',', $hosts);
883    } else {
884        return gethostbyaddr(trim($ips));
885    }
886}
887
888/**
889 * Checks if a given page is currently locked.
890 *
891 * removes stale lockfiles
892 *
893 * @param string $id page id
894 * @return bool page is locked?
895 * @author Andreas Gohr <andi@splitbrain.org>
896 *
897 */
898function checklock($id)
899{
900    global $conf;
901    /* @var Input $INPUT */
902    global $INPUT;
903
904    $lock = wikiLockFN($id);
905
906    //no lockfile
907    if (!file_exists($lock)) return false;
908
909    //lockfile expired
910    if ((time() - filemtime($lock)) > $conf['locktime']) {
911        @unlink($lock);
912        return false;
913    }
914
915    //my own lock
916    [$ip, $session] = sexplode("\n", io_readFile($lock), 2);
917    if ($ip == $INPUT->server->str('REMOTE_USER') || (session_id() && $session === session_id())) {
918        return false;
919    }
920
921    return $ip;
922}
923
924/**
925 * Lock a page for editing
926 *
927 * @param string $id page id to lock
928 * @author Andreas Gohr <andi@splitbrain.org>
929 *
930 */
931function lock($id)
932{
933    global $conf;
934    /* @var Input $INPUT */
935    global $INPUT;
936
937    if ($conf['locktime'] == 0) {
938        return;
939    }
940
941    $lock = wikiLockFN($id);
942    if ($INPUT->server->str('REMOTE_USER')) {
943        io_saveFile($lock, $INPUT->server->str('REMOTE_USER'));
944    } else {
945        io_saveFile($lock, clientIP() . "\n" . session_id());
946    }
947}
948
949/**
950 * Unlock a page if it was locked by the user
951 *
952 * @param string $id page id to unlock
953 * @return bool true if a lock was removed
954 * @author Andreas Gohr <andi@splitbrain.org>
955 *
956 */
957function unlock($id)
958{
959    /* @var Input $INPUT */
960    global $INPUT;
961
962    $lock = wikiLockFN($id);
963    if (file_exists($lock)) {
964        @[$ip, $session] = explode("\n", io_readFile($lock));
965        if ($ip == $INPUT->server->str('REMOTE_USER') || $session == session_id()) {
966            @unlink($lock);
967            return true;
968        }
969    }
970    return false;
971}
972
973/**
974 * convert line ending to unix format
975 *
976 * also makes sure the given text is valid UTF-8
977 *
978 * @param string $text
979 * @return string
980 * @see    formText() for 2crlf conversion
981 * @author Andreas Gohr <andi@splitbrain.org>
982 *
983 */
984function cleanText($text)
985{
986    $text = preg_replace("/(\015\012)|(\015)/", "\012", $text);
987
988    // if the text is not valid UTF-8 we simply assume latin1
989    // this won't break any worse than it breaks with the wrong encoding
990    // but might actually fix the problem in many cases
991    if (!Clean::isUtf8($text)) $text = Conversion::fromLatin1($text);
992
993    return $text;
994}
995
996/**
997 * Prepares text for print in Webforms by encoding special chars.
998 * It also converts line endings to Windows format which is
999 * pseudo standard for webforms.
1000 *
1001 * @param string $text
1002 * @return string
1003 * @see    cleanText() for 2unix conversion
1004 * @author Andreas Gohr <andi@splitbrain.org>
1005 *
1006 */
1007function formText($text)
1008{
1009    $text = str_replace("\012", "\015\012", $text ?? '');
1010    return htmlspecialchars($text);
1011}
1012
1013/**
1014 * Returns the specified local text in raw format
1015 *
1016 * @param string $id page id
1017 * @param string $ext extension of file being read, default 'txt'
1018 * @return string
1019 * @author Andreas Gohr <andi@splitbrain.org>
1020 *
1021 */
1022function rawLocale($id, $ext = 'txt')
1023{
1024    return io_readFile(localeFN($id, $ext));
1025}
1026
1027/**
1028 * Returns the raw WikiText
1029 *
1030 * @param string $id page id
1031 * @param string|int $rev timestamp when a revision of wikitext is desired
1032 * @return string
1033 * @author Andreas Gohr <andi@splitbrain.org>
1034 *
1035 */
1036function rawWiki($id, $rev = '')
1037{
1038    return io_readWikiPage(wikiFN($id, $rev), $id, $rev);
1039}
1040
1041/**
1042 * Returns the pagetemplate contents for the ID's namespace
1043 *
1044 * @triggers COMMON_PAGETPL_LOAD
1045 * @param string $id the id of the page to be created
1046 * @return string parsed pagetemplate content
1047 * @author Andreas Gohr <andi@splitbrain.org>
1048 *
1049 */
1050function pageTemplate($id)
1051{
1052    global $conf;
1053
1054    if (is_array($id)) $id = $id[0];
1055
1056    // prepare initial event data
1057    $data = [
1058        'id' => $id, // the id of the page to be created
1059        'tpl' => '', // the text used as template
1060        'tplfile' => '', // the file above text was/should be loaded from
1061        'doreplace' => true,
1062    ];
1063
1064    $evt = new Event('COMMON_PAGETPL_LOAD', $data);
1065    if ($evt->advise_before(true)) {
1066        // the before event might have loaded the content already
1067        if (empty($data['tpl'])) {
1068            // if the before event did not set a template file, try to find one
1069            if (empty($data['tplfile'])) {
1070                $path = dirname(wikiFN($id));
1071                if (file_exists($path . '/_template.txt')) {
1072                    $data['tplfile'] = $path . '/_template.txt';
1073                } else {
1074                    // search upper namespaces for templates
1075                    $len = strlen(rtrim($conf['datadir'], '/'));
1076                    while (strlen($path) >= $len) {
1077                        if (file_exists($path . '/__template.txt')) {
1078                            $data['tplfile'] = $path . '/__template.txt';
1079                            break;
1080                        }
1081                        $path = substr($path, 0, strrpos($path, '/'));
1082                    }
1083                }
1084            }
1085            // load the content
1086            $data['tpl'] = io_readFile($data['tplfile']);
1087        }
1088        if ($data['doreplace']) parsePageTemplate($data);
1089    }
1090    $evt->advise_after();
1091    unset($evt);
1092
1093    return $data['tpl'];
1094}
1095
1096/**
1097 * Performs common page template replacements
1098 * This works on data from COMMON_PAGETPL_LOAD
1099 *
1100 * @param array $data array with event data
1101 * @return string
1102 * @author Andreas Gohr <andi@splitbrain.org>
1103 *
1104 */
1105function parsePageTemplate(&$data)
1106{
1107    /**
1108     * @var string $id the id of the page to be created
1109     * @var string $tpl the text used as template
1110     * @var string $tplfile the file above text was/should be loaded from
1111     * @var bool $doreplace should wildcard replacements be done on the text?
1112     */
1113    extract($data);
1114
1115    global $USERINFO;
1116    global $conf;
1117    /* @var Input $INPUT */
1118    global $INPUT;
1119
1120    // replace placeholders
1121    $file = noNS($id);
1122    $page = strtr($file, $conf['sepchar'], ' ');
1123
1124    $tpl = str_replace(
1125        [
1126            '@ID@',
1127            '@NS@',
1128            '@CURNS@',
1129            '@!CURNS@',
1130            '@!!CURNS@',
1131            '@!CURNS!@',
1132            '@FILE@',
1133            '@!FILE@',
1134            '@!FILE!@',
1135            '@PAGE@',
1136            '@!PAGE@',
1137            '@!!PAGE@',
1138            '@!PAGE!@',
1139            '@USER@',
1140            '@NAME@',
1141            '@MAIL@',
1142            '@DATE@'
1143        ],
1144        [
1145            $id,
1146            getNS($id),
1147            curNS($id),
1148            PhpString::ucfirst(curNS($id)),
1149            PhpString::ucwords(curNS($id)),
1150            PhpString::strtoupper(curNS($id)),
1151            $file,
1152            PhpString::ucfirst($file),
1153            PhpString::strtoupper($file),
1154            $page,
1155            PhpString::ucfirst($page),
1156            PhpString::ucwords($page),
1157            PhpString::strtoupper($page),
1158            $INPUT->server->str('REMOTE_USER'),
1159            $USERINFO ? $USERINFO['name'] : '',
1160            $USERINFO ? $USERINFO['mail'] : '',
1161            $conf['dformat']
1162        ],
1163        $tpl
1164    );
1165
1166    // we need the callback to work around strftime's char limit
1167    $tpl = preg_replace_callback(
1168        '/%./',
1169        static fn($m) => dformat(null, $m[0]),
1170        $tpl
1171    );
1172    $data['tpl'] = $tpl;
1173    return $tpl;
1174}
1175
1176/**
1177 * Returns the raw Wiki Text in three slices.
1178 *
1179 * The range parameter needs to have the form "from-to"
1180 * and gives the range of the section in bytes - no
1181 * UTF-8 awareness is needed.
1182 * The returned order is prefix, section and suffix.
1183 *
1184 * @param string $range in form "from-to"
1185 * @param string $id page id
1186 * @param string $rev optional, the revision timestamp
1187 * @return string[] with three slices
1188 * @author Andreas Gohr <andi@splitbrain.org>
1189 *
1190 */
1191function rawWikiSlices($range, $id, $rev = '')
1192{
1193    $text = io_readWikiPage(wikiFN($id, $rev), $id, $rev);
1194
1195    // Parse range
1196    [$from, $to] = sexplode('-', $range, 2);
1197    // Make range zero-based, use defaults if marker is missing
1198    $from = $from ? $from - 1 : (0);
1199    $to = $to ? $to - 1 : (strlen($text));
1200
1201    $slices = [];
1202    $slices[0] = substr($text, 0, $from);
1203    $slices[1] = substr($text, $from, $to - $from);
1204    $slices[2] = substr($text, $to);
1205    return $slices;
1206}
1207
1208/**
1209 * Joins wiki text slices
1210 *
1211 * function to join the text slices.
1212 * When the pretty parameter is set to true it adds additional empty
1213 * lines between sections if needed (used on saving).
1214 *
1215 * @param string $pre prefix
1216 * @param string $text text in the middle
1217 * @param string $suf suffix
1218 * @param bool $pretty add additional empty lines between sections
1219 * @return string
1220 * @author Andreas Gohr <andi@splitbrain.org>
1221 *
1222 */
1223function con($pre, $text, $suf, $pretty = false)
1224{
1225    if ($pretty) {
1226        if (
1227            $pre !== '' && !str_ends_with($pre, "\n") &&
1228            !str_starts_with($text, "\n")
1229        ) {
1230            $pre .= "\n";
1231        }
1232        if (
1233            $suf !== '' && !str_ends_with($text, "\n") &&
1234            !str_starts_with($suf, "\n")
1235        ) {
1236            $text .= "\n";
1237        }
1238    }
1239
1240    return $pre . $text . $suf;
1241}
1242
1243/**
1244 * Checks if the current page version is newer than the last entry in the page's
1245 * changelog. If so, we assume it has been an external edit and we create an
1246 * attic copy and add a proper changelog line.
1247 *
1248 * This check is only executed when the page is about to be saved again from the
1249 * wiki, triggered in @param string $id the page ID
1250 * @see saveWikiText()
1251 *
1252 * @deprecated 2021-11-28
1253 */
1254function detectExternalEdit($id)
1255{
1256    dbg_deprecated(PageFile::class . '::detectExternalEdit()');
1257    (new PageFile($id))->detectExternalEdit();
1258}
1259
1260/**
1261 * Saves a wikitext by calling io_writeWikiPage.
1262 * Also directs changelog and attic updates.
1263 *
1264 * @param string $id page id
1265 * @param string $text wikitext being saved
1266 * @param string $summary summary of text update
1267 * @param bool $minor mark this saved version as minor update
1268 * @author Andreas Gohr <andi@splitbrain.org>
1269 * @author Ben Coburn <btcoburn@silicodon.net>
1270 *
1271 */
1272function saveWikiText($id, $text, $summary, $minor = false)
1273{
1274
1275    // get COMMON_WIKIPAGE_SAVE event data
1276    $data = (new PageFile($id))->saveWikiText($text, $summary, $minor);
1277    if (!$data) return; // save was cancelled (for no changes or by a plugin)
1278
1279    // send notify mails
1280    ['oldRevision' => $rev, 'newRevision' => $new_rev, 'summary' => $summary] = $data;
1281    notify($id, 'admin', $rev, $summary, $minor, $new_rev);
1282    notify($id, 'subscribers', $rev, $summary, $minor, $new_rev);
1283
1284    // if useheading is enabled, purge the cache of all linking pages
1285    if (useHeading('content')) {
1286        $pages = ft_backlinks($id, true);
1287        foreach ($pages as $page) {
1288            $cache = new CacheRenderer($page, wikiFN($page), 'xhtml');
1289            $cache->removeCache();
1290        }
1291    }
1292}
1293
1294/**
1295 * moves the current version to the attic and returns its revision date
1296 *
1297 * @param string $id page id
1298 * @return int|string revision timestamp
1299 * @author Andreas Gohr <andi@splitbrain.org>
1300 *
1301 * @deprecated 2021-11-28
1302 */
1303function saveOldRevision($id)
1304{
1305    dbg_deprecated(PageFile::class . '::saveOldRevision()');
1306    return (new PageFile($id))->saveOldRevision();
1307}
1308
1309/**
1310 * Sends a notify mail on page change or registration
1311 *
1312 * @param string $id The changed page
1313 * @param string $who Who to notify (admin|subscribers|register)
1314 * @param int|string $rev Old page revision
1315 * @param string $summary What changed
1316 * @param boolean $minor Is this a minor edit?
1317 * @param string[] $replace Additional string substitutions, @KEY@ to be replaced by value
1318 * @param int|string $current_rev New page revision
1319 * @return bool
1320 *
1321 * @author Andreas Gohr <andi@splitbrain.org>
1322 */
1323function notify($id, $who, $rev = '', $summary = '', $minor = false, $replace = [], $current_rev = false)
1324{
1325    global $conf;
1326    /* @var Input $INPUT */
1327    global $INPUT;
1328
1329    // decide if there is something to do, eg. whom to mail
1330    if ($who == 'admin') {
1331        if (empty($conf['notify'])) return false; //notify enabled?
1332        $tpl = 'mailtext';
1333        $to = $conf['notify'];
1334    } elseif ($who == 'subscribers') {
1335        if (!actionOK('subscribe')) return false; //subscribers enabled?
1336        if ($conf['useacl'] && $INPUT->server->str('REMOTE_USER') && $minor) return false; //skip minors
1337        $data = ['id' => $id, 'addresslist' => '', 'self' => false, 'replacements' => $replace];
1338        Event::createAndTrigger(
1339            'COMMON_NOTIFY_ADDRESSLIST',
1340            $data,
1341            [new SubscriberManager(), 'notifyAddresses']
1342        );
1343        $to = $data['addresslist'];
1344        if (empty($to)) return false;
1345        $tpl = 'subscr_single';
1346    } else {
1347        return false; //just to be safe
1348    }
1349
1350    // prepare content
1351    $subscription = new PageSubscriptionSender();
1352    return $subscription->sendPageDiff($to, $tpl, $id, $rev, $summary, $current_rev);
1353}
1354
1355/**
1356 * extracts the query from a search engine referrer
1357 *
1358 * @return array|string
1359 * @author Todd Augsburger <todd@rollerorgans.com>
1360 *
1361 * @author Andreas Gohr <andi@splitbrain.org>
1362 */
1363function getGoogleQuery()
1364{
1365    /* @var Input $INPUT */
1366    global $INPUT;
1367
1368    if (!$INPUT->server->has('HTTP_REFERER')) {
1369        return '';
1370    }
1371    $url = parse_url($INPUT->server->str('HTTP_REFERER'));
1372
1373    // only handle common SEs
1374    if (!array_key_exists('host', $url)) return '';
1375    if (!preg_match('/(google|bing|yahoo|ask|duckduckgo|babylon|aol|yandex)/', $url['host'])) return '';
1376
1377    $query = [];
1378    if (!array_key_exists('query', $url)) return '';
1379    parse_str($url['query'], $query);
1380
1381    $q = '';
1382    if (isset($query['q'])) {
1383        $q = $query['q'];
1384    } elseif (isset($query['p'])) {
1385        $q = $query['p'];
1386    } elseif (isset($query['query'])) {
1387        $q = $query['query'];
1388    }
1389    $q = trim($q);
1390
1391    if (!$q) return '';
1392    // ignore if query includes a full URL
1393    if (strpos($q, '//') !== false) return '';
1394    $q = preg_split('/[\s\'"\\\\`()\]\[?:!\.{};,#+*<>\\/]+/', $q, -1, PREG_SPLIT_NO_EMPTY);
1395    return $q;
1396}
1397
1398/**
1399 * Return the human readable size of a file
1400 *
1401 * @param int $size A file size
1402 * @param int $dec A number of decimal places
1403 * @return string human readable size
1404 *
1405 * @author      Martin Benjamin <b.martin@cybernet.ch>
1406 * @author      Aidan Lister <aidan@php.net>
1407 * @version     1.0.0
1408 */
1409function filesize_h($size, $dec = 1)
1410{
1411    $sizes = ['B', 'KB', 'MB', 'GB'];
1412    $count = count($sizes);
1413    $i = 0;
1414
1415    while ($size >= 1024 && ($i < $count - 1)) {
1416        $size /= 1024;
1417        $i++;
1418    }
1419
1420    return round($size, $dec) . "\xC2\xA0" . $sizes[$i]; //non-breaking space
1421}
1422
1423/**
1424 * Return the given timestamp as human readable, fuzzy age
1425 *
1426 * @param int $dt timestamp
1427 * @return string
1428 * @author Andreas Gohr <gohr@cosmocode.de>
1429 *
1430 */
1431function datetime_h($dt)
1432{
1433    global $lang;
1434
1435    $ago = time() - $dt;
1436    if ($ago > 24 * 60 * 60 * 30 * 12 * 2) {
1437        return sprintf($lang['years'], round($ago / (24 * 60 * 60 * 30 * 12)));
1438    }
1439    if ($ago > 24 * 60 * 60 * 30 * 2) {
1440        return sprintf($lang['months'], round($ago / (24 * 60 * 60 * 30)));
1441    }
1442    if ($ago > 24 * 60 * 60 * 7 * 2) {
1443        return sprintf($lang['weeks'], round($ago / (24 * 60 * 60 * 7)));
1444    }
1445    if ($ago > 24 * 60 * 60 * 2) {
1446        return sprintf($lang['days'], round($ago / (24 * 60 * 60)));
1447    }
1448    if ($ago > 60 * 60 * 2) {
1449        return sprintf($lang['hours'], round($ago / (60 * 60)));
1450    }
1451    if ($ago > 60 * 2) {
1452        return sprintf($lang['minutes'], round($ago / (60)));
1453    }
1454    return sprintf($lang['seconds'], $ago);
1455}
1456
1457/**
1458 * Wraps around strftime but provides support for fuzzy dates
1459 *
1460 * The format default to $conf['dformat']. It is passed to
1461 * strftime - %f can be used to get the value from datetime_h()
1462 *
1463 * @param int|null $dt timestamp when given, null will take current timestamp
1464 * @param string $format empty default to $conf['dformat'], or provide format as recognized by strftime()
1465 * @return string
1466 * @author Andreas Gohr <gohr@cosmocode.de>
1467 *
1468 * @see datetime_h
1469 */
1470function dformat($dt = null, $format = '')
1471{
1472    global $conf;
1473
1474    if (is_null($dt)) $dt = time();
1475    $dt = (int)$dt;
1476    if (!$format) $format = $conf['dformat'];
1477
1478    $format = str_replace('%f', datetime_h($dt), $format);
1479    return strftime($format, $dt);
1480}
1481
1482/**
1483 * Formats a timestamp as ISO 8601 date
1484 *
1485 * @param int $int_date current date in UNIX timestamp
1486 * @return string
1487 * @author <ungu at terong dot com>
1488 * @link http://php.net/manual/en/function.date.php#54072
1489 *
1490 */
1491function date_iso8601($int_date)
1492{
1493    $date_mod = date('Y-m-d\TH:i:s', $int_date);
1494    $pre_timezone = date('O', $int_date);
1495    $time_zone = substr($pre_timezone, 0, 3) . ":" . substr($pre_timezone, 3, 2);
1496    $date_mod .= $time_zone;
1497    return $date_mod;
1498}
1499
1500/**
1501 * return an obfuscated email address in line with $conf['mailguard'] setting
1502 *
1503 * @param string $email email address
1504 * @return string
1505 * @author Harry Fuecks <hfuecks@gmail.com>
1506 * @author Christopher Smith <chris@jalakai.co.uk>
1507 *
1508 */
1509function obfuscate($email)
1510{
1511    global $conf;
1512
1513    switch ($conf['mailguard']) {
1514        case 'visible':
1515            $obfuscate = ['@' => ' [at] ', '.' => ' [dot] ', '-' => ' [dash] '];
1516            return strtr($email, $obfuscate);
1517
1518        case 'hex':
1519            return Conversion::toHtml($email, true);
1520
1521        case 'none':
1522        default:
1523            return $email;
1524    }
1525}
1526
1527/**
1528 * Removes quoting backslashes
1529 *
1530 * @param string $string
1531 * @param string $char backslashed character
1532 * @return string
1533 * @author Andreas Gohr <andi@splitbrain.org>
1534 *
1535 */
1536function unslash($string, $char = "'")
1537{
1538    return str_replace('\\' . $char, $char, $string);
1539}
1540
1541/**
1542 * Convert php.ini shorthands to byte
1543 *
1544 * On 32 bit systems values >= 2GB will fail!
1545 *
1546 * -1 (infinite size) will be reported as -1
1547 *
1548 * @link   https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
1549 * @param string $value PHP size shorthand
1550 * @return int
1551 */
1552function php_to_byte($value)
1553{
1554    switch (strtoupper(substr($value, -1))) {
1555        case 'G':
1556            $ret = (int)substr($value, 0, -1) * 1024 * 1024 * 1024;
1557            break;
1558        case 'M':
1559            $ret = (int)substr($value, 0, -1) * 1024 * 1024;
1560            break;
1561        case 'K':
1562            $ret = (int)substr($value, 0, -1) * 1024;
1563            break;
1564        default:
1565            $ret = (int)$value;
1566            break;
1567    }
1568    return $ret;
1569}
1570
1571/**
1572 * Wrapper around preg_quote adding the default delimiter
1573 *
1574 * @param string $string
1575 * @return string
1576 */
1577function preg_quote_cb($string)
1578{
1579    return preg_quote($string, '/');
1580}
1581
1582/**
1583 * Shorten a given string by removing data from the middle
1584 *
1585 * You can give the string in two parts, the first part $keep
1586 * will never be shortened. The second part $short will be cut
1587 * in the middle to shorten but only if at least $min chars are
1588 * left to display it. Otherwise it will be left off.
1589 *
1590 * @param string $keep the part to keep
1591 * @param string $short the part to shorten
1592 * @param int $max maximum chars you want for the whole string
1593 * @param int $min minimum number of chars to have left for middle shortening
1594 * @param string $char the shortening character to use
1595 * @return string
1596 */
1597function shorten($keep, $short, $max, $min = 9, $char = '…')
1598{
1599    $max -= PhpString::strlen($keep);
1600    if ($max < $min) return $keep;
1601    $len = PhpString::strlen($short);
1602    if ($len <= $max) return $keep . $short;
1603    $half = floor($max / 2);
1604    return $keep .
1605        PhpString::substr($short, 0, $half - 1) .
1606        $char .
1607        PhpString::substr($short, $len - $half);
1608}
1609
1610/**
1611 * Return the users real name or e-mail address for use
1612 * in page footer and recent changes pages
1613 *
1614 * @param string|null $username or null when currently logged-in user should be used
1615 * @param bool $textonly true returns only plain text, true allows returning html
1616 * @return string html or plain text(not escaped) of formatted user name
1617 *
1618 * @author Andy Webber <dokuwiki AT andywebber DOT com>
1619 */
1620function editorinfo($username, $textonly = false)
1621{
1622    return userlink($username, $textonly);
1623}
1624
1625/**
1626 * Returns users realname w/o link
1627 *
1628 * @param string|null $username or null when currently logged-in user should be used
1629 * @param bool $textonly true returns only plain text, true allows returning html
1630 * @return string html or plain text(not escaped) of formatted user name
1631 *
1632 * @triggers COMMON_USER_LINK
1633 */
1634function userlink($username = null, $textonly = false)
1635{
1636    global $conf, $INFO;
1637    /** @var AuthPlugin $auth */
1638    global $auth;
1639    /** @var Input $INPUT */
1640    global $INPUT;
1641
1642    // prepare initial event data
1643    $data = [
1644        'username' => $username, // the unique user name
1645        'name' => '',
1646        'link' => [
1647            //setting 'link' to false disables linking
1648            'target' => '',
1649            'pre' => '',
1650            'suf' => '',
1651            'style' => '',
1652            'more' => '',
1653            'url' => '',
1654            'title' => '',
1655            'class' => '',
1656        ],
1657        'userlink' => '', // formatted user name as will be returned
1658        'textonly' => $textonly,
1659    ];
1660    if ($username === null) {
1661        $data['username'] = $username = $INPUT->server->str('REMOTE_USER');
1662        if ($textonly) {
1663            $data['name'] = $INFO['userinfo']['name'] . ' (' . $INPUT->server->str('REMOTE_USER') . ')';
1664        } else {
1665            $data['name'] = '<bdi>' . hsc($INFO['userinfo']['name']) . '</bdi> ' .
1666                '(<bdi>' . hsc($INPUT->server->str('REMOTE_USER')) . '</bdi>)';
1667        }
1668    }
1669
1670    $evt = new Event('COMMON_USER_LINK', $data);
1671    if ($evt->advise_before(true)) {
1672        if (empty($data['name'])) {
1673            if ($auth instanceof AuthPlugin) {
1674                $info = $auth->getUserData($username);
1675            }
1676            if ($conf['showuseras'] != 'loginname' && isset($info) && $info) {
1677                switch ($conf['showuseras']) {
1678                    case 'username':
1679                    case 'username_link':
1680                        $data['name'] = $textonly ? $info['name'] : hsc($info['name']);
1681                        break;
1682                    case 'email':
1683                    case 'email_link':
1684                        $data['name'] = obfuscate($info['mail']);
1685                        break;
1686                }
1687            } else {
1688                $data['name'] = $textonly ? $data['username'] : hsc($data['username']);
1689            }
1690        }
1691
1692        /** @var Doku_Renderer_xhtml $xhtml_renderer */
1693        static $xhtml_renderer = null;
1694
1695        if (!$data['textonly'] && empty($data['link']['url'])) {
1696            if (in_array($conf['showuseras'], ['email_link', 'username_link'])) {
1697                if (!isset($info) && $auth instanceof AuthPlugin) {
1698                    $info = $auth->getUserData($username);
1699                }
1700                if (isset($info) && $info) {
1701                    if ($conf['showuseras'] == 'email_link') {
1702                        $data['link']['url'] = 'mailto:' . obfuscate($info['mail']);
1703                    } else {
1704                        if (is_null($xhtml_renderer)) {
1705                            $xhtml_renderer = p_get_renderer('xhtml');
1706                        }
1707                        if ($xhtml_renderer->interwiki === []) {
1708                            $xhtml_renderer->interwiki = getInterwiki();
1709                        }
1710                        $shortcut = 'user';
1711                        $exists = null;
1712                        $data['link']['url'] = $xhtml_renderer->_resolveInterWiki($shortcut, $username, $exists);
1713                        $data['link']['class'] .= ' interwiki iw_user';
1714                        if ($exists !== null) {
1715                            if ($exists) {
1716                                $data['link']['class'] .= ' wikilink1';
1717                            } else {
1718                                $data['link']['class'] .= ' wikilink2';
1719                                $data['link']['rel'] = 'nofollow';
1720                            }
1721                        }
1722                    }
1723                } else {
1724                    $data['textonly'] = true;
1725                }
1726            } else {
1727                $data['textonly'] = true;
1728            }
1729        }
1730
1731        if ($data['textonly']) {
1732            $data['userlink'] = $data['name'];
1733        } else {
1734            $data['link']['name'] = $data['name'];
1735            if (is_null($xhtml_renderer)) {
1736                $xhtml_renderer = p_get_renderer('xhtml');
1737            }
1738            $data['userlink'] = $xhtml_renderer->_formatLink($data['link']);
1739        }
1740    }
1741    $evt->advise_after();
1742    unset($evt);
1743
1744    return $data['userlink'];
1745}
1746
1747/**
1748 * Returns the path to a image file for the currently chosen license.
1749 * When no image exists, returns an empty string
1750 *
1751 * @param string $type - type of image 'badge' or 'button'
1752 * @return string
1753 * @author Andreas Gohr <andi@splitbrain.org>
1754 *
1755 */
1756function license_img($type)
1757{
1758    global $license;
1759    global $conf;
1760    if (!$conf['license']) return '';
1761    if (!is_array($license[$conf['license']])) return '';
1762    $try = [];
1763    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.png';
1764    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.gif';
1765    if (str_starts_with($conf['license'], 'cc-')) {
1766        $try[] = 'lib/images/license/' . $type . '/cc.png';
1767    }
1768    foreach ($try as $src) {
1769        if (file_exists(DOKU_INC . $src)) return $src;
1770    }
1771    return '';
1772}
1773
1774/**
1775 * Checks if the given amount of memory is available
1776 *
1777 * If the memory_get_usage() function is not available the
1778 * function just assumes $bytes of already allocated memory
1779 *
1780 * @param int $mem Size of memory you want to allocate in bytes
1781 * @param int $bytes already allocated memory (see above)
1782 * @return bool
1783 * @author Andreas Gohr <andi@splitbrain.org>
1784 *
1785 * @author Filip Oscadal <webmaster@illusionsoftworks.cz>
1786 */
1787function is_mem_available($mem, $bytes = 1_048_576)
1788{
1789    $limit = trim(ini_get('memory_limit'));
1790    if (empty($limit)) return true; // no limit set!
1791    if ($limit == -1) return true; // unlimited
1792
1793    // parse limit to bytes
1794    $limit = php_to_byte($limit);
1795
1796    // get used memory if possible
1797    if (function_exists('memory_get_usage')) {
1798        $used = memory_get_usage();
1799    } else {
1800        $used = $bytes;
1801    }
1802
1803    if ($used + $mem > $limit) {
1804        return false;
1805    }
1806
1807    return true;
1808}
1809
1810/**
1811 * Send a HTTP redirect to the browser
1812 *
1813 * Works arround Microsoft IIS cookie sending bug. Exits the script.
1814 *
1815 * @link   http://support.microsoft.com/kb/q176113/
1816 * @author Andreas Gohr <andi@splitbrain.org>
1817 *
1818 * @param string $url url being directed to
1819 */
1820function send_redirect($url)
1821{
1822    $url = stripctl($url); // defend against HTTP Response Splitting
1823
1824    /* @var Input $INPUT */
1825    global $INPUT;
1826
1827    //are there any undisplayed messages? keep them in session for display
1828    global $MSG;
1829    if (isset($MSG) && count($MSG) && !defined('NOSESSION')) {
1830        //reopen session, store data and close session again
1831        @session_start();
1832        $_SESSION[DOKU_COOKIE]['msg'] = $MSG;
1833    }
1834
1835    // always close the session
1836    session_write_close();
1837
1838    // check if running on IIS < 6 with CGI-PHP
1839    if (
1840        $INPUT->server->has('SERVER_SOFTWARE') && $INPUT->server->has('GATEWAY_INTERFACE') &&
1841        (strpos($INPUT->server->str('GATEWAY_INTERFACE'), 'CGI') !== false) &&
1842        (preg_match('|^Microsoft-IIS/(\d)\.\d$|', trim($INPUT->server->str('SERVER_SOFTWARE')), $matches)) &&
1843        $matches[1] < 6
1844    ) {
1845        header('Refresh: 0;url=' . $url);
1846    } else {
1847        header('Location: ' . $url);
1848    }
1849
1850    // no exits during unit tests
1851    if (defined('DOKU_UNITTEST')) {
1852        // pass info about the redirect back to the test suite
1853        $testRequest = TestRequest::getRunning();
1854        if ($testRequest !== null) {
1855            $testRequest->addData('send_redirect', $url);
1856        }
1857        return;
1858    }
1859
1860    exit;
1861}
1862
1863/**
1864 * Validate a value using a set of valid values
1865 *
1866 * This function checks whether a specified value is set and in the array
1867 * $valid_values. If not, the function returns a default value or, if no
1868 * default is specified, throws an exception.
1869 *
1870 * @param string $param The name of the parameter
1871 * @param array $valid_values A set of valid values; Optionally a default may
1872 *                             be marked by the key “default”.
1873 * @param array $array The array containing the value (typically $_POST
1874 *                             or $_GET)
1875 * @param string $exc The text of the raised exception
1876 *
1877 * @return mixed
1878 * @throws Exception
1879 * @author Adrian Lang <lang@cosmocode.de>
1880 */
1881function valid_input_set($param, $valid_values, $array, $exc = '')
1882{
1883    if (isset($array[$param]) && in_array($array[$param], $valid_values)) {
1884        return $array[$param];
1885    } elseif (isset($valid_values['default'])) {
1886        return $valid_values['default'];
1887    } else {
1888        throw new Exception($exc);
1889    }
1890}
1891
1892/**
1893 * Read a preference from the DokuWiki cookie
1894 * (remembering both keys & values are urlencoded)
1895 *
1896 * @param string $pref preference key
1897 * @param mixed $default value returned when preference not found
1898 * @return string preference value
1899 */
1900function get_doku_pref($pref, $default)
1901{
1902    $enc_pref = urlencode($pref);
1903    if (isset($_COOKIE['DOKU_PREFS']) && strpos($_COOKIE['DOKU_PREFS'], $enc_pref) !== false) {
1904        $parts = explode('#', $_COOKIE['DOKU_PREFS']);
1905        $cnt = count($parts);
1906
1907        // due to #2721 there might be duplicate entries,
1908        // so we read from the end
1909        for ($i = $cnt - 2; $i >= 0; $i -= 2) {
1910            if ($parts[$i] === $enc_pref) {
1911                return urldecode($parts[$i + 1]);
1912            }
1913        }
1914    }
1915    return $default;
1916}
1917
1918/**
1919 * Add a preference to the DokuWiki cookie
1920 * (remembering $_COOKIE['DOKU_PREFS'] is urlencoded)
1921 * Remove it by setting $val to false
1922 *
1923 * @param string $pref preference key
1924 * @param string $val preference value
1925 */
1926function set_doku_pref($pref, $val)
1927{
1928    global $conf;
1929    $orig = get_doku_pref($pref, false);
1930    $cookieVal = '';
1931
1932    if ($orig !== false && ($orig !== $val)) {
1933        $parts = explode('#', $_COOKIE['DOKU_PREFS']);
1934        $cnt = count($parts);
1935        // urlencode $pref for the comparison
1936        $enc_pref = rawurlencode($pref);
1937        $seen = false;
1938        for ($i = 0; $i < $cnt; $i += 2) {
1939            if ($parts[$i] === $enc_pref) {
1940                if (!$seen) {
1941                    if ($val !== false) {
1942                        $parts[$i + 1] = rawurlencode($val ?? '');
1943                    } else {
1944                        unset($parts[$i]);
1945                        unset($parts[$i + 1]);
1946                    }
1947                    $seen = true;
1948                } else {
1949                    // no break because we want to remove duplicate entries
1950                    unset($parts[$i]);
1951                    unset($parts[$i + 1]);
1952                }
1953            }
1954        }
1955        $cookieVal = implode('#', $parts);
1956    } elseif ($orig === false && $val !== false) {
1957        $cookieVal = (isset($_COOKIE['DOKU_PREFS']) ? $_COOKIE['DOKU_PREFS'] . '#' : '') .
1958            rawurlencode($pref) . '#' . rawurlencode($val);
1959    }
1960
1961    $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
1962    if (defined('DOKU_UNITTEST')) {
1963        $_COOKIE['DOKU_PREFS'] = $cookieVal;
1964    } else {
1965        setcookie('DOKU_PREFS', $cookieVal, [
1966            'expires' => time() + 365 * 24 * 3600,
1967            'path' => $cookieDir,
1968            'secure' => ($conf['securecookie'] && is_ssl()),
1969            'samesite' => 'Lax'
1970        ]);
1971    }
1972}
1973
1974/**
1975 * Strips source mapping declarations from given text #601
1976 *
1977 * @param string &$text reference to the CSS or JavaScript code to clean
1978 */
1979function stripsourcemaps(&$text)
1980{
1981    $text = preg_replace('/^(\/\/|\/\*)[@#]\s+sourceMappingURL=.*?(\*\/)?$/im', '\\1\\2', $text);
1982}
1983
1984/**
1985 * Returns the contents of a given SVG file for embedding
1986 *
1987 * Inlining SVGs saves on HTTP requests and more importantly allows for styling them through
1988 * CSS. However it should used with small SVGs only. The $maxsize setting ensures only small
1989 * files are embedded.
1990 *
1991 * This strips unneeded headers, comments and newline. The result is not a vaild standalone SVG!
1992 *
1993 * @param string $file full path to the SVG file
1994 * @param int $maxsize maximum allowed size for the SVG to be embedded
1995 * @return string|false the SVG content, false if the file couldn't be loaded
1996 */
1997function inlineSVG($file, $maxsize = 2048)
1998{
1999    $file = trim($file);
2000    if ($file === '') return false;
2001    if (!file_exists($file)) return false;
2002    if (filesize($file) > $maxsize) return false;
2003    if (!is_readable($file)) return false;
2004    $content = file_get_contents($file);
2005    $content = preg_replace('/<!--.*?(-->)/s', '', $content); // comments
2006    $content = preg_replace('/<\?xml .*?\?>/i', '', $content); // xml header
2007    $content = preg_replace('/<!DOCTYPE .*?>/i', '', $content); // doc type
2008    $content = preg_replace('/>\s+</s', '><', $content); // newlines between tags
2009    $content = trim($content);
2010    if (!str_starts_with($content, '<svg ')) return false;
2011    return $content;
2012}
2013
2014//Setup VIM: ex: et ts=2 :
2015