Passed
Push — master ( 06d279...48903d )
by Michael
02:53 queued 26s
created

XoopsMemberHandler::getAllowedSortFields()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 23
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 19
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 23
rs 9.6333
1
<?php
2
/**
3
 * XOOPS Kernel Class - Enhanced Member Handler
4
 *
5
 * You may not change or alter any portion of this comment or credits
6
 * of supporting developers from this source code or any supporting source code
7
 * which is considered copyrighted (c) material of the original comment or credit authors.
8
 * This program is distributed in the hope that it will be useful,
9
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
 *
12
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
13
 * @license         GNU GPL 2 (https://www.gnu.org/licenses/gpl-2.0.html)
14
 * @package         kernel
15
 * @since           2.0.0
16
 * @author          Kazumi Ono (AKA onokazu) http://www.myweb.ne.jp/, http://jp.xoops.org/
17
 * @version         2.0.0 - Enhanced with security hardening and PHP 7.4-8.4 compatibility
18
 */
19
20
if (!defined('XOOPS_ROOT_PATH')) {
21
    throw new RuntimeException('Restricted access');
22
}
23
24
require_once __DIR__ . '/user.php';
25
require_once __DIR__ . '/group.php';
26
27
/**
28
 * XOOPS member handler class.
29
 * This class provides simple interface (a facade class) for handling groups/users/
30
 * membership data with enhanced security and performance optimizations.
31
 *
32
 * @package kernel
33
 */
