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