Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.59% covered (success)
91.59%
98 / 107
80.00% covered (success)
80.00%
20 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
System
91.59% covered (success)
91.59%
98 / 107
80.00% covered (success)
80.00%
20 / 25
58.93
0.00% covered (danger)
0.00%
0 / 1
 getVersion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isUpdateNecessary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isUpdateExemptRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getMajorVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApiVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPluginVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMcpServerVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPoweredByString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getPoweredByPlainString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDocumentationUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGitHubIssuesUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSqlite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isDevelopmentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDatabase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDatabase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableTemplates
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 getSupportedSafeDatabases
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 getSupportedDatabases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSystemUri
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getHttpsStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkDatabase
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 checkRequiredExtensions
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 checkInstallation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMissingExtensions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createHashes
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
12.07
1<?php
2
3/**
4 * Class for checking system requirements.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2010-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2010-01-13
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ;
21
22use DateTime;
23use DirectoryIterator;
24use Exception;
25use phpMyFAQ\Database\DatabaseDriver;
26use phpMyFAQ\Twig\TwigWrapper;
27use RecursiveDirectoryIterator;
28use RecursiveIteratorIterator;
29use Symfony\Component\HttpFoundation\Request;
30use UnexpectedValueException;
31
32/**
33 * Class System
34 *
35 * @package phpMyFAQ
36 */
37class System
38{
39    /**
40     * Major version.
41     */
42    private const int VERSION_MAJOR = 4;
43
44    /**
45     * Minor version.
46     */
47    private const int VERSION_MINOR = 2;
48
49    /**
50     * Patch level.
51     */
52    private const int VERSION_PATCH_LEVEL = 0;
53
54    /**
55     * Pre-release version.
56     */
57    private const string VERSION_PRE_RELEASE = 'alpha';
58
59    /**
60     * API version.
61     */
62    private const string VERSION_API = '4.0';
63
64    /**
65     * Plugin version.
66     */
67    private const string PLUGIN_VERSION = '0.2.0';
68
69    /**
70     * MCP Server version
71     */
72    private const string MCP_SERVER_VERSION = '0.1.0';
73
74    /**
75     * Minimum required PHP version.
76     */
77    final public const string VERSION_MINIMUM_PHP = '8.4.0';
78
79    /**
80     * phpMyFAQ homepage URL
81     */
82    final public const string PHPMYFAQ_URL = 'https://www.phpmyfaq.de/';
83
84    /**
85     * GitHub Issues URL
86     */
87    final public const string GITHUB_ISSUES_URL = 'https://github.com/thorsten/phpMyFAQ/issues';
88
89    /**
90     * Array of required PHP extensions.
91     *
92     * @var array<non-empty-string>
93     */
94    private array $requiredExtensions = [
95        'curl',
96        'fileinfo',
97        'filter',
98        'gd',
99        'intl',
100        'json',
101        'mbstring',
102        'openssl',
103        'sodium',
104        'xml',
105        'zip',
106    ];
107
108    /**
109     * Array of missing PHP extensions.
110     *
111     * @var array<string>
112     */
113    private array $missingExtensions = [];
114
115    /**
116     * Supported databases for phpMyFAQ.
117     *
118     * @var array<non-empty-string, array<int, string>>
119     */
120    private array $supportedDatabases = [
121        'pdo_mysql' => [
122            self::VERSION_MINIMUM_PHP,
123            'MySQL v8 / MariaDB v10 / Percona Server v8 / Galera Cluster v4 (PDO_MYSQL, preferred)',
124        ],
125        'mysqli' => [
126            self::VERSION_MINIMUM_PHP,
127            'MySQL v8 / MariaDB v10 / Percona Server v8 / Galera Cluster v4 (ext/mysqli)',
128        ],
129        'pdo_pgsql' => [
130            self::VERSION_MINIMUM_PHP,
131            'PostgreSQL v10 or later (PDO_PGSQL, preferred)',
132        ],
133        'pgsql' => [
134            self::VERSION_MINIMUM_PHP,
135            'PostgreSQL v10 or later',
136        ],
137        'pdo_sqlite' => [
138            self::VERSION_MINIMUM_PHP,
139            'SQLite 3 (PDO_SQLITE, preferred)',
140        ],
141        'sqlite3' => [
142            self::VERSION_MINIMUM_PHP,
143            'SQLite 3',
144        ],
145        'pdo_sqlsrv' => [
146            self::VERSION_MINIMUM_PHP,
147            'MS SQL Server 2016 or later / SQL Azure (PDO_SQLSRV, preferred)',
148        ],
149        'sqlsrv' => [
150            self::VERSION_MINIMUM_PHP,
151            'MS SQL Server 2016 or later',
152        ],
153    ];
154
155    /**
156     * Database handle.
157     */
158    private ?DatabaseDriver $databaseDriver = null;
159
160    /**
161     * Returns the current version of phpMyFAQ for installation and
162     * version in the database.
163     * Releases will be numbered with the following format:
164     * <major>.<minor>.<patch>[-<prerelease>]
165     */
166    public static function getVersion(): string
167    {
168        $version = self::VERSION_MAJOR . '.' . self::VERSION_MINOR . '.' . self::VERSION_PATCH_LEVEL;
169        return $version . (self::isDevelopmentVersion() ? '-' . self::VERSION_PRE_RELEASE : '');
170    }
171
172    /**
173     * Returns true if the installed database version is older than the current
174     * code base and the installation therefore needs to be updated. An empty or
175     * unknown installed version is not treated as outdated to avoid redirecting
176     * brand-new or non-installed setups to the updater.
177     */
178    public static function isUpdateNecessary(?string $installedVersion): bool
179    {
180        $installedVersion = (string) $installedVersion;
181
182        if ('' === $installedVersion) {
183            return false;
184        }
185
186        return version_compare(version1: $installedVersion, version2: self::getVersion(), operator: '<');
187    }
188
189    /**
190     * Admin page paths (by PATH_INFO) that must stay reachable while an update
191     * is pending, so the administrator can log in and start the update. The
192     * matching admin REST endpoints run through a separate /api/ front
193     * controller and are already exempt via the generic /api/ rule below.
194     *
195     * @var string[]
196     */
197    private const array UPDATE_RECOVERY_ADMIN_PATHS = [
198        '/login',
199        '/authenticate',
200        '/check',
201        '/token',
202        '/update',
203    ];
204
205    /**
206     * Returns true if the given request must NOT be redirected to the updater
207     * while an update is pending.
208     *
209     * Exempt are the standalone updater, the installer and all REST endpoints
210     * (to keep the update process functional and avoid redirect loops), plus a
211     * small allow-list of administration pages required to recover: the login
212     * flow and the upgrade UI. Content-facing admin pages stay blocked because
213     * they would hit the outdated database schema.
214     */
215    public static function isUpdateExemptRequest(string $scriptName, string $pathInfo): bool
216    {
217        if (
218            str_contains($scriptName, '/update/')
219            || str_contains($scriptName, '/setup/')
220            || str_contains($scriptName, '/api/')
221        ) {
222            return true;
223        }
224
225        if (str_contains($scriptName, '/admin/')) {
226            return in_array(needle: $pathInfo, haystack: self::UPDATE_RECOVERY_ADMIN_PATHS, strict: true);
227        }
228
229        return false;
230    }
231
232    /**
233     * Returns the current major version of phpMyFAQ
234     */
235    public static function getMajorVersion(): string
236    {
237        return self::VERSION_MAJOR . '.' . self::VERSION_MINOR;
238    }
239
240    /**
241     * Returns the current API version of phpMyFAQ for installation and
242     * version in the database.
243     */
244    public static function getApiVersion(): string
245    {
246        return self::VERSION_API;
247    }
248
249    /**
250     * Returns the current plugin version of phpMyFAQ
251     */
252    public static function getPluginVersion(): string
253    {
254        return self::PLUGIN_VERSION;
255    }
256
257    /**
258     * Returns the current MCP Server version of phpMyFAQ
259     */
260    public static function getMcpServerVersion(): string
261    {
262        return self::MCP_SERVER_VERSION;
263    }
264
265    public static function getPoweredByString(): string
266    {
267        return 'powered with ❤️ and ☕️ by <a class="link-light text-decoration-none" target="_blank" href="'
268        . self::PHPMYFAQ_URL
269        . '">phpMyFAQ</a> '
270        . self::getVersion();
271    }
272
273    public static function getPoweredByPlainString(): string
274    {
275        return 'powered with ❤️ and ☕️ by phpMyFAQ ' . self::getVersion();
276    }
277
278    /**
279     * Returns the URL of the documentation
280     */
281    public static function getDocumentationUrl(): string
282    {
283        return self::PHPMYFAQ_URL . 'docs/' . self::getMajorVersion();
284    }
285
286    public static function getGitHubIssuesUrl(): string
287    {
288        return self::GITHUB_ISSUES_URL;
289    }
290
291    /**
292     * Returns true or false on SQLite3.
293     *
294     * @static
295     */
296    public static function isSqlite(string $dbType): bool
297    {
298        return 'sqlite3' === $dbType || 'pdo_sqlite' === $dbType;
299    }
300
301    public static function isDevelopmentVersion(): bool
302    {
303        return self::VERSION_PRE_RELEASE !== '';
304    }
305
306    public function getDatabase(): ?DatabaseDriver
307    {
308        return $this->databaseDriver;
309    }
310
311    public function setDatabase(DatabaseDriver $databaseDriver): System
312    {
313        $this->databaseDriver = $databaseDriver;
314        return $this;
315    }
316
317    /**
318     * Returns all available templates as array.
319     *
320     * @return array<string, bool>
321     */
322    public function getAvailableTemplates(): array
323    {
324        $templates = [];
325        $systemFolder = ['admin', 'setup', 'error'];
326
327        foreach (new DirectoryIterator((string) PMF_ROOT_DIR . '/assets/templates/') as $item) {
328            $basename = $item->getBasename();
329            if ($item->isDot()) {
330                continue;
331            }
332
333            if (!$item->isDir()) {
334                continue;
335            }
336
337            if (in_array($basename, $systemFolder, strict: true)) {
338                continue;
339            }
340
341            $templates[$basename] = TwigWrapper::getTemplateSetName() === $basename;
342        }
343
344        return $templates;
345    }
346
347    /**
348     * Returns the locally supported databases.
349     *
350     * @return array<string, string>
351     */
352    public function getSupportedSafeDatabases(): array
353    {
354        $databases = [];
355        foreach ($this->getSupportedDatabases() as $extension => $database) {
356            if (!extension_loaded($extension)) {
357                continue;
358            }
359
360            if (version_compare(PHP_VERSION, $database[0]) < 0) {
361                continue;
362            }
363
364            $databases[$extension] = $database[1];
365        }
366
367        return $databases;
368    }
369
370    /**
371     * Returns the supported databases.
372     *
373     * @return array<non-empty-string, array<int, string>>
374     */
375    public function getSupportedDatabases(): array
376    {
377        return $this->supportedDatabases;
378    }
379
380    /**
381     * Checks if the system URI is running with http or https.
382     */
383    public function getSystemUri(Configuration $configuration): string
384    {
385        $mainUrl = $configuration->getDefaultUrl();
386
387        if (Request::createFromGlobals()->isSecure() && !str_contains($mainUrl, needle: 'https')) {
388            $mainUrl = str_replace(search: 'http://', replace: 'https://', subject: $mainUrl);
389        }
390
391        if (!str_ends_with($mainUrl, needle: '/')) {
392            $mainUrl .= '/';
393        }
394
395        return $mainUrl;
396    }
397
398    /**
399     * Returns true if phpMyFAQ is running on HTTPS
400     */
401    public function getHttpsStatus(): bool
402    {
403        return Request::createFromGlobals()->isSecure();
404    }
405
406    /**
407     * Checks for installed database extensions, if the first supported
408     * extension is enabled, return true.
409     */
410    public function checkDatabase(): bool
411    {
412        foreach (array_keys($this->supportedDatabases) as $extension) {
413            if (!extension_loaded($extension)) {
414                continue;
415            }
416
417            return true;
418        }
419
420        return false;
421    }
422
423    /**
424     * Checks for required PHP extensions.
425     */
426    public function checkRequiredExtensions(): bool
427    {
428        foreach ($this->requiredExtensions as $requiredExtension) {
429            if (extension_loaded($requiredExtension)) {
430                continue;
431            }
432
433            $this->missingExtensions[] = $requiredExtension;
434        }
435
436        return count($this->missingExtensions) <= 0;
437    }
438
439    /**
440     * Checks for an installed phpMyFAQ version
441     */
442    public function checkInstallation(): bool
443    {
444        return !is_file((string) PMF_ROOT_DIR . '/content/core/config/database.php');
445    }
446
447    /**
448     * @return array<string>
449     */
450    public function getMissingExtensions(): array
451    {
452        return $this->missingExtensions;
453    }
454
455    /**
456     * Creates a JSON object with all .php files of phpMyFAQ with their sha1 hashes.
457     *
458     * @throws Exception
459     */
460    public function createHashes(): string
461    {
462        $dateTime = new DateTime();
463        $files = new RecursiveIteratorIterator(
464            new RecursiveDirectoryIterator((string) PMF_ROOT_DIR),
465            RecursiveIteratorIterator::SELF_FIRST,
466        );
467
468        $hashes = [
469            'created' => $dateTime->format(format: 'Y-m-d H:i:sP'),
470        ];
471        $ignoredFiles = [
472            '/content/core/config/azure.php' => false,
473            '/content/core/config/constants.php' => false,
474            '/content/core/config/constants_elasticsearch.php' => false,
475            '/content/core/config/database.php' => false,
476            '/content/core/config/elasticsearch.php' => false,
477            '/content/core/config/ldap.php' => false,
478        ];
479        $current = '';
480
481        try {
482            foreach ($files as $file) {
483                if (!$file instanceof \SplFileInfo || !$file->isFile() || !$file->isReadable()) {
484                    continue;
485                }
486
487                if ('php' !== pathinfo((string) $file->getFilename(), PATHINFO_EXTENSION)) {
488                    continue;
489                }
490
491                if (str_contains((string) $file->getPath(), needle: '/tests/')) {
492                    continue;
493                }
494
495                if (str_contains((string) $file->getPath(), needle: '/multisite/')) {
496                    continue;
497                }
498
499                if (str_contains((string) $file->getPath(), needle: '/upgrade/')) {
500                    continue;
501                }
502
503                $current = str_replace((string) PMF_ROOT_DIR, replace: '', subject: (string) $file->getPathname());
504
505                if (array_key_exists($current, $ignoredFiles)) {
506                    continue;
507                }
508
509                $contents = file_get_contents($file->getPathname());
510                if ($contents === false) {
511                    continue;
512                }
513
514                $hashes[$current] = sha1($contents);
515            }
516        } catch (UnexpectedValueException $unexpectedValueException) {
517            $hashes[$current . ' failed'] = $unexpectedValueException->getMessage();
518        }
519
520        return json_encode($hashes, JSON_THROW_ON_ERROR);
521    }
522}