34
class XoopsMemberHandler
35
{
36
    // Security constants
37
    private const BIDI_CONTROL_REGEX = '/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u';
38
    private const SENSITIVE_PARAMS = [
39
        'token', 'access_token', 'id_token', 'password', 'pass', 'pwd',
40
        'secret', 'key', 'api_key', 'apikey', 'auth', 'authorization',
41
        'session', 'sid', 'code', 'csrf', 'nonce'
42
    ];
43
    private const MAX_BATCH_SIZE = 1000;
44
    private const LOG_CONTEXT_MAX_LENGTH = 256;
45
46
    /**
47
     * @var XoopsGroupHandler Reference to group handler(DAO) class
48
     */
49
    protected $groupHandler;
50
51
    /**
52
     * @var XoopsUserHandler Reference to user handler(DAO) class
53
     */
54
    protected $userHandler;
55
56
    /**
57
     * @var XoopsMembershipHandler Reference to membership handler(DAO) class
58
     */
59
    protected $membershipHandler;
60
61
    /**
62
     * @var array<int,XoopsUser> Temporary user objects cache
63
     */
64
    protected $membersWorkingList = [];
65
66
    /**
67
     * Constructor
68
     * @param XoopsDatabase $db Database connection object
69
     */
70
    public function __construct(XoopsDatabase $db)
71
    {
72
        $this->groupHandler = new XoopsGroupHandler($db);
73
        $this->userHandler = new XoopsUserHandler($db);
74
        $this->membershipHandler = new XoopsMembershipHandler($db);
75
    }
76
77
    /**
78
     * Create a new group
79
     * @return XoopsGroup Reference to the new group
80
     */
81
    public function &createGroup()
82
    {
83
        $inst = $this->groupHandler->create();
84
        return $inst;
85
    }
86
87
    /**
88
     * Create a new user
89
     * @return XoopsUser Reference to the new user
90
     */
91
    public function createUser()
92
    {
93
        $inst = $this->userHandler->create();
94
        return $inst;
95
    }
96
97
    /**
98
     * Retrieve a group
99
     * @param int $id ID for the group
100
     * @return XoopsGroup|false XoopsGroup reference to the group or false
101
     */
102
    public function getGroup($id)
103
    {
104
        return $this->groupHandler->get($id);
105
    }
106
107
    /**
108
     * Retrieve a user (with caching)
109
     * @param int $id ID for the user
110
     * @return XoopsUser|false Reference to the user or false
111
     */
112
    public function getUser($id)
113
    {
114
        $id = (int)$id;
115
        if (!isset($this->membersWorkingList[$id])) {
116
            $this->membersWorkingList[$id] = $this->userHandler->get($id);
117
        }
118
        return $this->membersWorkingList[$id];
119
    }
120
121
    /**
122
     * Delete a group
123
     * @param XoopsGroup $group Reference to the group to delete
124
     * @return bool TRUE on success, FALSE on failure
125
     */
126
    public function deleteGroup(XoopsGroup $group)
127
    {
128
        $criteria = $this->createSafeInCriteria('groupid', $group->getVar('groupid'));
129
        $s1 = $this->membershipHandler->deleteAll($criteria);
130
        $s2 = $this->groupHandler->delete($group);
131
        return ($s1 && $s2);
132
    }
133
134
    /**
135
     * Delete a user
136
     * @param XoopsUser $user Reference to the user to delete
137
     * @return bool TRUE on success, FALSE on failure
138
     */
139
    public function deleteUser(XoopsUser $user)
140
    {
141
        $criteria = $this->createSafeInCriteria('uid', $user->getVar('uid'));
142
        $s1 = $this->membershipHandler->deleteAll($criteria);
143
        $s2 = $this->userHandler->delete($user);
144
        return ($s1 && $s2);
145
    }
146
147
    /**
148
     * Insert a group into the database
149
     * @param XoopsGroup $group Reference to the group to insert
150
     * @return bool TRUE on success, FALSE on failure
151
     */
152
    public function insertGroup(XoopsGroup $group)
153
    {
154
        return $this->groupHandler->insert($group);
155
    }
156
157
    /**
158
     * Insert a user into the database
159
     * @param XoopsUser $user Reference to the user to insert
160
     * @param bool $force Force insertion even if user already exists
161
     * @return bool TRUE on success, FALSE on failure
162
     */
163
    public function insertUser(XoopsUser $user, $force = false)
164
    {
165
        return $this->userHandler->insert($user, $force);
166
    }
167
168
    /**
169
     * Retrieve groups from the database
170
     * @param CriteriaElement|null $criteria {@link CriteriaElement}
171
     * @param bool $id_as_key Use the group's ID as key for the array?
172
     * @return array Array of {@link XoopsGroup} objects
173
     */
174
    public function getGroups($criteria = null, $id_as_key = false)
175
    {
176
        return $this->groupHandler->getObjects($criteria, $id_as_key);
177
    }
178
179
    /**
180
     * Retrieve users from the database
181
     * @param CriteriaElement|null $criteria {@link CriteriaElement}
182
     * @param bool $id_as_key Use the user's ID as key for the array?
183
     * @return array Array of {@link XoopsUser} objects
184
     */
185
    public function getUsers($criteria = null, $id_as_key = false)
186
    {
187
        return $this->userHandler->getObjects($criteria, $id_as_key);
188
    }
189
190
    /**
191
     * Get a list of groupnames and their IDs
192
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
193
     * @return array Associative array of group-IDs and names
194
     */
195
    public function getGroupList($criteria = null)
196
    {
197
        $groups = $this->groupHandler->getObjects($criteria, true);
198
        $ret = [];
199
        foreach (array_keys($groups) as $i) {
200
            $ret[$i] = $groups[$i]->getVar('name');
201
        }
202
        return $ret;
203
    }
204
205
    /**
206
     * Get a list of usernames and their IDs
207
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
208
     * @return array Associative array of user-IDs and names
209
     */
210
    public function getUserList($criteria = null)
211
    {
212
        $users = $this->userHandler->getObjects($criteria, true);
213
        $ret = [];
214
        foreach (array_keys($users) as $i) {
215
            $ret[$i] = $users[$i]->getVar('uname');
216
        }
217
        return $ret;
218
    }
219
220
    /**
221
     * Add a user to a group
222
     * @param int $group_id ID of the group
223
     * @param int $user_id ID of the user
224
     * @return XoopsMembership|bool XoopsMembership object on success, FALSE on failure
225
     */
226
    public function addUserToGroup($group_id, $user_id)
227
    {
228
        $mship = $this->membershipHandler->create();
229
        $mship->setVar('groupid', (int)$group_id);
230
        $mship->setVar('uid', (int)$user_id);
231
        $result = $this->membershipHandler->insert($mship);
232
        return $result ? $mship : false;
233
    }
234
235
    /**
236
     * Remove a list of users from a group
237
     * @param int $group_id ID of the group
238
     * @param array $user_ids Array of user-IDs
239
     * @return bool TRUE on success, FALSE on failure
240
     */
241
    public function removeUsersFromGroup($group_id, $user_ids = [])
242
    {
243
        $ids = $this->sanitizeIds($user_ids);
244
        if (empty($ids)) {
245
            return true; // No-op success
246
        }
247
248
        // Handle large batches
249
        if (count($ids) > self::MAX_BATCH_SIZE) {
250
            $batches = array_chunk($ids, self::MAX_BATCH_SIZE);
251
            foreach ($batches as $batch) {
252
                $criteria = new CriteriaCompo();
253
                $criteria->add(new Criteria('groupid', (int)$group_id));
254
                $criteria->add(new Criteria('uid', '(' . implode(',', $batch) . ')', 'IN'));
255
                if (!$this->membershipHandler->deleteAll($criteria)) {
256
                    return false;
257
                }
258
            }
259
            return true;
260
        }
261
262
        $criteria = new CriteriaCompo();
263
        $criteria->add(new Criteria('groupid', (int)$group_id));
264
        $criteria->add(new Criteria('uid', '(' . implode(',', $ids) . ')', 'IN'));
265
        return $this->membershipHandler->deleteAll($criteria);
266
    }
267
268
    /**
269
     * Get a list of users belonging to a group
270
     * @param int $group_id ID of the group
271
     * @param bool $asobject Return the users as objects?
272
     * @param int $limit Number of users to return
273
     * @param int $start Index of the first user to return
274
     * @return array Array of {@link XoopsUser} objects or user IDs
275
     */
276
    public function getUsersByGroup($group_id, $asobject = false, $limit = 0, $start = 0)
277
    {
278
        $user_ids = $this->membershipHandler->getUsersByGroup($group_id, $limit, $start);
279
        if (!$asobject || empty($user_ids)) {
280
            return $user_ids;
281
        }
282
283
        // Batch fetch users for better performance
284
        $criteria = new Criteria('uid', '(' . implode(',', array_map('intval', $user_ids)) . ')', 'IN');
285
        $users = $this->userHandler->getObjects($criteria, true);
286
287
        $ret = [];
288
        foreach ($user_ids as $uid) {
289
            if (isset($users[$uid])) {
290
                $ret[] = $users[$uid];
291
            }
292
        }
293
        return $ret;
294
    }
295
296
    /**
297
     * Get a list of groups that a user is member of
298
     * @param int $user_id ID of the user
299
     * @param bool $asobject Return groups as {@link XoopsGroup} objects or arrays?
300
     * @return array Array of objects or arrays
301
     */
302
    public function getGroupsByUser($user_id, $asobject = false)
303
    {
304
        $group_ids = $this->membershipHandler->getGroupsByUser($user_id);
305
        if (!$asobject || empty($group_ids)) {
306
            return $group_ids;
307
        }
308
309
        // Batch fetch groups for better performance
310
        $criteria = new Criteria('groupid', '(' . implode(',', array_map('intval', $group_ids)) . ')', 'IN');
311
        $groups = $this->groupHandler->getObjects($criteria, true);
312
313
        $ret = [];
314
        foreach ($group_ids as $gid) {
315
            if (isset($groups[$gid])) {
316
                $ret[] = $groups[$gid];
317
            }
318
        }
319
        return $ret;
320
    }
321
322
    /**
323
     * Log in a user with enhanced security
324
     * @param string $uname Username as entered in the login form
325
     * @param string $pwd Password entered in the login form
326
     * @return XoopsUser|false Logged in XoopsUser, FALSE if failed to log in
327
     */
328
    public function loginUser($uname, $pwd)
329
    {
330
        // Use Criteria for safe querying of username; password is handled separately for verification
331
        $criteria = new Criteria('uname', (string)$uname);
332
        $criteria->setLimit(2); // Fetch at most 2 to detect duplicates
333
334
        $users = $this->userHandler->getObjects($criteria, false) ?: [];
335
        if (count($users) !== 1) {
336
            return false;
337
        }
338
339
        /** @var XoopsUser $user */
340
        $user = $users[0];
341
        $hash = $user->pass();
342
343
        // Check if password uses modern hashing (PHP 7.4 compatible check)
344
        $isModernHash = (isset($hash[0]) && $hash[0] === '$');
345
346
        if ($isModernHash) {
347
            // Modern password hash
348
            if (!password_verify($pwd, $hash)) {
349
                return false;
350
            }
351
            $rehash = password_needs_rehash($hash, PASSWORD_DEFAULT);
352
        } else {
353
            // Legacy MD5 hash - use timing-safe comparison
354
            $expectedHash = md5($pwd);
355
            if (!$this->hashEquals($expectedHash, $hash)) {
356
                return false;
357
            }
358
            $rehash = true; // Always upgrade from MD5
359
        }
360
361
        // Upgrade password hash if needed
362
        if ($rehash) {
363
            $columnLength = $this->getColumnCharacterLength('users', 'pass');
364
            if ($columnLength === null || $columnLength < 255) {
365
                $this->logSecurityEvent('Password column too small for modern hashes', [
366
                    'table' => 'users',
367
                    'column' => 'pass',
368
                    'current_length' => $columnLength
369
                ]);
370
            } else {
371
                $newHash = password_hash($pwd, PASSWORD_DEFAULT);
372
                $user->setVar('pass', $newHash);
373
                $this->userHandler->insert($user);
374
            }
375
        }
376
377
        return $user;
378
    }
379
380
    /**
381
     * Get maximum character length for a table column
382
     * @param string $table Database table name
383
     * @param string $column Table column name
384
     * @return int|null Max length or null on error
385
     */
386
    public function getColumnCharacterLength($table, $column)
387
    {
388
        /** @var XoopsMySQLDatabase $db */
389
        $db = XoopsDatabaseFactory::getDatabaseConnection();
390
391
        $dbname = constant('XOOPS_DB_NAME');
392
        $table = $db->prefix($table);
393
394
        // Use quoteString if available, otherwise fall back to escape
395
        $quoteFn = method_exists($db, 'quoteString') ? 'quoteString' : 'escape';
396
397
        $sql = sprintf(
398
            'SELECT `CHARACTER_MAXIMUM_LENGTH` FROM `information_schema`.`COLUMNS` WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s AND `COLUMN_NAME` = %s',
399
            $db->$quoteFn($dbname),
400
            $db->$quoteFn($table),
401
            $db->$quoteFn($column)
402
        );
403
404
        /** @var mysqli_result|resource|false $result */
405
        $result = $db->query($sql);
406
        if ($db->isResultSet($result)) {
407
            $row = $db->fetchRow($result);
408
            if ($row) {
409
                return (int)$row[0];
410
            }
411
        }
412
        return null;
413
    }
414
415
    /**
416
     * Count users matching certain conditions
417
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
418
     * @return int Number of users
419
     */
420
    public function getUserCount($criteria = null)
421
    {
422
        return $this->userHandler->getCount($criteria);
423
    }
424
425
    /**
426
     * Count users belonging to a group
427
     * @param int $group_id ID of the group
428
     * @return int Number of users in the group
429
     */
430
    public function getUserCountByGroup($group_id)
431
    {
432
        return $this->membershipHandler->getCount(new Criteria('groupid', (int)$group_id));
433
    }
434
435
    /**
436
     * Update a single field in a user's record
437
     * @param XoopsUser $user Reference to the {@link XoopsUser} object
438
     * @param string $fieldName Name of the field to update
439
     * @param mixed $fieldValue Updated value for the field
440
     * @return bool TRUE if success or unchanged, FALSE on failure
441
     */
442
    public function updateUserByField(XoopsUser $user, $fieldName, $fieldValue)
443
    {
444
        $user->setVar($fieldName, $fieldValue);
445
        return $this->insertUser($user);
446
    }
447
448
    /**
449
     * Update a single field for multiple users
450
     * @param string $fieldName Name of the field to update
451
     * @param mixed $fieldValue Updated value for the field
452
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
453
     * @return bool TRUE if success or unchanged, FALSE on failure
454
     */
455
    public function updateUsersByField($fieldName, $fieldValue, $criteria = null)
456
    {
457
        return $this->userHandler->updateAll($fieldName, $fieldValue, $criteria);
458
    }
459
460
    /**
461
     * Activate a user
462
     * @param XoopsUser $user Reference to the {@link XoopsUser} object
463
     * @return bool TRUE on success, FALSE on failure
464
     */
465
    public function activateUser(XoopsUser $user)
466
    {
467
        if ($user->getVar('level') != 0) {
468
            return true;
469
        }
470
        $user->setVar('level', 1);
471
472
        // Generate more secure activation key
473
        $actkey = $this->generateSecureToken(8);
474
        $user->setVar('actkey', $actkey);
475
476
        return $this->userHandler->insert($user, true);
477
    }
478
479
    /**
480
     * Get a list of users belonging to certain groups and matching criteria
481
     * @param int|array $groups IDs of groups
482
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
483
     * @param bool $asobject Return the users as objects?
484
     * @param bool $id_as_key Use the UID as key for the array if $asobject is TRUE
485
     * @return array Array of {@link XoopsUser} objects or user IDs
486
     */
487
    public function getUsersByGroupLink($groups, $criteria = null, $asobject = false, $id_as_key = false)
488
    {
489
        $groups = (array)$groups;
490
        $validGroups = $this->sanitizeIds($groups);
491
492
        // If groups were specified but none are valid, return empty array
493
        if (!empty($groups) && empty($validGroups)) {
494
            return [];
495
        }
496
497
        $isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment();
498
499
        /** @var XoopsMySQLDatabase $db */
500
        $db = $this->userHandler->db;
501
        $select = $asobject ? 'u.*' : 'u.uid';
502
        $sql = "SELECT {$select} FROM " . $db->prefix('users') . ' u';
503
        $whereParts = [];
504
505
        if (!empty($validGroups)) {
506
            $linkTable = $db->prefix('groups_users_link');
507
            $group_in = '(' . implode(', ', $validGroups) . ')';
508
            $whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})";
509
        }
