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