xref: /dokuwiki/inc/infoutils.php (revision 5e9d26e3624fd22ca3c57447be9e16d0b502761e)
1<?php
2
3/**
4 * Information and debugging functions
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Andreas Gohr <andi@splitbrain.org>
8 */
9
10use dokuwiki\Debug\DebugHelper;
11use dokuwiki\Extension\AuthPlugin;
12use dokuwiki\Extension\Event;
13use dokuwiki\HTTP\DokuHTTPClient;
14use dokuwiki\Logger;
15use dokuwiki\Search\Exception\IndexIntegrityException;
16use dokuwiki\Search\Indexer;
17use dokuwiki\Utf8;
18use dokuwiki\Utf8\PhpString;
19
20if (!defined('DOKU_MESSAGEURL')) {
21    if (in_array('ssl', stream_get_transports())) {
22        define('DOKU_MESSAGEURL', 'https://update.dokuwiki.org/check/');
23    } else {
24        define('DOKU_MESSAGEURL', 'http://update.dokuwiki.org/check/');
25    }
26}
27
28/**
29 * Check for new messages from upstream
30 *
31 * @author Andreas Gohr <andi@splitbrain.org>
32 */
33function checkUpdateMessages()
34{
35    global $conf;
36    global $INFO;
37    global $updateVersion;
38    if (!$conf['updatecheck']) return;
39    if ($conf['useacl'] && !$INFO['ismanager']) return;
40
41    $cf = getCacheName($updateVersion, '.updmsg');
42    $lm = @filemtime($cf);
43    $is_http = !str_starts_with(DOKU_MESSAGEURL, 'https');
44
45    // check if new messages needs to be fetched
46    if ($lm < time() - (60 * 60 * 24) || $lm < @filemtime(DOKU_INC . DOKU_SCRIPT)) {
47        @touch($cf);
48        Logger::debug(
49            sprintf(
50                'checkUpdateMessages(): downloading messages to %s%s',
51                $cf,
52                $is_http ? ' (without SSL)' : ' (with SSL)'
53            )
54        );
55        $http = new DokuHTTPClient();
56        $http->timeout = 12;
57        $resp = $http->get(DOKU_MESSAGEURL . $updateVersion);
58        if (is_string($resp) && ($resp == '' || str_ends_with(trim($resp), '%'))) {
59            // basic sanity check that this is either an empty string response (ie "no messages")
60            // or it looks like one of our messages, not WiFi login or other interposed response
61            io_saveFile($cf, $resp);
62        } else {
63            Logger::debug("checkUpdateMessages(): unexpected HTTP response received", $http->error);
64        }
65    } else {
66        Logger::debug("checkUpdateMessages(): messages up to date");
67    }
68
69    $data = io_readFile($cf);
70    // show messages through the usual message mechanism
71    $msgs = explode("\n%\n", $data);
72    foreach ($msgs as $msg) {
73        if ($msg) msg($msg, 2);
74    }
75}
76
77
78/**
79 * Return DokuWiki's version (split up in date and type)
80 *
81 * @author Andreas Gohr <andi@splitbrain.org>
82 */
83function getVersionData()
84{
85    $version = [];
86    //import version string
87    if (file_exists(DOKU_INC . 'VERSION')) {
88        //official release
89        $version['date'] = trim(io_readFile(DOKU_INC . 'VERSION'));
90        $version['type'] = 'Release';
91    } elseif (is_dir(DOKU_INC . '.git')) {
92        $version['type'] = 'Git';
93        $version['date'] = 'unknown';
94
95        // First try to get date and commit hash by calling Git
96        if (function_exists('shell_exec')) {
97            $commitInfo = shell_exec("git log -1 --pretty=format:'%h %cd' --date=short");
98            if ($commitInfo) {
99                [$version['sha'], $date] = explode(' ', $commitInfo);
100                $version['date'] = hsc($date);
101                return $version;
102            }
103        }
104
105        // we cannot use git on the shell -- let's do it manually!
106        if (file_exists(DOKU_INC . '.git/HEAD')) {
107            $headCommit = trim(file_get_contents(DOKU_INC . '.git/HEAD'));
108            if (str_starts_with($headCommit, 'ref: ')) {
109                // it is something like `ref: refs/heads/master`
110                $headCommit = substr($headCommit, 5);
111                $pathToHead = DOKU_INC . '.git/' . $headCommit;
112                if (file_exists($pathToHead)) {
113                    $headCommit = trim(file_get_contents($pathToHead));
114                } else {
115                    $packedRefs = file_get_contents(DOKU_INC . '.git/packed-refs');
116                    if (!preg_match("~([[:xdigit:]]+) $headCommit~", $packedRefs, $matches)) {
117                        # ref not found in pack file
118                        return $version;
119                    }
120                    $headCommit = $matches[1];
121                }
122            }
123            // At this point $headCommit is a SHA
124            $version['sha'] = $headCommit;
125
126            // Get commit date from Git object
127            $subDir = substr($headCommit, 0, 2);
128            $fileName = substr($headCommit, 2);
129            $gitCommitObject = DOKU_INC . ".git/objects/$subDir/$fileName";
130            if (file_exists($gitCommitObject) && function_exists('zlib_decode')) {
131                $commit = zlib_decode(file_get_contents($gitCommitObject));
132                $committerLine = explode("\n", $commit)[3];
133                $committerData = explode(' ', $committerLine);
134                end($committerData);
135                $ts = prev($committerData);
136                if ($ts && $date = date('Y-m-d', $ts)) {
137                    $version['date'] = $date;
138                }
139            }
140        }
141    } else {
142        global $updateVersion;
143        $version['date'] = 'update version ' . $updateVersion;
144        $version['type'] = 'snapshot?';
145    }
146    return $version;
147}
148
149/**
150 * Return DokuWiki's version
151 *
152 * This returns the version in the form "Type Date (SHA)". Where type is either
153 * "Release" or "Git" and date is the date of the release or the date of the
154 * last commit. SHA is the short SHA of the last commit - this is only added on
155 * git checkouts.
156 *
157 * If no version can be determined "snapshot? update version XX" is returned.
158 * Where XX represents the update version number set in doku.php.
159 *
160 * @return string The version string e.g. "Release 2023-04-04a"
161 * @author Anika Henke <anika@selfthinker.org>
162 */
163function getVersion()
164{
165    $version = getVersionData();
166    $sha = empty($version['sha']) ? '' : ' (' . $version['sha'] . ')';
167    return $version['type'] . ' ' . $version['date'] . $sha;
168}
169
170/**
171 * Get some data about the environment this wiki is running in
172 *
173 * @return array
174 */
175function getRuntimeVersions()
176{
177    $data = [];
178    $data['php'] = 'PHP ' . PHP_VERSION;
179
180    $osRelease = getOsRelease();
181    if (isset($osRelease['PRETTY_NAME'])) {
182        $data['dist'] = $osRelease['PRETTY_NAME'];
183    }
184
185    $data['os'] = php_uname('s') . ' ' . php_uname('r');
186    $data['sapi'] = PHP_SAPI;
187
188    if (getenv('KUBERNETES_SERVICE_HOST')) {
189        $data['container'] = 'Kubernetes';
190    } elseif (@file_exists('/.dockerenv')) {
191        $data['container'] = 'Docker';
192    }
193
194    return $data;
195}
196
197/**
198 * Get informational data about the linux distribution this wiki is running on
199 *
200 * @see https://gist.github.com/natefoo/814c5bf936922dad97ff
201 * @return array an os-release array, might be empty
202 */
203function getOsRelease()
204{
205    $reader = fn($file) => @parse_ini_string(preg_replace('/#.*$/m', '', file_get_contents($file)));
206
207    $osRelease = [];
208    if (@file_exists('/etc/os-release')) {
209        // pretty much any common Linux distribution has this
210        $osRelease = $reader('/etc/os-release');
211    } elseif (@file_exists('/etc/synoinfo.conf') && @file_exists('/etc/VERSION')) {
212        // Synology DSM has its own way
213        $synoInfo = $reader('/etc/synoinfo.conf');
214        $synoVersion = $reader('/etc/VERSION');
215        $osRelease['NAME'] = 'Synology DSM';
216        $osRelease['ID'] = 'synology';
217        $osRelease['ID_LIKE'] = 'linux';
218        $osRelease['VERSION_ID'] = $synoVersion['productversion'];
219        $osRelease['VERSION'] = $synoVersion['productversion'];
220        $osRelease['SYNO_MODEL'] = $synoInfo['upnpmodelname'];
221        $osRelease['PRETTY_NAME'] = implode(' ', [$osRelease['NAME'], $osRelease['VERSION'], $osRelease['SYNO_MODEL']]);
222    }
223    return $osRelease;
224}
225
226
227/**
228 * Run a few sanity checks
229 *
230 * @author Andreas Gohr <andi@splitbrain.org>
231 */
232function check()
233{
234    global $conf;
235    global $INFO;
236    /* @var Input $INPUT */
237    global $INPUT;
238
239    if ($INFO['isadmin'] || $INFO['ismanager']) {
240        msg('DokuWiki version: ' . getVersion(), 1);
241        if (version_compare(phpversion(), '8.2.0', '<')) {
242            msg('Your PHP version is too old (' . phpversion() . ' vs. 8.2+ needed)', -1);
243        } else {
244            msg('PHP version ' . phpversion(), 1);
245        }
246    } elseif (version_compare(phpversion(), '8.2.0', '<')) {
247        msg('Your PHP version is too old', -1);
248    }
249
250    $mem = php_to_byte(ini_get('memory_limit'));
251    if ($mem) {
252        if ($mem === -1) {
253            msg('PHP memory is unlimited', 1);
254        } elseif ($mem < 16_777_216) {
255            msg('PHP is limited to less than 16MB RAM (' . filesize_h($mem) . ').
256            Increase memory_limit in php.ini', -1);
257        } elseif ($mem < 20_971_520) {
258            msg('PHP is limited to less than 20MB RAM (' . filesize_h($mem) . '),
259                you might encounter problems with bigger pages. Increase memory_limit in php.ini', -1);
260        } elseif ($mem < 33_554_432) {
261            msg('PHP is limited to less than 32MB RAM (' . filesize_h($mem) . '),
262                but that should be enough in most cases. If not, increase memory_limit in php.ini', 0);
263        } else {
264            msg('More than 32MB RAM (' . filesize_h($mem) . ') available.', 1);
265        }
266    }
267
268    if (is_writable($conf['changelog'])) {
269        msg('Changelog is writable', 1);
270    } elseif (file_exists($conf['changelog'])) {
271        msg('Changelog is not writable', -1);
272    }
273
274    if (isset($conf['changelog_old']) && file_exists($conf['changelog_old'])) {
275        msg('Old changelog exists', 0);
276    }
277
278    if (file_exists($conf['changelog'] . '_failed')) {
279        msg('Importing old changelog failed', -1);
280    } elseif (file_exists($conf['changelog'] . '_importing')) {
281        msg('Importing old changelog now.', 0);
282    } elseif (file_exists($conf['changelog'] . '_import_ok')) {
283        msg('Old changelog imported', 1);
284        if (!plugin_isdisabled('importoldchangelog')) {
285            msg('Importoldchangelog plugin not disabled after import', -1);
286        }
287    }
288
289    if (is_writable(DOKU_CONF)) {
290        msg('conf directory is writable', 1);
291    } else {
292        msg('conf directory is not writable', -1);
293    }
294
295    if ($conf['authtype'] == 'plain') {
296        global $config_cascade;
297        if (is_writable($config_cascade['plainauth.users']['default'])) {
298            msg('conf/users.auth.php is writable', 1);
299        } else {
300            msg('conf/users.auth.php is not writable', 0);
301        }
302    }
303
304    if (function_exists('mb_strpos')) {
305        if (defined('UTF8_NOMBSTRING')) {
306            msg('mb_string extension is available but will not be used', 0);
307        } else {
308            msg('mb_string extension is available and will be used', 1);
309        }
310    } else {
311        msg('mb_string extension not available - PHP only replacements will be used', 0);
312    }
313
314    if (!UTF8_PREGSUPPORT) {
315        msg('PHP is missing UTF-8 support in Perl-Compatible Regular Expressions (PCRE)', -1);
316    }
317    if (!UTF8_PROPERTYSUPPORT) {
318        msg('PHP is missing Unicode properties support in Perl-Compatible Regular Expressions (PCRE)', -1);
319    }
320
321    $loc = setlocale(LC_ALL, 0);
322    if (!$loc) {
323        msg('No valid locale is set for your PHP setup. You should fix this', -1);
324    } elseif (stripos($loc, 'utf') === false) {
325        msg('Your locale <code>' . hsc($loc) . '</code> seems not to be a UTF-8 locale,
326             you should fix this if you encounter problems.', 0);
327    } else {
328        msg('Valid locale ' . hsc($loc) . ' found.', 1);
329    }
330
331    if ($conf['allowdebug']) {
332        msg('Debugging support is enabled. If you don\'t need it you should set $conf[\'allowdebug\'] = 0', -1);
333    } else {
334        msg('Debugging support is disabled', 1);
335    }
336
337    if (!empty($INFO['userinfo']['name'])) {
338        msg(sprintf(
339            "You are currently logged in as %s (%s)",
340            $INPUT->server->str('REMOTE_USER'),
341            $INFO['userinfo']['name']
342        ), 0);
343        msg('You are part of the groups ' . implode(', ', $INFO['userinfo']['grps']), 0);
344    } else {
345        msg('You are currently not logged in', 0);
346    }
347
348    msg('Your current permission for this page is ' . $INFO['perm'], 0);
349
350    if (file_exists($INFO['filepath']) && is_writable($INFO['filepath'])) {
351        msg('The current page is writable by the webserver', 1);
352    } elseif (!file_exists($INFO['filepath']) && is_writable(dirname($INFO['filepath']))) {
353        msg('The current page can be created by the webserver', 1);
354    } else {
355        msg('The current page is not writable by the webserver', -1);
356    }
357
358    if ($INFO['writable']) {
359        msg('The current page is writable by you', 1);
360    } else {
361        msg('The current page is not writable by you', -1);
362    }
363
364    // Check for corrupted search index
365    $indexer = new Indexer();
366    try {
367        $indexer->checkIntegrity();
368        if (!$indexer->isIndexEmpty()) {
369            msg('The search index seems to be working', 1);
370        } else {
371            msg(
372                'The search index is empty. See
373                <a href="https://www.dokuwiki.org/faq:searchindex">faq:searchindex</a>
374                for help on how to fix the search index. If the default indexer
375                isn\'t used or the wiki is actually empty this is normal.'
376            );
377        }
378    } catch (IndexIntegrityException $e) {
379        msg(
380            'The search index is corrupted. It might produce wrong results and most
381                probably needs to be rebuilt. See
382                <a href="https://www.dokuwiki.org/faq:searchindex">faq:searchindex</a>
383                for ways to rebuild the search index.',
384            -1
385        );
386    }
387
388    // rough time check
389    $http = new DokuHTTPClient();
390    $http->max_redirect = 0;
391    $http->timeout = 3;
392    $http->sendRequest('https://www.dokuwiki.org', '', 'HEAD');
393
394    $now = time();
395    if (isset($http->resp_headers['date'])) {
396        $time = strtotime($http->resp_headers['date']);
397        $diff = $time - $now;
398
399        if (abs($diff) < 4) {
400            msg("Server time seems to be okay. Diff: {$diff}s", 1);
401        } else {
402            msg("Your server's clock seems to be out of sync!
403                 Consider configuring a sync with a NTP server.  Diff: {$diff}s");
404        }
405    }
406}
407
408/**
409 * Display a message to the user
410 *
411 * If HTTP headers were not sent yet the message is added
412 * to the global message array else it's printed directly
413 * using html_msgarea()
414 *
415 * Triggers INFOUTIL_MSG_SHOW
416 *
417 * @param string $message
418 * @param int $lvl -1 = error, 0 = info, 1 = success, 2 = notify
419 * @param string $line line number
420 * @param string $file file number
421 * @param int $allow who's allowed to see the message, see MSG_* constants
422 * @see html_msgarea()
423 */
424function msg($message, $lvl = 0, $line = '', $file = '', $allow = MSG_PUBLIC)
425{
426    global $MSG, $MSG_shown;
427    static $errors = [
428        -1 => 'error',
429        0 => 'info',
430        1 => 'success',
431        2 => 'notify',
432    ];
433
434    $msgdata = [
435        'msg' => $message,
436        'lvl' => $errors[$lvl],
437        'allow' => $allow,
438        'line' => $line,
439        'file' => $file,
440    ];
441
442    $evt = new Event('INFOUTIL_MSG_SHOW', $msgdata);
443    if ($evt->advise_before()) {
444        /* Show msg normally - event could suppress message show */
445        if ($msgdata['line'] || $msgdata['file']) {
446            $basename = PhpString::basename($msgdata['file']);
447            $msgdata['msg'] .= ' [' . $basename . ':' . $msgdata['line'] . ']';
448        }
449
450        if (!isset($MSG)) $MSG = [];
451        $MSG[] = $msgdata;
452        if (isset($MSG_shown) || headers_sent()) {
453            if (function_exists('html_msgarea')) {
454                html_msgarea();
455            } else {
456                echo "ERROR(" . $msgdata['lvl'] . ") " . $msgdata['msg'] . "\n";
457            }
458            unset($GLOBALS['MSG']);
459        }
460    }
461    $evt->advise_after();
462    unset($evt);
463}
464
465/**
466 * Determine whether the current user is allowed to view the message
467 * in the $msg data structure
468 *
469 * @param array $msg dokuwiki msg structure:
470 *              msg   => string, the message;
471 *              lvl   => int, level of the message (see msg() function);
472 *              allow => int, flag used to determine who is allowed to see the message, see MSG_* constants
473 * @return bool
474 */
475function info_msg_allowed($msg)
476{
477    global $INFO, $auth;
478
479    // is the message public? - everyone and anyone can see it
480    if (empty($msg['allow']) || ($msg['allow'] == MSG_PUBLIC)) return true;
481
482    // restricted msg, but no authentication
483    if (!$auth instanceof AuthPlugin) return false;
484
485    switch ($msg['allow']) {
486        case MSG_USERS_ONLY:
487            return !empty($INFO['userinfo']);
488
489        case MSG_MANAGERS_ONLY:
490            return $INFO['ismanager'];
491
492        case MSG_ADMINS_ONLY:
493            return $INFO['isadmin'];
494
495        default:
496            trigger_error(
497                'invalid msg allow restriction.  msg="' . $msg['msg'] . '" allow=' . $msg['allow'] . '"',
498                E_USER_WARNING
499            );
500            return $INFO['isadmin'];
501    }
502}
503
504/**
505 * print debug messages
506 *
507 * little function to print the content of a var
508 *
509 * @param string $msg
510 * @param bool $hidden
511 *
512 * @author Andreas Gohr <andi@splitbrain.org>
513 */
514function dbg($msg, $hidden = false)
515{
516    if ($hidden) {
517        echo "<!--\n";
518        print_r($msg);
519        echo "\n-->";
520    } else {
521        echo '<pre class="dbg">';
522        echo hsc(print_r($msg, true));
523        echo '</pre>';
524    }
525}
526
527/**
528 * Print info to debug log file
529 *
530 * @param string $msg
531 * @param string $header
532 *
533 * @author Andreas Gohr <andi@splitbrain.org>
534 * @deprecated 2020-08-13
535 */
536function dbglog($msg, $header = '')
537{
538    dbg_deprecated('\\dokuwiki\\Logger');
539
540    // was the msg as single line string? use it as header
541    if ($header === '' && is_string($msg) && !str_contains($msg, "\n")) {
542        $header = $msg;
543        $msg = '';
544    }
545
546    Logger::getInstance(Logger::LOG_DEBUG)->log(
547        $header,
548        $msg
549    );
550}
551
552/**
553 * Log accesses to deprecated fucntions to the debug log
554 *
555 * @param string $alternative The function or method that should be used instead
556 * @triggers INFO_DEPRECATION_LOG
557 */
558function dbg_deprecated($alternative = '')
559{
560    DebugHelper::dbgDeprecatedFunction($alternative, 2);
561}
562
563/**
564 * Print a reversed, prettyprinted backtrace
565 *
566 * @author Gary Owen <gary_owen@bigfoot.com>
567 */
568function dbg_backtrace()
569{
570    // Get backtrace
571    $backtrace = debug_backtrace();
572
573    // Unset call to debug_print_backtrace
574    array_shift($backtrace);
575
576    // Iterate backtrace
577    $calls = [];
578    $depth = count($backtrace) - 1;
579    foreach ($backtrace as $i => $call) {
580        if (isset($call['file'])) {
581            $location = $call['file'] . ':' . ($call['line'] ?? '0');
582        } else {
583            $location = '[anonymous]';
584        }
585        if (isset($call['class'])) {
586            $function = $call['class'] . $call['type'] . $call['function'];
587        } else {
588            $function = $call['function'];
589        }
590
591        $params = [];
592        if (isset($call['args'])) {
593            foreach ($call['args'] as $arg) {
594                if (is_object($arg)) {
595                    $params[] = '[Object ' . $arg::class . ']';
596                } elseif (is_array($arg)) {
597                    $params[] = '[Array]';
598                } elseif (is_null($arg)) {
599                    $params[] = '[NULL]';
600                } else {
601                    $params[] = '"' . $arg . '"';
602                }
603            }
604        }
605        $params = implode(', ', $params);
606
607        $calls[$depth - $i] = sprintf(
608            '%s(%s) called at %s',
609            $function,
610            str_replace("\n", '\n', $params),
611            $location
612        );
613    }
614    ksort($calls);
615
616    return implode("\n", $calls);
617}
618
619/**
620 * Remove all data from an array where the key seems to point to sensitive data
621 *
622 * This is used to remove passwords, mail addresses and similar data from the
623 * debug output
624 *
625 * @param array $data
626 *
627 * @author Andreas Gohr <andi@splitbrain.org>
628 */
629function debug_guard(&$data)
630{
631    foreach ($data as $key => $value) {
632        if (preg_match('/(notify|pass|auth|secret|ftp|userinfo|token|buid|mail|proxy)/i', $key)) {
633            $data[$key] = '***';
634            continue;
635        }
636        if (is_array($value)) debug_guard($data[$key]);
637    }
638}
639