510
511
        $limit = 0;
512
        $start = 0;
513
        $orderBy = '';
514
515
        if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) {
516
            $criteriaCompo = new CriteriaCompo();
517
            $criteriaCompo->add($criteria, 'AND');
518
            $sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render()));
519
            if ($sqlCriteria !== '') {
520
                $whereParts[] = $sqlCriteria;
521
            }
522
523
            $limit = (int)$criteria->getLimit();
524
            $start = (int)$criteria->getStart();
525
526
            // Apply safe sorting
527
            $sort = trim((string)$criteria->getSort());
528
            $order = trim((string)$criteria->getOrder());
529
            if ($sort !== '') {
530
                $allowed = $this->getAllowedSortFields();
531
                if (isset($allowed[$sort])) {
532
                    $orderBy = ' ORDER BY ' . $allowed[$sort];
533
                    $orderBy .= (strtoupper($order) === 'DESC') ? ' DESC' : ' ASC';
534
                }
535
            }
536
        }
537
538
        if (!empty($whereParts)) {
539
            $sql .= ' WHERE ' . implode(' AND ', $whereParts);
540
        }
541
        $sql .= $orderBy;
542
543
        $result = $db->query($sql, $limit, $start);
544
        if (!$db->isResultSet($result)) {
545
            $this->logDatabaseError('Query failed in getUsersByGroupLink', $sql, [
546
                'groups_count' => count($validGroups),
547
                'has_criteria' => isset($criteria)
548
            ], $isDebug);
549
            return [];
550
        }
