Lines 45.91% 73 / 159
Methods 35.00% 7 / 20
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __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 / 7 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 / 10 0.00% 0 / 1 42
 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
 [phpMyFAQ\Setup\AbstractSetup] checkMinimumPhpVersion 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkMinimumUpdateVersion 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkMaintenanceMode 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\AbstractSetup] checkPreUpgrade 76.47% 13 / 17 0.00% 0 / 1 8.83
43class 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            // A failed update attempt may already have inserted the default form data,
279            // so the table is emptied before the defaults are inserted again.
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            // Form inputs may already exist
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     * Returns detailed dry-run results including all operation types.
311     *
312     * @return array<string, mixed>
313     */
314    public function getDryRunResults(): array
315    {
316        $pendingMigrations = $this->migrationRegistry->getPendingMigrations($this->version);
317        return $this->migrationExecutor->generateDryRunReport($pendingMigrations);
318    }
319
320    /**
321     * Returns the formatted dry-run report as a string.
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     * @throws Exception
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                // The failing statement is essential for diagnosing update problems
348                throw new Exception(sprintf('%s (Query: %s)', $throwable->getMessage(), $query));
349            }
350
351            // Some drivers (e.g. PostgreSQL) return false instead of throwing
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     * @throws RandomException
366     */
367    private function getBackupFilename(): string
368    {
369        if ($this->backupFilename === null) {
370            $randomHash = bin2hex(random_bytes(4)); // 8-character hex string
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                    // Execute UPDATE directly instead of adding to the queries array
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}

Inherited from phpMyFAQ\Setup\AbstractSetup

37    public function checkMinimumPhpVersion(): bool
38    {
39        return version_compare(version1: PHP_VERSION, version2: System::VERSION_MINIMUM_PHP) > 0;
40    }
45    public function checkMinimumUpdateVersion(string $version): bool
46    {
47        return version_compare(version1: $version, version2: '3.1.0', operator: '>');
48    }
53    public function checkMaintenanceMode(): bool
54    {
55        return (bool) Configuration::getConfigurationInstance()->get(item: 'main.maintenanceMode');
56    }
62    public function checkPreUpgrade(string $databaseType): void
63    {
64        $database = null;
65        if (!$this->checkMinimumPhpVersion()) {
66            throw new Exception(sprintf('Sorry, but you need PHP %s or later!', System::VERSION_MINIMUM_PHP));
67        }
68
69        if (
70            !is_readable((string) PMF_ROOT_DIR . '/content/core/config/database.php')
71            && !is_readable((string) PMF_ROOT_DIR . '/config/database.php')
72        ) {
73            throw new Exception(
74                'Sorry, but the database configuration file is not readable. Please check the permissions.',
75            );
76        }
77
78        if ('' !== $databaseType) {
79            $databaseFound = false;
80            foreach (array_keys($this->system->getSupportedDatabases()) as $database) {
81                if ($database !== $databaseType) {
82                    continue;
83                }
84
85                $databaseFound = true;
86                break;
87            }
88
89            if (!$databaseFound) {
90                throw new Exception(sprintf('Sorry, but the database %s is not supported!', ucfirst($databaseType)));
91            }
92        }
93    }