xref: /webtrees/app/Services/ServerCheckService.php (revision 3976b4703df669696105ed6b024b96d433c8fbdb)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>.
16 */
17declare(strict_types=1);
18
19namespace Fisharebest\Webtrees\Services;
20
21use Fisharebest\Webtrees\I18N;
22use Illuminate\Database\Capsule\Manager as DB;
23use Illuminate\Support\Collection;
24use Illuminate\Support\Str;
25use SQLite3;
26use stdClass;
27use Throwable;
28
29use function array_map;
30use function class_exists;
31use function date;
32use function e;
33use function explode;
34use function extension_loaded;
35use function function_exists;
36use function in_array;
37use function preg_replace;
38use function strpos;
39use function strtolower;
40use function sys_get_temp_dir;
41use function trim;
42use function version_compare;
43
44use const PATH_SEPARATOR;
45use const PHP_MAJOR_VERSION;
46use const PHP_MINOR_VERSION;
47use const PHP_VERSION;
48
49/**
50 * Check if the server meets the minimum requirements for webtrees.
51 */
52class ServerCheckService
53{
54    private const PHP_SUPPORT_URL   = 'https://secure.php.net/supported-versions.php';
55    private const PHP_MINOR_VERSION = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
56    private const PHP_SUPPORT_DATES = [
57        '7.1' => '2019-12-01',
58        '7.2' => '2020-11-30',
59        '7.3' => '2021-12-06',
60    ];
61
62    // As required by illuminate/database 5.8
63    private const MINIMUM_SQLITE_VERSION = '3.7.11';
64
65    /**
66     * Things that may cause webtrees to break.
67     *
68     * @param string $driver
69     *
70     * @return Collection
71     */
72    public function serverErrors($driver = ''): Collection
73    {
74        $errors = Collection::make([
75            $this->databaseDriverErrors($driver),
76            $this->checkPhpExtension('mbstring'),
77            $this->checkPhpExtension('iconv'),
78            $this->checkPhpExtension('pcre'),
79            $this->checkPhpExtension('session'),
80            $this->checkPhpExtension('xml'),
81            $this->checkPhpFunction('parse_ini_file'),
82        ]);
83
84        return $errors
85            ->flatten()
86            ->filter();
87    }
88
89    /**
90     * Things that should be fixed, but which won't stop completely webtrees from running.
91     *
92     * @param string $driver
93     *
94     * @return Collection
95     */
96    public function serverWarnings($driver = ''): Collection
97    {
98        $warnings = Collection::make([
99            $this->databaseDriverWarnings($driver),
100            $this->databaseEngineWarnings(),
101            $this->checkPhpExtension('curl'),
102            $this->checkPhpExtension('gd'),
103            $this->checkPhpExtension('zip'),
104            $this->checkPhpExtension('simplexml'),
105            $this->checkPhpIni('file_uploads', true),
106            $this->checkSystemTemporaryFolder(),
107            $this->checkPhpVersion(),
108        ]);
109
110        return $warnings
111            ->flatten()
112            ->filter();
113    }
114
115    /**
116     * Check if a PHP extension is loaded.
117     *
118     * @param string $extension
119     *
120     * @return string
121     */
122    private function checkPhpExtension(string $extension): string
123    {
124        if (!extension_loaded($extension)) {
125            return I18N::translate('The PHP extension “%s” is not installed.', $extension);
126        }
127
128        return '';
129    }
130
131    /**
132     * Check if a PHP setting is correct.
133     *
134     * @param string $varname
135     * @param bool   $expected
136     *
137     * @return string
138     */
139    private function checkPhpIni(string $varname, bool $expected): string
140    {
141        $ini_get = (bool) ini_get($varname);
142
143        if ($expected && $ini_get !== $expected) {
144            return I18N::translate('The PHP.INI setting “%1$s” is disabled.', $varname);
145        }
146
147        if (!$expected && $ini_get !== $expected) {
148            return I18N::translate('The PHP.INI setting “%1$s” is enabled.', $varname);
149        }
150
151        return '';
152    }
153
154    /**
155     * Check if a PHP function is in the list of disabled functions.
156     *
157     * @param string $function
158     *
159     * @return bool
160     */
161    public function isFunctionDisabled(string $function): bool
162    {
163        $disable_functions = explode(',', ini_get('disable_functions'));
164        $disable_functions = array_map(static function (string $func): string {
165            return strtolower(trim($func));
166        }, $disable_functions);
167
168        $function = strtolower($function);
169
170        return in_array($function, $disable_functions, true) || !function_exists($function);
171    }
172
173    /**
174     * Create a warning message for a disabled function.
175     *
176     * @param string $function
177     *
178     * @return string
179     */
180    private function checkPhpFunction(string $function): string
181    {
182        if ($this->isFunctionDisabled($function)) {
183            return I18N::translate('The PHP function “%1$s” is disabled.', $function . '()');
184        }
185
186        return '';
187    }
188
189    /**
190     * Some servers configure their temporary folder in an unaccessible place.
191     */
192    private function checkPhpVersion(): string
193    {
194        $today = date('Y-m-d');
195
196        foreach (self::PHP_SUPPORT_DATES as $version => $end_date) {
197            if ($today > $end_date && version_compare(self::PHP_MINOR_VERSION, $version) <= 0) {
198                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>';
199            }
200        }
201
202        return '';
203    }
204
205    /**
206     * Check the
207     *
208     * @return string
209     */
210    private function checkSqliteVersion(): string
211    {
212        if (class_exists(SQLite3::class)) {
213            $sqlite_version = SQLite3::version()['versionString'];
214
215            if (version_compare($sqlite_version, self::MINIMUM_SQLITE_VERSION) < 0) {
216                return I18N::translate('SQLite version %s is installed. SQLite version %s or later is required.', $sqlite_version, self::MINIMUM_SQLITE_VERSION);
217            }
218        }
219
220        return '';
221    }
222
223    /**
224     * Some servers configure their temporary folder in an unaccessible place.
225     */
226    private function checkSystemTemporaryFolder(): string
227    {
228        $open_basedir = ini_get('open_basedir');
229
230        if ($open_basedir === '') {
231            // open_basedir not used.
232            return '';
233        }
234
235        $open_basedirs = explode(PATH_SEPARATOR, $open_basedir);
236
237        $sys_temp_dir = sys_get_temp_dir();
238        $sys_temp_dir = $this->normalizeFolder($sys_temp_dir);
239
240        foreach ($open_basedirs as $dir) {
241            $dir = $this->normalizeFolder($dir);
242
243            if (strpos($sys_temp_dir, $dir) === 0) {
244                return '';
245            }
246        }
247
248        $message = I18N::translate('The server’s temporary folder cannot be accessed.');
249        $message .= '<br>sys_get_temp_dir() = "' . e($sys_temp_dir) . '"';
250        $message .= '<br>ini_get("open_basedir") = "' . e($open_basedir) . '"';
251
252        return $message;
253    }
254
255    /**
256     * Convert a folder name to a canonical form:
257     * - forward slashes.
258     * - trailing slash.
259     * We can't use realpath() as this can trigger open_basedir restrictions,
260     * and we are using this code to find out whether open_basedir will affect us.
261     *
262     * @param string $path
263     *
264     * @return string
265     */
266    private function normalizeFolder(string $path): string
267    {
268        $path = preg_replace('/[\\/]+/', '/', $path);
269        $path = Str::finish($path, '/');
270
271        return $path;
272    }
273
274    /**
275     * @param string $driver
276     *
277     * @return Collection
278     */
279    private function databaseDriverErrors(string $driver): Collection
280    {
281        switch ($driver) {
282            case 'mysql':
283                return Collection::make([
284                    $this->checkPhpExtension('pdo'),
285                    $this->checkPhpExtension('pdo_mysql'),
286                ]);
287
288            case '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 'pgsql':
297                return Collection::make([
298                    $this->checkPhpExtension('pdo'),
299                    $this->checkPhpExtension('pdo_pgsql'),
300                ]);
301
302            case 'sqlsvr':
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
317     */
318    private function databaseDriverWarnings(string $driver): Collection
319    {
320        switch ($driver) {
321            case 'sqlite':
322                return new Collection([
323                    I18N::translate('SQLite is only suitable for small sites, testing and evaluation.'),
324                ]);
325
326            case 'pgsql':
327                return new Collection([
328                    I18N::translate('Support for PostgreSQL is experimental.'),
329                ]);
330
331            case 'sqlsvr':
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    /**
342     * @return Collection
343     */
344    private function databaseEngineWarnings(): Collection
345    {
346        $warnings = new Collection();
347
348        try {
349            $connection = DB::connection();
350        } catch (Throwable $ex) {
351            // During setup, there won't be a connection.
352            return new Collection();
353        }
354
355        if ($connection->getDriverName() === 'mysql') {
356            $sql = "SELECT table_name FROM information_schema.tables JOIN information_schema.engines USING (engine) WHERE table_schema = ? AND LEFT(table_name, ?) = ? AND transactions <> 'YES'";
357
358            $bindings = [
359                $connection->getDatabaseName(),
360                mb_strlen($connection->getTablePrefix()),
361                $connection->getTablePrefix(),
362            ];
363
364            $rows = DB::connection()->select($sql, $bindings);
365
366            $rows = new Collection($rows);
367
368            $rows = $rows->map(static function (stdClass $row): string {
369                $table = $row->TABLE_NAME ?? $row->table_name;
370                return '<code>ALTER TABLE `' . $table . '` ENGINE=InnoDB;</code>';
371            });
372
373            if ($rows->isNotEmpty()) {
374                $warning =
375                    'The database uses non-transactional tables.' .
376                    ' ' .
377                    'You may get errors if more than one user updates data at the same time.' .
378                    ' ' .
379                    'To fix this, run the following SQL commands.' .
380                    '<br>' .
381                    $rows->implode('<br>');
382
383                $warnings->push($warning);
384            }
385        }
386
387        return $warnings;
388    }
389}
390