Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.38% covered (success)
99.38%
320 / 322
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractAdministrationController
99.38% covered (success)
99.38%
320 / 322
75.00% covered (warning)
75.00%
6 / 8
79
0.00% covered (danger)
0.00%
0 / 1
 initializeFromContainer
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 render
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getHeader
97.92% covered (success)
97.92%
47 / 48
0.00% covered (danger)
0.00%
0 / 1
3
 getSecondLevelEntries
100.00% covered (success)
100.00%
156 / 156
100.00% covered (success)
100.00%
1 / 1
6
 getPageFlags
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
1 / 1
61
 getGravatarImage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 userHasPermission
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFooter
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The abstract Administration 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 Exception;
23use phpMyFAQ\Administration\AdminLog;
24use phpMyFAQ\Administration\AdminMenuBuilder;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Controller\Exception\ForbiddenException;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Helper\LanguageHelper;
29use phpMyFAQ\Service\Gravatar;
30use phpMyFAQ\Session\Token;
31use phpMyFAQ\System;
32use phpMyFAQ\Translation;
33use phpMyFAQ\Twig\TwigWrapper;
34use Symfony\Component\HttpFoundation\Request;
35use Symfony\Component\HttpFoundation\Response;
36
37abstract class AbstractAdministrationController extends AbstractController
38{
39    protected AdminLog $adminLog;
40
41    #[\Override]
42    protected function initializeFromContainer(): void
43    {
44        parent::initializeFromContainer();
45
46        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
47        if (!$adminLog instanceof AdminLog) {
48            throw new \LogicException('AdminLog service not found in container.');
49        }
50
51        $this->adminLog = $adminLog;
52    }
53
54    /**
55     * Renders an administration template and marks the response as non-cacheable.
56     *
57     * Admin pages embed per-session CSRF tokens; a browser-cached copy would replay
58     * a stale token and the next form submission would fail with HTTP 401.
59     *
60     * @param array<array-key, mixed> $context
61     * @throws Exception
62     */
63    #[\Override]
64    public function render(string $file, array $context = [], ?Response $response = null): Response
65    {
66        $response = parent::render($file, $context, $response);
67        $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
68        $response->headers->set('Pragma', 'no-cache');
69
70        return $response;
71    }
72
73    /**
74     * @return array<string, mixed>
75     * @throws Exception
76     */
77    protected function getHeader(Request $request): array
78    {
79        $adminHelper = $this->container->get(id: 'phpmyfaq.admin.helper');
80        if (!$adminHelper instanceof AdminMenuBuilder) {
81            throw new \LogicException('AdminMenuBuilder service not found in container.');
82        }
83
84        $adminHelper->setUser($this->currentUser);
85
86        $secLevelEntries = $this->getSecondLevelEntries($adminHelper);
87        $pageFlags = $this->getPageFlags($request);
88        $gravatarImage = $this->getGravatarImage();
89
90        return [
91            'metaLanguage' => Translation::get(key: 'metaLanguage'),
92            'layoutMode' => 'light',
93            'defaultLayoutMode' => (string) ($this->configuration->get('layout.defaultLayoutMode') ?? 'auto'),
94            'allowUserLayoutMode' =>
95                $this->configuration->get('layout.allowUserLayoutMode') === true
96                    || $this->configuration->get('layout.allowUserLayoutMode') === 'true',
97            'pageTitle' => $this->configuration->getTitle() . ' - ' . System::getPoweredByPlainString(),
98            'baseHref' => $this->configuration->getDefaultUrl() . 'admin/',
99            'version' => System::getVersion(),
100            'currentYear' => date(format: 'Y'),
101            'metaRobots' => $this->configuration->get(item: 'seo.metaTagsAdmin'),
102            'templateSetName' => TwigWrapper::getTemplateSetName(),
103            'pageDirection' => Translation::get(key: 'direction'),
104            'userHasAccessPermission' => $adminHelper->canAccessContent($this->currentUser),
105            'msgSessionExpiration' => Translation::get(key: 'ad_session_expiration'),
106            'renderedLanguageSelection' => LanguageHelper::renderSelectLanguage(
107                $this->configuration->getLanguage()->getLanguage(),
108                true,
109                [],
110                'lang',
111            ),
112            'userName' => $this->currentUser->getUserData('display_name'),
113            'hasGravatarSupport' => $this->configuration->get(item: 'main.enableGravatarSupport'),
114            'gravatarImage' => $gravatarImage,
115            'msgChangePassword' => Translation::get(key: 'ad_menu_passwd'),
116            'csrfTokenLogout' => Token::getInstance($this->session)->getTokenString('admin-logout'),
117            'msgLogout' => Translation::get(key: 'admin_mainmenu_logout'),
118            'secondLevelEntries' => $secLevelEntries,
119            'menuUsers' => Translation::get(key: 'admin_mainmenu_users'),
120            'menuContent' => Translation::get(key: 'admin_mainmenu_content'),
121            'menuStatistics' => Translation::get(key: 'admin_mainmenu_statistics'),
122            'menuImportsExports' => Translation::get(key: 'admin_mainmenu_imports_exports'),
123            'menuBackup' => Translation::get(key: 'admin_mainmenu_backup'),
124            'menuConfiguration' => Translation::get(key: 'admin_mainmenu_configuration'),
125            'isSessionTimeoutCounterEnabled' => $this->configuration->get(
126                item: 'security.enableAdminSessionTimeoutCounter',
127            ),
128            'pluginStylesheets' => $this->configuration->getPluginManager()->getAllPluginStylesheets(),
129            'pluginScripts' => $this->configuration->getPluginManager()->getAllPluginScripts(),
130        ] + $pageFlags;
131    }
132
133    /**
134     * @return array<string, mixed>
135     */
136    private function getSecondLevelEntries(AdminMenuBuilder $adminHelper): array
137    {
138        $secLevelEntries = [];
139
140        $secLevelEntries['user'] = $adminHelper->addMenuEntry(
141            'add_user+edit_user+delete_user',
142            'ad_menu_user_administration',
143            'user',
144        );
145        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
146            $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
147                'addgroup+editgroup+delgroup',
148                'ad_menu_group_administration',
149                'group',
150            );
151        }
152
153        $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
154            PermissionType::PASSWORD_CHANGE->value,
155            'ad_menu_passwd',
156            'password/change',
157        );
158
159        $secLevelEntries['content'] = $adminHelper->addMenuEntry(
160            'addcateg+editcateg+delcateg',
161            'msgHeaderCategoryOverview',
162            'category',
163        );
164        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
165            PermissionType::FAQ_ADD->value,
166            'msgAddFAQ',
167            'faq/add',
168        );
169        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
170            'edit_faq+delete_faq',
171            'msgHeaderFAQOverview',
172            'faqs',
173        );
174        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
175            PermissionType::FAQ_EDIT->value,
176            'stickyRecordsHeader',
177            'sticky-faqs',
178        );
179        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
180            PermissionType::FAQ_EDIT->value,
181            'msgOrphanedFAQs',
182            'orphaned-faqs',
183        );
184        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
185            PermissionType::QUESTION_DELETE->value,
186            'ad_menu_open',
187            'questions',
188        );
189        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
190            PermissionType::COMMENT_DELETE->value,
191            'ad_menu_comments',
192            'comments',
193        );
194        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
195            'addattachment+editattachment+delattachment',
196            'msgAttachments',
197            'attachments',
198        );
199        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(PermissionType::FAQ_EDIT->value, 'msgTags', 'tags');
200        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
201            'addglossary+editglossary+delglossary',
202            'ad_menu_glossary',
203            'glossary',
204        );
205        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addnews+editnews+delnews', 'msgNews', 'news');
206        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addpage+editpage+delpage', 'ad_menu_pages', 'pages');
207
208        $secLevelEntries['statistics'] = $adminHelper->addMenuEntry(
209            PermissionType::STATISTICS_VIEWLOGS->value,
210            'ad_menu_stat',
211            'statistics/ratings',
212        );
213        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
214            PermissionType::STATISTICS_VIEWLOGS->value,
215            'ad_menu_session',
216            'statistics/sessions',
217        );
218        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
219            PermissionType::STATISTICS_ADMINLOG->value,
220            'ad_menu_adminlog',
221            'statistics/admin-log',
222        );
223        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
224            PermissionType::STATISTICS_VIEWLOGS->value,
225            'msgAdminElasticsearchStats',
226            'statistics/search',
227        );
228        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
229            PermissionType::REPORTS->value,
230            'ad_menu_reports',
231            'statistics/report',
232        );
233
234        $secLevelEntries['imports_exports'] = $adminHelper->addMenuEntry(
235            PermissionType::FAQ_ADD->value,
236            'msgImportRecords',
237            'import',
238        );
239        $secLevelEntries['imports_exports'] .= $adminHelper->addMenuEntry(
240            PermissionType::EXPORT->value,
241            'ad_menu_export',
242            'export',
243        );
244
245        $secLevelEntries['backup'] = $adminHelper->addMenuEntry(
246            PermissionType::CONFIGURATION_EDIT->value,
247            'ad_menu_backup',
248            'backup',
249        );
250
251        $secLevelEntries['config'] = $adminHelper->addMenuEntry(
252            PermissionType::CONFIGURATION_EDIT->value,
253            'ad_menu_editconfig',
254            'configuration',
255        );
256        $secLevelEntries['config'] .= $adminHelper->addMenuEntry('forms_edit', 'msgEditForms', 'forms');
257        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
258            'editinstances+addinstances+delinstances',
259            'ad_menu_instances',
260            'instances',
261        );
262        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
263            PermissionType::CONFIGURATION_EDIT->value,
264            'ad_menu_stopwordsconfig',
265            'stopwords',
266        );
267        if ($this->configuration->get(item: 'upgrade.onlineUpdateEnabled')) {
268            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
269                PermissionType::CONFIGURATION_EDIT->value,
270                'msgAdminHeaderUpdate',
271                'update',
272            );
273        }
274
275        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
276            PermissionType::CONFIGURATION_EDIT->value,
277            'msgPlugins',
278            'plugins',
279        );
280        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
281            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
282                PermissionType::CONFIGURATION_EDIT->value,
283                'msgAdminHeaderElasticsearch',
284                'elasticsearch',
285            );
286        }
287
288        if ($this->configuration->isLdapActive()) {
289            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
290                PermissionType::CONFIGURATION_EDIT->value,
291                'msgAdminHeaderLdap',
292                'ldap',
293            );
294        }
295
296        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
297            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
298                PermissionType::CONFIGURATION_EDIT->value,
299                'msgAdminHeaderOpenSearch',
300                'opensearch',
301            );
302        }
303
304        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
305            PermissionType::CONFIGURATION_EDIT->value,
306            'ad_system_info',
307            'system',
308        );
309
310        return $secLevelEntries;
311    }
312
313    /**
314     * @return array<string, bool>
315     */
316    private function getPageFlags(Request $request): array
317    {
318        $userPage = false;
319        $contentPage = false;
320        $statisticsPage = false;
321        $exportsPage = false;
322        $backupPage = false;
323        $configurationPage = false;
324
325        switch ($request->attributes->get('_route')) {
326            case 'admin.group':
327            case 'admin.group.add':
328            case 'admin.group.create':
329            case 'admin.password.change':
330            case 'admin.password.update':
331            case 'admin.user':
332            case 'admin.user.list':
333            case 'admin.user.edit':
334                $userPage = true;
335                break;
336            case 'admin.attachments':
337            case 'admin.category':
338            case 'admin.category.add':
339            case 'admin.category.add.child':
340            case 'admin.category.create':
341            case 'admin.category.edit':
342            case 'admin.category.hierarchy':
343            case 'admin.category.translate':
344            case 'admin.category.update':
345            case 'admin.content.orphaned-faqs':
346            case 'admin.content.sticky-faqs':
347            case 'admin.comments':
348            case 'admin.faq.add':
349            case 'admin.faq.answer':
350            case 'admin.faq.copy':
351            case 'admin.faq.edit':
352            case 'admin.faq.translate':
353            case 'admin.faqs':
354            case 'admin.glossary':
355            case 'admin.news':
356            case 'admin.news.add':
357            case 'admin.news.edit':
358            case 'admin.pages':
359            case 'admin.page.add':
360            case 'admin.page.edit':
361            case 'admin.page.translate':
362            case 'admin.questions':
363            case 'admin.tags':
364                $contentPage = true;
365                break;
366            case 'admin.statistics.admin-log':
367            case 'admin.statistics.ratings':
368            case 'admin.statistics.report':
369            case 'admin.statistics.sessions':
370            case 'admin.statistics.session.day':
371            case 'admin.statistics.session.id':
372            case 'admin.statistics.search':
373                $statisticsPage = true;
374                break;
375            case 'admin.export':
376            case 'admin.import':
377                $exportsPage = true;
378                break;
379            case 'admin.backup':
380            case 'admin.backup.export':
381            case 'admin.backup.restore':
382                $backupPage = true;
383                break;
384            case 'admin.configuration':
385            case 'admin.elasticsearch':
386            case 'admin.ldap':
387            case 'admin.opensearch':
388            case 'admin.forms':
389            case 'admin.instance.edit':
390            case 'admin.instance.update':
391            case 'admin.instances':
392            case 'admin.stopwords':
393            case 'admin.system':
394            case 'admin.configuration.plugins':
395            case 'admin.update':
396                $configurationPage = true;
397                break;
398        }
399
400        return [
401            'userPage' => $userPage,
402            'contentPage' => $contentPage,
403            'statisticsPage' => $statisticsPage,
404            'exportsPage' => $exportsPage,
405            'backupPage' => $backupPage,
406            'configurationPage' => $configurationPage,
407        ];
408    }
409
410    private function getGravatarImage(): string
411    {
412        if ($this->currentUser->isLoggedIn() && (bool) $this->configuration->get(item: 'main.enableGravatarSupport')) {
413            $email = $this->currentUser->getUserData('email');
414            $gravatar = new Gravatar();
415            return $gravatar->getImage(is_string($email) ? $email : '', [
416                'size' => '24',
417                'class' => 'img-profile rounded-circle',
418            ]);
419        }
420
421        return '';
422    }
423
424    /**
425     * @throws ForbiddenException
426     * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
427     */
428    protected function userHasPermission(PermissionType $permissionType): void
429    {
430        // Administration pages require authentication first: a logged-out user
431        // must be redirected to the login page (UnauthorizedHttpException),
432        // whereas ForbiddenException (403) is reserved for authenticated users
433        // who lack the required permission.
434        $this->userIsAuthenticated();
435        parent::userHasPermission($permissionType);
436    }
437
438    /**
439     * @return array<string, string|bool>
440     */
441    protected function getFooter(): array
442    {
443        return [
444            'msgModalSessionWarning' => sprintf(
445                Translation::getString('ad_session_expiring'),
446                PMF_AUTH_TIMEOUT_WARNING,
447            ),
448            'msgPoweredBy' => System::getPoweredByPlainString(),
449            'documentationUrl' => System::getDocumentationUrl(),
450            'phpMyFaqUrl' => System::PHPMYFAQ_URL,
451            'isUserLoggedIn' => $this->currentUser->isLoggedIn(),
452            'currentLanguage' => $this->configuration->getLanguage()->getLanguage(),
453            'currentYear' => date(format: 'Y'),
454        ];
455    }
456}