Lines 91.66% 99 / 108
Methods 80.76% 21 / 26
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 getVersion 100.00% 2 / 2 100.00% 1 / 1 2
 isUpdateNecessary 100.00% 4 / 4 100.00% 1 / 1 2
 isUpdateExemptRequest 100.00% 7 / 7 100.00% 1 / 1 6
 getUpdateRedirectPath 100.00% 1 / 1 100.00% 1 / 1 2
 getMajorVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getApiVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getPluginVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getMcpServerVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getPoweredByString 100.00% 4 / 4 100.00% 1 / 1 1
 getPoweredByPlainString 100.00% 1 / 1 100.00% 1 / 1 1
 getDocumentationUrl 100.00% 1 / 1 100.00% 1 / 1 1
 getGitHubIssuesUrl 100.00% 1 / 1 100.00% 1 / 1 1
 isSqlite 100.00% 1 / 1 100.00% 1 / 1 2
 isDevelopmentVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getDatabase 100.00% 1 / 1 100.00% 1 / 1 1
 setDatabase 100.00% 2 / 2 100.00% 1 / 1 1
 getAvailableTemplates 91.66% 11 / 12 0.00% 0 / 1 5.01
 getSupportedSafeDatabases 75.00% 6 / 8 0.00% 0 / 1 4.25
 getSupportedDatabases 100.00% 1 / 1 100.00% 1 / 1 1
 getSystemUri 100.00% 6 / 6 100.00% 1 / 1 4
 getHttpsStatus 100.00% 1 / 1 100.00% 1 / 1 1
 checkDatabase 60.00% 3 / 5 0.00% 0 / 1 3.58
 checkRequiredExtensions 80.00% 4 / 5 0.00% 0 / 1 3.07
 checkInstallation 100.00% 1 / 1 100.00% 1 / 1 1
 getMissingExtensions 100.00% 1 / 1 100.00% 1 / 1 1
 createHashes 92.10% 35 / 38 0.00% 0 / 1 12.07
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.2';
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 update wizard, 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        // The update wizard is a Symfony route served by the main front
230        // controller, so only the path identifies it there.
231        return $pathInfo === '/update' || str_starts_with($pathInfo, '/update/');
232    }
233
234    /**
235     * Returns the path (relative to the system URI) a request blocked during a
236     * pending update must be redirected to.
237     *
238     * Admin requests go to the admin update page: it is exempt, its auth guard
239     * redirects logged-out users to the exempt login page, and it is where the
240     * maintenance mode is enabled. All other requests go to the update wizard.
241     */
242    public static function getUpdateRedirectPath(string $scriptName): string
243    {
244        return str_contains($scriptName, '/admin/') ? 'admin/update' : 'update/';
245    }
246
247    /**
248     * Returns the current major version of phpMyFAQ
249     */
250    public static function getMajorVersion(): string
251    {
252        return self::VERSION_MAJOR . '.' . self::VERSION_MINOR;
253    }
254
255    /**
256     * Returns the current API version of phpMyFAQ for installation and
257     * version in the database.
258     */
259    public static function getApiVersion(): string
260    {
261        return self::VERSION_API;
262    }
263
264    /**
265     * Returns the current plugin version of phpMyFAQ
266     */
267    public static function getPluginVersion(): string
268    {
269        return self::PLUGIN_VERSION;
270    }
271
272    /**
273     * Returns the current MCP Server version of phpMyFAQ
274     */
275    public static function getMcpServerVersion(): string
276    {
277        return self::MCP_SERVER_VERSION;
278    }
279
280    public static function getPoweredByString(): string
281    {
282        return 'powered with ❤️ and ☕️ by <a class="link-light text-decoration-none" target="_blank" href="'
283        . self::PHPMYFAQ_URL
284        . '">phpMyFAQ</a> '
285        . self::getVersion();
286    }
287
288    public static function getPoweredByPlainString(): string
289    {
290        return 'powered with ❤️ and ☕️ by phpMyFAQ ' . self::getVersion();
291    }
292
293    /**
294     * Returns the URL of the documentation
295     */
296    public static function getDocumentationUrl(): string
297    {
298        return self::PHPMYFAQ_URL . 'docs/' . self::getMajorVersion();
299    }
300
301    public static function getGitHubIssuesUrl(): string
302    {
303        return self::GITHUB_ISSUES_URL;
304    }
305
306    /**
307     * Returns true or false on SQLite3.
308     *
309     * @static
310     */
311    public static function isSqlite(string $dbType): bool
312    {
313        return 'sqlite3' === $dbType || 'pdo_sqlite' === $dbType;
314    }
315
316    public static function isDevelopmentVersion(): bool
317    {
318        return self::VERSION_PRE_RELEASE !== '';
319    }
320
321    public function getDatabase(): ?DatabaseDriver
322    {
323        return $this->databaseDriver;
324    }
325
326    public function setDatabase(DatabaseDriver $databaseDriver): System
327    {
328        $this->databaseDriver = $databaseDriver;
329        return $this;
330    }
331
332    /**
333     * Returns all available templates as array.
334     *
335     * @return array<string, bool>
336     */
337    public function getAvailableTemplates(): array
338    {
339        $templates = [];
340        $systemFolder = ['admin', 'setup', 'error'];
341
342        foreach (new DirectoryIterator((string) PMF_ROOT_DIR . '/assets/templates/') as $item) {
343            $basename = $item->getBasename();
344            if ($item->isDot()) {
345                continue;
346            }
347
348            if (!$item->isDir()) {
349                continue;
350            }
351
352            if (in_array($basename, $systemFolder, strict: true)) {
353                continue;
354            }
355
356            $templates[$basename] = TwigWrapper::getTemplateSetName() === $basename;
357        }
358
359        return $templates;
360    }
361
362    /**
363     * Returns the locally supported databases.
364     *
365     * @return array<string, string>
366     */
367    public function getSupportedSafeDatabases(): array
368    {
369        $databases = [];
370        foreach ($this->getSupportedDatabases() as $extension => $database) {
371            if (!extension_loaded($extension)) {
372                continue;
373            }
374
375            if (version_compare(PHP_VERSION, $database[0]) < 0) {
376                continue;
377            }
378
379            $databases[$extension] = $database[1];
380        }
381
382        return $databases;
383    }
384
385    /**
386     * Returns the supported databases.
387     *
388     * @return array<non-empty-string, array<int, string>>
389     */
390    public function getSupportedDatabases(): array
391    {
392        return $this->supportedDatabases;
393    }
394
395    /**
396     * Checks if the system URI is running with http or https.
397     */
398    public function getSystemUri(Configuration $configuration): string
399    {
400        $mainUrl = $configuration->getDefaultUrl();
401
402        if (Request::createFromGlobals()->isSecure() && !str_contains($mainUrl, needle: 'https')) {
403            $mainUrl = str_replace(search: 'http://', replace: 'https://', subject: $mainUrl);
404        }
405
406        if (!str_ends_with($mainUrl, needle: '/')) {
407            $mainUrl .= '/';
408        }
409
410        return $mainUrl;
411    }
412
413    /**
414     * Returns true if phpMyFAQ is running on HTTPS
415     */
416    public function getHttpsStatus(): bool
417    {
418        return Request::createFromGlobals()->isSecure();
419    }
420
421    /**
422     * Checks for installed database extensions, if the first supported
423     * extension is enabled, return true.
424     */
425    public function checkDatabase(): bool
426    {
427        foreach (array_keys($this->supportedDatabases) as $extension) {
428            if (!extension_loaded($extension)) {
429                continue;
430            }
431
432            return true;
433        }
434
435        return false;
436    }
437
438    /**
439     * Checks for required PHP extensions.
440     */
441    public function checkRequiredExtensions(): bool
442    {
443        foreach ($this->requiredExtensions as $requiredExtension) {
444            if (extension_loaded($requiredExtension)) {
445                continue;
446            }
447
448            $this->missingExtensions[] = $requiredExtension;
449        }
450
451        return count($this->missingExtensions) <= 0;
452    }
453
454    /**
455     * Checks for an installed phpMyFAQ version
456     */
457    public function checkInstallation(): bool
458    {
459        return !is_file((string) PMF_ROOT_DIR . '/content/core/config/database.php');
460    }
461
462    /**
463     * @return array<string>
464     */
465    public function getMissingExtensions(): array
466    {
467        return $this->missingExtensions;
468    }
469
470    /**
471     * Creates a JSON object with all .php files of phpMyFAQ with their sha1 hashes.
472     *
473     * @throws Exception
474     */
475    public function createHashes(): string
476    {
477        $dateTime = new DateTime();
478        $files = new RecursiveIteratorIterator(
479            new RecursiveDirectoryIterator((string) PMF_ROOT_DIR),
480            RecursiveIteratorIterator::SELF_FIRST,
481        );
482
483        $hashes = [
484            'created' => $dateTime->format(format: 'Y-m-d H:i:sP'),
485        ];
486        $ignoredFiles = [
487            '/content/core/config/azure.php' => false,
488            '/content/core/config/constants.php' => false,
489            '/content/core/config/constants_elasticsearch.php' => false,
490            '/content/core/config/database.php' => false,
491            '/content/core/config/elasticsearch.php' => false,
492            '/content/core/config/ldap.php' => false,
493        ];
494        $current = '';
495
496        try {
497            foreach ($files as $file) {
498                if (!$file instanceof \SplFileInfo || !$file->isFile() || !$file->isReadable()) {
499                    continue;
500                }
501
502                if ('php' !== pathinfo((string) $file->getFilename(), PATHINFO_EXTENSION)) {
503                    continue;
504                }
505
506                if (str_contains((string) $file->getPath(), needle: '/tests/')) {
507                    continue;
508                }
509
510                if (str_contains((string) $file->getPath(), needle: '/multisite/')) {
511                    continue;
512                }
513
514                if (str_contains((string) $file->getPath(), needle: '/upgrade/')) {
515                    continue;
516                }
517
518                $current = str_replace((string) PMF_ROOT_DIR, replace: '', subject: (string) $file->getPathname());
519
520                if (array_key_exists($current, $ignoredFiles)) {
521                    continue;
522                }
523
524                $contents = file_get_contents($file->getPathname());
525                if ($contents === false) {
526                    continue;
527                }
528
529                $hashes[$current] = sha1($contents);
530            }
531        } catch (UnexpectedValueException $unexpectedValueException) {
532            $hashes[$current . ' failed'] = $unexpectedValueException->getMessage();
533        }
534
535        return json_encode($hashes, JSON_THROW_ON_ERROR);
536    }
537}