551
552
        $ret = [];
553
        /** @var array $myrow */
554
        while (false !== ($myrow = $db->fetchArray($result))) {
0 ignored issues
show
Bug introduced by
It seems like $result can also be of type boolean; however, parameter $result of XoopsMySQLDatabase::fetchArray() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

554
        while (false !== ($myrow = $db->fetchArray(/** @scrutinizer ignore-type */ $result))) {
Loading history...
555
            if ($asobject) {
556
                $user = new XoopsUser();
557
                $user->assignVars($myrow);
558
                if ($id_as_key) {
559
                    $ret[(int)$myrow['uid']] = $user;
560
                } else {
561
                    $ret[] = $user;
562
                }
563
            } else {
564
                $ret[] = (int)$myrow['uid'];
565
            }
566
        }
567
568
        return $ret;
569
    }
570
571
    /**
572
     * Get count of users belonging to certain groups and matching criteria
573
     * @param array $groups IDs of groups
574
     * @param CriteriaElement|null $criteria {@link CriteriaElement} object
575
     * @return int Count of users
576
     */
577
    public function getUserCountByGroupLink(array $groups, $criteria = null)
578
    {
579
        $validGroups = $this->sanitizeIds($groups);
580
581
        // If groups were specified but none are valid, return 0
582
        if (!empty($groups) && empty($validGroups)) {
583
            return 0;
584
        }
585
586
        $isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment();
587
588
        /** @var XoopsMySQLDatabase $db */
589
        $db = $this->userHandler->db;
590
        $sql = 'SELECT COUNT(*) FROM ' . $db->prefix('users') . ' u';
591
        $whereParts = [];
592
593
        if (!empty($validGroups)) {
594
            $linkTable = $db->prefix('groups_users_link');
595
            $group_in = '(' . implode(', ', $validGroups) . ')';
596
            $whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})";
