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