Lines
91.66%
99 / 108
Methods
80.76%
21 / 26
Classes
0.00%
0 / 1
| 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 | ||
| 37 | class 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 | } |