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