XoopsMemberHandler::sanitizeIds()   A
last analyzed

Complexity

Conditions 3
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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

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