xref: /webtrees/app/Services/ServerCheckService.php (revision 0f97530b568da7b969b729d6803c91fba4652cde)
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\Support\Collection;
23use Illuminate\Support\Str;
24use SQLite3;
25use stdClass;
26use Throwable;
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 preg_replace;
36use function strpos;
37use function strtolower;
38use function sys_get_temp_dir;
39use function trim;
40use function version_compare;
41use const PATH_SEPARATOR;
42use const PHP_MAJOR_VERSION;
43use const PHP_MINOR_VERSION;
44use const PHP_VERSION;
45
46/**
47 * Check if the server meets the minimum requirements for webtrees.
48 */
49class ServerCheckService
50{
51    private const PHP_SUPPORT_URL   = 'https://secure.php.net/supported-versions.php';
52    private const PHP_MINOR_VERSION = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
53    private const PHP_SUPPORT_DATES = [
54        '7.1' => '2019-12-01',
55        '7.2' => '2020-11-30',
56        '7.3' => '2021-12-06',
57    ];
58
59    // As required by illuminate/database 5.8
60    private const MINIMUM_SQLITE_VERSION = '3.7.11';
61
62    /**
63     * Things that may cause webtrees to break.
64     *
65     * @param string $driver
66     *
67     * @return Collection
68     */
69    public function serverErrors($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
92     */
93    public function serverWarnings($driver = ''): Collection
94    {
95        $warnings = Collection::make([
96            $this->databaseDriverWarnings($driver),
97            $this->databaseEngineWarnings(),
98            $this->checkPhpExtension('curl'),
99            $this->checkPhpExtension('gd'),
100            $this->checkPhpExtension('zip'),
101            $this->checkPhpExtension('simplexml'),
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        $ini_get = (bool) ini_get($varname);
139
140        if ($expected && $ini_get !== $expected) {
141            return I18N::translate('The PHP.INI setting “%1$s” is disabled.', $varname);
142        }
143
144        if (!$expected && $ini_get !== $expected) {
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        $disable_functions = explode(',', ini_get('disable_functions'));
161        $disable_functions = array_map(static function (string $func): string {
162            return strtolower(trim($func));
163        }, $disable_functions);
164
165        $function = strtolower($function);
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 unaccessible 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 unaccessible 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 (strpos($sys_temp_dir, $dir) === 0) {
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 = preg_replace('/[\\/]+/', '/', $path);
266        $path = Str::finish($path, '/');
267
268        return $path;
269    }
270
271    /**
272     * @param string $driver
273     *
274     * @return Collection
275     */
276    private function databaseDriverErrors(string $driver): Collection
277    {
278        switch ($driver) {
279            case 'mysql':
280                return Collection::make([
281                    $this->checkPhpExtension('pdo'),
282                    $this->checkPhpExtension('pdo_mysql'),
283                ]);
284
285            case 'sqlite':
286                return Collection::make([
287                    $this->checkPhpExtension('pdo'),
288                    $this->checkPhpExtension('sqlite3'),
289                    $this->checkPhpExtension('pdo_sqlite'),
290                    $this->checkSqliteVersion(),
291                ]);
292
293            case 'pgsql':
294                return Collection::make([
295                    $this->checkPhpExtension('pdo'),
296                    $this->checkPhpExtension('pdo_pgsql'),
297                ]);
298
299            case 'sqlsvr':
300                return Collection::make([
301                    $this->checkPhpExtension('pdo'),
302                    $this->checkPhpExtension('pdo_odbc'),
303                ]);
304
305            default:
306                return new Collection();
307        }
308    }
309
310    /**
311     * @param string $driver
312     *
313     * @return Collection
314     */
315    private function databaseDriverWarnings(string $driver): Collection
316    {
317        switch ($driver) {
318            case 'sqlite':
319                return new Collection([
320                    I18N::translate('SQLite is only suitable for small sites, testing and evaluation.'),
321                ]);
322
323            case 'pgsql':
324                return new Collection([
325                    I18N::translate('Support for PostgreSQL is experimental.'),
326                ]);
327
328            case 'sqlsvr':
329                return new Collection([
330                    I18N::translate('Support for SQL Server is experimental.'),
331                ]);
332
333            default:
334                return new Collection();
335        }
336    }
337
338    /**
339     * @return Collection
340     */
341    private function databaseEngineWarnings(): Collection
342    {
343        $warnings = new Collection();
344
345        try {
346            $connection = DB::connection();
347        } catch (Throwable $ex) {
348            // During setup, there won't be a connection.
349            return new Collection();
350        }
351
352        if ($connection->getDriverName() === 'mysql') {
353            $sql = "SELECT table_name FROM information_schema.tables JOIN information_schema.engines USING (engine) WHERE table_schema = ? AND LEFT(table_name, ?) = ? AND transactions <> 'YES'";
354
355            $bindings = [
356                $connection->getDatabaseName(),
357                mb_strlen($connection->getTablePrefix()),
358                $connection->getTablePrefix(),
359            ];
360
361            $rows = DB::connection()->select($sql, $bindings);
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