Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.58% covered (warning)
60.58%
83 / 137
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateController
60.58% covered (warning)
60.58%
83 / 137
36.36% covered (danger)
36.36%
4 / 11
104.79
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
 healthCheck
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
3.19
 versions
20.00% covered (danger)
20.00%
1 / 5
0.00% covered (danger)
0.00%
0 / 1
4.05
 updateCheck
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
5.01
 downloadPackage
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 extractPackage
6.67% covered (danger)
6.67%
1 / 15
0.00% covered (danger)
0.00%
0 / 1
10.32
 createTemporaryBackup
6.67% covered (danger)
6.67%
1 / 15
0.00% covered (danger)
0.00%
0 / 1
10.32
 installPackage
6.67% covered (danger)
6.67%
1 / 15
0.00% covered (danger)
0.00%
0 / 1
17.01
 updateDatabase
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 cleanUp
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isValidUpdatePackageToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Admin Update 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-07-29
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use DateTime;
23use DateTimeInterface;
24use phpMyFAQ\Administration\RemoteApiClient;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Filter;
29use phpMyFAQ\Session\Token;
30use phpMyFAQ\Setup\EnvironmentConfigurator;
31use phpMyFAQ\Setup\Update;
32use phpMyFAQ\Setup\Upgrade;
33use phpMyFAQ\Translation;
34use Symfony\Component\HttpClient\HttpClient;
35use Symfony\Component\HttpFoundation\JsonResponse;
36use Symfony\Component\HttpFoundation\Request;
37use Symfony\Component\HttpFoundation\Response;
38use Symfony\Component\HttpFoundation\StreamedResponse;
39use Symfony\Component\Routing\Attribute\Route;
40use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
41use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
42use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
43use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
44use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
45
46final class UpdateController extends AbstractController
47{
48    public function __construct(
49        private readonly Upgrade $upgrade,
50        private readonly RemoteApiClient $adminApi,
51        private readonly Update $update,
52        private readonly EnvironmentConfigurator $configurator,
53    ) {
54        parent::__construct();
55    }
56
57    /**
58     * @throws Exception|\Exception
59     */
60    #[Route(path: 'health-check', name: 'admin.api.health-check', methods: ['GET'])]
61    public function healthCheck(): JsonResponse
62    {
63        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
64
65        $dateTime = new DateTime();
66        $dateLastChecked = $dateTime->format(DateTimeInterface::ATOM);
67
68        if (!$this->upgrade->isMaintenanceEnabled()) {
69            return $this->json([
70                'warning' => Translation::get(key: 'msgNotInMaintenanceMode'),
71                'dateLastChecked' => $dateLastChecked,
72            ], Response::HTTP_CONFLICT);
73        }
74
75        try {
76            $this->upgrade->checkFilesystem();
77            return $this->json([
78                'success' => Translation::get(key: 'healthCheckOkay'),
79                'dateLastChecked' => $dateLastChecked,
80            ], Response::HTTP_OK);
81        } catch (Exception $exception) {
82            return $this->json([
83                'error' => $exception->getMessage(),
84                'dateLastChecked' => $dateLastChecked,
85            ], Response::HTTP_BAD_REQUEST);
86        }
87    }
88
89    #[Route(path: 'versions', name: 'admin.api.versions', methods: ['GET'])]
90    public function versions(): JsonResponse
91    {
92        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
93
94        try {
95            $versions = HttpClient::create(['timeout' => 30])->request('GET', 'https://api.phpmyfaq.de/versions');
96            return $this->json($versions->getContent(), Response::HTTP_OK);
97        } catch (
98            TransportExceptionInterface|ClientExceptionInterface|ServerExceptionInterface|RedirectionExceptionInterface $exception
99        ) {
100            return $this->json($exception->getMessage(), Response::HTTP_BAD_REQUEST);
101        }
102    }
103
104    /**
105     * @throws Exception|\Exception
106     */
107    #[Route(path: 'update-check', name: 'admin.api.update-check', methods: ['POST'])]
108    public function updateCheck(): JsonResponse
109    {
110        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
111
112        $dateTime = new DateTime();
113        $dateLastChecked = $dateTime->format(DateTimeInterface::ATOM);
114        $branch = (string) $this->configuration->get(item: 'upgrade.releaseEnvironment');
115
116        try {
117            $versions = $this->adminApi->getVersions();
118            $this->configuration->set('upgrade.dateLastChecked', $dateLastChecked);
119
120            $installed = $versions['installed'];
121            $available = $versions[$branch];
122
123            if (version_compare($installed, $available, operator: '<')) {
124                return $this->json([
125                    'version' => $available,
126                    'message' => Translation::getString(key: 'msgCurrentVersion') . $available,
127                    'dateLastChecked' => $dateLastChecked,
128                ], Response::HTTP_OK);
129            }
130
131            if ($branch !== 'nightly' && version_compare($installed, $available, operator: '>')) {
132                return $this->json([
133                    'version' => $available,
134                    'message' => Translation::get(key: 'msgInstalledNewerThanAvailable'),
135                    'dateLastChecked' => $dateLastChecked,
136                ], Response::HTTP_CONFLICT);
137            }
138
139            return $this->json([
140                'version' => $installed,
141                'message' => Translation::get(key: 'versionIsUpToDate'),
142                'dateLastChecked' => $dateLastChecked,
143            ], Response::HTTP_OK);
144        } catch (TransportExceptionInterface|DecodingExceptionInterface $e) {
145            return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
146        }
147    }
148
149    /**
150     * @throws TransportExceptionInterface
151     * @throws ServerExceptionInterface
152     * @throws RedirectionExceptionInterface
153     * @throws ClientExceptionInterface
154     * @throws \JsonException
155     * @throws Exception|\Exception
156     */
157    #[Route(path: 'download-package/{versionNumber}', name: 'admin.api.download-package', methods: ['POST'])]
158    public function downloadPackage(Request $request): JsonResponse
159    {
160        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
161
162        if (!$this->isValidUpdatePackageToken($request)) {
163            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
164        }
165
166        $versionNumber = Filter::filterVar(
167            $request->attributes->get('versionNumber'),
168            FILTER_SANITIZE_SPECIAL_CHARS,
169            '',
170        );
171
172        try {
173            $pathToPackage = $this->upgrade->downloadPackage($versionNumber);
174        } catch (Exception $exception) {
175            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
176        }
177
178        if (!$this->upgrade->isNightly()) {
179            $result = $this->upgrade->verifyPackage($pathToPackage, $versionNumber);
180            if ($result === false) {
181                return $this->json([
182                    'error' => Translation::get(key: 'verificationFailure'),
183                ], Response::HTTP_BAD_GATEWAY);
184            }
185        }
186
187        $this->configuration->set('upgrade.lastDownloadedPackage', urlencode($pathToPackage));
188
189        return $this->json(['success' => Translation::get(key: 'downloadSuccessful')], Response::HTTP_OK);
190    }
191
192    #[Route(path: 'extract-package', name: 'admin.api.extract-package', methods: ['POST'])]
193    public function extractPackage(Request $request): Response
194    {
195        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
196
197        if (!$this->isValidUpdatePackageToken($request)) {
198            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
199        }
200
201        $pathToPackage = urldecode((string) $this->configuration->get(item: 'upgrade.lastDownloadedPackage'));
202
203        return new StreamedResponse(function () use ($pathToPackage): void {
204            $progressCallback = static function ($progress): void {
205                echo (string) json_encode(['progress' => $progress]) . "\n";
206                ob_flush();
207                flush();
208            };
209            $message = $this->upgrade->extractPackage($pathToPackage, $progressCallback)
210                ? Translation::get(key: 'extractSuccessful')
211                : Translation::get(key: 'extractFailure');
212            echo json_encode(['message' => $message]);
213        });
214    }
215
216    #[Route(path: 'create-temporary-backup', name: 'admin.api.create-temporary-backup', methods: ['POST'])]
217    public function createTemporaryBackup(Request $request): Response
218    {
219        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
220
221        if (!$this->isValidUpdatePackageToken($request)) {
222            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
223        }
224
225        $backupHash = bin2hex(random_bytes(16));
226
227        return new StreamedResponse(function () use ($backupHash): void {
228            $progressCallback = static function ($progress): void {
229                echo (string) json_encode(['progress' => $progress]) . "\n";
230                ob_flush();
231                flush();
232            };
233            $message = $this->upgrade->createTemporaryBackup($backupHash . '.zip', $progressCallback)
234                ? 'Backup successful'
235                : 'Backup failed';
236            echo json_encode(['message' => $message]);
237        });
238    }
239
240    #[Route(path: 'install-package', name: 'admin.api.install-package', methods: ['POST'])]
241    public function installPackage(Request $request): Response
242    {
243        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
244
245        if (!$this->isValidUpdatePackageToken($request)) {
246            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
247        }
248
249        return new StreamedResponse(function (): void {
250            $progressCallback = static function ($progress): void {
251                echo (string) json_encode(['progress' => $progress]) . "\n";
252                ob_flush();
253                flush();
254            };
255            $message = $this->upgrade->installPackage($progressCallback)
256            && $this->configurator->adjustRewriteBaseHtaccess()
257                ? 'Package successfully installed.'
258                : 'Install package failed';
259            echo json_encode(['message' => $message]);
260        });
261    }
262
263    #[Route(path: 'update-database', name: 'admin.api.update-database', methods: ['POST'])]
264    public function updateDatabase(Request $request): JsonResponse
265    {
266        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
267
268        if (!$this->isValidUpdatePackageToken($request)) {
269            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
270        }
271
272        $this->update->version = (string) $this->configuration->get('main.currentVersion');
273
274        try {
275            if ($this->update->applyUpdates()) {
276                $this->configuration->set('main.maintenanceMode', 'false');
277                return new JsonResponse(['success' => 'Database successfully updated.'], Response::HTTP_OK);
278            }
279
280            $this->configuration->set('main.maintenanceMode', 'false');
281            return new JsonResponse(['error' => 'Update database failed.'], Response::HTTP_BAD_GATEWAY);
282        } catch (Exception|\Exception $exception) {
283            $this->configuration->set('main.maintenanceMode', 'false');
284            return new JsonResponse([
285                'error' => 'Update database failed: ' . $exception->getMessage(),
286            ], Response::HTTP_BAD_GATEWAY);
287        }
288    }
289
290    /**
291     * @throws Exception|\Exception
292     */
293    #[Route(path: 'cleanup', name: 'admin.api.cleanup', methods: ['POST'])]
294    public function cleanUp(Request $request): JsonResponse
295    {
296        $this->userHasPermission(PermissionType::CONFIGURATION_EDIT);
297
298        if (!$this->isValidUpdatePackageToken($request)) {
299            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
300        }
301
302        $this->upgrade->cleanUp();
303
304        return $this->json(['message' => 'Cleanup successful.'], Response::HTTP_OK);
305    }
306
307    /**
308     * Verifies the CSRF token sent with the updater package actions.
309     */
310    private function isValidUpdatePackageToken(Request $request): bool
311    {
312        $data = json_decode($request->getContent());
313        $csrfToken = is_object($data) ? (string) ($data->csrf ?? '') : '';
314
315        return Token::getInstance($this->session)->verifyToken('update-package', $csrfToken);
316    }
317}