Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
41.91% |
57 / 136 |
|
25.00% |
4 / 16 |
CRAP | |
0.00% |
0 / 1 |
| Update | |
41.91% |
57 / 136 |
|
25.00% |
4 / 16 |
647.91 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| isConfigTableNotAvailable | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| createConfigBackup | |
88.57% |
31 / 35 |
|
0.00% |
0 / 1 |
14.29 | |||
| checkInitialRewriteBasePath | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| applyUpdates | |
50.00% |
8 / 16 |
|
0.00% |
0 / 1 |
6.00 | |||
| allMigrationsSucceeded | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| collectDryRunQueries | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
19.12 | |||
| runPostMigrationTasks | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| insertFormInputs | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| optimizeTables | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| getDryRunResults | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getFormattedDryRunReport | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| executeQueries | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| updateVersion | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getBackupFilename | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| migrateAdminLogHashes | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
42 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * The Update class updates phpMyFAQ. Classy. |
| 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 2023-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 2023-04-03 |
| 16 | */ |
| 17 | |
| 18 | declare(strict_types=1); |
| 19 | |
| 20 | namespace phpMyFAQ\Setup; |
| 21 | |
| 22 | use phpMyFAQ\Administration\AdminLogRepository; |
| 23 | use phpMyFAQ\Configuration; |
| 24 | use phpMyFAQ\Core\Exception; |
| 25 | use phpMyFAQ\Database; |
| 26 | use phpMyFAQ\Database\DatabaseDriver; |
| 27 | use phpMyFAQ\Filesystem\Filesystem; |
| 28 | use phpMyFAQ\Forms; |
| 29 | use phpMyFAQ\Setup\Installation\DefaultDataSeeder; |
| 30 | use phpMyFAQ\Setup\Migration\MigrationExecutor; |
| 31 | use phpMyFAQ\Setup\Migration\MigrationInterface; |
| 32 | use phpMyFAQ\Setup\Migration\MigrationRegistry; |
| 33 | use phpMyFAQ\Setup\Migration\MigrationResult; |
| 34 | use phpMyFAQ\Setup\Migration\MigrationTracker; |
| 35 | use phpMyFAQ\System; |
| 36 | use Random\RandomException; |
| 37 | use RecursiveDirectoryIterator; |
| 38 | use RecursiveIteratorIterator; |
| 39 | use SplFileInfo; |
| 40 | use Symfony\Component\HttpFoundation\Request; |
| 41 | use ZipArchive; |
| 42 | |
| 43 | class Update extends AbstractSetup |
| 44 | { |
| 45 | public string $version { |
| 46 | set { |
| 47 | $this->version = $value; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /** @var string[] Legacy queries array for backward compatibility */ |
| 52 | private array $queries = []; |
| 53 | |
| 54 | public bool $dryRun = false { |
| 55 | set { |
| 56 | $this->dryRun = $value; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /** @var string[] Legacy dry-run queries for backward compatibility */ |
| 61 | public array $dryRunQueries = []; |
| 62 | |
| 63 | /** @var MigrationResult[] */ |
| 64 | public array $migrationResults = []; |
| 65 | |
| 66 | private ?string $backupFilename = null; |
| 67 | |
| 68 | private MigrationRegistry $migrationRegistry; |
| 69 | |
| 70 | private MigrationTracker $migrationTracker; |
| 71 | |
| 72 | private MigrationExecutor $migrationExecutor; |
| 73 | |
| 74 | public function __construct( |
| 75 | protected System $system, |
| 76 | private readonly Configuration $configuration, |
| 77 | ) { |
| 78 | parent::__construct($this->system); |
| 79 | |
| 80 | $this->migrationRegistry = new MigrationRegistry($this->configuration); |
| 81 | $this->migrationTracker = new MigrationTracker($this->configuration); |
| 82 | $this->migrationExecutor = new MigrationExecutor( |
| 83 | $this->configuration, |
| 84 | $this->migrationTracker, |
| 85 | new Filesystem((string) PMF_ROOT_DIR), |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Checks if the "faqconfig" table is available |
| 91 | */ |
| 92 | public function isConfigTableNotAvailable(DatabaseDriver $databaseDriver): bool |
| 93 | { |
| 94 | $query = sprintf('SELECT * FROM %s%s', Database::getTablePrefix(), 'faqconfig'); |
| 95 | $result = $databaseDriver->query($query); |
| 96 | return $databaseDriver->numRows($result) === 0; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Creates a backup of the current config files and returns the path to the archive. |
| 101 | * |
| 102 | * @throws Exception |
| 103 | * @throws RandomException |
| 104 | */ |
| 105 | public function createConfigBackup(string $configDir): string |
| 106 | { |
| 107 | $outputZipFile = $configDir . DIRECTORY_SEPARATOR . $this->getBackupFilename(); |
| 108 | |
| 109 | $zipArchive = new ZipArchive(); |
| 110 | if ($zipArchive->open($outputZipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { |
| 111 | throw new Exception('Cannot create config backup file.'); |
| 112 | } |
| 113 | |
| 114 | $files = new RecursiveIteratorIterator( |
| 115 | new RecursiveDirectoryIterator($configDir), |
| 116 | RecursiveIteratorIterator::SELF_FIRST, |
| 117 | ); |
| 118 | |
| 119 | foreach ($files as $file) { |
| 120 | $filePath = is_string($file) ? $file : (string) $file; |
| 121 | $realPath = realpath($filePath); |
| 122 | $filePath = $realPath !== false ? $realPath : $filePath; |
| 123 | $isDir = is_dir($filePath); |
| 124 | $isFile = is_file($filePath); |
| 125 | |
| 126 | if ($file instanceof SplFileInfo) { |
| 127 | $realPath = $file->getRealPath(); |
| 128 | $filePath = $realPath !== false ? $realPath : $file->getPathname(); |
| 129 | $isDir = $file->isDir(); |
| 130 | $isFile = $file->isFile(); |
| 131 | } |
| 132 | if ($filePath === '') { |
| 133 | continue; |
| 134 | } |
| 135 | |
| 136 | // Exclude the zip we are currently writing |
| 137 | if ($filePath === $outputZipFile) { |
| 138 | continue; |
| 139 | } |
| 140 | |
| 141 | // Only include entries inside the config directory |
| 142 | if (!str_contains($filePath, $configDir . DIRECTORY_SEPARATOR) && $filePath !== $configDir) { |
| 143 | continue; |
| 144 | } |
| 145 | |
| 146 | // Compute a relative path inside the archive |
| 147 | $relativePath = str_replace(search: $configDir . DIRECTORY_SEPARATOR, replace: '', subject: $filePath); |
| 148 | $relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR); |
| 149 | |
| 150 | if ($isDir) { |
| 151 | // Ensure directory entries end with a slash |
| 152 | $zipArchive->addEmptyDir(rtrim($relativePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
| 153 | } |
| 154 | |
| 155 | if ($isFile) { |
| 156 | $zipArchive->addFile($filePath, $relativePath); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | $zipArchive->close(); |
| 161 | |
| 162 | if (!file_exists($outputZipFile)) { |
| 163 | throw new Exception('Cannot store config backup file.'); |
| 164 | } |
| 165 | |
| 166 | // The archive holds the database credentials, so we return the path on the file |
| 167 | // system and never a URL: the backup is not meant to be downloaded over HTTP. |
| 168 | return $outputZipFile; |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * @throws Exception |
| 173 | */ |
| 174 | public function checkInitialRewriteBasePath(Request $request): bool |
| 175 | { |
| 176 | $basePath = $request->getBasePath(); |
| 177 | if (str_ends_with($basePath, 'update')) { |
| 178 | $basePath = substr($basePath, offset: 0, length: -strlen('update')); |
| 179 | } |
| 180 | |
| 181 | $htaccessPath = (string) PMF_ROOT_DIR . '/.htaccess'; |
| 182 | |
| 183 | $htaccessUpdater = new HtaccessUpdater(); |
| 184 | return $htaccessUpdater->updateRewriteBase($htaccessPath, $basePath); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * @throws Exception |
| 189 | * @throws \Exception |
| 190 | */ |
| 191 | public function applyUpdates(): bool |
| 192 | { |
| 193 | // Ensure the migration tracking table exists (only when not in dry-run mode) |
| 194 | if (!$this->dryRun) { |
| 195 | $this->migrationTracker->ensureTableExists(); |
| 196 | } |
| 197 | |
| 198 | // Get pending migrations based on a version |
| 199 | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 200 | |
| 201 | // Set dry-run mode |
| 202 | $this->migrationExecutor->setDryRun($this->dryRun); |
| 203 | |
| 204 | // Execute migrations |
| 205 | $this->migrationResults = $this->migrationExecutor->executeMigrations($pendingMigrations); |
| 206 | $allSucceeded = $this->allMigrationsSucceeded(); |
| 207 | |
| 208 | // If dry-run, collect all SQL queries for backward compatibility |
| 209 | if ($this->dryRun) { |
| 210 | $this->collectDryRunQueries($pendingMigrations); |
| 211 | return $allSucceeded; |
| 212 | } |
| 213 | |
| 214 | if (!$allSucceeded) { |
| 215 | return false; |
| 216 | } |
| 217 | |
| 218 | // Special handling for migrations that require immediate execution |
| 219 | $this->runPostMigrationTasks(); |
| 220 | $this->optimizeTables(); |
| 221 | $this->executeQueries(); |
| 222 | $this->updateVersion(); |
| 223 | |
| 224 | return true; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Returns true if all migrations succeeded. |
| 229 | */ |
| 230 | private function allMigrationsSucceeded(): bool |
| 231 | { |
| 232 | return array_all($this->migrationResults, static fn($result) => $result->isSuccess()); |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Collects SQL queries from migrations for dry-run backward compatibility. |
| 237 | * |
| 238 | * @param array<string, MigrationInterface> $migrations |
| 239 | */ |
| 240 | private function collectDryRunQueries(array $migrations): void |
| 241 | { |
| 242 | $report = $this->migrationExecutor->generateDryRunReport($migrations); |
| 243 | |
| 244 | foreach ($report['migrations'] as $migrationData) { |
| 245 | $operations = $migrationData['operations'] ?? []; |
| 246 | foreach (is_array($operations) ? $operations : [] as $operation) { |
| 247 | if (!is_array($operation) || ($operation['type'] ?? null) !== 'sql') { |
| 248 | continue; |
| 249 | } |
| 250 | |
| 251 | $this->dryRunQueries[] = (string) ($operation['query'] ?? ''); |
| 252 | } |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /** |
| 257 | * Run any post-migration tasks that can't be handled by the migration system. |
| 258 | */ |
| 259 | private function runPostMigrationTasks(): void |
| 260 | { |
| 261 | // Insert form inputs for 4.0.0-alpha.2 |
| 262 | if (version_compare(version1: $this->version, version2: '4.0.0-alpha.2', operator: '<')) { |
| 263 | $this->insertFormInputs(); |
| 264 | } |
| 265 | |
| 266 | // Handle admin log hash migration for 4.2.0-alpha |
| 267 | if (version_compare(version1: $this->version, version2: '4.2.0-alpha', operator: '<')) { |
| 268 | $this->migrateAdminLogHashes(); |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Insert form inputs (special handling required due to complex business logic). |
| 274 | */ |
| 275 | private function insertFormInputs(): void |
| 276 | { |
| 277 | try { |
| 278 | $forms = new Forms($this->configuration); |
| 279 | $seeder = new DefaultDataSeeder(); |
| 280 | foreach ($seeder->getFormInputs() as $input) { |
| 281 | $this->queries[] = $forms->getInsertQueries($input); |
| 282 | } |
| 283 | } catch (\Exception) { |
| 284 | // Form inputs may already exist |
| 285 | return; |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | public function optimizeTables(): void |
| 290 | { |
| 291 | switch (Database::getType()) { |
| 292 | case 'mysqli': |
| 293 | $tableNames = $this->configuration->getDb()->getTableNames(Database::getTablePrefix()); |
| 294 | foreach ($tableNames as $tableName) { |
| 295 | $this->queries[] = 'OPTIMIZE TABLE ' . $tableName; |
| 296 | } |
| 297 | |
| 298 | break; |
| 299 | case 'pgsql': |
| 300 | $this->queries[] = 'VACUUM ANALYZE;'; |
| 301 | break; |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Returns detailed dry-run results including all operation types. |
| 307 | * |
| 308 | * @return array<string, mixed> |
| 309 | */ |
| 310 | public function getDryRunResults(): array |
| 311 | { |
| 312 | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 313 | return $this->migrationExecutor->generateDryRunReport($pendingMigrations); |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Returns the formatted dry-run report as a string. |
| 318 | */ |
| 319 | public function getFormattedDryRunReport(): string |
| 320 | { |
| 321 | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 322 | $report = $this->migrationExecutor->generateDryRunReport($pendingMigrations); |
| 323 | return $this->migrationExecutor->formatDryRunReport($report); |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * @throws Exception |
| 328 | */ |
| 329 | private function executeQueries(): void |
| 330 | { |
| 331 | if ($this->dryRun) { |
| 332 | foreach ($this->queries as $query) { |
| 333 | $this->dryRunQueries[] = $query; |
| 334 | } |
| 335 | |
| 336 | return; |
| 337 | } |
| 338 | |
| 339 | foreach ($this->queries as $query) { |
| 340 | try { |
| 341 | $this->configuration->getDb()->query($query); |
| 342 | } catch (Exception $exception) { |
| 343 | throw new Exception($exception->getMessage()); |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | private function updateVersion(): void |
| 349 | { |
| 350 | $this->configuration->update(['main.currentApiVersion' => System::getApiVersion()]); |
| 351 | $this->configuration->update(['main.currentVersion' => System::getVersion()]); |
| 352 | } |
| 353 | |
| 354 | /** |
| 355 | * @throws RandomException |
| 356 | */ |
| 357 | private function getBackupFilename(): string |
| 358 | { |
| 359 | if ($this->backupFilename === null) { |
| 360 | $randomHash = bin2hex(random_bytes(4)); // 8-character hex string |
| 361 | $this->backupFilename = sprintf('phpmyfaq-config-backup.%s.%s.zip', date(format: 'Y-m-d'), $randomHash); |
| 362 | } |
| 363 | |
| 364 | return $this->backupFilename; |
| 365 | } |
| 366 | |
| 367 | private function migrateAdminLogHashes(): void |
| 368 | { |
| 369 | if (version_compare(version1: $this->version, version2: '4.2.0-alpha', operator: '<')) { |
| 370 | $repository = new AdminLogRepository($this->configuration); |
| 371 | |
| 372 | try { |
| 373 | $entries = $repository->getAll(); |
| 374 | $previousHash = null; |
| 375 | |
| 376 | foreach ($entries as $entity) { |
| 377 | if ($entity->getHash() !== null) { |
| 378 | continue; |
| 379 | } |
| 380 | |
| 381 | $entity->setPreviousHash($previousHash); |
| 382 | $hash = $entity->calculateHash(); |
| 383 | |
| 384 | // Execute UPDATE directly instead of adding to the queries array |
| 385 | $updateQuery = sprintf( |
| 386 | "UPDATE %sfaqadminlog SET hash = '%s', previous_hash = %s WHERE id = %d", |
| 387 | Database::getTablePrefix(), |
| 388 | $this->configuration->getDb()->escape($hash), |
| 389 | $previousHash !== null |
| 390 | ? "'" . $this->configuration->getDb()->escape($previousHash) . "'" |
| 391 | : 'NULL', |
| 392 | $entity->getId(), |
| 393 | ); |
| 394 | |
| 395 | $this->configuration->getDb()->query($updateQuery); |
| 396 | |
| 397 | $previousHash = $hash; |
| 398 | } |
| 399 | } catch (\Exception $e) { |
| 400 | $this->configuration->getLogger()->error('Admin log hash migration failed: ' . $e->getMessage()); |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | } |