xref: /webtrees/app/Services/ServerCheckService.php (revision 2ddcca20cfdcef0bfbc6eea9b12a3a71f21f2067)
1b7059dccSGreg Roach<?php
23976b470SGreg Roach
3b7059dccSGreg Roach/**
4b7059dccSGreg Roach * webtrees: online genealogy
5b7059dccSGreg Roach * Copyright (C) 2019 webtrees development team
6b7059dccSGreg Roach * This program is free software: you can redistribute it and/or modify
7b7059dccSGreg Roach * it under the terms of the GNU General Public License as published by
8b7059dccSGreg Roach * the Free Software Foundation, either version 3 of the License, or
9b7059dccSGreg Roach * (at your option) any later version.
10b7059dccSGreg Roach * This program is distributed in the hope that it will be useful,
11b7059dccSGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
12b7059dccSGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13b7059dccSGreg Roach * GNU General Public License for more details.
14b7059dccSGreg Roach * You should have received a copy of the GNU General Public License
15b7059dccSGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>.
16b7059dccSGreg Roach */
17fcfa147eSGreg Roach
18b7059dccSGreg Roachdeclare(strict_types=1);
19b7059dccSGreg Roach
20b7059dccSGreg Roachnamespace Fisharebest\Webtrees\Services;
21b7059dccSGreg Roach
22b7059dccSGreg Roachuse Fisharebest\Webtrees\I18N;
23497c5612SGreg Roachuse Illuminate\Database\Capsule\Manager as DB;
24b7059dccSGreg Roachuse Illuminate\Support\Collection;
25b7059dccSGreg Roachuse Illuminate\Support\Str;
26b7059dccSGreg Roachuse SQLite3;
27497c5612SGreg Roachuse stdClass;
287bf2ba3bSGreg Roachuse Throwable;
293976b470SGreg Roach
30b7059dccSGreg Roachuse function array_map;
31b33d97e2SGreg Roachuse function class_exists;
32b33d97e2SGreg Roachuse function date;
33b33d97e2SGreg Roachuse function e;
34b7059dccSGreg Roachuse function explode;
35b7059dccSGreg Roachuse function extension_loaded;
36b33d97e2SGreg Roachuse function function_exists;
37b7059dccSGreg Roachuse function in_array;
38b33d97e2SGreg Roachuse function preg_replace;
39b33d97e2SGreg Roachuse function strpos;
407bb10f9aSGreg Roachuse function strtolower;
41b7059dccSGreg Roachuse function sys_get_temp_dir;
42b7059dccSGreg Roachuse function trim;
43b7059dccSGreg Roachuse function version_compare;
443976b470SGreg Roach
45b7059dccSGreg Roachuse const PATH_SEPARATOR;
46b7059dccSGreg Roachuse const PHP_MAJOR_VERSION;
47b7059dccSGreg Roachuse const PHP_MINOR_VERSION;
48b33d97e2SGreg Roachuse const PHP_VERSION;
49b7059dccSGreg Roach
50b7059dccSGreg Roach/**
51b7059dccSGreg Roach * Check if the server meets the minimum requirements for webtrees.
52b7059dccSGreg Roach */
53b7059dccSGreg Roachclass ServerCheckService
54b7059dccSGreg Roach{
55*2ddcca20SGreg Roach    private const PHP_SUPPORT_URL   = 'https://www.php.net/supported-versions.php';
56bb5a472eSGreg Roach    private const PHP_MINOR_VERSION = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
57bb5a472eSGreg Roach    private const PHP_SUPPORT_DATES = [
58b7059dccSGreg Roach        '7.1' => '2019-12-01',
59b7059dccSGreg Roach        '7.2' => '2020-11-30',
60b7059dccSGreg Roach        '7.3' => '2021-12-06',
61*2ddcca20SGreg Roach        '7.4' => '2022-11-28',
62b7059dccSGreg Roach    ];
63b7059dccSGreg Roach
64b7059dccSGreg Roach    // As required by illuminate/database 5.8
65b7059dccSGreg Roach    private const MINIMUM_SQLITE_VERSION = '3.7.11';
66b7059dccSGreg Roach
67b7059dccSGreg Roach    /**
68b7059dccSGreg Roach     * Things that may cause webtrees to break.
69b7059dccSGreg Roach     *
70b7059dccSGreg Roach     * @param string $driver
71b7059dccSGreg Roach     *
72b5c8fd7eSGreg Roach     * @return Collection<string>
73b7059dccSGreg Roach     */
74b7059dccSGreg Roach    public function serverErrors($driver = ''): Collection
75b7059dccSGreg Roach    {
76b7059dccSGreg Roach        $errors = Collection::make([
77b7059dccSGreg Roach            $this->databaseDriverErrors($driver),
78b7059dccSGreg Roach            $this->checkPhpExtension('mbstring'),
79b7059dccSGreg Roach            $this->checkPhpExtension('iconv'),
80b7059dccSGreg Roach            $this->checkPhpExtension('pcre'),
81b7059dccSGreg Roach            $this->checkPhpExtension('session'),
82b7059dccSGreg Roach            $this->checkPhpExtension('xml'),
83b7059dccSGreg Roach            $this->checkPhpFunction('parse_ini_file'),
84b7059dccSGreg Roach        ]);
85b7059dccSGreg Roach
86b7059dccSGreg Roach        return $errors
87b7059dccSGreg Roach            ->flatten()
88b7059dccSGreg Roach            ->filter();
89b7059dccSGreg Roach    }
90b7059dccSGreg Roach
91b7059dccSGreg Roach    /**
92b7059dccSGreg Roach     * Things that should be fixed, but which won't stop completely webtrees from running.
93b7059dccSGreg Roach     *
94b7059dccSGreg Roach     * @param string $driver
95b7059dccSGreg Roach     *
96b5c8fd7eSGreg Roach     * @return Collection<string>
97b7059dccSGreg Roach     */
98b7059dccSGreg Roach    public function serverWarnings($driver = ''): Collection
99b7059dccSGreg Roach    {
100b7059dccSGreg Roach        $warnings = Collection::make([
101b7059dccSGreg Roach            $this->databaseDriverWarnings($driver),
102497c5612SGreg Roach            $this->databaseEngineWarnings(),
103b7059dccSGreg Roach            $this->checkPhpExtension('curl'),
104c95eb19bSGreg Roach            $this->checkPhpExtension('fileinfo'),
105b7059dccSGreg Roach            $this->checkPhpExtension('gd'),
10623c3b21dSGreg Roach            $this->checkPhpExtension('zip'),
107b7059dccSGreg Roach            $this->checkPhpExtension('simplexml'),
108b7059dccSGreg Roach            $this->checkPhpIni('file_uploads', true),
109b7059dccSGreg Roach            $this->checkSystemTemporaryFolder(),
110b7059dccSGreg Roach            $this->checkPhpVersion(),
111b7059dccSGreg Roach        ]);
112b7059dccSGreg Roach
113b7059dccSGreg Roach        return $warnings
114b7059dccSGreg Roach            ->flatten()
115b7059dccSGreg Roach            ->filter();
116b7059dccSGreg Roach    }
117b7059dccSGreg Roach
118b7059dccSGreg Roach    /**
119b7059dccSGreg Roach     * Check if a PHP extension is loaded.
120b7059dccSGreg Roach     *
121b7059dccSGreg Roach     * @param string $extension
122b7059dccSGreg Roach     *
123b7059dccSGreg Roach     * @return string
124b7059dccSGreg Roach     */
125b7059dccSGreg Roach    private function checkPhpExtension(string $extension): string
126b7059dccSGreg Roach    {
127b7059dccSGreg Roach        if (!extension_loaded($extension)) {
128b7059dccSGreg Roach            return I18N::translate('The PHP extension “%s” is not installed.', $extension);
129b7059dccSGreg Roach        }
130b7059dccSGreg Roach
131b7059dccSGreg Roach        return '';
132b7059dccSGreg Roach    }
133b7059dccSGreg Roach
134b7059dccSGreg Roach    /**
135b7059dccSGreg Roach     * Check if a PHP setting is correct.
136b7059dccSGreg Roach     *
137b7059dccSGreg Roach     * @param string $varname
138b7059dccSGreg Roach     * @param bool   $expected
139b7059dccSGreg Roach     *
140b7059dccSGreg Roach     * @return string
141b7059dccSGreg Roach     */
142b7059dccSGreg Roach    private function checkPhpIni(string $varname, bool $expected): string
143b7059dccSGreg Roach    {
144b7059dccSGreg Roach        $ini_get = (bool) ini_get($varname);
145b7059dccSGreg Roach
146b7059dccSGreg Roach        if ($expected && $ini_get !== $expected) {
147b7059dccSGreg Roach            return I18N::translate('The PHP.INI setting “%1$s” is disabled.', $varname);
148b7059dccSGreg Roach        }
149b7059dccSGreg Roach
150b7059dccSGreg Roach        if (!$expected && $ini_get !== $expected) {
151b7059dccSGreg Roach            return I18N::translate('The PHP.INI setting “%1$s” is enabled.', $varname);
152b7059dccSGreg Roach        }
153b7059dccSGreg Roach
154b7059dccSGreg Roach        return '';
155b7059dccSGreg Roach    }
156b7059dccSGreg Roach
157b7059dccSGreg Roach    /**
1587bb10f9aSGreg Roach     * Check if a PHP function is in the list of disabled functions.
1597bb10f9aSGreg Roach     *
1607bb10f9aSGreg Roach     * @param string $function
1617bb10f9aSGreg Roach     *
1627d99559cSGreg Roach     * @return bool
1637bb10f9aSGreg Roach     */
1647bb10f9aSGreg Roach    public function isFunctionDisabled(string $function): bool
1657bb10f9aSGreg Roach    {
1667bb10f9aSGreg Roach        $disable_functions = explode(',', ini_get('disable_functions'));
1670b5fd0a6SGreg Roach        $disable_functions = array_map(static function (string $func): string {
168e364afe4SGreg Roach            return strtolower(trim($func));
1697bb10f9aSGreg Roach        }, $disable_functions);
1707bb10f9aSGreg Roach
1717bb10f9aSGreg Roach        $function = strtolower($function);
1727bb10f9aSGreg Roach
1737bb10f9aSGreg Roach        return in_array($function, $disable_functions, true) || !function_exists($function);
1747bb10f9aSGreg Roach    }
1757bb10f9aSGreg Roach
1767bb10f9aSGreg Roach    /**
1777bb10f9aSGreg Roach     * Create a warning message for a disabled function.
178b7059dccSGreg Roach     *
179b7059dccSGreg Roach     * @param string $function
180b7059dccSGreg Roach     *
181b7059dccSGreg Roach     * @return string
182b7059dccSGreg Roach     */
183b7059dccSGreg Roach    private function checkPhpFunction(string $function): string
184b7059dccSGreg Roach    {
1857bb10f9aSGreg Roach        if ($this->isFunctionDisabled($function)) {
186acf70b2aSGreg Roach            return I18N::translate('The PHP function “%1$s” is disabled.', $function . '()');
187b7059dccSGreg Roach        }
188b7059dccSGreg Roach
189b7059dccSGreg Roach        return '';
190b7059dccSGreg Roach    }
191b7059dccSGreg Roach
192b7059dccSGreg Roach    /**
193b7059dccSGreg Roach     * Some servers configure their temporary folder in an unaccessible place.
194b7059dccSGreg Roach     */
195b7059dccSGreg Roach    private function checkPhpVersion(): string
196b7059dccSGreg Roach    {
197b7059dccSGreg Roach        $today = date('Y-m-d');
198b7059dccSGreg Roach
199b7059dccSGreg Roach        foreach (self::PHP_SUPPORT_DATES as $version => $end_date) {
200497c5612SGreg Roach            if ($today > $end_date && version_compare(self::PHP_MINOR_VERSION, $version) <= 0) {
201b7059dccSGreg Roach                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>';
202b7059dccSGreg Roach            }
203b7059dccSGreg Roach        }
204b7059dccSGreg Roach
205b7059dccSGreg Roach        return '';
206b7059dccSGreg Roach    }
207b7059dccSGreg Roach
208b7059dccSGreg Roach    /**
209b7059dccSGreg Roach     * Check the
210b7059dccSGreg Roach     *
211b7059dccSGreg Roach     * @return string
212b7059dccSGreg Roach     */
213b7059dccSGreg Roach    private function checkSqliteVersion(): string
214b7059dccSGreg Roach    {
215b7059dccSGreg Roach        if (class_exists(SQLite3::class)) {
216b7059dccSGreg Roach            $sqlite_version = SQLite3::version()['versionString'];
217b7059dccSGreg Roach
218b7059dccSGreg Roach            if (version_compare($sqlite_version, self::MINIMUM_SQLITE_VERSION) < 0) {
219b7059dccSGreg Roach                return I18N::translate('SQLite version %s is installed. SQLite version %s or later is required.', $sqlite_version, self::MINIMUM_SQLITE_VERSION);
220b7059dccSGreg Roach            }
221b7059dccSGreg Roach        }
222b7059dccSGreg Roach
223b7059dccSGreg Roach        return '';
224b7059dccSGreg Roach    }
225b7059dccSGreg Roach
226b7059dccSGreg Roach    /**
227b7059dccSGreg Roach     * Some servers configure their temporary folder in an unaccessible place.
228b7059dccSGreg Roach     */
229b7059dccSGreg Roach    private function checkSystemTemporaryFolder(): string
230b7059dccSGreg Roach    {
231b7059dccSGreg Roach        $open_basedir = ini_get('open_basedir');
232b7059dccSGreg Roach
233b33d97e2SGreg Roach        if ($open_basedir === '') {
234b33d97e2SGreg Roach            // open_basedir not used.
235b7059dccSGreg Roach            return '';
236b7059dccSGreg Roach        }
237b7059dccSGreg Roach
238b33d97e2SGreg Roach        $open_basedirs = explode(PATH_SEPARATOR, $open_basedir);
239b33d97e2SGreg Roach
240b33d97e2SGreg Roach        $sys_temp_dir = sys_get_temp_dir();
241b33d97e2SGreg Roach        $sys_temp_dir = $this->normalizeFolder($sys_temp_dir);
242b33d97e2SGreg Roach
243b33d97e2SGreg Roach        foreach ($open_basedirs as $dir) {
244b33d97e2SGreg Roach            $dir = $this->normalizeFolder($dir);
245b33d97e2SGreg Roach
246b33d97e2SGreg Roach            if (strpos($sys_temp_dir, $dir) === 0) {
247b33d97e2SGreg Roach                return '';
248b33d97e2SGreg Roach            }
249b33d97e2SGreg Roach        }
250b33d97e2SGreg Roach
251b7059dccSGreg Roach        $message = I18N::translate('The server’s temporary folder cannot be accessed.');
252b7059dccSGreg Roach        $message .= '<br>sys_get_temp_dir() = "' . e($sys_temp_dir) . '"';
253b7059dccSGreg Roach        $message .= '<br>ini_get("open_basedir") = "' . e($open_basedir) . '"';
254b7059dccSGreg Roach
255b7059dccSGreg Roach        return $message;
256b7059dccSGreg Roach    }
257b7059dccSGreg Roach
258b7059dccSGreg Roach    /**
259b33d97e2SGreg Roach     * Convert a folder name to a canonical form:
260b33d97e2SGreg Roach     * - forward slashes.
261b33d97e2SGreg Roach     * - trailing slash.
262b33d97e2SGreg Roach     * We can't use realpath() as this can trigger open_basedir restrictions,
263b33d97e2SGreg Roach     * and we are using this code to find out whether open_basedir will affect us.
264b33d97e2SGreg Roach     *
265b33d97e2SGreg Roach     * @param string $path
266b33d97e2SGreg Roach     *
267b33d97e2SGreg Roach     * @return string
268b33d97e2SGreg Roach     */
269b33d97e2SGreg Roach    private function normalizeFolder(string $path): string
270b33d97e2SGreg Roach    {
271b33d97e2SGreg Roach        $path = preg_replace('/[\\/]+/', '/', $path);
272b33d97e2SGreg Roach        $path = Str::finish($path, '/');
273b33d97e2SGreg Roach
274b33d97e2SGreg Roach        return $path;
275b33d97e2SGreg Roach    }
276b33d97e2SGreg Roach
277b33d97e2SGreg Roach    /**
278b7059dccSGreg Roach     * @param string $driver
279b7059dccSGreg Roach     *
280b5c8fd7eSGreg Roach     * @return Collection<string>
281b7059dccSGreg Roach     */
282b7059dccSGreg Roach    private function databaseDriverErrors(string $driver): Collection
283b7059dccSGreg Roach    {
284b7059dccSGreg Roach        switch ($driver) {
285b7059dccSGreg Roach            case 'mysql':
286b7059dccSGreg Roach                return Collection::make([
287b7059dccSGreg Roach                    $this->checkPhpExtension('pdo'),
288b7059dccSGreg Roach                    $this->checkPhpExtension('pdo_mysql'),
289b7059dccSGreg Roach                ]);
290b7059dccSGreg Roach
291b7059dccSGreg Roach            case 'sqlite':
292b7059dccSGreg Roach                return Collection::make([
293b7059dccSGreg Roach                    $this->checkPhpExtension('pdo'),
294b7059dccSGreg Roach                    $this->checkPhpExtension('sqlite3'),
295b7059dccSGreg Roach                    $this->checkPhpExtension('pdo_sqlite'),
296b7059dccSGreg Roach                    $this->checkSqliteVersion(),
297b7059dccSGreg Roach                ]);
298b7059dccSGreg Roach
299b7059dccSGreg Roach            case 'pgsql':
300b7059dccSGreg Roach                return Collection::make([
301b7059dccSGreg Roach                    $this->checkPhpExtension('pdo'),
302b7059dccSGreg Roach                    $this->checkPhpExtension('pdo_pgsql'),
303b7059dccSGreg Roach                ]);
304b7059dccSGreg Roach
305b7059dccSGreg Roach            case 'sqlsvr':
306b7059dccSGreg Roach                return Collection::make([
307b7059dccSGreg Roach                    $this->checkPhpExtension('pdo'),
308b7059dccSGreg Roach                    $this->checkPhpExtension('pdo_odbc'),
309b7059dccSGreg Roach                ]);
310b7059dccSGreg Roach
311b7059dccSGreg Roach            default:
312b7059dccSGreg Roach                return new Collection();
313b7059dccSGreg Roach        }
314b7059dccSGreg Roach    }
315b7059dccSGreg Roach
316b7059dccSGreg Roach    /**
317b7059dccSGreg Roach     * @param string $driver
318b7059dccSGreg Roach     *
319b5c8fd7eSGreg Roach     * @return Collection<string>
320b7059dccSGreg Roach     */
321b7059dccSGreg Roach    private function databaseDriverWarnings(string $driver): Collection
322b7059dccSGreg Roach    {
323b7059dccSGreg Roach        switch ($driver) {
324b7059dccSGreg Roach            case 'sqlite':
325b7059dccSGreg Roach                return new Collection([
326b7059dccSGreg Roach                    I18N::translate('SQLite is only suitable for small sites, testing and evaluation.'),
327b7059dccSGreg Roach                ]);
328b7059dccSGreg Roach
329b7059dccSGreg Roach            case 'pgsql':
330b7059dccSGreg Roach                return new Collection([
331b7059dccSGreg Roach                    I18N::translate('Support for PostgreSQL is experimental.'),
332b7059dccSGreg Roach                ]);
333b7059dccSGreg Roach
334b7059dccSGreg Roach            case 'sqlsvr':
335b7059dccSGreg Roach                return new Collection([
336b7059dccSGreg Roach                    I18N::translate('Support for SQL Server is experimental.'),
337b7059dccSGreg Roach                ]);
338b7059dccSGreg Roach
339b7059dccSGreg Roach            default:
340b7059dccSGreg Roach                return new Collection();
341b7059dccSGreg Roach        }
342b7059dccSGreg Roach    }
343497c5612SGreg Roach
344497c5612SGreg Roach    /**
345b5c8fd7eSGreg Roach     * @return Collection<string>
346497c5612SGreg Roach     */
347497c5612SGreg Roach    private function databaseEngineWarnings(): Collection
348497c5612SGreg Roach    {
349497c5612SGreg Roach        $warnings = new Collection();
350497c5612SGreg Roach
351497c5612SGreg Roach        try {
352497c5612SGreg Roach            $connection = DB::connection();
3537bf2ba3bSGreg Roach        } catch (Throwable $ex) {
354497c5612SGreg Roach            // During setup, there won't be a connection.
355497c5612SGreg Roach            return new Collection();
356497c5612SGreg Roach        }
357497c5612SGreg Roach
358497c5612SGreg Roach        if ($connection->getDriverName() === 'mysql') {
3594a9ddbdcSGreg Roach            $sql = "SELECT table_name FROM information_schema.tables JOIN information_schema.engines USING (engine) WHERE table_schema = ? AND LEFT(table_name, ?) = ? AND transactions <> 'YES'";
3604a9ddbdcSGreg Roach
3614a9ddbdcSGreg Roach            $bindings = [
362497c5612SGreg Roach                $connection->getDatabaseName(),
363497c5612SGreg Roach                mb_strlen($connection->getTablePrefix()),
364497c5612SGreg Roach                $connection->getTablePrefix(),
3654a9ddbdcSGreg Roach            ];
3664a9ddbdcSGreg Roach
3674a9ddbdcSGreg Roach            $rows = DB::connection()->select($sql, $bindings);
368497c5612SGreg Roach
369497c5612SGreg Roach            $rows = new Collection($rows);
370497c5612SGreg Roach
371497c5612SGreg Roach            $rows = $rows->map(static function (stdClass $row): string {
3721a6f7674SGreg Roach                $table = $row->TABLE_NAME ?? $row->table_name;
3731a6f7674SGreg Roach                return '<code>ALTER TABLE `' . $table . '` ENGINE=InnoDB;</code>';
374497c5612SGreg Roach            });
375497c5612SGreg Roach
376497c5612SGreg Roach            if ($rows->isNotEmpty()) {
377497c5612SGreg Roach                $warning =
378497c5612SGreg Roach                    'The database uses non-transactional tables.' .
379497c5612SGreg Roach                    ' ' .
380497c5612SGreg Roach                    'You may get errors if more than one user updates data at the same time.' .
381497c5612SGreg Roach                    ' ' .
382497c5612SGreg Roach                    'To fix this, run the following SQL commands.' .
383497c5612SGreg Roach                    '<br>' .
384497c5612SGreg Roach                    $rows->implode('<br>');
385497c5612SGreg Roach
386497c5612SGreg Roach                $warnings->push($warning);
387497c5612SGreg Roach            }
388497c5612SGreg Roach        }
389497c5612SGreg Roach
390497c5612SGreg Roach        return $warnings;
391497c5612SGreg Roach    }
392b7059dccSGreg Roach}
393