Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.98% covered (success)
86.98%
461 / 530
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryController
86.98% covered (success)
86.98%
461 / 530
70.00% covered (warning)
70.00%
7 / 10
67.94
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%
36 / 36
100.00% covered (success)
100.00%
1 / 1
3
 add
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 addChild
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 create
75.86% covered (warning)
75.86%
88 / 116
0.00% covered (danger)
0.00%
0 / 1
16.76
 edit
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
8
 hierarchy
85.00% covered (success)
85.00%
34 / 40
0.00% covered (danger)
0.00%
0 / 1
4.05
 translate
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 update
80.56% covered (success)
80.56%
145 / 180
0.00% covered (danger)
0.00%
0 / 1
28.23
 getBaseTemplateVars
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Administration Category 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-12-04
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration;
21
22use phpMyFAQ\Administration\Category as AdminCategory;
23use phpMyFAQ\Category;
24use phpMyFAQ\Category\Image;
25use phpMyFAQ\Category\Language\CategoryLanguageService;
26use phpMyFAQ\Category\Order;
27use phpMyFAQ\Category\Permission as CategoryPermission;
28use phpMyFAQ\Core\Exception;
29use phpMyFAQ\Database;
30use phpMyFAQ\Entity\CategoryEntity;
31use phpMyFAQ\Entity\SeoEntity;
32use phpMyFAQ\Enums\AdminLogType;
33use phpMyFAQ\Enums\PermissionType;
34use phpMyFAQ\Enums\SeoType;
35use phpMyFAQ\Filter;
36use phpMyFAQ\Helper\UserHelper;
37use phpMyFAQ\Language\LanguageCodes;
38use phpMyFAQ\Permission\MediumPermission;
39use phpMyFAQ\Seo;
40use phpMyFAQ\Session\Token;
41use phpMyFAQ\Translation;
42use phpMyFAQ\User\CurrentUser;
43use Symfony\Component\HttpFoundation\File\UploadedFile;
44use Symfony\Component\HttpFoundation\Request;
45use Symfony\Component\HttpFoundation\Response;
46use Symfony\Component\Routing\Attribute\Route;
47use Throwable;
48use Twig\Error\LoaderError;
49
50final class CategoryController extends AbstractAdministrationController
51{
52    public function __construct(
53        private readonly AdminCategory $adminCategory,
54        private readonly Order $categoryOrder,
55        private readonly CategoryPermission $categoryPermission,
56        private readonly Image $categoryImage,
57        private readonly Seo $seo,
58        private readonly UserHelper $userHelper,
59    ) {
60        parent::__construct();
61    }
62
63    /**
64     * @throws Exception
65     * @throws LoaderError
66     * @throws \Exception
67     */
68    #[Route(path: '/category', name: 'admin.category', methods: ['GET'])]
69    public function index(Request $request): Response
70    {
71        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
72
73        $currentUserId = $this->currentUser->getUserId();
74
75        $category = new Category($this->configuration, [], withPermission: false);
76        $category->buildCategoryTree();
77
78        $categoryInfo = $category->getAllCategories();
79
80        $orderedCategories = $this->categoryOrder->getAllCategories();
81        $categoryTree = $this->categoryOrder->getCategoryTree($orderedCategories);
82
83        if (in_array($categoryTree, [[], null, false], strict: true)) {
84            // Fallback if no category order is available
85            $categoryTree = $category->buildAdminCategoryTree($categoryInfo);
86        }
87
88        // Per-category translation state for the badge + popover (same source as hierarchy())
89        $categoryLanguageService = new CategoryLanguageService();
90        $allLanguages = $categoryLanguageService->getLanguagesInUse($this->configuration);
91        $allTranslations = $categoryLanguageService->getAllExistingTranslations($this->configuration);
92        $categoryTranslations = [];
93        foreach (array_keys($categoryInfo) as $categoryId) {
94            $categoryTranslations[$categoryId] = $allTranslations[(int) $categoryId] ?? [];
95        }
96
97        return $this->render(file: '@admin/content/category.overview.twig', context: [
98            ...$this->getHeader($request),
99            ...$this->getFooter(),
100            'csrfTokenInput' => Token::getInstance($this->session)->getTokenInput(page: 'category'),
101            'categoryTree' => $categoryTree,
102            'categoryInfo' => $categoryInfo,
103            'categoryTranslations' => $categoryTranslations,
104            'allLanguages' => $allLanguages,
105            'permissionAddCategory' => $this->currentUser->perm->hasPermission(
106                $currentUserId,
107                PermissionType::CATEGORY_ADD->value,
108            ),
109            'permissionDeleteCategory' => $this->currentUser->perm->hasPermission(
110                $currentUserId,
111                PermissionType::CATEGORY_DELETE->value,
112            ),
113            'permissionAddFaq' => $this->currentUser->perm->hasPermission(
114                $currentUserId,
115                PermissionType::FAQ_ADD->value,
116            ),
117        ]);
118    }
119
120    /**
121     * @throws Exception
122     * @throws LoaderError
123     * @throws \Exception
124     */
125    #[Route(path: '/category/add', name: 'admin.category.add', methods: ['GET'])]
126    public function add(Request $request): Response
127    {
128        $this->userHasPermission(PermissionType::CATEGORY_ADD);
129
130        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
131
132        $this->adminCategory->setUser($currentAdminUser);
133        $this->adminCategory->setGroups($currentAdminGroups);
134        $this->adminCategory->setLanguage($this->configuration->getLanguage()->getLanguage());
135        $this->adminCategory->loadCategories();
136
137        return $this->render(file: '@admin/content/category.add.twig', context: [
138            ...$this->getHeader($request),
139            ...$this->getFooter(),
140            ...$this->getBaseTemplateVars(),
141            'csrfTokenInput' => Token::getInstance($this->session)->getTokenInput(page: 'save-category'),
142            'faqLangCode' => $this->configuration->getLanguage()->getLanguage(),
143            'parentId' => 0,
144        ]);
145    }
146
147    /**
148     * @throws Exception
149     * @throws LoaderError
150     * @throws \Exception
151     */
152    #[Route(path: '/category/add/{parentId}/{language}', name: 'admin.category.add.child', methods: ['GET'])]
153    public function addChild(Request $request): Response
154    {
155        $this->userHasPermission(PermissionType::CATEGORY_ADD);
156
157        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
158
159        $this->adminCategory->setUser($currentAdminUser);
160        $this->adminCategory->setGroups($currentAdminGroups);
161        $this->adminCategory->setLanguage($this->configuration->getLanguage()->getLanguage());
162        $this->adminCategory->loadCategories();
163
164        $parentId = (int) Filter::filterVar($request->attributes->get(key: 'parentId'), FILTER_VALIDATE_INT);
165
166        $templateVars = [];
167        if ($this->currentUser->perm instanceof MediumPermission) {
168            $templateVars = [
169                'groupsOptions' => $this->currentUser->perm->getAllGroupsOptions([], $this->currentUser),
170            ];
171        }
172
173        return $this->render(file: '@admin/content/category.add.twig', context: [
174            ...$this->getHeader($request),
175            ...$this->getFooter(),
176            ...$this->getBaseTemplateVars(),
177            'faqLangCode' => $this->configuration->getLanguage()->getLanguage(),
178            'parentId' => $parentId,
179            'categoryNameLangCode' => LanguageCodes::get(
180                (string) ($this->adminCategory->categoryName[$parentId]['lang'] ?? 'en'),
181            ),
182            'userAllowed' => $this->categoryPermission->get(CategoryPermission::USER, [$parentId])[0] ?? -1,
183            'groupsAllowed' => $this->categoryPermission->get(CategoryPermission::GROUP, [$parentId]),
184            'categoryName' => $this->adminCategory->categoryName[$parentId]['name'],
185            'msgMainCategory' => Translation::get(key: 'msgMainCategory'),
186            ...$templateVars,
187        ]);
188    }
189
190    /**
191     * @throws Exception
192     * @throws LoaderError
193     * @throws \Exception
194     */
195    #[Route(path: '/category/create', name: 'admin.category.create', methods: ['POST'])]
196    public function create(Request $request): Response
197    {
198        $this->userHasPermission(PermissionType::CATEGORY_ADD);
199
200        $csrfToken = Filter::filterVar($request->request->get(key: 'pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
201        if (!Token::getInstance($this->session)->verifyToken(page: 'save-category', requestToken: $csrfToken)) {
202            throw new Exception('Invalid CSRF token');
203        }
204
205        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
206
207        $categoryPermission = new CategoryPermission($this->configuration);
208        $seo = $this->seo;
209
210        $category = new Category($this->configuration, [], false);
211        $category->setUser($currentAdminUser);
212        $category->setGroups($currentAdminGroups);
213
214        $parentId = (int) Filter::filterVar($request->request->get(key: 'parent_id'), FILTER_VALIDATE_INT);
215        $categoryId = $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqcategories', 'id');
216        $categoryLang = Filter::filterVar($request->request->get(key: 'lang'), FILTER_SANITIZE_SPECIAL_CHARS, '');
217
218        $uploadedFile = $request->files->get('image') ?? [];
219        if ($uploadedFile instanceof UploadedFile) {
220            $this->categoryImage->setUploadedFile($uploadedFile);
221        }
222
223        $hasUploadedImage = $uploadedFile instanceof UploadedFile;
224
225        $categoryEntity = new CategoryEntity();
226        $categoryEntity
227            ->setParentId($parentId)
228            ->setLang($categoryLang)
229            ->setName(Filter::filterVar($request->request->get(key: 'name'), FILTER_SANITIZE_SPECIAL_CHARS, ''))
230            ->setDescription(Filter::filterVar(
231                $request->request->get(key: 'description'),
232                FILTER_SANITIZE_SPECIAL_CHARS,
233            ))
234            ->setUserId(Filter::filterVar($request->request->get(key: 'user_id'), FILTER_VALIDATE_INT) ?? -1)
235            ->setGroupId(Filter::filterVar($request->request->get(key: 'group_id'), FILTER_VALIDATE_INT) ?? -1)
236            ->setActive((bool) Filter::filterVar($request->request->get(key: 'active'), FILTER_VALIDATE_INT))
237            ->setImage($hasUploadedImage ? $this->categoryImage->getFileName($categoryId, $categoryLang) : '')
238            ->setParentId($parentId)
239            ->setShowHome((bool) Filter::filterVar($request->request->get(key: 'show_home'), FILTER_VALIDATE_INT));
240
241        $permissions = [
242            'restricted_user' => [],
243            'restricted_groups' => [],
244        ];
245        $userPermissionMode = Filter::filterVar(
246            $request->request->get(key: 'userpermission'),
247            FILTER_SANITIZE_SPECIAL_CHARS,
248        );
249        if ('all' === $userPermissionMode) {
250            $permissions['restricted_user'] = [-1];
251        }
252
253        if ('all' !== $userPermissionMode) {
254            $restrictedUser = Filter::filterVar($request->request->get(key: 'restricted_users'), FILTER_VALIDATE_INT);
255            $permissions['restricted_user'] = $restrictedUser === null ? [] : [$restrictedUser];
256        }
257
258        $groupPermissionMode = Filter::filterVar(
259            $request->request->get(key: 'grouppermission'),
260            FILTER_SANITIZE_SPECIAL_CHARS,
261        );
262        if ('all' === $groupPermissionMode) {
263            $permissions['restricted_groups'] = [-1];
264        }
265
266        if ('all' !== $groupPermissionMode) {
267            $restrictedGroups = $request->request->all(key: 'restricted_groups');
268            $permissions['restricted_groups'] = array_values(array_filter(
269                array_map(static fn($value): ?int => Filter::filterVar($value, FILTER_VALIDATE_INT), $restrictedGroups),
270                static fn($value): bool => $value !== null,
271            ));
272        }
273
274        $templateVars = [
275            'msgHeaderCategoryMain' => Translation::get(key: 'msgHeaderCategoryOverview'),
276        ];
277
278        if ($category->checkIfCategoryExists($categoryEntity) > 0) {
279            $templateVars = [
280                ...$templateVars,
281                'isError' => true,
282                'errorMessage' => Translation::get(key: 'ad_categ_existing'),
283            ];
284        }
285
286        $categoryId = $category->create($categoryEntity);
287
288        if ($categoryId) {
289            $categoryPermission->add(CategoryPermission::USER, [$categoryId], $permissions['restricted_user']);
290            $categoryPermission->add(CategoryPermission::GROUP, [$categoryId], $permissions['restricted_groups']);
291
292            if ($hasUploadedImage) {
293                try {
294                    $this->categoryImage->upload();
295                } catch (Throwable $exception) {
296                    $templateVars = [
297                        ...$templateVars,
298                        'isWarning' => true,
299                        'warningMessage' => $exception->getMessage(),
300                    ];
301                }
302            }
303
304            // Category Order entry
305            $this->categoryOrder->add($categoryId, $parentId);
306
307            // SEO data
308            $seoEntity = new SeoEntity();
309            $seoEntity
310                ->setSeoType(SeoType::CATEGORY)
311                ->setReferenceId($categoryId)
312                ->setReferenceLanguage($categoryLang)
313                ->setTitle(Filter::filterVar(
314                    $request->request->get(key: 'serpTitle'),
315                    FILTER_SANITIZE_SPECIAL_CHARS,
316                    '',
317                ))
318                ->setDescription(Filter::filterVar(
319                    $request->request->get(key: 'serpDescription'),
320                    FILTER_SANITIZE_SPECIAL_CHARS,
321                    '',
322                ));
323            $seo->create($seoEntity);
324
325            // Admin Log
326            $this->adminLog->log($this->currentUser, AdminLogType::CATEGORY_ADD->value . ':' . $categoryId);
327
328            $templateVars = [
329                ...$templateVars,
330                'isSuccess' => true,
331                'successMessage' => Translation::get(key: 'ad_categ_added'),
332            ];
333        }
334
335        if (!$categoryId) {
336            $this->configuration->getLogger()->error('Failed to add category', [
337                'sqlError' => $this->configuration->getDb()->error(),
338            ]);
339
340            $templateVars = [
341                ...$templateVars,
342                'isError' => true,
343                'errorMessage' => Translation::get(key: 'ad_msg_mysqlerr'),
344            ];
345        }
346
347        return $this->render(file: '@admin/content/category.main.twig', context: [
348            ...$this->getHeader($request),
349            ...$this->getFooter(),
350            ...$this->getBaseTemplateVars(),
351            ...$templateVars,
352        ]);
353    }
354
355    /**
356     * @throws Exception
357     * @throws LoaderError
358     * @throws \Exception
359     */
360    #[Route(path: '/category/edit/{categoryId}', name: 'admin.category.edit', methods: ['GET'])]
361    public function edit(Request $request): Response
362    {
363        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
364
365        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
366
367        $categoryId = (int) Filter::filterVar(
368            $request->attributes->get(key: 'categoryId'),
369            FILTER_VALIDATE_INT,
370            default: 0,
371        );
372
373        $category = new Category($this->configuration, [], withPermission: false);
374        $category
375            ->setUser($currentAdminUser)
376            ->setGroups($currentAdminGroups)
377            ->setLanguage($this->configuration->getLanguage()->getLanguage());
378
379        $categoryEntity = $category->getCategoryData($categoryId);
380
381        $seoEntity = new SeoEntity();
382        $seoEntity->setSeoType(SeoType::CATEGORY);
383        $seoEntity->setReferenceId($categoryId);
384        $seoEntity->setReferenceLanguage($categoryEntity->getLang());
385
386        $seoData = $this->seo->get($seoEntity);
387
388        $userPermission = $this->categoryPermission->get(CategoryPermission::USER, [$categoryId]);
389        $allUsers = $userPermission[0] === -1;
390        $restrictedUsers = !$allUsers;
391
392        $groupPermission = $this->categoryPermission->get(CategoryPermission::GROUP, [$categoryId]);
393        $allGroups = $groupPermission[0] === -1;
394        $restrictedGroups = !$allGroups;
395
396        $header =
397            Translation::getString(key: 'ad_categ_edit_1')
398            . ' "'
399            . $categoryEntity->getName()
400            . '" '
401            . Translation::getString(key: 'ad_categ_edit_2');
402
403        $allGroupsOptions = '';
404        $restrictedGroupOptions = '';
405        $permission = $this->currentUser->perm;
406        if ($permission instanceof MediumPermission) {
407            $allGroupsOptions = $permission->getAllGroupsOptions([$categoryEntity->getGroupId()], $this->currentUser);
408            $restrictedGroupOptions = $permission->getAllGroupsOptions($groupPermission, $this->currentUser);
409        }
410
411        return $this->render(file: '@admin/content/category.edit.twig', context: [
412            ...$this->getHeader($request),
413            ...$this->getFooter(),
414            'header' => $header,
415            'categoryId' => $categoryId,
416            'categoryLanguage' => $categoryEntity->getLang(),
417            'parentId' => $categoryEntity->getParentId(),
418            'csrfInputToken' => Token::getInstance($this->session)->getTokenInput(page: 'update-category'),
419            'categoryImage' => $categoryEntity->getImage(),
420            'categoryName' => $categoryEntity->getName(),
421            'categoryDescription' => $categoryEntity->getDescription(),
422            'categoryActive' => 1 === (int) $categoryEntity->getActive() ? 'checked' : '',
423            'categoryShowHome' => 1 === (int) $categoryEntity->getShowHome() ? 'checked' : '',
424            'categoryImageReset' => Translation::get(key: 'msgCategoryImageReset'),
425            'userSelection' => $this->userHelper->getAllUsersForTemplate($categoryEntity->getUserId()),
426            'isMediumPermission' => $this->configuration->get(item: 'security.permLevel') !== 'basic',
427            'allGroupsOptions' => $allGroupsOptions,
428            'allGroups' => $allGroups ? 'checked' : '',
429            'restrictedGroups' => $restrictedGroups ? 'checked' : '',
430            'restrictedGroupsLabel' => Translation::get(key: 'ad_entry_restricted_groups'),
431            'restrictedGroupsOptions' => $restrictedGroupOptions,
432            'userPermissionLabel' => Translation::get(key: 'ad_entry_userpermission'),
433            'allUsers' => $allUsers ? 'checked' : '',
434            'restrictedUsers' => $restrictedUsers ? 'checked' : '',
435            'restrictedUsersLabel' => Translation::get(key: 'ad_entry_restricted_users'),
436            'serpTitle' => $seoData->getTitle(),
437            'serpDescription' => $seoData->getDescription(),
438            'buttonCancel' => Translation::get(key: 'ad_gen_cancel'),
439            'buttonUpdate' => Translation::get(key: 'ad_gen_save'),
440        ]);
441    }
442
443    /**
444     * @throws Exception
445     * @throws LoaderError
446     * @throws \Exception
447     */
448    #[Route(path: '/category/hierarchy', name: 'admin.category.hierarchy', methods: ['GET'])]
449    public function hierarchy(Request $request): Response
450    {
451        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
452
453        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
454
455        $category = new Category($this->configuration, [], withPermission: false);
456        $category->setUser($currentAdminUser);
457        $category->setGroups($currentAdminGroups);
458
459        $category->getMissingCategories();
460        $category->buildCategoryTree();
461
462        $currentLangCode = $this->configuration->getLanguage()->getLanguage();
463        $currentLanguage = LanguageCodes::get($currentLangCode);
464
465        // get languages in use for all categories via service
466        $categoryLanguageService = new CategoryLanguageService();
467        $languages = $categoryLanguageService->getLanguagesInUse($this->configuration); // [code => name]
468
469        $translations = [];
470        foreach ($category->getCategoryTree() as $cat) {
471            $categoryTreeId = (int) ($cat['id'] ?? 0);
472            // [code => name]
473            $existing = $categoryLanguageService->getExistingTranslations($this->configuration, $categoryTreeId);
474            $translations[$categoryTreeId] = array_keys($existing);
475        }
476
477        // Build language codes list: current first
478        $languageCodes = [$currentLangCode];
479        foreach (array_keys($languages) as $code) {
480            if ($code === $currentLangCode) {
481                continue;
482            }
483
484            $languageCodes[] = $code;
485        }
486
487        return $this->render(file: '@admin/content/category.hierarchy.twig', context: [
488            ...$this->getHeader($request),
489            ...$this->getFooter(),
490            'currentLanguage' => $currentLanguage,
491            'allLangs' => $languages,
492            'allLangCodes' => $languageCodes,
493            'categoryTree' => $category->getCategoryTree(),
494            'basePath' => $request->getBasePath(),
495            'faqlangcode' => $currentLangCode,
496            'msgCategoryRemark_overview' => Translation::get(key: 'msgCategoryRemark_overview'),
497            'categoryNameLabel' => Translation::get(key: 'categoryNameLabel'),
498            'ad_categ_translate' => Translation::get(key: 'ad_categ_translate'),
499            'ad_menu_categ_structure' => Translation::get(key: 'ad_menu_categ_structure'),
500            'msgAddCategory' => Translation::get(key: 'msgAddCategory'),
501            'msgHeaderCategoryOverview' => Translation::get(key: 'msgHeaderCategoryOverview'),
502            'msgCategory' => Translation::get(key: 'msgCategory'),
503            'translations' => $translations,
504            'ad_categ_translated' => Translation::get(key: 'ad_categ_translated'),
505        ]);
506    }
507
508    /**
509     * @throws Exception
510     * @throws LoaderError
511     * @throws \Exception
512     */
513    #[Route(path: '/category/translate/{categoryId}', name: 'admin.category.translate', methods: ['GET'])]
514    public function translate(Request $request): Response
515    {
516        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
517
518        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
519
520        $categoryPermission = new CategoryPermission($this->configuration);
521        $userHelper = new UserHelper($this->currentUser);
522
523        $category = new Category($this->configuration, [], withPermission: false);
524        $category->setUser($currentAdminUser);
525        $category->setGroups($currentAdminGroups);
526
527        $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
528        $translateTo = Filter::filterVar($request->query->get(key: 'translateTo'), FILTER_SANITIZE_SPECIAL_CHARS);
529
530        // Re-add permission arrays used in the template
531        $userPermission = $this->categoryPermission->get(CategoryPermission::USER, [$categoryId]);
532        $groupPermission = $this->categoryPermission->get(CategoryPermission::GROUP, [$categoryId]);
533
534        // Prepare language selection data via service
535        $categoryLanguageService = new CategoryLanguageService();
536        $toTranslate = $categoryLanguageService->getLanguagesToTranslate($this->configuration, $categoryId);
537
538        return $this->render(file: '@admin/content/category.translate.twig', context: [
539            ...$this->getHeader($request),
540            ...$this->getFooter(),
541            'categoryName' => $category->getCategoryName($categoryId),
542            'ad_categ_trans_1' => Translation::get(key: 'ad_categ_trans_1'),
543            'ad_categ_trans_2' => Translation::get(key: 'ad_categ_trans_2'),
544            'categoryId' => $categoryId,
545            'category' => $category->getCategoryName($categoryId),
546            'permLevel' => $this->configuration->get(item: 'security.permLevel'),
547            'groupPermission' => $groupPermission[0] ?? -1,
548            'userPermission' => $userPermission[0] ?? -1,
549            'csrfInputToken' => Token::getInstance($this->session)->getTokenInput(page: 'update-category'),
550            'categoryNameLabel' => Translation::get(key: 'categoryNameLabel'),
551            'ad_categ_lang' => Translation::get(key: 'ad_categ_lang'),
552            'languagesToTranslate' => $toTranslate,
553            'selectedLanguage' => $translateTo ?? '',
554            'categoryDescriptionLabel' => Translation::get(key: 'categoryDescriptionLabel'),
555            'categoryOwnerLabel' => Translation::get(key: 'categoryOwnerLabel'),
556            'userSelection' => $userHelper->getAllUsersForTemplate($category->getOwner($categoryId)),
557            'ad_categ_transalready' => Translation::get(key: 'ad_categ_transalready'),
558            'langTranslated' => $category->getCategoryLanguagesTranslated($categoryId),
559            'ad_categ_translatecateg' => Translation::get(key: 'ad_categ_translatecateg'),
560        ]);
561    }
562
563    /**
564     * @throws Exception
565     * @throws LoaderError
566     * @throws \Exception
567     */
568    #[Route(path: '/category/update', name: 'admin.category.update', methods: ['POST'])]
569    public function update(Request $request): Response
570    {
571        $this->userHasPermission(PermissionType::CATEGORY_EDIT);
572
573        $csrfToken = Filter::filterVar($request->request->get(key: 'pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
574        if (!Token::getInstance($this->session)->verifyToken(page: 'update-category', requestToken: $csrfToken)) {
575            throw new Exception(message: 'Invalid CSRF token');
576        }
577
578        [$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);
579
580        $categoryPermission = new CategoryPermission($this->configuration);
581
582        $category = new Category($this->configuration, [], withPermission: false);
583        $category->setUser($currentAdminUser);
584        $category->setGroups($currentAdminGroups);
585
586        $parentId = (int) Filter::filterVar($request->request->get(key: 'parent_id'), FILTER_VALIDATE_INT);
587        $categoryId = (int) Filter::filterVar($request->request->get(key: 'id'), FILTER_VALIDATE_INT);
588        $categoryLang = Filter::filterVar($request->request->get(key: 'catlang'), FILTER_SANITIZE_SPECIAL_CHARS, '');
589        $existingImage = Filter::filterVar(
590            $request->request->get(key: 'existing_image'),
591            FILTER_SANITIZE_SPECIAL_CHARS,
592        );
593
594        $uploadedFile = $request->files->get(key: 'image') ?? [];
595        if ($uploadedFile instanceof UploadedFile) {
596            $this->categoryImage->setUploadedFile($uploadedFile);
597        }
598
599        // Strip any path components: the category image is always a bare file
600        // name inside the upload directory. This prevents traversal sequences
601        // (e.g. "../") from being stored and later passed to Image::delete().
602        $existingImage = is_null($existingImage) ? '' : basename((string) $existingImage);
603        $hasUploadedImage = $uploadedFile instanceof UploadedFile;
604        $image = $hasUploadedImage ? $this->categoryImage->getFileName($categoryId, $categoryLang) : $existingImage;
605
606        $categoryEntity = new CategoryEntity();
607        $categoryEntity
608            ->setId($categoryId)
609            ->setLang($categoryLang)
610            ->setParentId($parentId)
611            ->setName(Filter::filterVar($request->request->get(key: 'name'), FILTER_SANITIZE_SPECIAL_CHARS, ''))
612            ->setDescription(Filter::filterVar(
613                $request->request->get(key: 'description'),
614                FILTER_SANITIZE_SPECIAL_CHARS,
615            ))
616            ->setUserId((int) Filter::filterVar($request->request->get(key: 'user_id'), FILTER_VALIDATE_INT))
617            ->setGroupId((int) Filter::filterVar($request->request->get(key: 'group_id'), FILTER_VALIDATE_INT) ?? -1)
618            ->setActive((bool) Filter::filterVar($request->request->get(key: 'active'), FILTER_VALIDATE_INT))
619            ->setImage($image)
620            ->setShowHome((bool) Filter::filterVar($request->request->get(key: 'show_home'), FILTER_VALIDATE_INT));
621
622        $permissions = [
623            'restricted_user' => [],
624            'restricted_groups' => [],
625        ];
626        $userPermissionMode = Filter::filterVar(
627            $request->request->get(key: 'userpermission'),
628            FILTER_SANITIZE_SPECIAL_CHARS,
629        );
630        if ('all' === $userPermissionMode) {
631            $permissions['restricted_user'] = [-1];
632        }
633
634        if ('all' !== $userPermissionMode) {
635            $restrictedUser = Filter::filterVar($request->request->get(key: 'restricted_users'), FILTER_VALIDATE_INT);
636            $permissions['restricted_user'] = $restrictedUser === null ? [] : [$restrictedUser];
637        }
638
639        $groupPermissionMode = Filter::filterVar(
640            $request->request->get(key: 'grouppermission'),
641            FILTER_SANITIZE_SPECIAL_CHARS,
642        );
643        if ('all' === $groupPermissionMode) {
644            $permissions['restricted_groups'] = [-1];
645        }
646
647        if ('all' !== $groupPermissionMode) {
648            $restrictedGroups = $request->request->all(key: 'restricted_groups');
649            $permissions['restricted_groups'] = array_values(array_filter(
650                array_map(static fn($value): ?int => Filter::filterVar($value, FILTER_VALIDATE_INT), $restrictedGroups),
651                static fn($value): bool => $value !== null,
652            ));
653        }
654
655        $templateVars = [
656            'msgHeaderCategoryMain' => Translation::get(key: 'msgHeaderCategoryOverview'),
657        ];
658
659        $needsTranslation = !$category->hasLanguage($categoryEntity->getId(), $categoryEntity->getLang());
660        if ($needsTranslation) {
661            $translated =
662                $category->create($categoryEntity)
663                && $categoryPermission->add(
664                    CategoryPermission::USER,
665                    [$categoryEntity->getId()],
666                    $permissions['restricted_user'],
667                )
668                && $categoryPermission->add(
669                    CategoryPermission::GROUP,
670                    [$categoryEntity->getId()],
671                    $permissions['restricted_groups'],
672                );
673            if ($translated) {
674                // Add SERP-Title and Description to the translated category
675                $seoEntity = new SeoEntity();
676                $seoEntity
677                    ->setSeoType(SeoType::CATEGORY)
678                    ->setReferenceId($categoryEntity->getId())
679                    ->setReferenceLanguage($categoryEntity->getLang())
680                    ->setTitle(Filter::filterVar(
681                        $request->request->get('serpTitle'),
682                        FILTER_SANITIZE_SPECIAL_CHARS,
683                        '',
684                    ))
685                    ->setDescription(Filter::filterVar(
686                        $request->request->get('serpDescription'),
687                        FILTER_SANITIZE_SPECIAL_CHARS,
688                        '',
689                    ));
690
691                $existingSeoId = $this->seo->get($seoEntity)->getId();
692                if ($existingSeoId === null) {
693                    $this->seo->create($seoEntity);
694                }
695
696                if ($existingSeoId !== null) {
697                    $this->seo->update($seoEntity);
698                }
699
700                $templateVars = [
701                    ...$templateVars,
702                    'isSuccess' => true,
703                    'successMessage' => Translation::get(key: 'ad_categ_translated'),
704                ];
705            }
706
707            if (!$translated) {
708                $templateVars = [
709                    ...$templateVars,
710                    'isError' => true,
711                    'errorMessage' => Translation::get(key: 'ad_msg_mysqlerr'),
712                ];
713
714                $this->configuration->getLogger()->error('Failed to translate category', [
715                    'categoryId' => $categoryId,
716                    'sqlError' => $this->configuration->getDb()->error(),
717                ]);
718            }
719        }
720
721        if (!$needsTranslation) {
722            $updated = $category->update($categoryEntity);
723            if ($updated) {
724                $categoryPermission->delete(CategoryPermission::USER, [$categoryEntity->getId()]);
725                $categoryPermission->delete(CategoryPermission::GROUP, [$categoryEntity->getId()]);
726                $categoryPermission->add(
727                    CategoryPermission::USER,
728                    [$categoryEntity->getId()],
729                    $permissions['restricted_user'],
730                );
731                $categoryPermission->add(
732                    CategoryPermission::GROUP,
733                    [$categoryEntity->getId()],
734                    $permissions['restricted_groups'],
735                );
736
737                if ($hasUploadedImage) {
738                    try {
739                        $this->categoryImage->upload();
740                    } catch (Throwable $exception) {
741                        $templateVars = [
742                            ...$templateVars,
743                            'isWarning' => true,
744                            'warningMessage' => $exception->getMessage(),
745                        ];
746                    }
747                }
748
749                // SEO data
750                $seoEntity = new SeoEntity();
751                $seoEntity
752                    ->setSeoType(SeoType::CATEGORY)
753                    ->setReferenceId($categoryId)
754                    ->setReferenceLanguage($categoryLang)
755                    ->setTitle(Filter::filterVar(
756                        $request->request->get('serpTitle'),
757                        FILTER_SANITIZE_SPECIAL_CHARS,
758                        '',
759                    ))
760                    ->setDescription(Filter::filterVar(
761                        $request->request->get('serpDescription'),
762                        FILTER_SANITIZE_SPECIAL_CHARS,
763                        '',
764                    ));
765
766                $existingSeoId = $this->seo->get($seoEntity)->getId();
767                if ($existingSeoId === null) {
768                    $this->seo->create($seoEntity);
769                }
770
771                if ($existingSeoId !== null) {
772                    $this->seo->update($seoEntity);
773                }
774
775                // Admin Log
776                $this->adminLog->log($this->currentUser, AdminLogType::CATEGORY_EDIT->value . ':' . $categoryId);
777
778                $templateVars = [
779                    ...$templateVars,
780                    'isSuccess' => true,
781                    'successMessage' => Translation::get(key: 'ad_categ_updated'),
782                ];
783            }
784
785            if (!$updated) {
786                $this->configuration->getLogger()->error('Failed to update category', [
787                    'categoryId' => $categoryId,
788                    'sqlError' => $this->configuration->getDb()->error(),
789                ]);
790
791                $templateVars = [
792                    ...$templateVars,
793                    'isError' => true,
794                    'errorMessage' => Translation::get(key: 'ad_msg_mysqlerr'),
795                ];
796            }
797        }
798
799        return $this->render(file: '@admin/content/category.main.twig', context: [
800            ...$this->getHeader($request),
801            ...$this->getFooter(),
802            ...$this->getBaseTemplateVars(),
803            ...$templateVars,
804        ]);
805    }
806
807    /**
808     * @throws \Exception
809     * @return array<string, mixed>
810     */
811    private function getBaseTemplateVars(): array
812    {
813        return [
814            'csrfTokenInput' => Token::getInstance($this->session)->getTokenInput(page: 'save-category'),
815            'userSelection' => $this->userHelper->getAllUsersForTemplate(),
816            'permLevel' => $this->configuration->get(item: 'security.permLevel'),
817            'msgAccessAllUsers' => Translation::get(key: 'msgAccessAllUsers'),
818            'ad_entry_restricted_users' => Translation::get(key: 'ad_entry_restricted_users'),
819            'ad_entry_userpermission' => Translation::get(key: 'ad_entry_userpermission'),
820            'ad_categ_add' => Translation::get(key: 'ad_categ_add'),
821            'ad_entry_restricted_groups' => Translation::get(key: 'ad_entry_restricted_groups'),
822            'restricted_groups' => $this->currentUser->perm instanceof MediumPermission
823                ? $this->currentUser->perm->getAllGroupsOptions([], $this->currentUser)
824                : '',
825            'buttonCancel' => Translation::get(key: 'ad_gen_cancel'),
826        ];
827    }
828}