Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.18% covered (success)
88.18%
97 / 110
80.00% covered (success)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstanceController
88.18% covered (success)
88.18%
97 / 110
80.00% covered (success)
80.00%
4 / 5
17.48
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 edit
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 update
77.97% covered (warning)
77.97%
46 / 59
0.00% covered (danger)
0.00%
0 / 1
13.54
 getBaseTemplateVars
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Administration Instances 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\Core\Exception;
23use phpMyFAQ\Entity\InstanceEntity;
24use phpMyFAQ\Enums\PermissionType;
25use phpMyFAQ\Filesystem\Filesystem;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Instance;
28use phpMyFAQ\Instance\Client;
29use phpMyFAQ\Session\Token;
30use phpMyFAQ\Translation;
31use Symfony\Component\HttpFoundation\Request;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
34use Symfony\Component\Routing\Attribute\Route;
35use Twig\Error\LoaderError;
36
37final class InstanceController extends AbstractAdministrationController
38{
39    public function __construct(
40        private readonly Instance $instance,
41        private readonly Client $instanceClient,
42    ) {
43        parent::__construct();
44    }
45
46    /**
47     * @throws LoaderError
48     * @throws Exception
49     * @throws \Exception
50     */
51    #[Route(path: '/instances', name: 'admin.instances', methods: ['GET'])]
52    public function index(Request $request): Response
53    {
54        $this->userHasPermission(PermissionType::INSTANCE_ADD);
55
56        return $this->render('@admin/configuration/instances.twig', [
57            ...$this->getHeader($request),
58            ...$this->getFooter(),
59            ...$this->getBaseTemplateVars(),
60        ]);
61    }
62
63    /**
64     * @throws Exception
65     * @throws LoaderError
66     * @throws \Exception
67     */
68    #[Route(path: '/instance/edit/{id}', name: 'admin.instance.edit', methods: ['GET'])]
69    public function edit(Request $request): Response
70    {
71        $this->userHasPermission(PermissionType::INSTANCE_EDIT);
72
73        $instanceId = (int) Filter::filterVar($request->attributes->get('id'), FILTER_VALIDATE_INT);
74
75        $instanceData = $this->instance->getById($instanceId);
76
77        return $this->render('@admin/configuration/instances.edit.twig', [
78            ...$this->getHeader($request),
79            ...$this->getFooter(),
80            'ad_menu_instances' => Translation::get(key: 'ad_menu_instances'),
81            'instanceConfig' => $this->instance->getInstanceConfig((int) $instanceData->id),
82            'ad_instance_url' => Translation::get(key: 'ad_instance_url'),
83            'ad_instance_button' => Translation::get(key: 'ad_instance_button'),
84            'ad_instance_path' => Translation::get(key: 'ad_instance_path'),
85            'ad_instance_name' => Translation::get(key: 'ad_instance_name'),
86            'ad_instance_config' => Translation::get(key: 'ad_instance_config'),
87            'ad_entry_back' => Translation::get(key: 'ad_entry_back'),
88            'instance' => $instanceData,
89            'csrfTokenUpdateInstance' => Token::getInstance($this->session)->getTokenString('update-instance'),
90        ]);
91    }
92
93    /**
94     * @throws LoaderError
95     * @throws Exception
96     * @throws \Exception
97     */
98    #[Route(path: '/instance/update', name: 'admin.instance.update', methods: ['POST'])]
99    public function update(Request $request): Response
100    {
101        $this->userHasPermission(PermissionType::INSTANCE_EDIT);
102
103        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
104        if (!Token::getInstance($this->session)->verifyToken('update-instance', $csrfToken)) {
105            throw new UnauthorizedHttpException('Invalid CSRF token');
106        }
107
108        $instanceId = (int) Filter::filterVar($request->attributes->get('id'), FILTER_VALIDATE_INT);
109
110        $fileSystem = new Filesystem((string) PMF_ROOT_DIR);
111        $currentClient = clone $this->instanceClient;
112        $currentClient->setFileSystem($fileSystem);
113
114        $updatedClient = clone $this->instanceClient;
115
116        $moveInstance = false;
117        $this->instance->setId($instanceId);
118
119        // Collect updated data for database
120        $instanceEntity = new InstanceEntity();
121        $instanceUrl = Filter::filterVar($request->attributes->get('url'), FILTER_VALIDATE_URL);
122        $instanceEntity->setUrl(is_string($instanceUrl) ? $instanceUrl : '');
123        $instanceEntity->setInstance(Filter::filterVar(
124            $request->attributes->get('instance'),
125            FILTER_SANITIZE_SPECIAL_CHARS,
126            '',
127        ));
128        $instanceEntity->setComment(Filter::filterVar(
129            $request->attributes->get('comment'),
130            FILTER_SANITIZE_SPECIAL_CHARS,
131            '',
132        ));
133
134        if (is_null($instanceEntity->getUrl()) || !$updatedClient->isValidClientUrl($instanceEntity->getUrl())) {
135            $result = ['updateError' => 'Invalid instance URL.'];
136
137            return $this->render('@admin/configuration/instances.twig', [
138                ...$result,
139                ...$this->getHeader($request),
140                ...$this->getFooter(),
141                ...$this->getBaseTemplateVars(),
142            ]);
143        }
144
145        // Original data
146        $originalData = $currentClient->getById($instanceId);
147
148        if ($originalData->url !== $instanceEntity->getUrl() && !$this->instance->getConfig('isMaster')) {
149            $moveInstance = true;
150        }
151
152        $result = [];
153        if ($moveInstance && !$currentClient->isValidClientUrl((string) $originalData->url)) {
154            $this->configuration->getLogger()->error('Instance URL is null during update', [
155                'instanceId' => $instanceId,
156                'sqlError' => $this->configuration->getDb()->error(),
157            ]);
158
159            $result = ['updateError' => Translation::get(key: 'ad_msg_mysqlerr')];
160        }
161
162        if ($updatedClient->update($instanceId, $instanceEntity)) {
163            if ($moveInstance) {
164                $updatedClient->moveClientFolder((string) $originalData->url, $instanceEntity->getUrl());
165                $updatedClient->deleteClientFolder((string) $originalData->url);
166            }
167
168            $result = ['updateSuccess' => Translation::get(key: 'ad_config_saved')];
169        }
170
171        if (!$updatedClient->update($instanceId, $instanceEntity)) {
172            $this->configuration->getLogger()->error('Failed to update instance', [
173                'instanceId' => $instanceId,
174                'sqlError' => $this->configuration->getDb()->error(),
175            ]);
176
177            $result = ['updateError' => Translation::get(key: 'ad_msg_mysqlerr')];
178        }
179
180        return $this->render('@admin/configuration/instances.twig', [
181            ...$result,
182            ...$this->getHeader($request),
183            ...$this->getFooter(),
184            ...$this->getBaseTemplateVars(),
185        ]);
186    }
187
188    /**
189     * @return array<string, mixed>
190     * @throws \Exception
191     * @throws LoaderError
192     * @throws Exception
193     */
194    private function getBaseTemplateVars(): array
195    {
196        $userPermInstanceAdd = $this->currentUser?->perm->hasPermission(
197            $this->currentUser->getUserId(),
198            PermissionType::INSTANCE_ADD->value,
199        );
200
201        $mainConfig = [];
202        foreach ($this->instance->getAll() as $site) {
203            $mainConfig[(int) $site->id] = $this->instance->getInstanceConfig((int) $site->id)['isMaster'] ?? '';
204        }
205
206        return [
207            'userPermInstanceAdd' => $userPermInstanceAdd,
208            'multisiteFolderIsWritable' => is_writable((string) PMF_ROOT_DIR . DIRECTORY_SEPARATOR . 'multisite'),
209            'ad_instance_add' => Translation::get(key: 'ad_instance_add'),
210            'allInstances' => $this->instance->getAll(),
211            'csrfTokenDeleteInstance' => Token::getInstance($this->session)->getTokenString('delete-instance'),
212            'csrfTokenAddInstance' => Token::getInstance($this->session)->getTokenString('add-instance'),
213            'mainConfig' => $mainConfig,
214            'requestHost' => Request::createFromGlobals()->getHost(),
215            'ad_instance_button' => Translation::get(key: 'ad_instance_button'),
216            'ad_instance_hint' => Translation::get(key: 'ad_instance_hint'),
217            'ad_instance_admin' => Translation::get(key: 'ad_instance_admin'),
218            'ad_instance_password' => Translation::get(key: 'ad_instance_password'),
219            'ad_instance_email' => Translation::get(key: 'ad_instance_email'),
220            'ad_instance_name' => Translation::get(key: 'ad_instance_name'),
221            'ad_instance_path' => Translation::get(key: 'ad_instance_path'),
222            'ad_instance_url' => Translation::get(key: 'ad_instance_url'),
223            'ad_instance_error_notwritable' => Translation::get(key: 'ad_instance_error_notwritable'),
224            'ad_menu_instances' => Translation::get(key: 'ad_menu_instances'),
225        ];
226    }
227}