Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
91 / 98
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
GroupCategoryPermissionRepository
92.86% covered (success)
92.86%
91 / 98
57.14% covered (warning)
57.14%
4 / 7
32.37
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
 getCategoryRestrictions
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 getAllCategoryRestrictions
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 setCategoryRestrictions
82.76% covered (success)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
7.25
 deleteCategoryRestrictions
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 deleteAllForGroup
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 checkUserGroupRightForCategory
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * Repository for group-level category permission restrictions.
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 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     2026-03-15
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Permission;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24
25readonly class GroupCategoryPermissionRepository
26{
27    public function __construct(
28        private Configuration $configuration,
29    ) {
30    }
31
32    /**
33     * Returns the category IDs that a group's right is restricted to.
34     * An empty array means the right is unrestricted (applies globally).
35     *
36     * @param int $groupId Group ID
37     * @param int $rightId Right ID
38     * @return array<int>
39     */
40    public function getCategoryRestrictions(int $groupId, int $rightId): array
41    {
42        if ($groupId <= 0 || $rightId <= 0) {
43            return [];
44        }
45
46        $select = sprintf(
47            'SELECT category_id FROM %sfaqgroup_right_category WHERE group_id = %d AND right_id = %d',
48            Database::getTablePrefix(),
49            $groupId,
50            $rightId,
51        );
52
53        $res = $this->configuration->getDb()->query($select);
54        if (!$res) {
55            return [];
56        }
57
58        $result = [];
59        while (true) {
60            $row = $this->configuration->getDb()->fetchArray($res);
61            if ($row === false || $row === null || $row === []) {
62                break;
63            }
64
65            $result[] = (int) $row['category_id'];
66        }
67
68        return $result;
69    }
70
71    /**
72     * Returns all category restrictions for a group, keyed by right ID.
73     *
74     * @param int $groupId Group ID
75     * @return array<int, array<int>> Map of right_id => [category_ids]
76     */
77    public function getAllCategoryRestrictions(int $groupId): array
78    {
79        if ($groupId <= 0) {
80            return [];
81        }
82
83        $select = sprintf(
84            'SELECT right_id, category_id FROM %sfaqgroup_right_category WHERE group_id = %d ORDER BY right_id',
85            Database::getTablePrefix(),
86            $groupId,
87        );
88
89        $res = $this->configuration->getDb()->query($select);
90        if (!$res) {
91            return [];
92        }
93
94        $result = [];
95        while (true) {
96            $row = $this->configuration->getDb()->fetchArray($res);
97            if ($row === false || $row === null || $row === []) {
98                break;
99            }
100
101            $rightId = (int) $row['right_id'];
102            $result[$rightId][] = (int) $row['category_id'];
103        }
104
105        return $result;
106    }
107
108    /**
109     * Sets category restrictions for a group's right.
110     * Replaces any existing restrictions for this group-right pair.
111     *
112     * @param int $groupId Group ID
113     * @param int $rightId Right ID
114     * @param array<int> $categoryIds Category IDs to restrict to
115     */
116    public function setCategoryRestrictions(int $groupId, int $rightId, array $categoryIds): bool
117    {
118        if ($groupId <= 0 || $rightId <= 0) {
119            return false;
120        }
121
122        $db = $this->configuration->getDb();
123
124        $db->query('BEGIN');
125
126        // Remove existing restrictions
127        $delete = sprintf(
128            'DELETE FROM %sfaqgroup_right_category WHERE group_id = %d AND right_id = %d',
129            Database::getTablePrefix(),
130            $groupId,
131            $rightId,
132        );
133
134        if (!$db->query($delete)) {
135            $db->query('ROLLBACK');
136            return false;
137        }
138
139        // Insert new restrictions
140        foreach ($categoryIds as $categoryId) {
141            $categoryId = (int) $categoryId;
142            if ($categoryId <= 0) {
143                continue;
144            }
145
146            $insert = sprintf(
147                'INSERT INTO %sfaqgroup_right_category (group_id, right_id, category_id) VALUES (%d, %d, %d)',
148                Database::getTablePrefix(),
149                $groupId,
150                $rightId,
151                $categoryId,
152            );
153
154            if (!$db->query($insert)) {
155                $db->query('ROLLBACK');
156                return false;
157            }
158        }
159
160        $db->query('COMMIT');
161
162        return true;
163    }
164
165    /**
166     * Deletes all category restrictions for a specific group-right pair.
167     *
168     * @param int $groupId Group ID
169     * @param int $rightId Right ID
170     */
171    public function deleteCategoryRestrictions(int $groupId, int $rightId): bool
172    {
173        if ($groupId <= 0 || $rightId <= 0) {
174            return false;
175        }
176
177        $delete = sprintf(
178            'DELETE FROM %sfaqgroup_right_category WHERE group_id = %d AND right_id = %d',
179            Database::getTablePrefix(),
180            $groupId,
181            $rightId,
182        );
183
184        return (bool) $this->configuration->getDb()->query($delete);
185    }
186
187    /**
188     * Deletes all category restrictions for a group.
189     *
190     * @param int $groupId Group ID
191     */
192    public function deleteAllForGroup(int $groupId): bool
193    {
194        if ($groupId <= 0) {
195            return false;
196        }
197
198        $delete = sprintf(
199            'DELETE FROM %sfaqgroup_right_category WHERE group_id = %d',
200            Database::getTablePrefix(),
201            $groupId,
202        );
203
204        return (bool) $this->configuration->getDb()->query($delete);
205    }
206
207    /**
208     * Checks if a user has a specific right for a given category via group membership.
209     * Returns true if:
210     * - The user's group has the right with no category restrictions (global), OR
211     * - The user's group has the right restricted to categories that include the given category.
212     *
213     * @param int $userId User ID
214     * @param int $rightId Right ID
215     * @param int $categoryId Category ID
216     */
217    public function checkUserGroupRightForCategory(int $userId, int $rightId, int $categoryId): bool
218    {
219        if ($userId <= 0 || $rightId <= 0 || $categoryId <= 0) {
220            return false;
221        }
222
223        // Check if user has the right via any group that either:
224        // 1. Has no category restrictions for this right (global), OR
225        // 2. Has the specific category in its restrictions
226        $select = sprintf(
227            '
228            SELECT
229                fgr.group_id
230            FROM
231                %sfaqgroup_right fgr
232            INNER JOIN
233                %sfaquser_group fug ON fgr.group_id = fug.group_id
234            WHERE
235                fug.user_id = %d AND
236                fgr.right_id = %d AND
237                (
238                    NOT EXISTS (
239                        SELECT 1 FROM %sfaqgroup_right_category fgrc
240                        WHERE fgrc.group_id = fgr.group_id AND fgrc.right_id = fgr.right_id
241                    )
242                    OR EXISTS (
243                        SELECT 1 FROM %sfaqgroup_right_category fgrc
244                        WHERE fgrc.group_id = fgr.group_id
245                          AND fgrc.right_id = fgr.right_id
246                          AND fgrc.category_id = %d
247                    )
248                )',
249            Database::getTablePrefix(),
250            Database::getTablePrefix(),
251            $userId,
252            $rightId,
253            Database::getTablePrefix(),
254            Database::getTablePrefix(),
255            $categoryId,
256        );
257
258        $res = $this->configuration->getDb()->query($select);
259
260        return $this->configuration->getDb()->numRows($res) > 0;
261    }
262}