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