597
        }
598
599
        if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) {
600
            $criteriaCompo = new CriteriaCompo();
601
            $criteriaCompo->add($criteria, 'AND');
602
            $sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render()));
603
            if ($sqlCriteria !== '') {
604
                $whereParts[] = $sqlCriteria;
605
            }
606
        }
607
608
        if (!empty($whereParts)) {
609
            $sql .= ' WHERE ' . implode(' AND ', $whereParts);
610
        }
611
612
        $result = $db->query($sql);
613
        if (!$db->isResultSet($result)) {
614
            $this->logDatabaseError('Query failed in getUserCountByGroupLink', $sql, [
615
                'groups_count' => count($validGroups),
616
                'has_criteria' => $criteria instanceof \CriteriaElement,
617
            ], $isDebug);
618
            return 0;
619
        }
620
        /** @var \mysqli_result $result */
621
        $row = $db->fetchRow($result);
622
        return $row ? (int)$row[0] : 0;
623
    }
624
625
    // =========================================================================
626
    // Private Helper Methods
627
    // =========================================================================
628
629
    /**
630
     * Create a safe IN criteria for IDs
631
     * @param string $field Field name
632
     * @param mixed $value Single value or array of values
633
     * @return CriteriaElement
634
     */
635
    private function createSafeInCriteria($field, $value)
