Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.91% covered (danger)
41.91%
57 / 136
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Update
41.91% covered (danger)
41.91%
57 / 136
25.00% covered (danger)
25.00%
4 / 16
647.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 isConfigTableNotAvailable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createConfigBackup
88.57% covered (success)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
14.29
 checkInitialRewriteBasePath
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 applyUpdates
50.00% covered (danger)
50.00%
8 / 16
0.00% covered (danger)
0.00%
0 / 1
6.00
 allMigrationsSucceeded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collectDryRunQueries
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
19.12
 runPostMigrationTasks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 insertFormInputs
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 optimizeTables
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getDryRunResults
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFormattedDryRunReport
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 executeQueries
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 updateVersion
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getBackupFilename
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 migrateAdminLogHashes
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
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
18declare(strict_types=1);
19
20namespace phpMyFAQ\Setup;
21
22use phpMyFAQ\Administration\AdminLogRepository;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Database;
26use phpMyFAQ\Database\DatabaseDriver;
27use phpMyFAQ\Filesystem\Filesystem;
28use phpMyFAQ\Forms;
29use phpMyFAQ\Setup\Installation\DefaultDataSeeder;
30use phpMyFAQ\Setup\Migration\MigrationExecutor;
31use phpMyFAQ\Setup\Migration\MigrationInterface;
32use phpMyFAQ\Setup\Migration\MigrationRegistry;
33use phpMyFAQ\Setup\Migration\MigrationResult;
34use phpMyFAQ\Setup\Migration\MigrationTracker;
35use phpMyFAQ\System;
36use Random\RandomException;
37use RecursiveDirectoryIterator;
38use RecursiveIteratorIterator;
39use SplFileInfo;
40use Symfony\Component\HttpFoundation\Request;
41use ZipArchive;
42
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            $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}