Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.61% covered (success)
82.61%
38 / 46
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
BackupController
82.61% covered (success)
82.61%
38 / 46
50.00% covered (danger)
50.00%
1 / 2
10.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 download
81.40% covered (success)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
8.41
1<?php
2
3/**
4 * The Backup Controller for the REST API
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-03-24
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Api;
21
22use Exception;
23use OpenApi\Attributes as OA;
24use phpMyFAQ\Administration\Backup;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Database\DatabaseHelper;
27use phpMyFAQ\Enums\BackupType;
28use phpMyFAQ\Enums\PermissionType;
29use phpMyFAQ\Filter;
30use SodiumException;
31use Symfony\Component\HttpFoundation\BinaryFileResponse;
32use Symfony\Component\HttpFoundation\HeaderUtils;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\HttpFoundation\ResponseHeaderBag;
36use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
37use Symfony\Component\Routing\Attribute\Route;
38
39final class BackupController extends AbstractController
40{
41    public function __construct()
42    {
43        parent::__construct();
44
45        if (!$this->isApiEnabled()) {
46            throw new UnauthorizedHttpException(challenge: 'API is not enabled');
47        }
48    }
49
50    /**
51     * @throws Exception
52     */
53    #[OA\Get(path: '/api/v4.0/backup/{type}', operationId: 'createBackup', tags: ['Endpoints with Authentication'])]
54    #[OA\Header(
55        header: 'Accept-Language',
56        description: 'The language code for the login.',
57        schema: new OA\Schema(type: 'string'),
58    )]
59    #[OA\Header(
60        header: 'x-pmf-token',
61        description: 'phpMyFAQ client API Token, generated in admin backend',
62        schema: new OA\Schema(type: 'string'),
63    )]
64    #[OA\Parameter(
65        name: 'type',
66        description: 'The backup type. Can be "data", "logs" or "content".',
67        in: 'path',
68        required: true,
69        schema: new OA\Schema(type: 'string'),
70    )]
71    #[OA\Response(
72        response: 200,
73        description: 'The current backup as a file or a ZipArchive in case of "content"-type.',
74        content: new OA\MediaType(
75            mediaType: 'application/octet-stream or application/zip',
76            schema: new OA\Schema(type: 'string'),
77        ),
78    )]
79    #[OA\Response(
80        response: 400,
81        description: 'If the backup type is wrong or an internal error occurred',
82        content: new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string')),
83    )]
84    #[OA\Response(
85        response: 401,
86        description: 'If the user is not authenticated and/or does not have sufficient permissions.',
87    )]
88    #[Route(path: 'v4.0/backup/{type}', name: 'api.backup', methods: ['GET'])]
89    public function download(Request $request): Response
90    {
91        $this->userHasPermission(PermissionType::BACKUP);
92
93        $type = Filter::filterVar($request->attributes->get(key: 'type'), FILTER_SANITIZE_SPECIAL_CHARS);
94
95        switch ($type) {
96            case 'data':
97                $backupType = BackupType::BACKUP_TYPE_DATA;
98                break;
99            case 'logs':
100                $backupType = BackupType::BACKUP_TYPE_LOGS;
101                break;
102            case 'content':
103                $backupType = BackupType::BACKUP_TYPE_CONTENT;
104                break;
105            default:
106                return new Response(content: 'Invalid backup type.', status: Response::HTTP_BAD_REQUEST);
107        }
108
109        $databaseHelper = new DatabaseHelper($this->configuration);
110        $backup = new Backup($this->configuration, $databaseHelper);
111
112        // Create ZipArchive of the content-folder
113        if ($backupType === BackupType::BACKUP_TYPE_CONTENT) {
114            // The archive lives outside the document root and is removed once it has been streamed
115            $backupFile = $backup->createContentFolderBackup();
116
117            try {
118                $backupFileName = sprintf('content_%s.zip', date('dmY_H-i'));
119
120                $response = new BinaryFileResponse($backupFile);
121                $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $backupFileName);
122                $response->headers->set(key: 'Content-Type', values: 'application/zip');
123                $response->deleteFileAfterSend();
124                $response->setStatusCode(Response::HTTP_OK);
125            } catch (\Throwable $throwable) {
126                unlink($backupFile);
127                throw $throwable;
128            }
129
130            return $response;
131        }
132
133        $tableNames = $backup->getBackupTableNames($backupType);
134        $backupQueries = $backup->generateBackupQueries($tableNames);
135
136        try {
137            $backupFileName = $backup->createBackup($backupType->value, $backupQueries);
138
139            $response = new Response($backupQueries);
140
141            $disposition = HeaderUtils::makeDisposition(
142                HeaderUtils::DISPOSITION_ATTACHMENT,
143                urlencode($backupFileName),
144            );
145
146            $response->headers->set(key: 'Content-Type', values: 'application/octet-stream');
147            $response->headers->set(key: 'Content-Disposition', values: $disposition);
148            $response->setStatusCode(Response::HTTP_OK);
149            return $response;
150        } catch (SodiumException) {
151            return new Response(
152                content: 'An error occurred while creating the backup.',
153                status: Response::HTTP_INTERNAL_SERVER_ERROR,
154            );
155        }
156    }
157}