636
    {
637
        $ids = $this->sanitizeIds((array)$value);
638
        $inClause = !empty($ids) ? '(' . implode(',', $ids) . ')' : '(0)';
639
        return new Criteria($field, $inClause, 'IN');
640
    }
641
642
    /**
643
     * Sanitize an array of IDs
644
     * @param array $ids Array of potential IDs
645
     * @return array Array of valid integer IDs
646
     */
647
    private function sanitizeIds(array $ids)
648
    {
649
        return array_values(array_filter(
650
            array_map('intval', array_filter($ids, function($v) {
651
                return is_int($v) || (is_string($v) && ctype_digit($v));
652
            })),
653
            function($v) { return $v > 0; }
654
        ));
655
    }
656
657
    /**
658
     * Get allowed sort fields for SQL queries
659
     * @return array Map of allowed field names to SQL columns
660
     */
661
    private function getAllowedSortFields()
662
    {
663
        return [
664
            // Non-prefixed (backward compatibility)
665
            'uid' => 'u.uid',
666
            'uname' => 'u.uname',
667
            'email' => 'u.email',
668
            'user_regdate' => 'u.user_regdate',
669
            'last_login' => 'u.last_login',
670
            'user_avatar' => 'u.user_avatar',
671
            'name' => 'u.name',
672
            'posts' => 'u.posts',
673
            'level' => 'u.level',
674
            // Prefixed (explicit)
675
            'u.uid' => 'u.uid',
676
            'u.uname' => 'u.uname',
677
            'u.email' => 'u.email',
678
            'u.user_regdate' => 'u.user_regdate',
679
            'u.last_login' => 'u.last_login',
680
            'u.user_avatar' => 'u.user_avatar',
681
            'u.name' => 'u.name',
682
            'u.posts' => 'u.posts',
683
            'u.level' => 'u.level'
684
        ];
685
    }
686
687
    /**
688
     * Timing-safe string comparison (PHP 7.4 compatible)
689
     * @param string $expected Expected string
690
     * @param string $actual Actual string
691
     * @return bool TRUE if strings are equal
692
     */
693
    private function hashEquals($expected, $actual)
