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