xref: /webtrees/app/Services/ServerCheckService.php (revision d11be7027e34e3121be11cc025421873364403f9)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Services;
21
22use Fisharebest\Webtrees\I18N;
23use Illuminate\Support\Collection;
24use SQLite3;
25
26use function array_map;
27use function class_exists;
28use function date;
29use function e;
30use function explode;
31use function extension_loaded;
32use function function_exists;
33use function in_array;
34use function str_ends_with;
35use function str_starts_with;
36use function strtolower;
37use function sys_get_temp_dir;
38use function trim;
39use function version_compare;
40
41use const PATH_SEPARATOR;
42use const PHP_MAJOR_VERSION;
43use const PHP_MINOR_VERSION;
44use const PHP_VERSION;
45
46/**
47 * Check if the server meets the minimum requirements for webtrees.
48 */
49class ServerCheckService
50{
51    private const PHP_SUPPORT_URL   = 'https://www.php.net/supported-versions.php';
52    private const PHP_MINOR_VERSION = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
53    private const PHP_SUPPORT_DATES = [
54        '8.1' => '2024-11-25',
55        '8.2' => '2025-12-08',
56    ];
57
58    // As required by illuminate/database 8.x
59    private const MINIMUM_SQLITE_VERSION = '3.8.8';
60
61    /**
62     * Things that may cause webtrees to break.
63     *
64     * @param string $driver
65     *
66     * @return Collection<int,string>
67     */
68    public function serverErrors(string $driver = ''): Collection
69    {
70        $errors = Collection::make([
71            $this->databaseDriverErrors($driver),
72            $this->checkPhpExtension('mbstring'),
73            $this->checkPhpExtension('iconv'),
74            $this->checkPhpExtension('pcre'),
75            $this->checkPhpExtension('session'),
76            $this->checkPhpExtension('xml'),
77            $this->checkPhpFunction('parse_ini_file'),
78        ]);
79
80        return $errors
81            ->flatten()
82            ->filter();
83    }
84
85    /**
86     * Things that should be fixed, but which won't stop completely webtrees from running.
87     *
88     * @param string $driver
89     *
90     * @return Collection<int,string>
91     */
92    public function serverWarnings(string $driver = ''): Collection
93    {
94        $warnings = Collection::make([
95            $this->databaseDriverWarnings($driver),
96            $this->checkPhpExtension('curl'),
97            $this->checkPhpExtension('exif'),
98            $this->checkPhpExtension('fileinfo'),
99            $this->checkPhpExtension('gd'),
100            $this->checkPhpExtension('intl'),
101            $this->checkPhpExtension('zip'),
102            $this->checkPhpExtension('simplexml'),
103            $this->checkPhpIni('file_uploads', true),
104            $this->checkSystemTemporaryFolder(),
105            $this->checkPhpVersion(),
106        ]);
107
108        return $warnings
109            ->flatten()
110            ->filter();
111    }
112
113    /**
114     * Check if a PHP extension is loaded.
115     *
116     * @param string $extension
117     *
118     * @return string
119     */
120    private function checkPhpExtension(string $extension): string
121    {
122        if (!extension_loaded($extension)) {
123            return I18N::translate('The PHP extension “%s” is not installed.', $extension);
124        }
125
126        return '';
127    }
128
129    /**
130     * Check if a PHP setting is correct.
131     *
132     * @param string $varname
133     * @param bool   $expected
134     *
135     * @return string
136     */
137    private function checkPhpIni(string $varname, bool $expected): string
138    {
139        $actual = (bool) ini_get($varname);
140
141        if ($expected && !$actual) {
142            return I18N::translate('The PHP.INI setting “%1$s” is disabled.', $varname);
143        }
144
145        if (!$expected && $actual) {
146            return I18N::translate('The PHP.INI setting “%1$s” is enabled.', $varname);
147        }
148
149        return '';
150    }
151
152    /**
153     * Check if a PHP function is in the list of disabled functions.
154     *
155     * @param string $function
156     *
157     * @return bool
158     */
159    public function isFunctionDisabled(string $function): bool
160    {
161        $function = strtolower($function);
162
163        $disable_functions = explode(',', (string) ini_get('disable_functions'));
164        $disable_functions = array_map(static fn (string $func): string => strtolower(trim($func)), $disable_functions);
165
166        return in_array($function, $disable_functions, true) || !function_exists($function);
167    }
168
169    /**
170     * Create a warning message for a disabled function.
171     *
172     * @param string $function
173     *
174     * @return string
175     */
176    private function checkPhpFunction(string $function): string
177    {
178        if ($this->isFunctionDisabled($function)) {
179            return I18N::translate('The PHP function “%1$s” is disabled.', $function . '()');
180        }
181
182        return '';
183    }
184
185    /**
186     * Some servers configure their temporary folder in an inaccessible place.
187     */
188    private function checkPhpVersion(): string
189    {
190        $today = date('Y-m-d');
191
192        foreach (self::PHP_SUPPORT_DATES as $version => $end_date) {
193            if ($today > $end_date && version_compare(self::PHP_MINOR_VERSION, $version) <= 0) {
194                return I18N::translate('Your web server is using PHP version %s, which is no longer receiving security updates. You should upgrade to a later version as soon as possible.', PHP_VERSION) . ' <a href="' . e(self::PHP_SUPPORT_URL) . '">' . e(self::PHP_SUPPORT_URL) . '</a>';
195            }
196        }
197
198        return '';
199    }
200
201    /**
202     * Check the
203     *
204     * @return string
205     */
206    private function checkSqliteVersion(): string
207    {
208        if (class_exists(SQLite3::class)) {
209            $sqlite_version = SQLite3::version()['versionString'];
210
211            if (version_compare($sqlite_version, self::MINIMUM_SQLITE_VERSION) < 0) {
212                return I18N::translate('SQLite version %s is installed. SQLite version %s or later is required.', $sqlite_version, self::MINIMUM_SQLITE_VERSION);
213            }
214        }
215
216        return '';
217    }
218
219    /**
220     * Some servers configure their temporary folder in an inaccessible place.
221     */
222    private function checkSystemTemporaryFolder(): string
223    {
224        $open_basedir = ini_get('open_basedir');
225
226        if ($open_basedir === '') {
227            // open_basedir not used.
228            return '';
229        }
230
231        $open_basedirs = explode(PATH_SEPARATOR, $open_basedir);
232
233        $sys_temp_dir = sys_get_temp_dir();
234        $sys_temp_dir = $this->normalizeFolder($sys_temp_dir);
235
236        foreach ($open_basedirs as $dir) {
237            $dir = $this->normalizeFolder($dir);
238
239            if (str_starts_with($sys_temp_dir, $dir)) {
240                return '';
241            }
242        }
243
244        $message = I18N::translate('The server’s temporary folder cannot be accessed.');
245        $message .= '<br>sys_get_temp_dir() = "' . e($sys_temp_dir) . '"';
246        $message .= '<br>ini_get("open_basedir") = "' . e($open_basedir) . '"';
247
248        return $message;
249    }
250
251    /**
252     * Convert a folder name to a canonical form:
253     * - forward slashes.
254     * - trailing slash.
255     * We can't use realpath() as this can trigger open_basedir restrictions,
256     * and we are using this code to find out whether open_basedir will affect us.
257     *
258     * @param string $path
259     *
260     * @return string
261     */
262    private function normalizeFolder(string $path): string
263    {
264        $path = strtr($path, ['\\' => '/']);
265
266        if (str_ends_with($path, '/')) {
267            return $path;
268        }
269
270        return $path . '/';
271    }
272
273    /**
274     * @param string $driver
275     *
276     * @return Collection<int,string>
277     */
278    private function databaseDriverErrors(string $driver): Collection
279    {
280        switch ($driver) {
281            case 'mysql':
282                return Collection::make([
283                    $this->checkPhpExtension('pdo'),
284                    $this->checkPhpExtension('pdo_mysql'),
285                ]);
286
287            case 'sqlite':
288                return Collection::make([
289                    $this->checkPhpExtension('pdo'),
290                    $this->checkPhpExtension('sqlite3'),
291                    $this->checkPhpExtension('pdo_sqlite'),
292                    $this->checkSqliteVersion(),
293                ]);
294
295            case 'pgsql':
296                return Collection::make([
297                    $this->checkPhpExtension('pdo'),
298                    $this->checkPhpExtension('pdo_pgsql'),
299                ]);
300
301            case 'sqlsrv':
302                return Collection::make([
303                    $this->checkPhpExtension('pdo'),
304                    $this->checkPhpExtension('pdo_odbc'),
305                ]);
306
307            default:
308                return new Collection();
309        }
310    }
311
312    /**
313     * @param string $driver
314     *
315     * @return Collection<int,string>
316     */
317    private function databaseDriverWarnings(string $driver): Collection
318    {
319        switch ($driver) {
320            case 'sqlite':
321                return new Collection([
322                    I18N::translate('SQLite is only suitable for small sites, testing and evaluation.'),
323                ]);
324
325            case 'pgsql':
326                return new Collection([
327                    I18N::translate('Support for PostgreSQL is experimental.'),
328                ]);
329
330            case 'sqlsrv':
331                return new Collection([
332                    I18N::translate('Support for SQL Server is experimental.'),
333                ]);
334
335            default:
336                return new Collection();
337        }
338    }
339}
340