Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.54% covered (success)
95.54%
150 / 157
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
BackupController
95.54% covered (success)
95.54%
150 / 157
75.00% covered (warning)
75.00%
3 / 4
24
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
 index
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 export
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 restore
94.44% covered (success)
94.44%
119 / 126
0.00% covered (danger)
0.00%
0 / 1
18.06
1<?php
2
3/**
4 * The Administration Backup Controller
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 2024-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     2024-11-22
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration;
21
22use phpMyFAQ\Administration\Backup;
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Enums\AdminLogType;
25use phpMyFAQ\Enums\BackupType;
26use phpMyFAQ\Enums\PermissionType;
27use phpMyFAQ\Session\Token;
28use phpMyFAQ\Strings;
29use phpMyFAQ\Translation;
30use SodiumException;
31use Symfony\Component\HttpFoundation\File\UploadedFile;
32use Symfony\Component\HttpFoundation\HeaderUtils;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
36use Symfony\Component\Routing\Attribute\Route;
37use Twig\Error\LoaderError;
38
39use function in_array;
40
41final class BackupController extends AbstractAdministrationController
42{
43    public function __construct(
44        private readonly Backup $backup,
45    ) {
46        parent::__construct();
47    }
48
49    /**
50     * @throws Exception
51     * @throws LoaderError
52     * @throws \Exception
53     */
54    #[Route(path: '/backup', name: 'admin.backup', methods: ['GET'])]
55    public function index(Request $request): Response
56    {
57        $this->userHasPermission(PermissionType::BACKUP);
58
59        return $this->render(file: '@admin/backup/main.twig', context: [
60            ...$this->getHeader($request),
61            ...$this->getFooter(),
62            'adminHeaderBackup' => Translation::get(key: 'msgBackup'),
63            'adminBackupCardHeader' => Translation::get(key: 'ad_csv_head'),
64            'adminBackupCardBody' => Translation::get(key: 'ad_csv_make'),
65            'adminBackupLinkData' => Translation::get(key: 'ad_csv_linkdat'),
66            'adminBackupLinkLogs' => Translation::get(key: 'ad_csv_linklog'),
67            'csrfToken' => Token::getInstance($this->session)->getTokenString(page: 'restore'),
68            'adminRestoreCardHeader' => Translation::get(key: 'ad_csv_head2'),
69            'adminRestoreCardBody' => Translation::get(key: 'ad_csv_restore'),
70            'adminRestoreLabel' => Translation::get(key: 'ad_csv_file'),
71            'adminRestoreButton' => Translation::get(key: 'ad_csv_ok'),
72        ]);
73    }
74
75    #[Route(path: '/backup/export/{type}', name: 'admin.backup.export', methods: ['GET'])]
76    public function export(Request $request): Response
77    {
78        $this->userHasPermission(PermissionType::BACKUP);
79
80        $type = $request->attributes->get(key: 'type');
81        if (!in_array($type, ['content', 'logs'], strict: true)) {
82            return new Response(status: Response::HTTP_BAD_REQUEST);
83        }
84
85        $this->adminLog->log($this->currentUser, AdminLogType::BACKUP_EXPORT->value);
86
87        $backup = $this->backup;
88
89        $backupType = $type === 'content' ? BackupType::BACKUP_TYPE_DATA : BackupType::BACKUP_TYPE_LOGS;
90
91        try {
92            $result = $backup->export($backupType);
93        } catch (SodiumException) {
94            return new Response(status: Response::HTTP_INTERNAL_SERVER_ERROR);
95        }
96
97        $response = new Response($result->content);
98
99        $disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, urlencode($result->fileName));
100
101        $response->headers->set(key: 'Content-Type', values: 'application/octet-stream; charset=UTF-8');
102        $response->headers->set(key: 'Content-Disposition', values: $disposition);
103
104        return $response;
105    }
106
107    /**
108     * @throws \Exception
109     */
110    #[Route(path: '/backup/restore', name: 'admin.backup.restore', methods: ['POST'])]
111    public function restore(Request $request): Response
112    {
113        $this->userHasPermission(PermissionType::RESTORE);
114
115        $csrfToken = $request->query->get(key: 'csrf');
116        if (!Token::getInstance($this->session)->verifyToken(page: 'restore', requestToken: $csrfToken)) {
117            throw new UnauthorizedHttpException(challenge: 'Invalid CSRF token');
118        }
119
120        $this->adminLog->log($this->currentUser, AdminLogType::BACKUP_RESTORE->value);
121
122        $file = $request->files->get(key: 'userfile');
123
124        $templateVars = [
125            'adminHeaderRestore' => Translation::get(key: 'ad_csv_rest'),
126        ];
127
128        if (!$file instanceof UploadedFile) {
129            $templateVars = [
130                ...$templateVars,
131                'errorMessageUpload' => Translation::get(key: 'ad_csv_no'),
132                'errorMessageUploadDetails' => 'No file was uploaded. Please select a backup file to restore.',
133            ];
134
135            return $this->render(file: '@admin/backup/import.twig', context: [
136                ...$this->getHeader($request),
137                ...$this->getFooter(),
138                ...$templateVars,
139            ]);
140        }
141
142        if (!$file->isValid()) {
143            $errorMessage = match ($file->getError()) {
144                1 => 'The uploaded file exceeds the upload_max_filesize directive in php.ini.',
145                2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.',
146                3 => 'The uploaded file was only partially uploaded.',
147                4 => 'No file was uploaded.',
148                6 => 'Missing a temporary folder.',
149                7 => 'Failed to write file to disk.',
150                8 => 'A PHP extension stopped the file upload.',
151                default => 'Undefined error.',
152            };
153
154            $templateVars = [
155                ...$templateVars,
156                'errorMessageUpload' => Translation::get(key: 'ad_csv_no'),
157                'errorMessageUploadDetails' => $errorMessage,
158            ];
159
160            return $this->render(file: '@admin/backup/import.twig', context: [
161                ...$this->getHeader($request),
162                ...$this->getFooter(),
163                ...$templateVars,
164            ]);
165        }
166
167        $backup = $this->backup;
168
169        $fileName = $file->getClientOriginalName();
170
171        try {
172            $backupContent = file_get_contents($file->getPathname());
173            $verification = $backupContent !== false && $backup->verifyBackup($backupContent, $fileName);
174            if (!$verification) {
175                $templateVars = [
176                    ...$templateVars,
177                    'errorMessageNoVerification' => 'This file is not a verified backup file.',
178                ];
179
180                return $this->render(file: '@admin/backup/import.twig', context: [
181                    ...$this->getHeader($request),
182                    ...$this->getFooter(),
183                    ...$templateVars,
184                ]);
185            }
186        } catch (SodiumException) {
187            $templateVars = ['errorMessageNoVerification' => 'This file cannot be verified.'];
188
189            return $this->render(file: '@admin/backup/import.twig', context: [
190                ...$this->getHeader($request),
191                ...$this->getFooter(),
192                ...$templateVars,
193            ]);
194        }
195
196        try {
197            $parseResult = $backup->parseBackupFile($file->getPathname(), $this->configuration->getVersion());
198        } catch (Exception $exception) {
199            $templateVars = [
200                ...$templateVars,
201                'errorMessageImportNotPossible' => $exception->getMessage(),
202            ];
203
204            return $this->render(file: '@admin/backup/import.twig', context: [
205                ...$this->getHeader($request),
206                ...$this->getFooter(),
207                ...$templateVars,
208            ]);
209        }
210
211        if (!$parseResult->versionMatches) {
212            $templateVars = [
213                ...$templateVars,
214                'errorMessageVersionMisMatch' => sprintf(
215                    '%s (Version check failure: "%s" found, "%s" expected)',
216                    Translation::getString('ad_csv_no'),
217                    $parseResult->versionFound,
218                    $parseResult->versionExpected,
219                ),
220            ];
221
222            return $this->render(file: '@admin/backup/import.twig', context: [
223                ...$this->getHeader($request),
224                ...$this->getFooter(),
225                ...$templateVars,
226            ]);
227        }
228
229        $templateVars = [
230            ...$templateVars,
231            'prepareMessage' => Translation::get(key: 'ad_csv_prepare'),
232        ];
233
234        $executeResult = $backup->executeBackupQueries($parseResult->queries, $parseResult->tablePrefix);
235
236        $templateVars = [
237            ...$templateVars,
238            'processMessage' => Translation::get(key: 'ad_csv_process'),
239        ];
240
241        if ($executeResult->queriesFailed > 0) {
242            $templateVars = [
243                ...$templateVars,
244                'errorMessageQueryFailed' => sprintf(
245                    '<strong>Query</strong>: "%s" failed (Reason: %s)',
246                    Strings::htmlspecialchars((string) $executeResult->lastErrorQuery, ENT_QUOTES),
247                    $executeResult->lastErrorReason,
248                ),
249                'errorMessageImportNotPossible' => Translation::get(key: 'ad_csv_no'),
250            ];
251
252            return $this->render(file: '@admin/backup/import.twig', context: [
253                ...$this->getHeader($request),
254                ...$this->getFooter(),
255                ...$templateVars,
256            ]);
257        }
258
259        $templateVars = [
260            ...$templateVars,
261            'successMessage' => sprintf(
262                '%d %s %d %s',
263                $executeResult->queriesOk,
264                Translation::getString('ad_csv_of'),
265                $executeResult->queriesOk,
266                Translation::getString('ad_csv_suc'),
267            ),
268        ];
269
270        return $this->render(file: '@admin/backup/import.twig', context: [
271            ...$this->getHeader($request),
272            ...$this->getFooter(),
273            ...$templateVars,
274        ]);
275    }
276}