Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.18% covered (warning)
72.18%
179 / 248
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
GroupController
72.18% covered (warning)
72.18%
179 / 248
25.00% covered (danger)
25.00%
3 / 12
171.54
0.00% covered (danger)
0.00%
0 / 1
 listGroups
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 listUsers
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 groupData
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 listMembers
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 listPermissions
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 listCategoryRestrictions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 saveCategoryRestrictions
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 updateGroup
76.74% covered (warning)
76.74%
33 / 43
0.00% covered (danger)
0.00%
0 / 1
13.81
 updateMembers
86.27% covered (success)
86.27%
44 / 51
0.00% covered (danger)
0.00%
0 / 1
14.51
 updatePermissions
83.33% covered (success)
83.33%
35 / 42
0.00% covered (danger)
0.00%
0 / 1
13.78
 deleteGroup
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
7.33
 listCategories
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Admin Group 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 2023-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     2023-10-27
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration\Api;
21
22use phpMyFAQ\Administration\Category;
23use phpMyFAQ\Category\Order;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Enums\AdminLogType;
26use phpMyFAQ\Enums\PermissionType;
27use phpMyFAQ\Filter;
28use phpMyFAQ\Permission\MediumPermission;
29use phpMyFAQ\Session\Token;
30use phpMyFAQ\Translation;
31use phpMyFAQ\User\CurrentUser;
32use Symfony\Component\HttpFoundation\JsonResponse;
33use Symfony\Component\HttpFoundation\Request;
34use Symfony\Component\HttpFoundation\Response;
35use Symfony\Component\Routing\Attribute\Route;
36
37/* @mago-expect lint:kan-defect - permission-level guards on every endpoint raise the score; split planned with the admin API rework */
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}