694
    {
695
        // Use hash_equals if available (PHP 5.6+)
696
        if (function_exists('hash_equals')) {
697
            return hash_equals($expected, $actual);
698
        }
699
700
        // Fallback implementation
701
        $expected = (string)$expected;
702
        $actual = (string)$actual;
703
        $expectedLength = strlen($expected);
704
        $actualLength = strlen($actual);
705
        $diff = $expectedLength ^ $actualLength;
706
707
        for ($i = 0; $i < $actualLength; $i++) {
708
            $diff |= ord($expected[$i % $expectedLength]) ^ ord($actual[$i]);
709
        }
710
711
        return $diff === 0;
712
    }
713
714
    /**
715
     * Generate a secure random token (hex-encoded).
716
     * @param int $length Desired token length in HEX characters (4 bits per char). Clamped to [8, 32].
717
     * @return string Random token
718
     */
719
    private function generateSecureToken(int $length = 32): string
720
    {
721
        $length = max(8, min(128, $length));
722
        $bytes  = intdiv($length + 1, 2); // ceil($length/2)
723
724
        try {
725
            $raw = random_bytes($bytes);
726
        } catch (\Throwable $e) {
727
            $msg = '[CRITICAL] No CSPRNG available to generate a secure token in ' . __METHOD__;
728
            if (class_exists('XoopsLogger')) {
729
                \XoopsLogger::getInstance()->handleError(E_USER_ERROR, $msg, __FILE__, __LINE__);
730
            } else {
731
                error_log($msg);
732
            }
733
            throw new \RuntimeException('No CSPRNG available to generate a secure token.', 0, $e);
734
        }
735
736
        return substr(bin2hex($raw), 0, $length);
737
    }
738
739
    /**
740
     * Check if debug output is allowed
741
     * @return bool TRUE if debugging is allowed
742
     */
743
    private function isDebugAllowed()
744
    {
745
        $mode = (int)($GLOBALS['xoopsConfig']['debug_mode'] ?? 0);
746
        if (!in_array($mode, [1, 2], true)) {
747
            return false;
748
        }
749
750
        $level = (int)($GLOBALS['xoopsConfig']['debugLevel'] ?? 0);
751
        $user = $GLOBALS['xoopsUser'] ?? null;
752
        $isAdmin = (bool)($GLOBALS['xoopsUserIsAdmin'] ?? false);
753
754
        switch ($level) {
755
            case 2:
756
                return $isAdmin;
757
            case 1:
758
                return $user !== null;
759
            default:
760
                return true;
761
        }
762
    }
763
764
    /**
765
     * Check if running in production environment
766
     * @return bool TRUE if in production
767
     */
768
    private function isProductionEnvironment()
769
    {
770
        // Check explicit production flag
771
        if (\defined('XOOPS_PRODUCTION') && \constant('XOOPS_PRODUCTION')) {
772
            return true;
773
        }
774
775
        // Check environment variable
776
        if (getenv('XOOPS_ENV') === 'production') {
777
            return true;
778
        }
779
780
        // Check for development indicators
781
        if ((\defined('XOOPS_DEBUG') && \constant('XOOPS_DEBUG')) ||
782
            (PHP_SAPI === 'cli') ||
783
            (isset($_SERVER['SERVER_ADDR']) && in_array($_SERVER['SERVER_ADDR'], ['127.0.0.1', '::1'], true))) {
784
            return false;
785
        }
786
787
        // Default to production for safety
788
        return true;
789
    }
790
791
    /**
792
     * Redact sensitive information from SQL queries
793
     * @param string $sql SQL query string
794
     * @return string Redacted SQL query
795
     */
796
    private function redactSql($sql)
797
    {
798
        // Redact quoted strings
799
        $sql = preg_replace("/'[^']*'/", "'?'", $sql);
800
        $sql = preg_replace('/"[^"]*"/', '"?"', $sql);
801
        $sql = preg_replace("/x'[0-9A-Fa-f]+'/", "x'?'", $sql);
802
        return $sql;
803
    }
804
805
    /**
806
     * Sanitize string for logging
807
     * @param string $str String to sanitize
808
     * @return string Sanitized string
809
     */
810
    private function sanitizeForLog($str)
811
    {
812
        // Remove control characters
813
        $str = preg_replace('/[\x00-\x1F\x7F]/', '', $str);
814
        // Remove BIDI control characters
815
        $str = preg_replace(self::BIDI_CONTROL_REGEX, '', $str);
816
        // Normalize whitespace
817
        $str = preg_replace('/\s+/', ' ', $str);
818
        // Limit length
819
        if (function_exists('mb_substr')) {
820
            return mb_substr($str, 0, self::LOG_CONTEXT_MAX_LENGTH);
821
        }
822
        return substr($str, 0, self::LOG_CONTEXT_MAX_LENGTH);
823
    }
