Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.70% covered (success)
88.70%
102 / 115
73.33% covered (warning)
73.33%
22 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediumPermission
88.70% covered (success)
88.70%
102 / 115
73.33% covered (warning)
73.33%
22 / 30
73.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getGroupRights
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPermission
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
6.56
 checkUserGroupRight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 grantGroupRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addGroup
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getGroupId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkGroupData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 changeGroup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteGroup
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getGroupMembers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllGroupsOptions
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 getAllGroups
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 getGroupName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllUserRights
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getUserRightsCount
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getUserGroupRights
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 autoJoin
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addToGroup
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 getGroupData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeFromAllGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeFromGroup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 refuseAllGroupRights
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeAllUsersFromGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findOrCreateGroupByName
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 hasPermissionForCategory
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
7.60
 getCategoryRestrictions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllCategoryRestrictions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCategoryRestrictions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The medium permission class provides group rights.
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    Lars Tiedemann <php@larstiedemann.de>
12 * @copyright 2005-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     2005-09-17
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Permission;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Enums\PermissionType;
25use phpMyFAQ\User\CurrentUser;
26
27/**
28 * Class MediumPermission
29 *
30 * @package phpMyFAQ\Permission
31 */
32/* @mago-expect lint:too-many-methods - implements the full group-permission contract */
33class MediumPermission extends BasicPermission implements PermissionInterface
34{
35    protected MediumPermissionRepository $mediumRepository;
36
37    protected GroupCategoryPermissionRepository $categoryPermissionRepository;
38
39    public function __construct(
40        protected Configuration $configuration,
41    ) {
42        parent::__construct($configuration);
43        $this->mediumRepository = new MediumPermissionRepository($configuration);
44        $this->categoryPermissionRepository = new GroupCategoryPermissionRepository($configuration);
45    }
46
47    /**
48     * Default data for new groups.
49     *
50     * @var array<string, string|bool>
51     */
52    public array $defaultGroupData = [
53        'name' => 'DEFAULT_GROUP',
54        'description' => 'Short group description.',
55        'auto_join' => false,
56    ];
57
58    /**
59     * Returns an array that contains the right-IDs of all
60     * group-rights the group $groupId owns.
61     *
62     * @param int $groupId Group ID
63     *
64     * @return array<int>
65     */
66    public function getGroupRights(int $groupId): array
67    {
68        return $this->mediumRepository->getGroupRights($groupId);
69    }
70
71    /**
72     * Returns true, if the user given by $userId owns the right
73     * specified by $right. It does not matter if the user owns this
74     * right as a user-right or because of a group-membership.
75     * The parameter $right may be a right-ID (recommended for
76     * performance) or a right-name.
77     *
78     * @param int   $userId Group ID
79     * @param mixed $right  Rights
80     * @throws Exception
81     */
82    #[\Override]
83    public function hasPermission(int $userId, mixed $right): bool
84    {
85        $currentUser = new CurrentUser($this->configuration);
86        $currentUser->getUserById($userId);
87
88        if ($currentUser->isSuperAdmin()) {
89            return true;
90        }
91
92        // get right id
93        if (!is_numeric($right) && is_string($right)) {
94            $right = $this->getRightId($right);
95        }
96
97        if ($right instanceof PermissionType) {
98            $right = $this->getRightId($right->value);
99        }
100
101        $rightId = (int) $right;
102
103        // check user right and group right
104        if ($this->checkUserGroupRight($userId, $rightId)) {
105            return true;
106        }
107
108        return $this->checkUserRight($userId, $rightId);
109    }
110
111    /**
112     * Returns true if the user $userId owns the right $rightId
113     * because of a group-membership, otherwise false.
114     *
115     * @param int $userId  User ID
116     * @param int $rightId Right ID
117     */
118    public function checkUserGroupRight(int $userId, int $rightId): bool
119    {
120        return $this->mediumRepository->checkUserGroupRight($userId, $rightId);
121    }
122
123    /**
124     * Grants the group given by $groupId the right specified by
125     * $rightId.
126     *
127     * @param int $groupId Group ID
128     * @param int $rightId Right ID
129     */
130    public function grantGroupRight(int $groupId, int $rightId): bool
131    {
132        $right_data = $this->getRightData($rightId);
133        if ($right_data === [] || !($right_data['for_groups'] ?? false)) {
134            return false;
135        }
136
137        return $this->mediumRepository->grantGroupRight($groupId, $rightId);
138    }
139
140    /**
141     * Adds a new group to the database and returns the ID of the
142     * new group. The associative array $groupData contains the
143     * data for the new group.
144     *
145     * @param array<string, int|string|bool> $groupData Array of group data
146     */
147    public function addGroup(array $groupData): int
148    {
149        if ($this->getGroupId((string) ($groupData['name'] ?? '')) > 0) {
150            return 0;
151        }
152
153        $nextId = $this->mediumRepository->nextGroupId();
154        $groupData = $this->checkGroupData($groupData);
155
156        if (!$this->mediumRepository->addGroup($groupData, $nextId)) {
157            return 0;
158        }
159
160        return $nextId;
161    }
162
163    /**
164     * Returns the ID of the group that has the name $name. Returns
165     * 0 if the group-name cannot be found.
166     *
167     * @param string $name Group name
168     */
169    public function getGroupId(string $name): int
170    {
171        return $this->mediumRepository->getGroupId($name);
172    }
173
174    /**
175     * Checks the given associative array $groupData. If a
176     * parameter is incorrect or is missing, it will be replaced
177     * by the default values in $this->defaultGroupData.
178     * Returns the corrected $groupData associative array.
179     *
180     * @param array<string, int|string|bool> $groupData Array of group data
181     *
182     * @return array<string, int|string>
183     */
184    public function checkGroupData(array $groupData): array
185    {
186        $name = $groupData['name'] ?? null;
187        $description = $groupData['description'] ?? null;
188
189        return [
190            'name' => is_string($name) ? $name : (string) $this->defaultGroupData['name'],
191            'description' => is_string($description) ? $description : (string) $this->defaultGroupData['description'],
192            'auto_join' => (int) ($groupData['auto_join'] ?? $this->defaultGroupData['auto_join']),
193        ];
194    }
195
196    /**
197     * Changes the group data of the given group.
198     *
199     * @param int $groupId Group ID
200     * @param array<string, int|string|bool> $groupData Array of group data
201     */
202    public function changeGroup(int $groupId, array $groupData): bool
203    {
204        $checkedData = $this->checkGroupData($groupData);
205        return $this->mediumRepository->changeGroup($groupId, $checkedData);
206    }
207
208    /**
209     * Removes the group given by $groupId from the database.
210     * Returns true if successful, otherwise false.
211     *
212     * @param int $groupId Group ID
213     */
214    public function deleteGroup(int $groupId): bool
215    {
216        if (!$this->mediumRepository->deleteGroup($groupId)) {
217            return false;
218        }
219
220        if (!$this->mediumRepository->deleteGroupMemberships($groupId)) {
221            return false;
222        }
223
224        $this->categoryPermissionRepository->deleteAllForGroup($groupId);
225
226        return $this->mediumRepository->deleteGroupRights($groupId);
227    }
228
229    /**
230     * Returns an array that contains the user-IDs of all members
231     * of the group $groupId.
232     *
233     * @param int $groupId Group ID
234     *
235     * @return array<int>
236     */
237    public function getGroupMembers(int $groupId): array
238    {
239        return $this->mediumRepository->getGroupMembers($groupId);
240    }
241
242    /**
243     * Returns an array that contains the IDs of all groups in which
244     * the user $userId is a member.
245     *
246     * @param int $userId User ID
247     *
248     * @return array<int>
249     */
250    public function getUserGroups(int $userId): array
251    {
252        return $this->mediumRepository->getUserGroups($userId);
253    }
254
255    /**
256     * Get all groups in <option> tags.
257     *
258     * @param array<int> $groups Selected groups
259     * @todo   Move into the Helper class
260     */
261    public function getAllGroupsOptions(array $groups, CurrentUser $currentUser): string
262    {
263        $options = '';
264        $allGroups = $this->getAllGroups($currentUser);
265
266        foreach ($allGroups as $allGroup) {
267            if (-1 === $allGroup) {
268                continue;
269            }
270
271            $options .= sprintf(
272                '<option value="%d" %s>%s</option>',
273                $allGroup,
274                in_array($allGroup, $groups, strict: true) || ($groups[0] ?? null) === -1 ? 'selected' : '',
275                $this->getGroupName($allGroup),
276            );
277        }
278
279        return $options;
280    }
281
282    /**
283     * Returns an array with the IDs of all groups stored in the
284     * database if no user is passed.
285     *
286     * @return array<int>
287     */
288    public function getAllGroups(CurrentUser $currentUser): array
289    {
290        if (
291            !$this->configuration->get(item: 'main.enableCategoryRestrictions')
292            && $currentUser->getUserId() !== 1
293            && !$currentUser->isSuperAdmin()
294        ) {
295            return $this->mediumRepository->getAllGroups($currentUser->getUserId());
296        }
297
298        return $this->mediumRepository->getAllGroups();
299    }
300
301    /**
302     * Returns the name of the group $groupId.
303     *
304     * @param int $groupId Group ID
305     */
306    public function getGroupName(int $groupId): string
307    {
308        return $this->mediumRepository->getGroupName($groupId);
309    }
310
311    /**
312     * Returns an array that contains the right-IDs of all rights
313     * the user $userId owns. User-rights and the rights the user
314     * owns because of a group-membership is taken into account.
315     *
316     * @param int $userId User ID
317     *
318     * @return array<int>
319     */
320    #[\Override]
321    public function getAllUserRights(int $userId): array
322    {
323        if ($userId <= 0) {
324            return [];
325        }
326
327        $userRights = $this->getUserRights($userId);
328        $groupRights = $this->getUserGroupRights($userId);
329
330        return array_unique(array_merge($userRights, $groupRights));
331    }
332
333    /**
334     * Returns the number of user- and group-rights the user specified by
335     * user_id owns.
336     *
337     * @param CurrentUser $currentUser User object
338     */
339    #[\Override]
340    public function getUserRightsCount(CurrentUser $currentUser): int
341    {
342        $userRights = $this->getAllUserRights($currentUser->getUserId());
343
344        return is_countable($userRights) ? count($userRights) : 0;
345    }
346
347    /**
348     * Returns an array that contains the IDs of all rights the user
349     * $userId owns because of a group-membership.
350     *
351     * @param int $userId User ID
352     *
353     * @return array<int>
354     */
355    public function getUserGroupRights(int $userId): array
356    {
357        return $this->mediumRepository->getUserGroupRights($userId);
358    }
359
360    /**
361     * Adds the user $userId to all groups with the auto_join
362     * option. By using the auto_join option, user administration
363     * can be much easier. For example, by setting this option only
364     * for a single group called 'All Users'. The autoJoin() method
365     * then has to be called every time a new user registers.
366     * Returns true if successful, otherwise false.
367     *
368     * @param int $userId User ID
369     */
370    public function autoJoin(int $userId): bool
371    {
372        if ($userId <= 0) {
373            return false;
374        }
375
376        $autoJoinGroups = $this->mediumRepository->getAutoJoinGroups();
377
378        // add to groups
379        foreach ($autoJoinGroups as $autoJoinGroup) {
380            $this->addToGroup($userId, $autoJoinGroup);
381        }
382
383        return true;
384    }
385
386    /**
387     * Adds a new member $userId to the group $groupId.
388     * Returns true if successful, otherwise false.
389     *
390     * @param int $userId  User ID
391     * @param int $groupId Group ID
392     */
393    public function addToGroup(int $userId, int $groupId): bool
394    {
395        if ($userId <= 0 || $groupId <= 0) {
396            return false;
397        }
398
399        if (!$this->getGroupData($groupId)) {
400            return false;
401        }
402
403        return $this->mediumRepository->addToGroup($userId, $groupId);
404    }
405
406    /**
407     * Returns an associative array with the group-data of the group
408     * $groupId.
409     *
410     * @param int $groupId Group ID
411     *
412     * @return array<array-key, mixed>
413     */
414    public function getGroupData(int $groupId): array
415    {
416        return $this->mediumRepository->getGroupData($groupId);
417    }
418
419    /**
420     * Removes the user $userId from all groups.
421     * Returns true for success, otherwise false.
422     *
423     * @param int $userId User ID
424     */
425    public function removeFromAllGroups(int $userId): bool
426    {
427        return $this->mediumRepository->removeFromAllGroups($userId);
428    }
429
430    /**
431     * Removes a user from a single group.
432     *
433     * @param int $userId User ID
434     * @param int $groupId Group ID
435     */
436    public function removeFromGroup(int $userId, int $groupId): bool
437    {
438        if ($userId <= 0 || $groupId <= 0) {
439            return false;
440        }
441
442        return $this->mediumRepository->removeFromGroup($userId, $groupId);
443    }
444
445    /**
446     * Refuses all group rights.
447     * Returns true for success, otherwise false.
448     *
449     * @param int $groupId Group ID
450     */
451    public function refuseAllGroupRights(int $groupId): bool
452    {
453        return $this->mediumRepository->refuseAllGroupRights($groupId);
454    }
455
456    /**
457     * Removes all users from the group $groupId.
458     * Returns true for success, otherwise false.
459     *
460     * @param int $groupId Group ID
461     */
462    public function removeAllUsersFromGroup(int $groupId): bool
463    {
464        return $this->mediumRepository->removeAllUsersFromGroup($groupId);
465    }
466
467    /**
468     * Finds or creates a group by name.
469     * Returns the group ID on success, 0 on failure.
470     *
471     * @param string $name Group name
472     * @param string $description Optional group description
473     */
474    public function findOrCreateGroupByName(string $name, string $description = ''): int
475    {
476        $groupId = $this->getGroupId($name);
477
478        if ($groupId > 0) {
479            return $groupId;
480        }
481
482        // Create a new group if it doesn't exist
483        $groupData = [
484            'name' => $name,
485            'description' => $description === '' ? 'Auto-created group for ' . $name : $description,
486            'auto_join' => false,
487        ];
488
489        return $this->addGroup($groupData);
490    }
491
492    /**
493     * Returns true if the user has the specified right for the given category,
494     * taking into account group-level category restrictions.
495     *
496     * If the user's group has no category restrictions for a right, the right
497     * applies globally. If restrictions exist, the right only applies to
498     * the specified categories.
499     *
500     * @param int   $userId     User ID
501     * @param mixed $right      Right ID, name, or PermissionType enum
502     * @param int   $categoryId Category ID
503     * @param CurrentUser|null $currentUser Optional pre-loaded user to avoid repeated instantiation
504     * @throws Exception
505     */
506    public function hasPermissionForCategory(
507        int $userId,
508        mixed $right,
509        int $categoryId,
510        ?CurrentUser $currentUser = null,
511    ): bool {
512        if ($currentUser === null) {
513            $currentUser = new CurrentUser($this->configuration);
514            $currentUser->getUserById($userId);
515        }
516
517        if ($currentUser->isSuperAdmin()) {
518            return true;
519        }
520
521        // Resolve right to ID
522        if (!is_numeric($right) && is_string($right)) {
523            $right = $this->getRightId($right);
524        }
525
526        if ($right instanceof PermissionType) {
527            $right = $this->getRightId($right->value);
528        }
529
530        $rightId = (int) $right;
531
532        // Check direct user right (always global, no category restriction)
533        if ($this->checkUserRight($userId, $rightId)) {
534            return true;
535        }
536
537        // Check group right with category restrictions
538        return $this->categoryPermissionRepository->checkUserGroupRightForCategory($userId, $rightId, $categoryId);
539    }
540
541    /**
542     * Returns the category IDs that a group's right is restricted to.
543     * An empty array means the right is unrestricted (applies globally).
544     *
545     * @param int $groupId Group ID
546     * @param int $rightId Right ID
547     * @return array<int>
548     */
549    public function getCategoryRestrictions(int $groupId, int $rightId): array
550    {
551        return $this->categoryPermissionRepository->getCategoryRestrictions($groupId, $rightId);
552    }
553
554    /**
555     * Returns all category restrictions for a group, keyed by right ID.
556     *
557     * @param int $groupId Group ID
558     * @return array<int, array<int>> Map of right_id => [category_ids]
559     */
560    public function getAllCategoryRestrictions(int $groupId): array
561    {
562        return $this->categoryPermissionRepository->getAllCategoryRestrictions($groupId);
563    }
564
565    /**
566     * Sets category restrictions for a group's right.
567     *
568     * @param int $groupId Group ID
569     * @param int $rightId Right ID
570     * @param array<int> $categoryIds Category IDs to restrict to (empty = unrestricted)
571     */
572    public function setCategoryRestrictions(int $groupId, int $rightId, array $categoryIds): bool
573    {
574        return $this->categoryPermissionRepository->setCategoryRestrictions($groupId, $rightId, $categoryIds);
575    }
576}