Passed
Push — master ( 9ba014...497c56 )
by Greg
07:49 queued 13s
created

ServerCheckService::databaseEngineWarnings()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 40
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 25
c 1
b 0
f 0
nc 4
nop 0
dl 0
loc 40
rs 9.52
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
 */
16
declare(strict_types=1);
17
18
namespace Fisharebest\Webtrees\Services;
19
20
use Exception;
21
use Fisharebest\Webtrees\I18N;
22
use Illuminate\Database\Capsule\Manager as DB;
23
use Illuminate\Database\Query\Expression;
24
use Illuminate\Support\Collection;
25
use Illuminate\Support\Str;
26
use SQLite3;
27
use stdClass;
28
use function array_map;
29
use function class_exists;
30
use function date;
31
use function e;
32
use function explode;
33
use function extension_loaded;
34
use function function_exists;
35
use function in_array;
36
use function preg_replace;
37
use function strpos;
38
use function strtolower;
39
use function sys_get_temp_dir;
40
use function trim;
41
use function version_compare;
42
use const PATH_SEPARATOR;
43
use const PHP_MAJOR_VERSION;
44
use const PHP_MINOR_VERSION;
45
use const PHP_VERSION;
46
47
/**
48
 * Check if the server meets the minimum requirements for webtrees.
49
 */
50
class 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 (Exception $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, engine 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
                return '<code>ALTER TABLE ' . $row->TABLE_NAME . ' ENGINE=InnoDB;</code>';
367
            });
368
369
            if ($rows->isNotEmpty()) {
370
                $warning =
371
                    'The database uses non-transactional tables.' .
372
                    ' ' .
373
                    'You may get errors if more than one user updates data at the same time.' .
374
                    ' ' .
375
                    'To fix this, run the following SQL commands.' .
376
                    '<br>' .
377
                    $rows->implode('<br>');
378
379
                $warnings->push($warning);
380
            }
381
        }
382
383
        return $warnings;
384
    }
385
}
386