Lines 78.96% 304 / 385
Methods 55.55% 20 / 36
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 listGroups 100.00% 12 / 12 100.00% 1 / 1 3
 listUsers 100.00% 10 / 10 100.00% 1 / 1 2
 groupData 83.33% 5 / 6 0.00% 0 / 1 2.02
 listMembers 92.30% 12 / 13 0.00% 0 / 1 3.00
 listPermissions 83.33% 5 / 6 0.00% 0 / 1 2.02
 listCategoryRestrictions 0.00% 0 / 7 0.00% 0 / 1 12
 saveCategoryRestrictions 0.00% 0 / 29 0.00% 0 / 1 72
 updateGroup 76.74% 33 / 43 0.00% 0 / 1 13.81
 updateMembers 86.27% 44 / 51 0.00% 0 / 1 14.51
 updatePermissions 83.33% 35 / 42 0.00% 0 / 1 13.78
 deleteGroup 66.66% 12 / 18 0.00% 0 / 1 7.33
 listCategories 100.00% 11 / 11 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController] initializeFromContainer 80.00% 4 / 5 0.00% 0 / 1 2.03
 [phpMyFAQ\Controller\AbstractController] __construct 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] render 100.00% 5 / 5 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] renderView 0.00% 0 / 3 0.00% 0 / 1 2
 [phpMyFAQ\Controller\AbstractController] json 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getJsonObject 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\Controller\AbstractController] getTwigWrapper 100.00% 10 / 10 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] hasValidToken 85.71% 6 / 7 0.00% 0 / 1 5.07
 [phpMyFAQ\Controller\AbstractController] isSecured 100.00% 10 / 10 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] isPublicAuthenticationPath 100.00% 23 / 23 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] userIsAuthenticated 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userIsSuperAdmin 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userHasGroupPermission 100.00% 8 / 8 100.00% 1 / 1 6
 [phpMyFAQ\Controller\AbstractController] userHasUserPermission 100.00% 7 / 7 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] userHasPermission 100.00% 5 / 5 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] userHasAnyPermission 100.00% 10 / 10 100.00% 1 / 1 4
 [phpMyFAQ\Controller\AbstractController] verifySessionCsrfToken 70.00% 7 / 10 0.00% 0 / 1 4.43
 [phpMyFAQ\Controller\AbstractController] captchaCodeIsValid 85.71% 6 / 7 0.00% 0 / 1 2.01
 [phpMyFAQ\Controller\AbstractController] isApiEnabled 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addExtension 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addFilter 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getRateLimiter 100.00% 4 / 4 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] createFallbackContainer 71.42% 5 / 7 0.00% 0 / 1 2.09
