Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.63% covered (success)
92.63%
88 / 95
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SetupController
92.63% covered (success)
92.63%
88 / 95
80.00% covered (success)
80.00%
8 / 10
30.36
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
 isSecured
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 backup
80.95% covered (success)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
6.25
 updateDatabase
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 createUpdate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 denyUnauthorizedRequest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 isAuthorizedForUpdate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isAuthenticatedAdministrator
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 getUpdateToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Setup 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 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-10-17
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use Closure;
23use phpMyFAQ\Controller\AbstractController;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Database;
26use phpMyFAQ\Enums\PermissionType;
27use phpMyFAQ\Filter;
28use phpMyFAQ\Setup\Update;
29use phpMyFAQ\Setup\UpdateToken;
30use phpMyFAQ\System;
31use Symfony\Component\HttpFoundation\JsonResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35
36final class SetupController extends AbstractController
37{
38    public const string TOKEN_HEADER = 'x-pmf-update-token';
39
40    /**
41     * @param ?Closure(System, \phpMyFAQ\Configuration): Update $updateFactory
42     */
43    public function __construct(
44        private readonly ?Closure $updateFactory = null,
45    ) {
46        parent::__construct();
47    }
48
49    /**
50     * Setup endpoints cannot rely on the regular login, so we override the security
51     * check from AbstractController. Every endpoint checks for itself that the caller
52     * is allowed to run the update, see isAuthorizedForUpdate().
53     */
54    protected function isSecured(): void
55    {
56        // No-op: authorization is handled per endpoint by isAuthorizedForUpdate()
57    }
58
59    #[Route(path: 'setup/check', name: 'api.private.setup.check', methods: ['POST'])]
60    public function check(Request $request): JsonResponse
61    {
62        $unauthorized = $this->denyUnauthorizedRequest($request);
63        if ($unauthorized instanceof JsonResponse) {
64            return $unauthorized;
65        }
66
67        if (trim($request->getContent()) === '') {
68            return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
69        }
70
71        $installedVersion = Filter::filterVar($request->getContent(), FILTER_SANITIZE_SPECIAL_CHARS, '');
72
73        $update = $this->createUpdate($installedVersion);
74
75        if (!$update->checkMaintenanceMode()) {
76            return $this->json([
77                'message' => 'Maintenance mode is not enabled. Please enable it first.',
78            ], Response::HTTP_CONFLICT);
79        }
80
81        if (!$update->checkMinimumUpdateVersion($installedVersion)) {
82            $message =
83                'Your installed version is phpMyFAQ '
84                . $installedVersion
85                . '. Please update to at least phpMyFAQ 3.0 first.';
86            return $this->json(['message' => $message], Response::HTTP_CONFLICT);
87        }
88
89        // Check hard requirements
90        try {
91            $update->checkPreUpgrade(Database::getType());
92        } catch (Exception $exception) {
93            return $this->json(['message' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
94        }
95
96        return $this->json(['message' => 'Installation check successful'], Response::HTTP_OK);
97    }
98
99    #[Route(path: 'setup/backup', name: 'api.private.setup.backup', methods: ['POST'])]
100    public function backup(Request $request): JsonResponse
101    {
102        $unauthorized = $this->denyUnauthorizedRequest($request);
103        if ($unauthorized instanceof JsonResponse) {
104            return $unauthorized;
105        }
106
107        if (trim($request->getContent()) === '') {
108            return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
109        }
110
111        $update = $this->createUpdate($this->configuration->getVersion());
112
113        if (!$update->checkMaintenanceMode()) {
114            return $this->json([
115                'message' => 'Maintenance mode is not enabled. Please enable it first.',
116            ], Response::HTTP_CONFLICT);
117        }
118
119        $installedVersion = Filter::filterVar($request->getContent(), FILTER_SANITIZE_SPECIAL_CHARS, '');
120
121        $configPath = (string) PMF_ROOT_DIR . '/content/core/config';
122        if (version_compare(version1: $installedVersion, version2: '4.0.0-alpha', operator: '<')) {
123            $configPath = (string) PMF_ROOT_DIR . '/config';
124        }
125
126        try {
127            $pathToBackup = $update->createConfigBackup($configPath);
128        } catch (Exception $exception) {
129            return $this->json(['message' => $exception->getMessage()], Response::HTTP_BAD_GATEWAY);
130        }
131
132        // The archive contains the database credentials, so we only report its name
133        // and never a URL that could be used to download it.
134        return $this->json([
135            'message' => 'Backup successful',
136            'backupFile' => basename($pathToBackup),
137        ], Response::HTTP_OK);
138    }
139
140    #[Route(path: 'setup/update-database', name: 'api.private.setup.update-database', methods: ['POST'])]
141    public function updateDatabase(Request $request): JsonResponse
142    {
143        $unauthorized = $this->denyUnauthorizedRequest($request);
144        if ($unauthorized instanceof JsonResponse) {
145            return $unauthorized;
146        }
147
148        if (trim($request->getContent()) === '') {
149            return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
150        }
151
152        $installedVersion = Filter::filterVar($request->getContent(), FILTER_SANITIZE_SPECIAL_CHARS, '');
153
154        $update = $this->createUpdate($installedVersion);
155
156        if (!$update->checkMaintenanceMode()) {
157            return $this->json([
158                'message' => 'Maintenance mode is not enabled. Please enable it first.',
159            ], Response::HTTP_CONFLICT);
160        }
161
162        try {
163            if ($update->applyUpdates()) {
164                $this->configuration->set(key: 'main.maintenanceMode', value: 'false');
165                // The update is done, so the token must not authorize another run
166                $this->getUpdateToken()->delete();
167                return new JsonResponse(['success' => 'Database successfully updated.'], Response::HTTP_OK);
168            }
169
170            return new JsonResponse(['error' => 'Update database failed.'], Response::HTTP_BAD_GATEWAY);
171        } catch (Exception|\Exception $exception) {
172            return new JsonResponse([
173                'error' => 'Update database failed: ' . $exception->getMessage(),
174            ], Response::HTTP_BAD_GATEWAY);
175        }
176    }
177
178    private function createUpdate(string $version): Update
179    {
180        $system = new System();
181        $update = ($this->updateFactory
182        ?? static fn(System $system, \phpMyFAQ\Configuration $configuration): Update => new Update(
183            $system,
184            $configuration,
185        ))($system, $this->configuration);
186        $update->version = $version;
187
188        return $update;
189    }
190
191    /**
192     * Returns a 401 response if the caller is not allowed to run the update, otherwise null.
193     */
194    private function denyUnauthorizedRequest(Request $request): ?JsonResponse
195    {
196        if ($this->isAuthorizedForUpdate($request)) {
197            return null;
198        }
199
200        return $this->json([
201            'message' =>
202                'You are not allowed to run the update. Please log in as an administrator or provide the '
203                    . 'update token from '
204                    . UpdateToken::TOKEN_FILENAME
205                    . ' in the configuration directory.',
206        ], Response::HTTP_UNAUTHORIZED);
207    }
208
209    /**
210     * The update may run either for a logged-in administrator, or for someone who can
211     * prove access to the file system of the server by sending the update token. The
212     * second way is needed because the login can be broken until the migration has run.
213     */
214    private function isAuthorizedForUpdate(Request $request): bool
215    {
216        if ($this->isAuthenticatedAdministrator()) {
217            return true;
218        }
219
220        return $this->getUpdateToken()->isValid($request->headers->get(self::TOKEN_HEADER));
221    }
222
223    private function isAuthenticatedAdministrator(): bool
224    {
225        try {
226            if (!$this->currentUser->isLoggedIn()) {
227                return false;
228            }
229
230            if ($this->currentUser->isSuperAdmin()) {
231                return true;
232            }
233
234            return $this->currentUser->perm->hasPermission(
235                $this->currentUser->getUserId(),
236                PermissionType::CONFIGURATION_EDIT->value,
237            );
238        } catch (\Throwable) {
239            // A database that is not migrated yet can break the permission lookup,
240            // in that case the update token is the only way in.
241            return false;
242        }
243    }
244
245    private function getUpdateToken(): UpdateToken
246    {
247        return new UpdateToken(PMF_CONFIG_DIR);
248    }
249}