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