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