Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.71% covered (success)
84.71%
72 / 85
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SetupController
84.71% covered (success)
84.71%
72 / 85
33.33% covered (danger)
33.33%
2 / 6
22.58
0.00% covered (danger)
0.00%
0 / 1
 index
84.62% covered (success)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
4.06
 install
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 update
85.71% covered (success)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
7.14
 hasAdministratorSession
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 getRelativeTokenFilePath
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 render
100.00% covered (success)
100.00%
5 / 5
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 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-06-01
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend;
21
22use Elastic\Elasticsearch\Exception\AuthenticationException;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Enums\PermissionType;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Language\LanguageCodes;
28use phpMyFAQ\Setup\Installer;
29use phpMyFAQ\Setup\Update;
30use phpMyFAQ\Setup\UpdateToken;
31use phpMyFAQ\System;
32use phpMyFAQ\Twig\TemplateException;
33use phpMyFAQ\Twig\TwigWrapper;
34use phpMyFAQ\User\CurrentUser;
35use Symfony\Component\HttpFoundation\Request;
36use Symfony\Component\HttpFoundation\Response;
37use Symfony\Component\Routing\Attribute\Route;
38use Twig\Error\LoaderError;
39
40final class SetupController
41{
42    /**
43     * @throws TemplateException
44     * @throws \Exception
45     */
46    #[Route(path: '/setup', name: 'public.setup.update', methods: ['GET'])]
47    public function index(Request $request): Response
48    {
49        $system = new System();
50
51        if (!$system->checkInstallation()) {
52            return new Response('phpMyFAQ is already installed.', Response::HTTP_FORBIDDEN);
53        }
54
55        $installer = new Installer($system);
56
57        $checkBasicError = '';
58        try {
59            $installer->checkBasicStuff();
60        } catch (Exception $exception) {
61            $checkBasicError = $exception->getMessage();
62        }
63
64        try {
65            $installer->checkInitialRewriteBasePath($request);
66        } catch (Exception $exception) {
67            $checkBasicError = $exception->getMessage();
68        }
69
70        return $this->render('@setup/index.twig', [
71            'newVersion' => System::getVersion(),
72            'setupType' => 'Setup',
73            'currentYear' => date(format: 'Y'),
74            'currentLanguage' => 'en',
75            'documentationUrl' => System::getDocumentationUrl(),
76            'checkBasicError' => $checkBasicError,
77            'nonCriticalSettings' => $installer->checkNoncriticalSettings(),
78            'filePermissions' => $installer->checkFilesystemPermissions(),
79            'supportedDatabases' => $system->getSupportedSafeDatabases(),
80            'currentPath' => dirname(path: __DIR__, levels: 4),
81            'isLdapEnabled' => $installer->hasLdapSupport(),
82            'isElasticsearchEnabled' => $installer->hasElasticsearchSupport(),
83            'supportedTranslations' => LanguageCodes::getAllSupported(),
84        ]);
85    }
86
87    /**
88     * @throws TemplateException
89     * @throws \Exception
90     */
91    #[Route(path: '/setup/install', name: 'public.setup.install', methods: ['GET'])]
92    public function install(): Response
93    {
94        $system = new System();
95
96        if (!$system->checkInstallation()) {
97            return new Response('phpMyFAQ is already installed.', Response::HTTP_FORBIDDEN);
98        }
99
100        $installer = new Installer($system);
101
102        $installationError = '';
103
104        try {
105            $installer->startInstall();
106        } catch (Exception|AuthenticationException $exception) {
107            $installationError = $exception->getMessage();
108        }
109
110        return $this->render('@setup/install.twig', [
111            'newVersion' => System::getVersion(),
112            'setupType' => 'Setup',
113            'currentYear' => date(format: 'Y'),
114            'documentationUrl' => System::getDocumentationUrl(),
115            'installationError' => $installationError,
116        ]);
117    }
118
119    /**
120     * @throws TemplateException
121     * @throws Exception
122     * @throws \Exception
123     */
124    #[Route(path: '/update', name: 'public.update.index', methods: ['GET'])]
125    public function update(Request $request): Response
126    {
127        $currentStep = Filter::filterVar($request->query->get('step') ?? 1, FILTER_VALIDATE_INT);
128        if ($currentStep === null || $currentStep < 1 || $currentStep > 3) {
129            $currentStep = 1;
130        }
131
132        $configuration = Configuration::getConfigurationInstance();
133
134        $update = new Update(new System(), $configuration);
135
136        $checkBasicError = '';
137        try {
138            $update->checkInitialRewriteBasePath($request);
139        } catch (Exception $exception) {
140            $checkBasicError = $exception->getMessage();
141        }
142
143        $updateTokenError = '';
144        $updateTokenRequired = !$this->hasAdministratorSession($configuration);
145        if ($updateTokenRequired) {
146            try {
147                // The token itself is never rendered, it has to be read from the file system
148                new UpdateToken(PMF_CONFIG_DIR)->getOrCreate();
149            } catch (Exception $exception) {
150                $updateTokenError = $exception->getMessage();
151            }
152        }
153
154        return $this->render('@setup/update.twig', [
155            'currentStep' => $currentStep,
156            'installedVersion' => $configuration->getVersion(),
157            'newVersion' => System::getVersion(),
158            'checkBasicError' => $checkBasicError,
159            'currentYear' => date(format: 'Y'),
160            'documentationUrl' => System::getDocumentationUrl(),
161            'configTableNotAvailable' => $update->isConfigTableNotAvailable($configuration->getDb()),
162            'updateTokenRequired' => $updateTokenRequired,
163            'updateTokenError' => $updateTokenError,
164            'updateTokenFile' => $this->getRelativeTokenFilePath(),
165            'updateTokenLifetime' => (int) (UpdateToken::TOKEN_LIFETIME / 60),
166        ]);
167    }
168
169    /**
170     * Returns true if the update is started by a logged-in administrator. This is only
171     * possible as long as the database still matches what the new code expects, so the
172     * update wizard falls back to the update token whenever this returns false.
173     */
174    private function hasAdministratorSession(Configuration $configuration): bool
175    {
176        try {
177            $currentUser = CurrentUser::getCurrentUser($configuration);
178
179            if (!$currentUser->isLoggedIn()) {
180                return false;
181            }
182
183            return $currentUser->isSuperAdmin()
184            || $currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::CONFIGURATION_EDIT->value);
185        } catch (\Throwable) {
186            return false;
187        }
188    }
189
190    /**
191     * Returns the path of the token file relative to the phpMyFAQ root directory,
192     * so that we can tell the administrator where to find it.
193     */
194    private function getRelativeTokenFilePath(): string
195    {
196        $tokenFilePath = new UpdateToken(PMF_CONFIG_DIR)->getTokenFilePath();
197
198        if (str_starts_with($tokenFilePath, PMF_ROOT_DIR . DIRECTORY_SEPARATOR)) {
199            return substr($tokenFilePath, strlen(PMF_ROOT_DIR) + 1);
200        }
201
202        return $tokenFilePath;
203    }
204
205    /**
206     * Returns a Twig-rendered template as a response.
207     *
208     * @param array<string, mixed> $templateVars
209     * @throws Exception|LoaderError
210     */
211    public function render(string $pathToTwigFile, array $templateVars = [], ?Response $response = null): Response
212    {
213        $response ??= new Response();
214        $twigWrapper = new TwigWrapper((string) PMF_ROOT_DIR . '/assets/templates', true);
215        $templateWrapper = $twigWrapper->loadTemplate($pathToTwigFile);
216
217        $response->setContent($templateWrapper->render($templateVars));
218
219        return $response;
220    }
221}