Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.56% covered (success)
86.56%
264 / 305
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserController
86.56% covered (success)
86.56%
264 / 305
45.45% covered (danger)
45.45%
5 / 11
115.46
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
 list
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
5
 csvExport
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
5
 userData
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 userPermissions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 activate
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
9.73
 overwritePassword
83.87% covered (success)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
15.94
 deleteUser
61.54% covered (warning)
61.54%
16 / 26
0.00% covered (danger)
0.00%
0 / 1
9.79
 addUser
70.45% covered (warning)
70.45%
31 / 44
0.00% covered (danger)
0.00%
0 / 1
20.80
 editUser
91.23% covered (success)
91.23%
52 / 57
0.00% covered (danger)
0.00%
0 / 1
21.30
 updateUserRights
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
14.03
1<?php
2
3/**
4 * The Admin User 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\Report;
23use phpMyFAQ\Auth;
24use phpMyFAQ\Category;
25use phpMyFAQ\Core\Exception;
26use phpMyFAQ\Enums\AdminLogType;
27use phpMyFAQ\Enums\PermissionType;
28use phpMyFAQ\Filter;
29use phpMyFAQ\Helper\MailHelper;
30use phpMyFAQ\Permission;
31use phpMyFAQ\Permission\MediumPermission;
32use phpMyFAQ\Session\Token;
33use phpMyFAQ\Strings;
34use phpMyFAQ\Translation;
35use phpMyFAQ\User;
36use phpMyFAQ\User\CurrentUser;
37use stdClass;
38use Symfony\Component\HttpFoundation\JsonResponse;
39use Symfony\Component\HttpFoundation\Request;
40use Symfony\Component\HttpFoundation\Response;
41use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
42use Symfony\Component\Routing\Attribute\Route;
43
44/* @mago-expect lint:cyclomatic-complexity - each endpoint validates its full payload inline; split planned with the admin API rework */
45final class UserController extends AbstractAdministrationApiController
46{
47    public function __construct(
48        private readonly CurrentUser $currentUserService,
49    ) {
50        parent::__construct();
51    }
52
53    /**
54     * @throws Exception
55     */
56    #[Route(path: 'user/users', name: 'admin.api.user.users', methods: ['GET'])]
57    public function list(Request $request): JsonResponse
58    {
59        $this->userHasUserPermission();
60
61        $currentUser = CurrentUser::getCurrentUser($this->configuration);
62
63        $filtered = Filter::filterVar($request->query->get(key: 'filter'), FILTER_SANITIZE_SPECIAL_CHARS, '');
64
65        if ('' === $filtered) {
66            $allUsers = $currentUser->getAllUsers(withoutAnonymous: false);
67            $userData = [];
68            foreach ($allUsers as $allUser) {
69                $currentUser->getUserById($allUser, allowBlockedUsers: true);
70                $user = new stdClass();
71                $user->id = $currentUser->getUserId();
72                $user->status = $currentUser->getStatus();
73                $user->isSuperAdmin = $currentUser->isSuperAdmin();
74                $user->isVisible = $currentUser->getUserData(field: 'is_visible');
75                $displayName = $currentUser->getUserData(field: 'display_name');
76                $user->displayName = Report::sanitize(is_string($displayName) ? $displayName : '');
77                $user->userName = Report::sanitize($currentUser->getLogin());
78                $user->email = $currentUser->getUserData(field: 'email');
79                $user->authSource = $currentUser->getUserAuthSource();
80                $userData[] = $user;
81            }
82
83            return $this->json($userData, Response::HTTP_OK);
84        }
85
86        $allUsers = [];
87        foreach ($currentUser->searchUsers($filtered) as $singleUser) {
88            $users = new stdClass();
89            $users->label = $singleUser['login'];
90            $users->value = (int) $singleUser['user_id'];
91            $allUsers[] = $users;
92        }
93
94        return $this->json($allUsers, Response::HTTP_OK);
95    }
96
97    /**
98     * @throws Exception
99     */
100    #[Route(path: 'user/users.csv', name: 'admin.api.user.users.csv', methods: ['GET'])]
101    public function csvExport(): Response
102    {
103        $this->userHasUserPermission();
104
105        $currentUser = CurrentUser::getCurrentUser($this->configuration);
106        $allUsers = $currentUser->getAllUsers(withoutAnonymous: false);
107
108        $handle = fopen(filename: 'php://temp', mode: 'r+');
109        fputcsv(
110            $handle,
111            ['ID', 'Status', 'Super Admin', 'Visible', 'Display Name', 'Username', 'Email', 'Auth Source'],
112            separator: ',',
113            enclosure: '"',
114            eol: PHP_EOL,
115        );
116
117        foreach ($allUsers as $allUser) {
118            $currentUser->getUserById($allUser, allowBlockedUsers: true);
119            fputcsv(
120                $handle,
121                [
122                    $currentUser->getUserId(),
123                    $currentUser->getStatus(),
124                    $currentUser->isSuperAdmin() ? 'true' : 'false',
125                    $currentUser->getUserData(field: 'is_visible') ? 'true' : 'false',
126                    Report::sanitize(
127                        is_string($displayName = $currentUser->getUserData(field: 'display_name')) ? $displayName : '',
128                    ),
129                    Report::sanitize($currentUser->getLogin()),
130                    $currentUser->getUserData(field: 'email'),
131                    $currentUser->getUserAuthSource(),
132                ],
133                separator: ',',
134                enclosure: '"',
135                eol: PHP_EOL,
136            );
137        }
138
139        rewind($handle);
140
141        $content = (string) stream_get_contents($handle);
142
143        fclose($handle);
144
145        $this->adminLog->log($this->currentUser, AdminLogType::DATA_EXPORT_USERS->value);
146
147        $response = new Response($content);
148        $response->headers->set(key: 'Content-Type', values: 'text/csv');
149        $response->headers->set(key: 'Content-Disposition', values: 'attachment; filename="users.csv"');
150
151        return $response;
152    }
153
154    /**
155     * @throws Exception|\Exception
156     */
157    #[Route(path: 'user/data/{userId}', name: 'admin.api.user.data', methods: ['GET'])]
158    public function userData(Request $request): JsonResponse
159    {
160        $this->userHasUserPermission();
161
162        $this->currentUserService->getUserById((int) $request->attributes->get(key: 'userId'), allowBlockedUsers: true);
163
164        $userData = [];
165
166        $data = $this->currentUserService->userData()->get(field: '*');
167        if (is_array($data)) {
168            $userData = $data;
169            $userData['userId'] = $this->currentUserService->getUserId();
170            $userData['status'] = $this->currentUserService->getStatus();
171            $userData['login'] = $this->currentUserService->getLogin();
172            $userData['displayName'] = $userData['display_name'];
173            $userData['isSuperadmin'] = $this->currentUserService->isSuperAdmin();
174            $userData['authSource'] = $this->currentUserService->getUserAuthSource();
175            $userData['isVisible'] = $userData['is_visible'];
176            $userData['twoFactorEnabled'] = $userData['twofactor_enabled'];
177            $userData['lastModified'] = $userData['last_modified'];
178        }
179
180        return $this->json($userData, Response::HTTP_OK);
181    }
182
183    /**
184     * @throws Exception
185     */
186    #[Route(path: 'user/permissions/{userId}', name: 'admin.api.user.permissions', methods: ['GET'])]
187    public function userPermissions(Request $request): JsonResponse
188    {
189        $this->userHasUserPermission();
190
191        $currentUser = CurrentUser::getCurrentUser($this->configuration);
192
193        $userId = $request->attributes->get(key: 'userId');
194        $currentUser->getUserById((int) $userId, allowBlockedUsers: true);
195
196        return $this->json($currentUser->perm->getUserRights((int) $userId), Response::HTTP_OK);
197    }
198
199    /**
200     * @throws Exception
201     * @throws \Exception
202     */
203    #[Route(path: 'user/activate', name: 'admin.api.user.activate', methods: ['PUT'])]
204    public function activate(Request $request): JsonResponse
205    {
206        $this->userHasUserPermission();
207
208        $currentUser = CurrentUser::getCurrentUser($this->configuration);
209
210        $data = $this->getJsonObject($request);
211        if (!Token::getInstance($this->session)->verifyToken(
212            page: 'activate-user',
213            requestToken: (string) ($data->csrfToken ?? ''),
214        )) {
215            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
216        }
217
218        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
219
220        if (!$currentUser->getUserById($userId, allowBlockedUsers: true)) {
221            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
222        }
223
224        // A non-SuperAdmin must never be able to alter a SuperAdmin or protected account.
225        if (
226            !$this->currentUser->isSuperAdmin()
227            && ($currentUser->isSuperAdmin() || $currentUser->getStatus() === 'protected')
228        ) {
229            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
230        }
231
232        try {
233            if ($currentUser->activateUser()) {
234                $this->adminLog->log($this->currentUser, AdminLogType::USER_EDIT->value . ' (activated):' . $userId);
235                return $this->json(['success' => $currentUser->getStatus()], Response::HTTP_OK);
236            }
237
238            return $this->json(['error' => $currentUser->getStatus()], Response::HTTP_BAD_REQUEST);
239        } catch (TransportExceptionInterface|\Exception $exception) {
240            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
241        }
242    }
243
244    /**
245     * @throws Exception
246     * @throws \Exception
247     */
248    #[Route(path: 'user/overwrite-password', name: 'admin.api.user.overwrite-password', methods: ['PUT'])]
249    public function overwritePassword(Request $request): JsonResponse
250    {
251        $this->userHasUserPermission();
252
253        $data = $this->getJsonObject($request);
254
255        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
256        $csrfToken = Filter::filterVar($data->csrf ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
257        $newPassword = is_string($data->newPassword ?? null) ? $data->newPassword : '';
258        $retypedPassword = is_string($data->passwordRepeat ?? null) ? $data->passwordRepeat : '';
259
260        if (!Token::getInstance($this->session)->verifyToken(page: 'overwrite-password', requestToken: $csrfToken)) {
261            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
262        }
263
264        if ($userId <= 0) {
265            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
266        }
267
268        if (strlen($newPassword) <= 7 || strlen($retypedPassword) <= 7) {
269            return $this->json(['error' => Translation::get(key: 'msgPasswordTooShort')], Response::HTTP_BAD_REQUEST);
270        }
271
272        $isSelf = $this->currentUser->getUserId() === (int) $userId;
273        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
274
275        // Only SuperAdmins may change other users' passwords. Self-service is always allowed.
276        if (!$isSelf && !$actingIsSuperAdmin) {
277            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
278        }
279
280        $targetUser = new User($this->configuration);
281        $targetUser->getUserById((int) $userId, allowBlockedUsers: true);
282
283        if ($targetUser->getUserId() <= 0) {
284            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
285        }
286
287        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or protected account,
288        // even when isSelf would short-circuit the check above.
289        if (!$actingIsSuperAdmin && ($targetUser->isSuperAdmin() || $targetUser->getStatus() === 'protected')) {
290            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
291        }
292
293        $auth = new Auth($this->configuration);
294        $authSource = $auth->selectAuth($targetUser->getAuthSource(key: 'name') ?? '');
295        $authSource->getEncryptionContainer((string) ($targetUser->getAuthData(key: 'encType') ?? ''));
296
297        if (hash_equals($newPassword, $retypedPassword)) {
298            if (!$targetUser->changePassword($newPassword)) {
299                return $this->json(['error' => Translation::get(key: 'ad_passwd_fail')], Response::HTTP_BAD_REQUEST);
300            }
301
302            $this->adminLog->log($this->currentUser, AdminLogType::USER_CHANGE_PASSWORD->value . ':' . $userId);
303
304            return $this->json(['success' => Translation::get(key: 'ad_passwdsuc')], Response::HTTP_OK);
305        }
306
307        return $this->json(['error' => Translation::get(key: 'msgPasswordsMustBeEqual')], Response::HTTP_BAD_REQUEST);
308    }
309
310    /**
311     * @throws Exception
312     * @throws \Exception
313     */
314    #[Route(path: 'user/delete', name: 'admin.api.user.delete', methods: ['DELETE'])]
315    public function deleteUser(Request $request): JsonResponse
316    {
317        $this->userHasPermission(PermissionType::USER_DELETE);
318
319        $currentUser = CurrentUser::getCurrentUser($this->configuration);
320
321        $data = $this->getJsonObject($request);
322
323        if (!Token::getInstance($this->session)->verifyToken(
324            page: 'delete-user',
325            requestToken: (string) ($data->csrfToken ?? ''),
326        )) {
327            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
328        }
329
330        $userId = Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
331
332        if (!is_int($userId)) {
333            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
334        }
335
336        $currentUser->getUserById($userId, allowBlockedUsers: true);
337        $superAdminIds = User::getSuperAdminIds($this->configuration);
338        if ($currentUser->getStatus() === 'protected' || in_array($userId, $superAdminIds, strict: true)) {
339            return $this->json([
340                'error' => Translation::get(key: 'ad_user_error_protectedAccount'),
341            ], Response::HTTP_BAD_REQUEST);
342        }
343
344        if (!$currentUser->deleteUser()) {
345            return $this->json(['error' => Translation::get(key: 'ad_user_error_delete')], Response::HTTP_BAD_REQUEST);
346        }
347
348        $category = new Category($this->configuration, [], withPermission: false);
349        $category->moveOwnership((int) $userId, newOwner: 1);
350
351        // Remove the user from groups
352        if ('basic' !== $this->configuration->get(item: 'security.permLevel')) {
353            $permissions = new MediumPermission($this->configuration);
354            $permissions->removeFromAllGroups($userId);
355        }
356
357        $this->adminLog->log($this->currentUser, AdminLogType::USER_DELETE->value . ':' . $userId);
358
359        return $this->json(['success' => Translation::get(key: 'ad_user_deleted')], Response::HTTP_OK);
360    }
361
362    /**
363     * @throws Exception
364     * @throws \Exception
365     */
366    #[Route(path: 'user/add', name: 'admin.api.user.add', methods: ['POST'])]
367    public function addUser(Request $request): JsonResponse
368    {
369        $this->userHasUserPermission();
370
371        $data = $this->getJsonObject($request);
372
373        if (!Token::getInstance($this->session)->verifyToken(
374            page: 'add-user',
375            requestToken: (string) ($data->csrf ?? ''),
376        )) {
377            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
378        }
379
380        $errorMessage = [];
381
382        $userName = Filter::filterVar($data->userName ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
383        $userRealName = trim(strip_tags((string) ($data->realName ?? '')));
384        $userEmail = (string) Filter::filterEmail($data->email ?? '', default: '');
385        $automaticPassword = (bool) Filter::filterVar($data->automaticPassword ?? false, FILTER_VALIDATE_BOOLEAN);
386        $userPassword = Filter::filterVar($data->password ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
387        $userPasswordConfirm = Filter::filterVar($data->passwordConfirm ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
388        $userIsSuperAdmin = (bool) Filter::filterVar($data->isSuperAdmin ?? false, FILTER_VALIDATE_BOOLEAN);
389
390        // Only SuperAdmins may grant the SuperAdmin flag. Reject the request when a
391        // non-SuperAdmin attempts to set it, to prevent privilege escalation through
392        // mass-assignment of is_superadmin on user creation.
393        if (!$this->currentUser->isSuperAdmin() && $userIsSuperAdmin) {
394            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
395        }
396
397        $newUser = new User($this->configuration);
398
399        if (!$newUser->isValidLogin($userName)) {
400            $errorMessage[] = Translation::get(key: 'ad_user_error_loginInvalid');
401        }
402
403        if ($newUser->getUserByLogin($userName, raiseError: false)) {
404            $errorMessage[] = Translation::get(key: 'ad_adus_exerr');
405        }
406
407        if ($userRealName === '') {
408            $errorMessage[] = Translation::get(key: 'ad_user_error_noRealName');
409        }
410
411        if ($userEmail === '') {
412            $errorMessage[] = Translation::get(key: 'ad_user_error_noEmail');
413        }
414
415        if (!$automaticPassword && (strlen($userPassword) <= 7 || strlen($userPasswordConfirm) <= 7)) {
416            $errorMessage[] = Translation::get(key: 'ad_passwd_fail');
417        }
418
419        if ($automaticPassword) {
420            $userPassword = $newUser->createPassword(minimumLength: 8, allowUnderscore: false);
421        }
422
423        if ($errorMessage === []) {
424            if (!$newUser->createUser($userName, $userPassword)) {
425                $errorMessage[] = $newUser->error();
426                return $this->json($errorMessage, Response::HTTP_BAD_REQUEST);
427            }
428
429            $newUser->userData()->set(['display_name', 'email', 'is_visible'], [$userRealName, $userEmail, 0]);
430            $newUser->setStatus(status: 'active');
431            $newUser->setSuperAdmin($userIsSuperAdmin);
432
433            $mailHelper = new MailHelper($this->configuration);
434            try {
435                $mailHelper->sendMailToNewUser($newUser, $userPassword);
436            } catch (Exception|TransportExceptionInterface $exception) {
437                $this->configuration->getLogger()->warning('Failed to send new user mail.', [$exception->getMessage()]);
438            }
439
440            $this->adminLog->log($this->currentUser, AdminLogType::USER_ADD->value . ':' . $newUser->getUserId());
441
442            return $this->json(['success' => Translation::get(key: 'ad_adus_suc')], Response::HTTP_OK);
443        }
444
445        return $this->json($errorMessage, Response::HTTP_BAD_REQUEST);
446    }
447
448    /**
449     * @throws Exception|\Exception|TransportExceptionInterface
450     */
451    #[Route(path: 'user/edit', name: 'admin.api.user.edit', methods: ['PUT'])]
452    public function editUser(Request $request): JsonResponse
453    {
454        $this->userHasPermission(PermissionType::USER_EDIT);
455
456        $data = $this->getJsonObject($request);
457
458        if (!Token::getInstance($this->session)->verifyToken(
459            page: 'update-user-data',
460            requestToken: (string) ($data->csrfToken ?? ''),
461        )) {
462            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
463        }
464
465        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT, default: 0);
466        if ($userId === 0) {
467            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
468        }
469
470        $userData = [];
471        $userData['display_name'] = trim(strip_tags((string) ($data->display_name ?? '')));
472        $userData['email'] = (string) Filter::filterEmail($data->email ?? '', default: '');
473        $userData['last_modified'] = Filter::filterVar($data->last_modified ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
474        $userStatus = Filter::filterVar(
475            $data->user_status ?? 'active',
476            FILTER_SANITIZE_SPECIAL_CHARS,
477            default: 'active',
478        );
479        $isSuperAdmin = Filter::filterVar($data->is_superadmin ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
480        $deleteTwoFactor = (bool) Filter::filterVar($data->overwrite_twofactor ?? false, FILTER_VALIDATE_BOOLEAN);
481
482        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
483
484        // Only SuperAdmins may grant or revoke the SuperAdmin flag. Reject the request when a
485        // non-SuperAdmin attempts to set it, to prevent privilege escalation through
486        // mass-assignment of is_superadmin.
487        if (!$actingIsSuperAdmin && (bool) $isSuperAdmin) {
488            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
489        }
490
491        $user = new User($this->configuration);
492        if (!$user->getUserById($userId, allowBlockedUsers: true)) {
493            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
494        }
495
496        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or
497        // protected account.
498        if (!$actingIsSuperAdmin && ($user->isSuperAdmin() || $user->getStatus() === 'protected')) {
499            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
500        }
501
502        $stats = $user->getStatus();
503        $wasSuperAdmin = $user->isSuperAdmin();
504
505        // reset two-factor authentication if required
506        if ($deleteTwoFactor) {
507            $user->setUserData(['secret' => '', 'twofactor_enabled' => 0]);
508            $this->adminLog->log($this->currentUser, AdminLogType::AUTH_2FA_RESET->value . ':' . $userId);
509        }
510
511        // set a new password and sent email if a user is switched to active
512        if ($stats === 'blocked' && $userStatus === 'active' && !$user->activateUser()) {
513            $userStatus = 'invalid_status';
514        }
515
516        // Only SuperAdmins may change the super-admin flag.
517        if ($actingIsSuperAdmin) {
518            $user->setSuperAdmin((bool) $isSuperAdmin);
519        }
520
521        // Log status change
522        if ($stats !== $userStatus) {
523            $this->adminLog->log(
524                $this->currentUser,
525                AdminLogType::USER_STATUS_CHANGED->value . ':' . $userId . ' (' . $stats . ' -> ' . $userStatus . ')',
526            );
527        }
528
529        // Log super-admin flag changes
530        if (!$wasSuperAdmin && (bool) $isSuperAdmin) {
531            $this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_GRANTED->value . ':' . $userId);
532        }
533
534        if ($wasSuperAdmin && !(bool) $isSuperAdmin) {
535            $this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_REVOKED->value . ':' . $userId);
536        }
537
538        if (!$user->userData()->set(array_keys($userData), array_values($userData)) || !$user->setStatus($userStatus)) {
539            return $this->json(['error' => 'ad_msg_mysqlerr'], Response::HTTP_BAD_REQUEST);
540        }
541
542        $this->adminLog->log($this->currentUser, AdminLogType::USER_EDIT->value . ':' . $userId);
543
544        $success =
545            Translation::getString(key: 'ad_msg_savedsuc_1')
546            . ' "'
547            . Strings::htmlentities($user->getLogin(), ENT_QUOTES)
548            . '" '
549            . Translation::getString(key: 'ad_msg_savedsuc_2');
550        return $this->json(['success' => $success], Response::HTTP_OK);
551    }
552
553    /**
554     * @throws Exception
555     * @throws \Exception
556     */
557    #[Route(path: 'user/update-rights', name: 'admin.api.user.update-rights', methods: ['PUT'])]
558    public function updateUserRights(Request $request): JsonResponse
559    {
560        $this->userHasPermission(PermissionType::USER_EDIT);
561
562        $data = $this->getJsonObject($request);
563
564        if (!Token::getInstance($this->session)->verifyToken(
565            page: 'update-user-rights',
566            requestToken: (string) ($data->csrfToken ?? ''),
567        )) {
568            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
569        }
570
571        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT, default: 0);
572
573        if (0 === (int) $userId) {
574            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
575        }
576
577        // userRights arrives as a JSON array of permission ids. Validate each element as a
578        // positive integer and drop anything malformed, so only real right ids can reach the
579        // escalation guard and grantUserRight() (a bad value must never become right id 0).
580        $submittedRights = is_array($data->userRights ?? null) ? $data->userRights : [];
581        $userRights = [];
582        foreach ($submittedRights as $submittedRight) {
583            $rightId = Filter::filterVar($submittedRight, FILTER_VALIDATE_INT, default: 0);
584            if ($rightId > 0) {
585                $userRights[] = $rightId;
586            }
587        }
588
589        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
590
591        // A non-SuperAdmin may only assign rights they hold themselves. This prevents an
592        // administrator with the delegable USER_EDIT right from granting privileges they do not
593        // possess (privilege escalation).
594        if (!$actingIsSuperAdmin) {
595            $actingUserId = $this->currentUser->getUserId();
596            foreach ($userRights as $userRight) {
597                if (!$this->currentUser->perm->hasPermission($actingUserId, (int) $userRight)) {
598                    return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
599                }
600            }
601        }
602
603        $user = new User($this->configuration);
604        $user->getUserById($userId);
605
606        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or
607        // protected account.
608        if (!$actingIsSuperAdmin && ($user->isSuperAdmin() || $user->getStatus() === 'protected')) {
609            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
610        }
611
612        if (!$user->perm->refuseAllUserRights($userId)) {
613            return $this->json(['error' => Translation::get(key: 'ad_msg_mysqlerr')], Response::HTTP_BAD_REQUEST);
614        }
615
616        foreach ($userRights as $userRight) {
617            $user->perm->grantUserRight($userId, (int) $userRight);
618        }
619
620        $this->adminLog->log($this->currentUser, AdminLogType::USER_CHANGE_PERMISSIONS->value . ':' . $userId);
621
622        $user->terminateSessionId();
623        $success =
624            Translation::getString(key: 'ad_msg_savedsuc_1')
625            . ' "'
626            . Strings::htmlentities($user->getLogin(), ENT_QUOTES)
627            . '" '
628            . Translation::getString(key: 'ad_msg_savedsuc_2');
629
630        return $this->json(['success' => $success], Response::HTTP_OK);
631    }
632}