Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.02% covered (success)
95.02%
191 / 201
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Backup
95.02% covered (success)
95.02%
191 / 201
69.23% covered (warning)
69.23%
9 / 13
61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastBackupInfo
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
7.02
 createBackup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 verifyBackup
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 generateBackupQueries
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getBackupTableNames
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 getBackupHeader
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 createContentFolderBackup
80.95% covered (success)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
7.34
 export
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parseBackupFile
93.88% covered (success)
93.88%
46 / 49
0.00% covered (danger)
0.00%
0 / 1
14.04
 executeBackupQueries
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 getRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCompleteStatement
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
1<?php
2
3/**
4 * Provides methods for phpMyFAQ backups
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 2022-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     2022-10-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Administration;
21
22use DateTimeImmutable;
23use phpMyFAQ\Administration\Backup\BackupExecuteResult;
24use phpMyFAQ\Administration\Backup\BackupExportResult;
25use phpMyFAQ\Administration\Backup\BackupParseResult;
26use phpMyFAQ\Administration\Backup\BackupRepository;
27use phpMyFAQ\Configuration;
28use phpMyFAQ\Core\Exception;
29use phpMyFAQ\Database;
30use phpMyFAQ\Database\DatabaseHelper;
31use phpMyFAQ\Enums\BackupType;
32use phpMyFAQ\Strings;
33use RecursiveDirectoryIterator;
34use RecursiveIteratorIterator;
35use SodiumException;
36use ZipArchive;
37
38/**
39 * Class Backup
40 *
41 * @package phpMyFAQ
42 */
43readonly class Backup
44{
45    private BackupRepository $backupRepository;
46
47    /**
48     * Constructor.
49     */
50    public function __construct(
51        private Configuration $configuration,
52        private DatabaseHelper $databaseHelper,
53    ) {
54        $this->backupRepository = new BackupRepository($this->configuration);
55    }
56
57    /**
58     * Returns last backup date (formatted) and whether it's older than 30 days.
59     *
60     * @return array{lastBackupDate: ?string, isBackupOlderThan30Days: bool}
61     */
62    public function getLastBackupInfo(): array
63    {
64        $lastBackupDateFormatted = null;
65        try {
66            $backups = $this->getRepository()->getAll();
67            $lastBackup = $backups[0] ?? null;
68
69            if ($lastBackup === null || !property_exists($lastBackup, 'created') || $lastBackup->created === null) {
70                $isBackupOlderThan30Days = true;
71                return [
72                    'lastBackupDate' => $lastBackupDateFormatted,
73                    'isBackupOlderThan30Days' => $isBackupOlderThan30Days,
74                ];
75            }
76
77            $createdRaw = (string) $lastBackup->created;
78            $createdDate = DateTimeImmutable::createFromFormat(format: 'Y-m-d H:i:s', datetime: $createdRaw);
79            $createdDate = $createdDate === false ? null : $createdDate;
80            if ($createdDate === null) {
81                $isBackupOlderThan30Days = true;
82                return [
83                    'lastBackupDate' => $lastBackupDateFormatted,
84                    'isBackupOlderThan30Days' => $isBackupOlderThan30Days,
85                ];
86            }
87
88            $lastBackupDateFormatted = $createdDate->format(format: 'Y-m-d H:i:s');
89            $threshold = new DateTimeImmutable(datetime: '-30 days');
90            $isBackupOlderThan30Days = $createdDate < $threshold;
91        } catch (\Throwable) {
92            $isBackupOlderThan30Days = true;
93        }
94
95        return [
96            'lastBackupDate' => $lastBackupDateFormatted,
97            'isBackupOlderThan30Days' => $isBackupOlderThan30Days,
98        ];
99    }
100
101    /**
102     * @throws SodiumException
103     */
104    public function createBackup(string $backupType, string $backupFile): string
105    {
106        $backupDate = date(format: 'Y-m-d-H-i-s');
107
108        $fileNamePrefix = Database::getTablePrefix() !== '' ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq';
109        $fileName = sprintf('%s-%s.%s.sql', $fileNamePrefix, $backupType, $backupDate);
110
111        $authKey = sodium_crypto_auth_keygen();
112        $authCode = sodium_crypto_auth($backupFile, $authKey);
113
114        // persist backup metadata via repository
115        $this->getRepository()->add($fileName, sodium_bin2hex($authKey), sodium_bin2hex($authCode), $backupDate);
116
117        return $fileName;
118    }
119
120    /**
121     * @throws SodiumException
122     */
123    public function verifyBackup(string $backup, string $backupFileName): bool
124    {
125        $row = $this->getRepository()->findByFilename($backupFileName);
126        if ($row !== null) {
127            return sodium_crypto_auth_verify(
128                sodium_hex2bin((string) $row->authcode),
129                $backup,
130                sodium_hex2bin((string) $row->authkey),
131            );
132        }
133
134        return false;
135    }
136
137    public function generateBackupQueries(string $tableNames): string
138    {
139        $backup = implode(separator: "\r\n", array: $this->getBackupHeader($tableNames));
140
141        foreach (explode(separator: ' ', string: $tableNames) as $tableName) {
142            if ('' === $tableName) {
143                continue;
144            }
145
146            $backup .= implode(separator: "\r\n", array: $this->databaseHelper->buildInsertQueries(
147                'SELECT * FROM ' . $tableName,
148                $tableName,
149            ));
150        }
151
152        return $backup;
153    }
154
155    /**
156     * @throws \Exception
157     */
158    public function getBackupTableNames(BackupType $backupType): string
159    {
160        $tables = $this->configuration->getDb()->getTableNames(Database::getTablePrefix());
161        $tableNames = '';
162
163        switch ($backupType) {
164            case BackupType::BACKUP_TYPE_DATA:
165                foreach ($tables as $table) {
166                    if (Database::getTablePrefix() . 'faqadminlog' === trim((string) $table)) {
167                        continue;
168                    }
169
170                    if (Database::getTablePrefix() . 'faqsessions' === trim((string) $table)) {
171                        continue;
172                    }
173
174                    $tableNames .= $table . ' ';
175                }
176
177                break;
178            case BackupType::BACKUP_TYPE_LOGS:
179                foreach ($tables as $table) {
180                    if (
181                        Database::getTablePrefix() . 'faqadminlog' !== trim((string) $table)
182                        && Database::getTablePrefix() . 'faqsessions' !== trim((string) $table)
183                    ) {
184                        continue;
185                    }
186
187                    $tableNames .= $table . ' ';
188                }
189
190                break;
191            case BackupType::BACKUP_TYPE_CONTENT:
192                throw new \Exception(message: 'To be implemented');
193        }
194
195        return $tableNames;
196    }
197
198    /**
199     * Returns the backup file header
200     * @return string[]
201     */
202    private function getBackupHeader(string $tableNames): array
203    {
204        return [
205            sprintf(
206                '-- pmf%s: %s',
207                substr(string: $this->configuration->getVersion(), offset: 0, length: 3),
208                $tableNames,
209            ),
210            '-- DO NOT REMOVE THE FIRST LINE!',
211            '-- pmftableprefix: ' . Database::getTablePrefix(),
212            '-- DO NOT REMOVE THE LINES ABOVE!',
213            '-- Otherwise this backup will be broken.',
214        ];
215    }
216
217    /**
218     * Creates a ZipArchive of the content-folder in a temporary, non-web-accessible location.
219     * The caller is responsible for removing the returned file.
220     *
221     * @throws \Exception
222     */
223    public function createContentFolderBackup(): string
224    {
225        $zipFile = tempnam(directory: sys_get_temp_dir(), prefix: 'pmf_content_backup_');
226        if ($zipFile === false) {
227            throw new Exception(message: 'Error while creating temporary file for ZipArchive');
228        }
229
230        $zipArchive = new ZipArchive();
231        if (true !== $zipArchive->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
232            unlink($zipFile);
233            throw new Exception(message: 'Error while creating ZipArchive');
234        }
235
236        $files = new RecursiveIteratorIterator(
237            new RecursiveDirectoryIterator(PMF_CONTENT_DIR),
238            RecursiveIteratorIterator::LEAVES_ONLY,
239        );
240
241        foreach ($files as $file) {
242            if (!$file instanceof \SplFileInfo || $file->isDir()) {
243                continue;
244            }
245
246            $filePath = $file->getRealPath();
247            if ($filePath === false) {
248                continue;
249            }
250
251            $relativePath = substr($filePath, strlen((string) PMF_CONTENT_DIR) + 1);
252            $zipArchive->addFile($filePath, $relativePath);
253        }
254
255        $zipArchive->close();
256
257        return $zipFile;
258    }
259
260    /**
261     * Creates a backup for the given type and returns filename + content.
262     *
263     * @throws SodiumException
264     * @throws \Exception
265     *
266     */
267    public function export(BackupType $backupType): BackupExportResult
268    {
269        $tableNames = $this->getBackupTableNames($backupType);
270
271        $backupContent = $this->generateBackupQueries($tableNames);
272
273        $fileName = $this->createBackup($backupType->value, $backupContent);
274
275        return new BackupExportResult($fileName, $backupContent);
276    }
277
278    /**
279     * Parses a backup file, checks the version and creates SQL queries + table prefix.
280     *
281     * @throws Exception
282     */
283    public function parseBackupFile(string $filePath, string $currentVersion): BackupParseResult
284    {
285        $handle = fopen($filePath, mode: 'r');
286        if (false === $handle) {
287            throw new Exception(message: sprintf('Cannot open backup file "%s".', $filePath));
288        }
289
290        $firstLine = fgets($handle, length: 65_536);
291        if (false === $firstLine) {
292            fclose($handle);
293            throw new Exception(message: 'Empty backup file.');
294        }
295
296        $versionFound = Strings::substr(string: $firstLine, start: 0, length: 9);
297
298        $versionExpected = '-- pmf' . substr(string: $currentVersion, offset: 0, length: 3);
299
300        // Tabellen aus der ersten Zeile extrahieren
301        $tablesLine = trim(Strings::substr(string: $firstLine, start: 11));
302        $tables = explode(separator: ' ', string: $tablesLine);
303
304        $queries = [];
305        foreach ($tables as $table) {
306            if ('' === $table) {
307                continue;
308            }
309
310            $queries[] = sprintf('DELETE FROM %s', $table);
311        }
312
313        $tablePrefix = '';
314        $currentQuery = '';
315
316        while (true) {
317            $line = fgets($handle, length: 65_536);
318            if ($line === false) {
319                break;
320            }
321
322            $trimmedLine = trim($line);
323            $backupPrefixPattern = '-- pmftableprefix:';
324            $backupPrefixPatternLength = Strings::strlen($backupPrefixPattern);
325
326            if (
327                Strings::substr(string: $trimmedLine, start: 0, length: $backupPrefixPatternLength)
328                === $backupPrefixPattern
329            ) {
330                $tablePrefix = trim(Strings::substr($trimmedLine, $backupPrefixPatternLength));
331
332                continue;
333            }
334
335            // Skip comment lines (-- or # at start of line)
336            if (
337                Strings::substr(string: $trimmedLine, start: 0, length: 2) === '--'
338                || Strings::substr(string: $trimmedLine, start: 0, length: 1) === '#'
339            ) {
340                continue;
341            }
342
343            // Skip empty lines
344            if ($trimmedLine === '') {
345                continue;
346            }
347
348            // Accumulate lines to handle multi-line SQL statements
349            $currentQuery .= $line;
350
351            // Check if statement is complete (ends with ; and quotes are balanced)
352            if ($this->isCompleteStatement($currentQuery)) {
353                $queries[] = trim(rtrim(trim($currentQuery), characters: ';'));
354                $currentQuery = '';
355            }
356        }
357
358        // Handle any remaining incomplete query
359        $remainingQuery = trim($currentQuery);
360        if ($remainingQuery !== '' && $remainingQuery !== ';') {
361            $queries[] = rtrim($remainingQuery, characters: ';');
362        }
363
364        fclose($handle);
365
366        $versionMatches = $versionFound === $versionExpected;
367
368        return new BackupParseResult(
369            versionMatches: $versionMatches,
370            versionFound: $versionFound,
371            versionExpected: $versionExpected,
372            queries: $queries,
373            tablePrefix: $tablePrefix,
374        );
375    }
376
377    /**
378     * Executes the given backup queries with the correct table prefix.
379     */
380    public function executeBackupQueries(array $queries, string $tablePrefix): BackupExecuteResult
381    {
382        $databaseDriver = $this->configuration->getDb();
383
384        $ok = 0;
385        $failed = 0;
386        $lastErrorQuery = null;
387        $lastErrorReason = null;
388
389        foreach ($queries as $query) {
390            $alignedQuery = $this->databaseHelper::alignTablePrefix(
391                (string) $query,
392                $tablePrefix,
393                Database::getTablePrefix(),
394            );
395
396            $result = $databaseDriver->query($alignedQuery);
397            if (!$result) {
398                ++$failed;
399                $lastErrorQuery = $alignedQuery;
400                $lastErrorReason = $databaseDriver->error();
401
402                continue;
403            }
404
405            ++$ok;
406        }
407
408        return new BackupExecuteResult(
409            queriesOk: $ok,
410            queriesFailed: $failed,
411            lastErrorQuery: $lastErrorQuery,
412            lastErrorReason: $lastErrorReason,
413        );
414    }
415
416    private function getRepository(): BackupRepository
417    {
418        return $this->backupRepository;
419    }
420
421    /**
422     * Checks if a SQL statement is complete (ends with semicolon outside of string literals).
423     */
424    private function isCompleteStatement(string $query): bool
425    {
426        $trimmed = rtrim($query);
427        if (!str_ends_with($trimmed, ';')) {
428            return false;
429        }
430
431        // Count unescaped single quotes to determine if we're inside a string literal
432        // We need to account for escaped quotes: \' and '' (SQL escape)
433        $inString = false;
434        $length = strlen($trimmed);
435
436        for ($i = 0; $i < $length; $i++) {
437            $char = $trimmed[$i];
438
439            if ($char !== "'") {
440                continue;
441            }
442
443            if (!$inString) {
444                $inString = true;
445                continue;
446            }
447
448            // Inside a string - check for escaped quotes
449            // Double single quote escape ('')
450            if (($i + 1) < $length && $trimmed[$i + 1] === "'") {
451                $i++;
452                continue;
453            }
454
455            // Backslash escape (\')
456            if ($i > 0 && $trimmed[$i - 1] === '\\') {
457                continue;
458            }
459
460            // End of string literal
461            $inString = false;
462        }
463
464        // Statement is complete if we're not inside a string literal
465        return !$inString;
466    }
467}