38final class GroupController extends AbstractAdministrationApiController
39{
40    /**
41     * @throws Exception
42     */
43    #[Route(path: 'group/groups', name: 'admin.api.group.groups', methods: ['GET'])]
44    public function listGroups(): JsonResponse
45    {
46        $this->userHasGroupPermission();
47
48        $currentUser = CurrentUser::getCurrentUser($this->configuration);
49
50        $groups = [];
51        $permission = $currentUser->perm;
52        if ($permission instanceof MediumPermission) {
53            foreach ($permission->getAllGroups($currentUser) as $groupId) {
54                $data = $permission->getGroupData((int) $groupId);
55                $groups[] = [
56                    'group_id' => (int) ($data['group_id'] ?? 0),
57                    'name' => (string) ($data['name'] ?? ''),
58                ];
59            }
60        }
61
62        return $this->json($groups, Response::HTTP_OK);
63    }
64
65    /**
66     * @throws Exception
67     */
68    #[Route(path: 'group/users', name: 'admin.api.group.users', methods: ['GET'])]
69    public function listUsers(): JsonResponse
70    {
71        $this->userHasGroupPermission();
72
73        $currentUser = CurrentUser::getCurrentUser($this->configuration);
74
75        $users = [];
76        foreach ($currentUser->getAllUsers(true, false) as $singleUser) {
77            $currentUser->getUserById($singleUser, true);
78            $users[] = [
79                'user_id' => $currentUser->getUserId(),
80                'login' => $currentUser->getLogin(),
81            ];
82        }
83
84        return $this->json($users, Response::HTTP_OK);
85    }
86
87    /**
88     * @throws Exception
89     */
90    #[Route(path: 'group/data/{groupId}', name: 'admin.api.group.data', methods: ['GET'])]
91    public function groupData(Request $request): JsonResponse
92    {
93        $this->userHasGroupPermission();
94
95        $currentUser = CurrentUser::getCurrentUser($this->configuration);
96
97        $groupId = (int) $request->attributes->get('groupId');
98
99        if (!$currentUser->perm instanceof MediumPermission) {
100            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
101        }
102
103        return $this->json($currentUser->perm->getGroupData($groupId), Response::HTTP_OK);
104    }
105
106    /**
107     * @throws Exception
108     */
109    #[Route(path: 'group/members/{groupId}', name: 'admin.api.group.members', methods: ['GET'])]
110    public function listMembers(Request $request): JsonResponse
111    {
112        $this->userHasGroupPermission();
113
114        $currentUser = CurrentUser::getCurrentUser($this->configuration);
115
116        $groupId = (int) $request->attributes->get('groupId');
117
118        if (!$currentUser->perm instanceof MediumPermission) {
119            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
120        }
121
122        $members = [];
123        foreach ($currentUser->perm->getGroupMembers($groupId) as $groupMember) {
124            $currentUser->getUserById((int) $groupMember, true);
125            $members[] = [
126                'user_id' => $currentUser->getUserId(),
127                'login' => $currentUser->getLogin(),
128            ];
129        }
130
131        return $this->json($members, Response::HTTP_OK);
132    }
133
134    /**
135     * @throws Exception
136     */
137    #[Route(path: 'group/permissions/{groupId}', name: 'admin.api.group.permissions', methods: ['GET'])]
138    public function listPermissions(Request $request): JsonResponse
139    {
140        $this->userHasGroupPermission();
141
142        $currentUser = CurrentUser::getCurrentUser($this->configuration);
143
144        $groupId = (int) $request->attributes->get('groupId');
145
146        if (!$currentUser->perm instanceof MediumPermission) {
147            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
148        }
149
150        return $this->json($currentUser->perm->getGroupRights($groupId), Response::HTTP_OK);
151    }
152
153    /**
154     * @throws Exception
155     */
156    #[Route(
157        path: 'group/category-restrictions/{groupId}',
158        name: 'admin.api.group.category-restrictions',
159        methods: ['GET'],
160    )]
161    public function listCategoryRestrictions(Request $request): JsonResponse
162    {
163        $this->userHasGroupPermission();
164
165        $currentUser = CurrentUser::getCurrentUser($this->configuration);
166
167        $groupId = (int) $request->attributes->get('groupId');
168
169        if (!$currentUser->perm instanceof MediumPermission) {
170            return $this->json(new \stdClass(), Response::HTTP_OK);
171        }
172
173        $restrictions = $currentUser->perm->getAllCategoryRestrictions($groupId);
174
175        return $this->json($restrictions === [] ? new \stdClass() : $restrictions, Response::HTTP_OK);
176    }
177
178    /**
179     * @throws Exception
180     */
181    #[Route(path: 'group/category-restrictions', name: 'admin.api.group.category-restrictions.save', methods: ['POST'])]
182    public function saveCategoryRestrictions(Request $request): JsonResponse
183    {
184        $this->userHasGroupPermission();
185
186        $currentUser = CurrentUser::getCurrentUser($this->configuration);
187
188        if (!$currentUser->perm instanceof MediumPermission) {
189            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
190        }
191
192        $data = json_decode($request->getContent(), associative: true);
193        if (!is_array($data)) {
194            return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
195        }
196
197        if (!Token::getInstance($this->session)->verifyToken(
198            'save-category-restrictions',
199            (string) ($data['csrfToken'] ?? ''),
200        )) {
201            return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
202        }
203
204        $groupId = (int) ($data['groupId'] ?? 0);
205        $rightId = (int) ($data['rightId'] ?? 0);
206
207        if ($groupId <= 0 || $rightId <= 0) {
208            return $this->json(['error' => 'Invalid group or right ID.'], Response::HTTP_BAD_REQUEST);
209        }
210
211        $rawCategoryIds = $data['categoryIds'] ?? [];
212        if (!is_array($rawCategoryIds)) {
213            return $this->json(['error' => 'categoryIds must be an array.'], Response::HTTP_BAD_REQUEST);
214        }
215
216        $categoryIds = array_values(array_filter(
217            array_map('intval', $rawCategoryIds),
218            static fn(int $id): bool => $id > 0,
219        ));
220
221        $success = $currentUser->perm->setCategoryRestrictions($groupId, $rightId, $categoryIds);
222
223        if (!$success) {
224            return $this->json([
225                'error' => 'Failed to save category restrictions.',
226            ], Response::HTTP_INTERNAL_SERVER_ERROR);
227        }
228
229        return $this->json(['success' => true], Response::HTTP_OK);
230    }
231
232    /**
233     * @throws Exception
234     */
235    #[Route(path: 'group/update', name: 'admin.api.group.update', methods: ['POST'])]
236    public function updateGroup(Request $request): JsonResponse
237    {
238        $this->userHasPermission(PermissionType::GROUP_EDIT);
239
240        $data = json_decode($request->getContent(), associative: true);
241        if (!is_array($data)) {
242            return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
243        }
244
245        if (!Token::getInstance($this->session)->verifyToken('update-group', (string) ($data['csrfToken'] ?? ''))) {
246            return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
247        }
248
249        $groupId = (int) ($data['groupId'] ?? 0);
250        if ($groupId <= 0) {
251            return $this->json(['error' => 'Invalid group ID.'], Response::HTTP_BAD_REQUEST);
252        }
253
254        $name = Filter::filterVar($data['name'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
255        if (trim($name) === '') {
256            return $this->json(['error' => Translation::get('ad_group_error_noName')], Response::HTTP_BAD_REQUEST);
257        }
258
259        $autoJoin = (bool) ($data['autoJoin'] ?? false);
260
261        // Enabling auto-join makes every newly registered user inherit this group's rights,
262        // so a non-SuperAdmin may only enable it on a group whose rights they fully hold
263        // (same escalation rule as membership management, fail closed).
264        if ($autoJoin && !$this->currentUser->isSuperAdmin()) {
265            if (!$this->currentUser->perm instanceof MediumPermission) {
266                return $this->json([
267                    'error' => 'Cannot enable auto-join without group permission support.',
268                ], Response::HTTP_FORBIDDEN);
269            }
270
271            $actingUserId = $this->currentUser->getUserId();
272            foreach ($this->currentUser->perm->getGroupRights($groupId) as $groupRight) {
273                if (!$this->currentUser->perm->hasPermission($actingUserId, (int) $groupRight)) {
274                    return $this->json([
275                        'error' => 'Cannot enable auto-join on a group whose rights you do not hold.',
276                    ], Response::HTTP_FORBIDDEN);
277                }
278            }
279        }
280
281        $currentUser = CurrentUser::getCurrentUser($this->configuration);
282        if (!$currentUser->perm instanceof MediumPermission) {
283            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
284        }
285
286        $groupData = [
287            'name' => $name,
288            'description' => (string) Filter::filterVar($data['description'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS, ''),
289            'auto_join' => $autoJoin,
290        ];
291
292        if (!$currentUser->perm->changeGroup($groupId, $groupData)) {
293            return $this->json(['error' => Translation::get('ad_msg_mysqlerr')], Response::HTTP_INTERNAL_SERVER_ERROR);
294        }
295
296        $this->adminLog?->log($this->currentUser, AdminLogType::GROUP_EDIT->value . ':' . $groupId);
297
298        return $this->json([
299            'success' => sprintf(
300                '%s %s %s',
301                Translation::getString('ad_msg_savedsuc_1'),
302                $currentUser->perm->getGroupName($groupId),
303                Translation::getString('ad_msg_savedsuc_2'),
304            ),
305        ], Response::HTTP_OK);
306    }
307
308    /**
309     * @throws Exception
310     */
311    #[Route(path: 'group/members', name: 'admin.api.group.members.update', methods: ['POST'])]
312    public function updateMembers(Request $request): JsonResponse
313    {
314        $this->userHasPermission(PermissionType::GROUP_EDIT);
315
316        $data = json_decode($request->getContent(), associative: true);
317        if (!is_array($data)) {
318            return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
319        }
320
321        if (!Token::getInstance($this->session)->verifyToken(
322            'update-group-members',
323            (string) ($data['csrfToken'] ?? ''),
324        )) {
325            return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
326        }
327
328        $groupId = (int) ($data['groupId'] ?? 0);
329        if ($groupId <= 0) {
330            return $this->json(['error' => 'Invalid group ID.'], Response::HTTP_BAD_REQUEST);
331        }
332
333        $rawMemberIds = $data['memberIds'] ?? [];
334        if (!is_array($rawMemberIds)) {
335            return $this->json(['error' => 'memberIds must be an array.'], Response::HTTP_BAD_REQUEST);
336        }
337
338        $memberIds = array_values(array_filter(
339            array_map('intval', $rawMemberIds),
340            static fn(int $id): bool => $id > 0,
341        ));
342
343        // A non-SuperAdmin may only manage membership of a group whose rights they fully hold
344        // themselves. Otherwise an administrator with the delegable GROUP_EDIT right could join
345        // themselves (or anyone else) to a privileged group and inherit rights they do not possess
346        // (privilege escalation via group membership inheritance).
347        if (!$this->currentUser->isSuperAdmin()) {
348            // Fail closed: if the permission backend cannot enumerate group rights, we cannot prove
349            // the acting user holds them, so the operation must be denied rather than allowed.
350            if (!$this->currentUser->perm instanceof MediumPermission) {
351                return $this->json([
352                    'error' => 'Cannot manage group membership without group permission support.',
353                ], Response::HTTP_FORBIDDEN);
354            }
355
356            $actingUserId = $this->currentUser->getUserId();
357            foreach ($this->currentUser->perm->getGroupRights($groupId) as $groupRight) {
358                if (!$this->currentUser->perm->hasPermission($actingUserId, (int) $groupRight)) {
359                    return $this->json([
360                        'error' => 'Cannot manage a group whose rights you do not hold.',
361                    ], Response::HTTP_FORBIDDEN);
362                }
363            }
364        }
365
366        $currentUser = CurrentUser::getCurrentUser($this->configuration);
367        if (!$currentUser->perm instanceof MediumPermission) {
368            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
369        }
370
371        if (!$currentUser->perm->removeAllUsersFromGroup($groupId)) {
372            return $this->json(['error' => Translation::get('ad_msg_mysqlerr')], Response::HTTP_INTERNAL_SERVER_ERROR);
373        }
374
375        $failed = false;
376        foreach ($memberIds as $memberId) {
377            if ($currentUser->perm->addToGroup($memberId, $groupId)) {
378                continue;
379            }
380
381            $failed = true;
382        }
383
384        if ($failed) {
385            return $this->json(['error' => Translation::get('ad_msg_mysqlerr')], Response::HTTP_INTERNAL_SERVER_ERROR);
386        }
387
388        $this->adminLog?->log($this->currentUser, AdminLogType::GROUP_EDIT->value . ' (members):' . $groupId);
389
390        return $this->json([
391            'success' => sprintf(
392                '%s %s %s',
393                Translation::getString('ad_msg_savedsuc_1'),
394                $currentUser->perm->getGroupName($groupId),
395                Translation::getString('ad_msg_savedsuc_2'),
396            ),
397        ], Response::HTTP_OK);
398    }
399
400    /**
401     * @throws Exception
402     */
403    #[Route(path: 'group/permissions', name: 'admin.api.group.permissions.update', methods: ['POST'])]
404    public function updatePermissions(Request $request): JsonResponse
405    {
406        $this->userHasPermission(PermissionType::GROUP_EDIT);
407
408        $data = json_decode($request->getContent(), associative: true);
409        if (!is_array($data)) {
410            return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
411        }
412
413        if (!Token::getInstance($this->session)->verifyToken(
414            'update-group-permissions',
415            (string) ($data['csrfToken'] ?? ''),
416        )) {
417            return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
418        }
419
420        $groupId = (int) ($data['groupId'] ?? 0);
421        if ($groupId <= 0) {
422            return $this->json(['error' => 'Invalid group ID.'], Response::HTTP_BAD_REQUEST);
423        }
424
425        $rawRightIds = $data['rightIds'] ?? [];
426        if (!is_array($rawRightIds)) {
427            return $this->json(['error' => 'rightIds must be an array.'], Response::HTTP_BAD_REQUEST);
428        }
429
430        $rightIds = array_values(array_filter(array_map('intval', $rawRightIds), static fn(int $id): bool => $id > 0));
431
432        // A non-SuperAdmin may only assign rights they hold themselves. This prevents an
433        // administrator with the delegable GROUP_EDIT right from granting privileges they do not
434        // possess to a group (privilege escalation via group membership inheritance).
435        if (!$this->currentUser->isSuperAdmin()) {
436            $actingUserId = $this->currentUser->getUserId();
437            foreach ($rightIds as $rightId) {
438                if (!$this->currentUser->perm->hasPermission($actingUserId, $rightId)) {
439                    return $this->json(['error' => 'Cannot grant a right you do not hold.'], Response::HTTP_FORBIDDEN);
440                }
441            }
442        }
443
444        $currentUser = CurrentUser::getCurrentUser($this->configuration);
445        if (!$currentUser->perm instanceof MediumPermission) {
446            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
447        }
448
449        if (!$currentUser->perm->refuseAllGroupRights($groupId)) {
450            return $this->json(['error' => Translation::get('ad_msg_mysqlerr')], Response::HTTP_INTERNAL_SERVER_ERROR);
451        }
452
453        $failed = false;
454        foreach ($rightIds as $rightId) {
455            if ($currentUser->perm->grantGroupRight($groupId, $rightId)) {
456                continue;
457            }
458
459            $failed = true;
460        }
461
462        if ($failed) {
463            return $this->json(['error' => Translation::get('ad_msg_mysqlerr')], Response::HTTP_INTERNAL_SERVER_ERROR);
464        }
465
466        $this->adminLog?->log($this->currentUser, AdminLogType::GROUP_CHANGE_PERMISSIONS->value . ':' . $groupId);
467
468        return $this->json([
469            'success' => sprintf(
470                '%s %s %s',
471                Translation::getString('ad_msg_savedsuc_1'),
472                $currentUser->perm->getGroupName($groupId),
473                Translation::getString('ad_msg_savedsuc_2'),
474            ),
475        ], Response::HTTP_OK);
476    }
477
478    /**
479     * @throws Exception
480     */
481    #[Route(path: 'group/delete', name: 'admin.api.group.delete', methods: ['POST'])]
482    public function deleteGroup(Request $request): JsonResponse
483    {
484        $this->userHasPermission(PermissionType::GROUP_DELETE);
485
486        $data = json_decode($request->getContent(), associative: true);
487        if (!is_array($data)) {
488            return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
489        }
490
491        if (!Token::getInstance($this->session)->verifyToken('delete-group', (string) ($data['csrfToken'] ?? ''))) {
492            return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
493        }
494
495        $groupId = (int) ($data['groupId'] ?? 0);
496        if ($groupId <= 0) {
497            return $this->json(['error' => 'Invalid group ID.'], Response::HTTP_BAD_REQUEST);
498        }
499
500        $currentUser = CurrentUser::getCurrentUser($this->configuration);
501        if (!$currentUser->perm instanceof MediumPermission) {
502            return $this->json(['error' => 'Group permissions are not enabled.'], Response::HTTP_BAD_REQUEST);
503        }
504
505        if (!$currentUser->perm->deleteGroup($groupId)) {
506            return $this->json([
507                'error' => Translation::get('ad_group_error_delete'),
508            ], Response::HTTP_INTERNAL_SERVER_ERROR);
509        }
510
511        $this->adminLog?->log($this->currentUser, AdminLogType::GROUP_DELETE->value . ':' . $groupId);
512
513        return $this->json(['success' => Translation::get('ad_group_deleted')], Response::HTTP_OK);
514    }
515
516    /**
517     * @throws Exception
518     */
519    #[Route(path: 'group/categories', name: 'admin.api.group.categories', methods: ['GET'])]
520    public function listCategories(): JsonResponse
521    {
522        $this->userHasGroupPermission();
523
524        $category = new Category($this->configuration);
525        // Restrict to the current UI language â€” loadCategories() would
526        // otherwise return every translation of each category, with an
527        // arbitrary one winning the per-id overwrite.
528        $category->setLanguage($this->configuration->getLanguage()->getLanguage());
529
530        // Depth-first tree order with nesting levels, sibling order taken
531        // from the stored category order (same as the category overview page).
532        $orderedCategories = new Order($this->configuration)->getOrderedFlatList($category->loadCategories());
533
534        $categories = array_map(static fn(array $cat): array => [
535            'id' => $cat['id'],
536            'name' => $cat['name'],
537            'parent_id' => $cat['parent_id'],
538            'level' => $cat['level'],
539        ], $orderedCategories);
540
541        return $this->json($categories, Response::HTTP_OK);
542    }
543}

Inherited from phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController

31    protected function initializeFromContainer(): void
32    {
33        parent::initializeFromContainer();
34
35        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
36        if (!$adminLog instanceof AdminLog) {
37            throw new \LogicException('AdminLog service not found in container.');
38        }
39
40        $this->adminLog = $adminLog;
41    }

Inherited from phpMyFAQ\Controller\AbstractController

83    public function __construct()
84    {
85        $this->container = ContainerRegistry::get() ?? $this->createFallbackContainer();
86        $this->initializeFromContainer();
87    }
93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
137    public function render(string $file, array $context = [], ?Response $response = null): Response
138    {
139        $response ??= new Response();
140        $twigWrapper = $this->getTwigWrapper();
141        $templateWrapper = $twigWrapper->loadTemplate($file);
142
143        $response->setContent($templateWrapper->render($context));
144
145        return $response;
146    }
154    public function renderView(string $pathToTwigFile, array $templateVars = []): string
155    {
156        $twigWrapper = $this->getTwigWrapper();
157        $templateWrapper = $twigWrapper->loadTemplate($pathToTwigFile);
158
159        return $templateWrapper->render($templateVars);
160    }
167    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
168    {
169        return new JsonResponse($data, $status, $headers);
170    }
182    protected function getJsonObject(Request $request): \stdClass
183    {
184        /* @mago-expect analysis:mixed-assignment - json_decode() is mixed by nature; validated to stdClass below */
185        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
186
187        if (!$data instanceof \stdClass) {
188            throw new JsonException('The request body must be a JSON object.');
189        }
190
191        return $data;
192    }
197    public function getTwigWrapper(): TwigWrapper
198    {
199        $twigWrapper = new TwigWrapper(
200            (string) PMF_ROOT_DIR . '/assets/templates',
201            false,
202            $this->configuration->getTemplateSet(),
203        );
204
205        foreach ($this->twigExtensions as $twigExtension) {
206            $twigWrapper->addExtension($twigExtension);
207        }
208
209        foreach ($this->twigFilters as $twigFilter) {
210            $twigWrapper->addFilter($twigFilter);
211        }
212
213        return $twigWrapper;
214    }
219    protected function hasValidToken(): void
220    {
221        $configuredToken = $this->configuration->get(item: 'api.apiClientToken');
222        if (!is_string($configuredToken) || $configuredToken === '') {
223            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
224        }
225
226        $request = Request::createFromGlobals();
227        $requestToken = $request->headers->get(key: 'x-pmf-token');
228        if (!is_string($requestToken) || !hash_equals($configuredToken, $requestToken)) {
229            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
230        }
231    }
236    protected function isSecured(): void
237    {
238        if ($this->currentUser->isLoggedIn()) {
239            return;
240        }
241
242        if (!$this->configuration->get(item: 'security.enableLoginOnly')) {
243            return;
244        }
245
246        $request = Request::createFromGlobals();
247        $pathInfo = rtrim($request->getPathInfo(), characters: '/');
248        $pathInfo = $pathInfo === '' ? '/' : $pathInfo;
249
250        if ($this->isPublicAuthenticationPath($pathInfo)) {
251            return;
252        }
253
254        throw new UnauthorizedHttpException(challenge: 'You are not allowed to view this content.');
255    }
257    private function isPublicAuthenticationPath(string $pathInfo): bool
258    {
259        $publicAuthenticationPaths = [
260            '/login',
261            '/authenticate',
262            '/forgot-password',
263            '/token',
264            '/check',
265            '/contact.html',
266            '/imprint.html',
267            '/privacy.html',
268            '/terms.html',
269            '/accessibility.html',
270            '/auth/azure/authorize',
271            '/auth/azure/callback',
272            '/auth/azure/callback.php',
273            '/auth/keycloak/authorize',
274            '/auth/keycloak/callback',
275            '/auth/keycloak/logout',
276            '/services/azure/callback',
277            '/services/azure/callback.php',
278            '/api/webauthn/prepare-login',
279            '/api/webauthn/login',
280        ];
281
282        return in_array($pathInfo, $publicAuthenticationPaths, strict: true);
283    }
288    public function userIsAuthenticated(): void
289    {
290        if (!$this->currentUser->isLoggedIn()) {
291            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
292        }
293    }
298    protected function userIsSuperAdmin(): void
299    {
300        if (!$this->currentUser->isSuperAdmin()) {
301            throw new UnauthorizedHttpException(challenge: 'User is not super admin.');
302        }
303    }
308    protected function userHasGroupPermission(): void
309    {
310        if (!$this->currentUser->isLoggedIn()) {
311            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
312        }
313
314        $currentUser = $this->currentUser;
315        if (
316            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
317            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
318            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
319            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::GROUP_EDIT->value)
320        ) {
321            throw new ForbiddenException(message: 'User has no group permission.');
322        }
323    }
328    protected function userHasUserPermission(): void
329    {
330        if (!$this->currentUser->isLoggedIn()) {
331            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
332        }
333
334        $currentUser = $this->currentUser;
335        if (
336            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
337            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
338            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
339        ) {
340            throw new ForbiddenException(message: 'User has no user permission.');
341        }
342    }
347    protected function userHasPermission(PermissionType $permissionType): void
348    {
349        if (!$this->currentUser->isLoggedIn()) {
350            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
351        }
352
353        $currentUser = $this->currentUser;
354        if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
355            throw new ForbiddenException(message: sprintf('User has no "%s" permission.', $permissionType->name));
356        }
357    }
364    protected function userHasAnyPermission(PermissionType ...$permissionTypes): void
365    {
366        if (!$this->currentUser->isLoggedIn()) {
367            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
368        }
369
370        $currentUser = $this->currentUser;
371        foreach ($permissionTypes as $permissionType) {
372            if ($currentUser->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
373                return;
374            }
375        }
376
377        throw new ForbiddenException(message: sprintf('User has none of the required permissions: %s.', implode(', ', array_map(
378            static fn(PermissionType $type): string => $type->name,
379            $permissionTypes,
380        ))));
381    }
389    protected function verifySessionCsrfToken(string $page, #[\SensitiveParameter] string $requestToken): bool
390    {
391        if ($requestToken === '') {
392            return false;
393        }
394
395        $sessionKey = sprintf('pmf-csrf-token.%s', $page);
396        $storedToken = $this->session->get($sessionKey);
397
398        if (!$storedToken instanceof Token) {
399            return false;
400        }
401
402        if (time() > $storedToken->getExpiry()) {
403            $this->session->remove($sessionKey);
404            return false;
405        }
406
407        return hash_equals($storedToken->getSessionToken(), $requestToken);
408    }
414    protected function captchaCodeIsValid(Request $request): bool
415    {
416        $captcha = Captcha::getInstance($this->configuration);
417        $captcha->setUserIsLoggedIn($this->currentUser->isLoggedIn());
418
419        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
420
421        $code = Filter::filterVar($data->captcha ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
422        if ($this->configuration->get(item: 'security.enableGoogleReCaptchaV2')) {
423            $code = Filter::filterVar($data->{'g-recaptcha-response'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
424        }
425
426        return $captcha->checkCaptchaCode((string) $code);
427    }
429    public function isApiEnabled(): bool
430    {
431        return (bool) $this->configuration->get(item: 'api.enableAccess');
432    }
434    public function addExtension(ExtensionInterface $extension): void
435    {
436        $this->twigExtensions[] = $extension;
437    }
439    public function addFilter(TwigFilter $twigFilter): void
440    {
441        $this->twigFilters[] = $twigFilter;
442    }
444    protected function getRateLimiter(): ?RateLimiter
445    {
446        if (!$this->container->has('phpmyfaq.http.rate-limiter')) {
447            return null;
448        }
449
450        $rateLimiter = $this->container->get('phpmyfaq.http.rate-limiter');
451
452        return $rateLimiter instanceof RateLimiter ? $rateLimiter : null;
453    }
455    private function createFallbackContainer(): ContainerBuilder
456    {
457        $containerBuilder = new ContainerBuilder();
458        $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
459        try {
460            $phpFileLoader->load(resource: '../../services.php');
461        } catch (\Exception $exception) {
462            error_log($exception->getMessage());
463        }
464
465        // Register Forms services
466        FormsServiceProvider::register($containerBuilder);
467
468        return $containerBuilder;
469    }