xref: /dokuwiki/inc/common.php (revision 093fe67e98c0cdb4b73fd46938e49b64971483c2)
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 ((string) $in !== '') 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        !str_contains($INPUT->server->str('SERVER_SOFTWARE'), 'Microsoft-IIS')
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 (str_contains($q, '//')) 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    $ret = match (strtoupper(substr($value, -1))) {
1548        'G' => (int)substr($value, 0, -1) * 1024 * 1024 * 1024,
1549        'M' => (int)substr($value, 0, -1) * 1024 * 1024,
1550        'K' => (int)substr($value, 0, -1) * 1024,
1551        default => (int)$value,
1552    };
1553    return $ret;
1554}
1555
1556/**
1557 * Wrapper around preg_quote adding the default delimiter
1558 *
1559 * @param string $string
1560 * @return string
1561 */
1562function preg_quote_cb($string)
1563{
1564    return preg_quote($string, '/');
1565}
1566
1567/**
1568 * Shorten a given string by removing data from the middle
1569 *
1570 * You can give the string in two parts, the first part $keep
1571 * will never be shortened. The second part $short will be cut
1572 * in the middle to shorten but only if at least $min chars are
1573 * left to display it. Otherwise it will be left off.
1574 *
1575 * @param string $keep the part to keep
1576 * @param string $short the part to shorten
1577 * @param int $max maximum chars you want for the whole string
1578 * @param int $min minimum number of chars to have left for middle shortening
1579 * @param string $char the shortening character to use
1580 * @return string
1581 */
1582function shorten($keep, $short, $max, $min = 9, $char = '…')
1583{
1584    $max -= PhpString::strlen($keep);
1585    if ($max < $min) return $keep;
1586    $len = PhpString::strlen($short);
1587    if ($len <= $max) return $keep . $short;
1588    $half = floor($max / 2);
1589    return $keep .
1590        PhpString::substr($short, 0, $half - 1) .
1591        $char .
1592        PhpString::substr($short, $len - $half);
1593}
1594
1595/**
1596 * Return the users real name or e-mail address for use
1597 * in page footer and recent changes pages
1598 *
1599 * @param string|null $username or null when currently logged-in user should be used
1600 * @param bool $textonly true returns only plain text, true allows returning html
1601 * @return string html or plain text(not escaped) of formatted user name
1602 *
1603 * @author Andy Webber <dokuwiki AT andywebber DOT com>
1604 */
1605function editorinfo($username, $textonly = false)
1606{
1607    return userlink($username, $textonly);
1608}
1609
1610/**
1611 * Returns users realname w/o link
1612 *
1613 * @param string|null $username or null when currently logged-in user should be used
1614 * @param bool $textonly true returns only plain text, true allows returning html
1615 * @return string html or plain text(not escaped) of formatted user name
1616 *
1617 * @triggers COMMON_USER_LINK
1618 */
1619function userlink($username = null, $textonly = false)
1620{
1621    global $conf, $INFO;
1622    /** @var AuthPlugin $auth */
1623    global $auth;
1624    /** @var Input $INPUT */
1625    global $INPUT;
1626
1627    // prepare initial event data
1628    $data = [
1629        'username' => $username, // the unique user name
1630        'name' => '',
1631        'link' => [
1632            //setting 'link' to false disables linking
1633            'target' => '',
1634            'pre' => '',
1635            'suf' => '',
1636            'style' => '',
1637            'more' => '',
1638            'url' => '',
1639            'title' => '',
1640            'class' => '',
1641        ],
1642        'userlink' => '', // formatted user name as will be returned
1643        'textonly' => $textonly,
1644    ];
1645    if ($username === null) {
1646        $data['username'] = $username = $INPUT->server->str('REMOTE_USER');
1647        if ($textonly) {
1648            $data['name'] = $INFO['userinfo']['name'] . ' (' . $INPUT->server->str('REMOTE_USER') . ')';
1649        } else {
1650            $data['name'] = '<bdi>' . hsc($INFO['userinfo']['name']) . '</bdi> ' .
1651                '(<bdi>' . hsc($INPUT->server->str('REMOTE_USER')) . '</bdi>)';
1652        }
1653    }
1654
1655    $evt = new Event('COMMON_USER_LINK', $data);
1656    if ($evt->advise_before(true)) {
1657        if (empty($data['name'])) {
1658            if ($auth instanceof AuthPlugin) {
1659                $info = $auth->getUserData($username);
1660            }
1661            if ($conf['showuseras'] != 'loginname' && isset($info) && $info) {
1662                switch ($conf['showuseras']) {
1663                    case 'username':
1664                    case 'username_link':
1665                        $data['name'] = $textonly ? $info['name'] : hsc($info['name']);
1666                        break;
1667                    case 'email':
1668                    case 'email_link':
1669                        $data['name'] = obfuscate($info['mail']);
1670                        break;
1671                }
1672            } else {
1673                $data['name'] = $textonly ? $data['username'] : hsc($data['username']);
1674            }
1675        }
1676
1677        /** @var Doku_Renderer_xhtml $xhtml_renderer */
1678        static $xhtml_renderer = null;
1679
1680        if (!$data['textonly'] && empty($data['link']['url'])) {
1681            if (in_array($conf['showuseras'], ['email_link', 'username_link'])) {
1682                if (!isset($info) && $auth instanceof AuthPlugin) {
1683                    $info = $auth->getUserData($username);
1684                }
1685                if (isset($info) && $info) {
1686                    if ($conf['showuseras'] == 'email_link') {
1687                        $data['link']['url'] = 'mailto:' . obfuscate($info['mail']);
1688                    } else {
1689                        if (is_null($xhtml_renderer)) {
1690                            $xhtml_renderer = p_get_renderer('xhtml');
1691                        }
1692                        if ($xhtml_renderer->interwiki === []) {
1693                            $xhtml_renderer->interwiki = getInterwiki();
1694                        }
1695                        $shortcut = 'user';
1696                        $exists = null;
1697                        $data['link']['url'] = $xhtml_renderer->_resolveInterWiki($shortcut, $username, $exists);
1698                        $data['link']['class'] .= ' interwiki iw_user';
1699                        if ($exists !== null) {
1700                            if ($exists) {
1701                                $data['link']['class'] .= ' wikilink1';
1702                            } else {
1703                                $data['link']['class'] .= ' wikilink2';
1704                                $data['link']['rel'] = 'nofollow';
1705                            }
1706                        }
1707                    }
1708                } else {
1709                    $data['textonly'] = true;
1710                }
1711            } else {
1712                $data['textonly'] = true;
1713            }
1714        }
1715
1716        if ($data['textonly']) {
1717            $data['userlink'] = $data['name'];
1718        } else {
1719            $data['link']['name'] = $data['name'];
1720            if (is_null($xhtml_renderer)) {
1721                $xhtml_renderer = p_get_renderer('xhtml');
1722            }
1723            $data['userlink'] = $xhtml_renderer->_formatLink($data['link']);
1724        }
1725    }
1726    $evt->advise_after();
1727    unset($evt);
1728
1729    return $data['userlink'];
1730}
1731
1732/**
1733 * Returns the path to a image file for the currently chosen license.
1734 * When no image exists, returns an empty string
1735 *
1736 * @param string $type - type of image 'badge' or 'button'
1737 * @return string
1738 * @author Andreas Gohr <andi@splitbrain.org>
1739 *
1740 */
1741function license_img($type)
1742{
1743    global $license;
1744    global $conf;
1745    if (!$conf['license']) return '';
1746    if (!is_array($license[$conf['license']])) return '';
1747    $try = [];
1748    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.png';
1749    $try[] = 'lib/images/license/' . $type . '/' . $conf['license'] . '.gif';
1750    if (str_starts_with($conf['license'], 'cc-')) {
1751        $try[] = 'lib/images/license/' . $type . '/cc.png';
1752    }
1753    foreach ($try as $src) {
1754        if (file_exists(DOKU_INC . $src)) return $src;
1755    }
1756    return '';
1757}
1758
1759/**
1760 * Checks if the given amount of memory is available
1761 *
1762 * If the memory_get_usage() function is not available the
1763 * function just assumes $bytes of already allocated memory
1764 *
1765 * @param int $mem Size of memory you want to allocate in bytes
1766 * @param int $bytes already allocated memory (see above)
1767 * @return bool
1768 * @author Andreas Gohr <andi@splitbrain.org>
1769 *
1770 * @author Filip Oscadal <webmaster@illusionsoftworks.cz>
1771 */
1772function is_mem_available($mem, $bytes = 1_048_576)
1773{
1774    $limit = trim(ini_get('memory_limit'));
1775    if (empty($limit)) return true; // no limit set!
1776    if ($limit == -1) return true; // unlimited
1777
1778    // parse limit to bytes
1779    $limit = php_to_byte($limit);
1780
1781    // get used memory if possible
1782    if (function_exists('memory_get_usage')) {
1783        $used = memory_get_usage();
1784    } else {
1785        $used = $bytes;
1786    }
1787
1788    if ($used + $mem > $limit) {
1789        return false;
1790    }
1791
1792    return true;
1793}
1794
1795/**
1796 * Send a HTTP redirect to the browser
1797 *
1798 * Works arround Microsoft IIS cookie sending bug. Exits the script.
1799 *
1800 * @link   http://support.microsoft.com/kb/q176113/
1801 * @author Andreas Gohr <andi@splitbrain.org>
1802 *
1803 * @param string $url url being directed to
1804 */
1805function send_redirect($url)
1806{
1807    $url = stripctl($url); // defend against HTTP Response Splitting
1808
1809    /* @var Input $INPUT */
1810    global $INPUT;
1811
1812    //are there any undisplayed messages? keep them in session for display
1813    global $MSG;
1814    if (isset($MSG) && count($MSG) && !defined('NOSESSION')) {
1815        //reopen session, store data and close session again
1816        @session_start();
1817        $_SESSION[DOKU_COOKIE]['msg'] = $MSG;
1818    }
1819
1820    // always close the session
1821    session_write_close();
1822
1823    // check if running on IIS < 6 with CGI-PHP
1824    if (
1825        $INPUT->server->has('SERVER_SOFTWARE') && $INPUT->server->has('GATEWAY_INTERFACE') &&
1826        (str_contains($INPUT->server->str('GATEWAY_INTERFACE'), 'CGI')) &&
1827        (preg_match('|^Microsoft-IIS/(\d)\.\d$|', trim($INPUT->server->str('SERVER_SOFTWARE')), $matches)) &&
1828        $matches[1] < 6
1829    ) {
1830        header('Refresh: 0;url=' . $url);
1831    } else {
1832        header('Location: ' . $url);
1833    }
1834
1835    // no exits during unit tests
1836    if (defined('DOKU_UNITTEST')) {
1837        // pass info about the redirect back to the test suite
1838        $testRequest = TestRequest::getRunning();
1839        if ($testRequest !== null) {
1840            $testRequest->addData('send_redirect', $url);
1841        }
1842        return;
1843    }
1844
1845    exit;
1846}
1847
1848/**
1849 * Validate a value using a set of valid values
1850 *
1851 * This function checks whether a specified value is set and in the array
1852 * $valid_values. If not, the function returns a default value or, if no
1853 * default is specified, throws an exception.
1854 *
1855 * @param string $param The name of the parameter
1856 * @param array $valid_values A set of valid values; Optionally a default may
1857 *                             be marked by the key “default”.
1858 * @param array $array The array containing the value (typically $_POST
1859 *                             or $_GET)
1860 * @param string $exc The text of the raised exception
1861 *
1862 * @return mixed
1863 * @throws Exception
1864 * @author Adrian Lang <lang@cosmocode.de>
1865 */
1866function valid_input_set($param, $valid_values, $array, $exc = '')
1867{
1868    if (isset($array[$param]) && in_array($array[$param], $valid_values)) {
1869        return $array[$param];
1870    } elseif (isset($valid_values['default'])) {
1871        return $valid_values['default'];
1872    } else {
1873        throw new Exception($exc);
1874    }
1875}
1876
1877/**
1878 * Read a preference from the DokuWiki cookie
1879 *
1880 * Consider using PrefCookie directly
1881 *
1882 * @param string $pref preference key
1883 * @param mixed $default value returned when preference not found
1884 * @return mixed preference value
1885 */
1886function get_doku_pref($pref, $default)
1887{
1888    return (new PrefCookie())->get($pref, $default);
1889}
1890
1891/**
1892 * Add a preference to the DokuWiki cookie
1893 *
1894 * Remove it by setting $val to false.
1895 * Consider using PrefCookie directly
1896 *
1897 * @param string $pref preference key
1898 * @param string|false $val preference value
1899 */
1900function set_doku_pref($pref, $val)
1901{
1902    if ($val === false) {
1903        $val = null;
1904    } else {
1905        $val = (string) $val;
1906    }
1907
1908    (new PrefCookie())->set($pref, $val);
1909}
1910
1911/**
1912 * Strips source mapping declarations from given text #601
1913 *
1914 * @param string &$text reference to the CSS or JavaScript code to clean
1915 */
1916function stripsourcemaps(&$text)
1917{
1918    $text = preg_replace('/^(\/\/|\/\*)[@#]\s+sourceMappingURL=.*?(\*\/)?$/im', '\\1\\2', $text);
1919}
1920
1921/**
1922 * Returns the contents of a given SVG file for embedding
1923 *
1924 * Inlining SVGs saves on HTTP requests and more importantly allows for styling them through
1925 * CSS. However it should used with small SVGs only. The $maxsize setting ensures only small
1926 * files are embedded.
1927 *
1928 * This strips unneeded headers, comments and newline. The result is not a vaild standalone SVG!
1929 *
1930 * @param string $file full path to the SVG file
1931 * @param int $maxsize maximum allowed size for the SVG to be embedded
1932 * @return string|false the SVG content, false if the file couldn't be loaded
1933 */
1934function inlineSVG($file, $maxsize = 2048)
1935{
1936    $file = trim($file);
1937    if ($file === '') return false;
1938    if (!file_exists($file)) return false;
1939    if (filesize($file) > $maxsize) return false;
1940    if (!is_readable($file)) return false;
1941    $content = file_get_contents($file);
1942    $content = preg_replace('/<!--.*?(-->)/s', '', $content); // comments
1943    $content = preg_replace('/<\?xml .*?\?>/i', '', $content); // xml header
1944    $content = preg_replace('/<!DOCTYPE .*?>/i', '', $content); // doc type
1945    $content = preg_replace('/>\s+</s', '><', $content); // newlines between tags
1946    $content = trim($content);
1947    if (!str_starts_with($content, '<svg ')) return false;
1948    return $content;
1949}
1950
1951//Setup VIM: ex: et ts=2 :
1952