824
825
    /**
826
     * Log database errors with context
827
     * @param string $message Error message
828
     * @param string $sql     SQL query that failed
829
     * @param array  $context Additional context
830
     * @param bool   $isDebug Whether debug mode is enabled
831
     * @return void
832
     */
833
    private function logDatabaseError($message, $sql, array $context, $isDebug)
834
    {
835
        /** @var XoopsMySQLDatabase $db */
836
        $db = $this->userHandler->db;
837
838
        $errorInfo = [
839
            'message' => $message,
840
            'user_id' => isset($GLOBALS['xoopsUser']) ? (int)$GLOBALS['xoopsUser']->getVar('uid') : 'anonymous',
841
            'timestamp' => date('Y-m-d H:i:s')
842
        ];
843
844
        // Add request context
845
        if (isset($_SERVER['REQUEST_METHOD'])) {
846
            $errorInfo['method'] = $this->sanitizeForLog($_SERVER['REQUEST_METHOD']);
847
            $errorInfo['uri'] = $this->sanitizeRequestUri();
848
        }
849
850
        // Add provided context
851
        $errorInfo = array_merge($errorInfo, $context);
852
853
        // Add database error if available
854
        if (method_exists($db, 'error')) {
855
            $errorInfo['db_error'] = $db->error();
856
        }
857
        if (method_exists($db, 'errno')) {
858
            $errorInfo['db_errno'] = $db->errno();
859
        }
860
861
        // Build log message
862
        $logMessage = $message . ' | Context: ' . json_encode($errorInfo, JSON_UNESCAPED_SLASHES);
863
864
        // Add SQL in debug mode only
865
        if ($isDebug) {
866
            $logMessage .= ' | SQL: ' . $this->redactSql($sql);
867
        }
868
869
        // Log the error
870
        if (class_exists('XoopsLogger')) {
871
            XoopsLogger::getInstance()->handleError(E_USER_WARNING, $logMessage, __FILE__, __LINE__);
872
        } else {
873
            error_log($logMessage);
874
        }
875
    }
876
877
    /**
878
     * Sanitize request URI for logging
879
     * @return string Sanitized URI
880
     */
881
    private function sanitizeRequestUri()
882
    {
883
        if (!isset($_SERVER['REQUEST_URI'])) {
884
            return 'cli';
885
        }
886
887
        $parts = parse_url($_SERVER['REQUEST_URI']);
888
        $path = $this->sanitizeForLog($parts['path'] ?? '/');
889
890
        if (isset($parts['query'])) {
891
            parse_str($parts['query'], $queryParams);
892
            foreach ($queryParams as $key => &$value) {
893
                if (in_array(strtolower($key), self::SENSITIVE_PARAMS, true)) {
894
                    $value = 'REDACTED';
895
                } else {
896
                    $value = is_array($value) ? '[ARRAY]' : $this->sanitizeForLog((string)$value);
897
                }
898
            }
899
            unset($value);
900
            $queryString = http_build_query($queryParams);
901
            return $queryString ? $path . '?' . $queryString : $path;
902
        }
903
904
        return $path;
905
    }
906
907
    /**
908
     * Log security-related events
909
     * @param string $event Event description
910
     * @param array $context Additional context
911
     */
912
    private function logSecurityEvent($event, array $context = [])
913
    {
914
        $logData = [
915
            'event' => $event,
916
            'timestamp' => date('Y-m-d H:i:s'),
917
            'user_id' => isset($GLOBALS['xoopsUser']) ? (int)$GLOBALS['xoopsUser']->getVar('uid') : 'anonymous',
918
            'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
919
        ];
920
921
        $logData = array_merge($logData, $context);
922
        $message = 'Security Event: ' . json_encode($logData, JSON_UNESCAPED_SLASHES);
923
924
        if (class_exists('XoopsLogger')) {
925
            XoopsLogger::getInstance()->handleError(E_USER_NOTICE, $message, __FILE__, __LINE__);
926
        } else {
927
            error_log($message);
928
        }
929
    }
930
}
931