xref: /webtrees/app/Services/ServerCheckService.php (revision 75e7614a3c4ae5641ce6af45e2bb8ce501918735)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees\Services;
19
20use Fisharebest\Webtrees\I18N;
21use Illuminate\Database\Capsule\Manager as DB;
22use Illuminate\Database\Query\Expression;
23use Illuminate\Support\Collection;
24use Illuminate\Support\Str;
25use SQLite3;
26use stdClass;
27use Throwable;
28use function array_map;
29use function class_exists;
30use function date;
31use function e;
32use function explode;
33use function extension_loaded;
34use function function_exists;
35use function in_array;
36use function preg_replace;
37use function strpos;
38use function strtolower;
39use function sys_get_temp_dir;
40use function trim;
41use function version_compare;
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://secure.php.net/supported-versions.php';
53    private const PHP_MINOR_VERSION = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
54    private const PHP_SUPPORT_DATES = [
55        '7.1' => '2019-12-01',
56        '7.2' => '2020-11-30',
57        '7.3' => '2021-12-06',
58    ];
59
60    // As required by illuminate/database 5.8
61    private const MINIMUM_SQLITE_VERSION = '3.7.11';
62
63    /**
64     * Things that may cause webtrees to break.
65     *
66     * @param string $driver
67     *
68     * @return Collection
69     */
70    public function serverErrors($driver = ''): Collection
71    {
72        $errors = Collection::make([
73            $this->databaseDriverErrors($driver),
74            $this->checkPhpExtension('mbstring'),
75            $this->checkPhpExtension('iconv'),
76            $this->checkPhpExtension('pcre'),
77            $this->checkPhpExtension('session'),
78            $this->checkPhpExtension('xml'),
79            $this->checkPhpFunction('parse_ini_file'),
80        ]);
81
82        return $errors
83            ->flatten()
84            ->filter();
85    }
86
87    /**
88     * Things that should be fixed, but which won't stop completely webtrees from running.
89     *
90     * @param string $driver
91     *
92     * @return Collection
93     */
94    public function serverWarnings($driver = ''): Collection
95    {
96        $warnings = Collection::make([
97            $this->databaseDriverWarnings($driver),
98            $this->databaseEngineWarnings(),
99            $this->checkPhpExtension('curl'),
100            $this->checkPhpExtension('gd'),
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        $ini_get = (bool) ini_get($varname);
140
141        if ($expected && $ini_get !== $expected) {
142            return I18N::translate('The PHP.INI setting “%1$s” is disabled.', $varname);
143        }
144
145        if (!$expected && $ini_get !== $expected) {
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        $disable_functions = explode(',', ini_get('disable_functions'));
162        $disable_functions = array_map(static function (string $func): string {
163            return strtolower(trim($func));
164        }, $disable_functions);
165
166        $function = strtolower($function);
167
168        return in_array($function, $disable_functions, true) || !function_exists($function);
169    }
170
171    /**
172     * Create a warning message for a disabled function.
173     *
174     * @param string $function
175     *
176     * @return string
177     */
178    private function checkPhpFunction(string $function): string
179    {
180        if ($this->isFunctionDisabled($function)) {
181            return I18N::translate('The PHP function “%1$s” is disabled.', $function . '()');
182        }
183
184        return '';
185    }
186
187    /**
188     * Some servers configure their temporary folder in an unaccessible place.
189     */
190    private function checkPhpVersion(): string
191    {
192        $today = date('Y-m-d');
193
194        foreach (self::PHP_SUPPORT_DATES as $version => $end_date) {
195            if ($today > $end_date && version_compare(self::PHP_MINOR_VERSION, $version) <= 0) {
196                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>';
197            }
198        }
199
200        return '';
201    }
202
203    /**
204     * Check the
205     *
206     * @return string
207     */
208    private function checkSqliteVersion(): string
209    {
210        if (class_exists(SQLite3::class)) {
211            $sqlite_version = SQLite3::version()['versionString'];
212
213            if (version_compare($sqlite_version, self::MINIMUM_SQLITE_VERSION) < 0) {
214                return I18N::translate('SQLite version %s is installed. SQLite version %s or later is required.', $sqlite_version, self::MINIMUM_SQLITE_VERSION);
215            }
216        }
217
218        return '';
219    }
220
221    /**
222     * Some servers configure their temporary folder in an unaccessible place.
223     */
224    private function checkSystemTemporaryFolder(): string
225    {
226        $open_basedir = ini_get('open_basedir');
227
228        if ($open_basedir === '') {
229            // open_basedir not used.
230            return '';
231        }
232
233        $open_basedirs = explode(PATH_SEPARATOR, $open_basedir);
234
235        $sys_temp_dir = sys_get_temp_dir();
236        $sys_temp_dir = $this->normalizeFolder($sys_temp_dir);
237
238        foreach ($open_basedirs as $dir) {
239            $dir = $this->normalizeFolder($dir);
240
241            if (strpos($sys_temp_dir, $dir) === 0) {
242                return '';
243            }
244        }
245
246        $message = I18N::translate('The server’s temporary folder cannot be accessed.');
247        $message .= '<br>sys_get_temp_dir() = "' . e($sys_temp_dir) . '"';
248        $message .= '<br>ini_get("open_basedir") = "' . e($open_basedir) . '"';
249
250        return $message;
251    }
252
253    /**
254     * Convert a folder name to a canonical form:
255     * - forward slashes.
256     * - trailing slash.
257     * We can't use realpath() as this can trigger open_basedir restrictions,
258     * and we are using this code to find out whether open_basedir will affect us.
259     *
260     * @param string $path
261     *
262     * @return string
263     */
264    private function normalizeFolder(string $path): string
265    {
266        $path = preg_replace('/[\\/]+/', '/', $path);
267        $path = Str::finish($path, '/');
268
269        return $path;
270    }
271
272    /**
273     * @param string $driver
274     *
275     * @return Collection
276     */
277    private function databaseDriverErrors(string $driver): Collection
278    {
279        switch ($driver) {
280            case 'mysql':
281                return Collection::make([
282                    $this->checkPhpExtension('pdo'),
283                    $this->checkPhpExtension('pdo_mysql'),
284                ]);
285
286            case '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 'pgsql':
295                return Collection::make([
296                    $this->checkPhpExtension('pdo'),
297                    $this->checkPhpExtension('pdo_pgsql'),
298                ]);
299
300            case 'sqlsvr':
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
315     */
316    private function databaseDriverWarnings(string $driver): Collection
317    {
318        switch ($driver) {
319            case 'sqlite':
320                return new Collection([
321                    I18N::translate('SQLite is only suitable for small sites, testing and evaluation.'),
322                ]);
323
324            case 'pgsql':
325                return new Collection([
326                    I18N::translate('Support for PostgreSQL is experimental.'),
327                ]);
328
329            case 'sqlsvr':
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    /**
340     * @param string $driver
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            $rows = DB::select(
357                "SELECT table_name FROM information_schema.tables JOIN information_schema.engines USING (engine) WHERE table_schema = ? AND LEFT(table_name, ?) = ? AND transactions <> 'YES'", [
358                    $connection->getDatabaseName(),
359                    mb_strlen($connection->getTablePrefix()),
360                    $connection->getTablePrefix(),
361                ]);
362
363            $rows = new Collection($rows);
364
365            $rows = $rows->map(static function (stdClass $row): string {
366                $table = $row->TABLE_NAME ?? $row->table_name;
367                return '<code>ALTER TABLE `' . $table . '` ENGINE=InnoDB;</code>';
368            });
369
370            if ($rows->isNotEmpty()) {
371                $warning =
372                    'The database uses non-transactional tables.' .
373                    ' ' .
374                    'You may get errors if more than one user updates data at the same time.' .
375                    ' ' .
376                    'To fix this, run the following SQL commands.' .
377                    '<br>' .
378                    $rows->implode('<br>');
379
380                $warnings->push($warning);
381            }
382        }
383
384        return $warnings;
385    }
386}
387