| 43 | | class Update extends AbstractSetup |
| 44 | | { |
| 45 | | public string $version { |
| 46 | | set { |
| 47 | | $this->version = $value; |
| 48 | | } |
| 49 | | } |
| 50 | | |
| 51 | | |
| 52 | | private array $queries = []; |
| 53 | | |
| 54 | | public bool $dryRun = false { |
| 55 | | set { |
| 56 | | $this->dryRun = $value; |
| 57 | | } |
| 58 | | } |
| 59 | | |
| 60 | | |
| 61 | | public array $dryRunQueries = []; |
| 62 | | |
| 63 | | |
| 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 | | |
| 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 | | |
| 101 | | |
| 102 | | |
| 103 | | |
| 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 | | |
| 137 | | if ($filePath === $outputZipFile) { |
| 138 | | continue; |
| 139 | | } |
| 140 | | |
| 141 | | |
| 142 | | if (!str_contains($filePath, $configDir . DIRECTORY_SEPARATOR) && $filePath !== $configDir) { |
| 143 | | continue; |
| 144 | | } |
| 145 | | |
| 146 | | |
| 147 | | $relativePath = str_replace(search: $configDir . DIRECTORY_SEPARATOR, replace: '', subject: $filePath); |
| 148 | | $relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR); |
| 149 | | |
| 150 | | if ($isDir) { |
| 151 | | |
| 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 | | |
| 167 | | |
| 168 | | return $outputZipFile; |
| 169 | | } |
| 170 | | |
| 171 | | |
| 172 | | |
| 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 | | |
| 189 | | |
| 190 | | |
| 191 | | public function applyUpdates(): bool |
| 192 | | { |
| 193 | | |
| 194 | | if (!$this->dryRun) { |
| 195 | | $this->migrationTracker->ensureTableExists(); |
| 196 | | } |
| 197 | | |
| 198 | | |
| 199 | | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 200 | | |
| 201 | | |
| 202 | | $this->migrationExecutor->setDryRun($this->dryRun); |
| 203 | | |
| 204 | | |
| 205 | | $this->migrationResults = $this->migrationExecutor->executeMigrations($pendingMigrations); |
| 206 | | $allSucceeded = $this->allMigrationsSucceeded(); |
| 207 | | |
| 208 | | |
| 209 | | if ($this->dryRun) { |
| 210 | | $this->collectDryRunQueries($pendingMigrations); |
| 211 | | return $allSucceeded; |
| 212 | | } |
| 213 | | |
| 214 | | if (!$allSucceeded) { |
| 215 | | return false; |
| 216 | | } |
| 217 | | |
| 218 | | |
| 219 | | $this->runPostMigrationTasks(); |
| 220 | | $this->optimizeTables(); |
| 221 | | $this->executeQueries(); |
| 222 | | $this->updateVersion(); |
| 223 | | |
| 224 | | return true; |
| 225 | | } |
| 226 | | |
| 227 | | |
| 228 | | |
| 229 | | |
| 230 | | private function allMigrationsSucceeded(): bool |
| 231 | | { |
| 232 | | return array_all($this->migrationResults, static fn($result) => $result->isSuccess()); |
| 233 | | } |
| 234 | | |
| 235 | | |
| 236 | | |
| 237 | | |
| 238 | | |
| 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 | | |
| 258 | | |
| 259 | | private function runPostMigrationTasks(): void |
| 260 | | { |
| 261 | | |
| 262 | | if (version_compare(version1: $this->version, version2: '4.0.0-alpha.2', operator: '<')) { |
| 263 | | $this->insertFormInputs(); |
| 264 | | } |
| 265 | | |
| 266 | | |
| 267 | | if (version_compare(version1: $this->version, version2: '4.2.0-alpha', operator: '<')) { |
| 268 | | $this->migrateAdminLogHashes(); |
| 269 | | } |
| 270 | | } |
| 271 | | |
| 272 | | |
| 273 | | |
| 274 | | |
| 275 | | private function insertFormInputs(): void |
| 276 | | { |
| 277 | | try { |
| 278 | | |
| 279 | | |
| 280 | | $this->queries[] = sprintf('DELETE FROM %sfaqforms', Database::getTablePrefix()); |
| 281 | | |
| 282 | | $forms = new Forms($this->configuration); |
| 283 | | $seeder = new DefaultDataSeeder(); |
| 284 | | foreach ($seeder->getFormInputs() as $input) { |
| 285 | | $this->queries[] = $forms->getInsertQueries($input); |
| 286 | | } |
| 287 | | } catch (\Exception) { |
| 288 | | |
| 289 | | return; |
| 290 | | } |
| 291 | | } |
| 292 | | |
| 293 | | public function optimizeTables(): void |
| 294 | | { |
| 295 | | switch (Database::getType()) { |
| 296 | | case 'mysqli': |
| 297 | | $tableNames = $this->configuration->getDb()->getTableNames(Database::getTablePrefix()); |
| 298 | | foreach ($tableNames as $tableName) { |
| 299 | | $this->queries[] = 'OPTIMIZE TABLE ' . $tableName; |
| 300 | | } |
| 301 | | |
| 302 | | break; |
| 303 | | case 'pgsql': |
| 304 | | $this->queries[] = 'VACUUM ANALYZE;'; |
| 305 | | break; |
| 306 | | } |
| 307 | | } |
| 308 | | |
| 309 | | |
| 310 | | |
| 311 | | |
| 312 | | |
| 313 | | |
| 314 | | public function getDryRunResults(): array |
| 315 | | { |
| 316 | | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 317 | | return $this->migrationExecutor->generateDryRunReport($pendingMigrations); |
| 318 | | } |
| 319 | | |
| 320 | | |
| 321 | | |
| 322 | | |
| 323 | | public function getFormattedDryRunReport(): string |
| 324 | | { |
| 325 | | $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version); |
| 326 | | $report = $this->migrationExecutor->generateDryRunReport($pendingMigrations); |
| 327 | | return $this->migrationExecutor->formatDryRunReport($report); |
| 328 | | } |
| 329 | | |
| 330 | | |
| 331 | | |
| 332 | | |
| 333 | | private function executeQueries(): void |
| 334 | | { |
| 335 | | if ($this->dryRun) { |
| 336 | | foreach ($this->queries as $query) { |
| 337 | | $this->dryRunQueries[] = $query; |
| 338 | | } |
| 339 | | |
| 340 | | return; |
| 341 | | } |
| 342 | | |
| 343 | | foreach ($this->queries as $query) { |
| 344 | | try { |
| 345 | | $result = $this->configuration->getDb()->query($query); |
| 346 | | } catch (\Throwable $throwable) { |
| 347 | | |
| 348 | | throw new Exception(sprintf('%s (Query: %s)', $throwable->getMessage(), $query)); |
| 349 | | } |
| 350 | | |
| 351 | | |
| 352 | | if ($result === false) { |
| 353 | | throw new Exception(sprintf('%s (Query: %s)', $this->configuration->getDb()->error(), $query)); |
| 354 | | } |
| 355 | | } |
| 356 | | } |
| 357 | | |
| 358 | | private function updateVersion(): void |
| 359 | | { |
| 360 | | $this->configuration->update(['main.currentApiVersion' => System::getApiVersion()]); |
| 361 | | $this->configuration->update(['main.currentVersion' => System::getVersion()]); |
| 362 | | } |
| 363 | | |
| 364 | | |
| 365 | | |
| 366 | | |
| 367 | | private function getBackupFilename(): string |
| 368 | | { |
| 369 | | if ($this->backupFilename === null) { |
| 370 | | $randomHash = bin2hex(random_bytes(4)); |
| 371 | | $this->backupFilename = sprintf('phpmyfaq-config-backup.%s.%s.zip', date(format: 'Y-m-d'), $randomHash); |
| 372 | | } |
| 373 | | |
| 374 | | return $this->backupFilename; |
| 375 | | } |
| 376 | | |
| 377 | | private function migrateAdminLogHashes(): void |
| 378 | | { |
| 379 | | if (version_compare(version1: $this->version, version2: '4.2.0-alpha', operator: '<')) { |
| 380 | | $repository = new AdminLogRepository($this->configuration); |
| 381 | | |
| 382 | | try { |
| 383 | | $entries = $repository->getAll(); |
| 384 | | $previousHash = null; |
| 385 | | |
| 386 | | foreach ($entries as $entity) { |
| 387 | | if ($entity->getHash() !== null) { |
| 388 | | continue; |
| 389 | | } |
| 390 | | |
| 391 | | $entity->setPreviousHash($previousHash); |
| 392 | | $hash = $entity->calculateHash(); |
| 393 | | |
| 394 | | |
| 395 | | $updateQuery = sprintf( |
| 396 | | "UPDATE %sfaqadminlog SET hash = '%s', previous_hash = %s WHERE id = %d", |
| 397 | | Database::getTablePrefix(), |
| 398 | | $this->configuration->getDb()->escape($hash), |
| 399 | | $previousHash !== null |
| 400 | | ? "'" . $this->configuration->getDb()->escape($previousHash) . "'" |
| 401 | | : 'NULL', |
| 402 | | $entity->getId(), |
| 403 | | ); |
| 404 | | |
| 405 | | $this->configuration->getDb()->query($updateQuery); |
| 406 | | |
| 407 | | $previousHash = $hash; |
| 408 | | } |
| 409 | | } catch (\Exception $e) { |
| 410 | | $this->configuration->getLogger()->error('Admin log hash migration failed: ' . $e->getMessage()); |
| 411 | | } |
| 412 | | } |
| 413 | | } |
| 414 | | } |