Passed
Pull Request — master (#1560)
by Michael
14:55 queued 05:32
created

XoopsMemberHandler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
/**
3
 * XOOPS Kernel Class
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
 */
18
defined('XOOPS_ROOT_PATH') || exit('Restricted access');
19
20
require_once __DIR__ . '/user.php';
21
require_once __DIR__ . '/group.php';
22
23
/**
24
 * XOOPS member handler class.
25
 * This class provides simple interface (a facade class) for handling groups/users/
26
 * membership data.
27
 *
28
 *
29
 * @author              Kazumi Ono <[email protected]>
30
 * @copyright       (c) 2000-2025 XOOPS Project (https://xoops.org)
31
 * @package             kernel
32
 */
33
class XoopsMemberHandler
34
{
35
    
36
    private const BIDI_CONTROL_REGEX = '/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u';
37
    private const SENSITIVE_PARAMS = ['token', 'access_token', 'id_token', 'password', 'pass', 'pwd', 'secret', 'key', 'api_key', 'apikey', 'auth', 'authorization', 'session', 'sid', 'code'];
38
39
40
    /**
41
     * holds reference to group handler(DAO) class
42
     * @access private
43
     */
44
    protected $groupHandler;
45
46
    /**
47
     * holds reference to user handler(DAO) class
48
     */
49
    protected $userHandler;
50
51
    /**
52
     * holds reference to membership handler(DAO) class
53
     */
54
    protected $membershipHandler;
55
56
    /**
57
     * holds temporary user objects
58
     */
59
    protected $membersWorkingList = [];
60
61
    /**
62
     * constructor
63
     * @param XoopsDatabase|null| $db
64
     */
65
    public function __construct(XoopsDatabase $db)
66
    {
67
        $this->groupHandler = new XoopsGroupHandler($db);
68
        $this->userHandler = new XoopsUserHandler($db);
69
        $this->membershipHandler = new XoopsMembershipHandler($db);
70
    }
71
72
    /**
73
     * create a new group
74
     *
75
     * @return XoopsGroup XoopsGroup reference to the new group
76
     */
77
    public function &createGroup()
78
    {
79
        $inst = $this->groupHandler->create();
80
81
        return $inst;
82
    }
83
84
    /**
85
     * create a new user
86
     *
87
     * @return XoopsUser reference to the new user
88
     */
89
    public function createUser()
90
    {
91
        $inst = $this->userHandler->create();
92
93
        return $inst;
94
    }
95
96
    /**
97
     * retrieve a group
98
     *
99
     * @param  int $id ID for the group
100
     * @return XoopsGroup|false XoopsGroup reference to the group
101
     */
102
    public function getGroup($id)
103
    {
104
        return $this->groupHandler->get($id);
105
    }
106
107
    /**
108
     * retrieve a user
109
     *
110
     * @param  int $id ID for the user
111
     * @return XoopsUser reference to the user
112
     */
113
    public function getUser($id)
114
    {
115
        if (!isset($this->membersWorkingList[$id])) {
116
            $this->membersWorkingList[$id] = $this->userHandler->get($id);
117
        }
118
119
        return $this->membersWorkingList[$id];
120
    }
121
122
    /**
123
     * delete a group
124
     *
125
     * @param  XoopsGroup $group reference to the group to delete
126
     * @return bool   FALSE if failed
127
     */
128
    public function deleteGroup(XoopsGroup $group)
129
    {
130
        $s1 = $this->membershipHandler->deleteAll(new Criteria('groupid', $group->getVar('groupid')));
0 ignored issues
show
Bug introduced by
It seems like $group->getVar('groupid') can also be of type array and array; however, parameter $value of Criteria::__construct() does only seem to accept string, 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

130
        $s1 = $this->membershipHandler->deleteAll(new Criteria('groupid', /** @scrutinizer ignore-type */ $group->getVar('groupid')));
Loading history...
131
        $s2 = $this->groupHandler->delete($group);
132
133
        return ($s1 && $s2);// ? true : false;
134
    }
135
136
    /**
137
     * delete a user
138
     *
139
     * @param  XoopsUser $user reference to the user to delete
140
     * @return bool   FALSE if failed
141
     */
142
    public function deleteUser(XoopsUser $user)
143
    {
144
        $s1 = $this->membershipHandler->deleteAll(new Criteria('uid', $user->getVar('uid')));
0 ignored issues
show
Bug introduced by
It seems like $user->getVar('uid') can also be of type array and array; however, parameter $value of Criteria::__construct() does only seem to accept string, 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

144
        $s1 = $this->membershipHandler->deleteAll(new Criteria('uid', /** @scrutinizer ignore-type */ $user->getVar('uid')));
Loading history...
145
        $s2 = $this->userHandler->delete($user);
146
147
        return ($s1 && $s2);// ? true : false;
148
    }
149
150
    /**
151
     * insert a group into the database
152
     *
153
     * @param  XoopsGroup $group reference to the group to insert
154
     * @return bool       TRUE if already in database and unchanged
155
     *                           FALSE on failure
156
     */
157
    public function insertGroup(XoopsGroup $group)
158
    {
159
        return $this->groupHandler->insert($group);
160
    }
161
162
    /**
163
     * insert a user into the database
164
     *
165
     * @param XoopsUser $user reference to the user to insert
166
     * @param bool      $force
167
     *
168
     * @return bool TRUE if already in database and unchanged
169
     *              FALSE on failure
170
     */
171
    public function insertUser(XoopsUser $user, $force = false)
172
    {
173
        return $this->userHandler->insert($user, $force);
174
    }
175
176
    /**
177
     * retrieve groups from the database
178
     *
179
     * @param  CriteriaElement $criteria  {@link CriteriaElement}
180
     * @param  bool            $id_as_key use the group's ID as key for the array?
181
     * @return array           array of {@link XoopsGroup} objects
182
     */
183
    public function getGroups(?CriteriaElement $criteria = null, $id_as_key = false)
184
    {
185
        return $this->groupHandler->getObjects($criteria, $id_as_key);
186
    }
187
188
    /**
189
     * retrieve users from the database
190
     *
191
     * @param  CriteriaElement $criteria  {@link CriteriaElement}
192
     * @param  bool            $id_as_key use the group's ID as key for the array?
193
     * @return array           array of {@link XoopsUser} objects
194
     */
195
    public function getUsers(?CriteriaElement $criteria = null, $id_as_key = false)
196
    {
197
        return $this->userHandler->getObjects($criteria, $id_as_key);
198
    }
199
200
    /**
201
     * get a list of groupnames and their IDs
202
     *
203
     * @param  CriteriaElement $criteria {@link CriteriaElement} object
204
     * @return array           associative array of group-IDs and names
205
     */
206
    public function getGroupList(?CriteriaElement $criteria = null)
207
    {
208
        $groups = $this->groupHandler->getObjects($criteria, true);
209
        $ret    = [];
210
        foreach (array_keys($groups) as $i) {
211
            $ret[$i] = $groups[$i]->getVar('name');
212
        }
213
214
        return $ret;
215
    }
216
217
    /**
218
     * get a list of usernames and their IDs
219
     *
220
     * @param  CriteriaElement $criteria {@link CriteriaElement} object
221
     * @return array           associative array of user-IDs and names
222
     */
223
    public function getUserList(?CriteriaElement $criteria = null)
224
    {
225
        $users = & $this->userHandler->getObjects($criteria, true);
226
        $ret   = [];
227
        foreach (array_keys($users) as $i) {
228
            $ret[$i] = $users[$i]->getVar('uname');
229
        }
230
231
        return $ret;
232
    }
233
234
    /**
235
     * add a user to a group
236
     *
237
     * @param  int $group_id ID of the group
238
     * @param  int $user_id  ID of the user
239
     * @return XoopsMembership XoopsMembership
240
     */
241
    public function addUserToGroup($group_id, $user_id)
242
    {
243
        $mship = $this->membershipHandler->create();
244
        $mship->setVar('groupid', $group_id);
245
        $mship->setVar('uid', $user_id);
246
247
        return $this->membershipHandler->insert($mship);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->membershipHandler->insert($mship) returns the type boolean which is incompatible with the documented return type XoopsMembership.
Loading history...
248
    }
249
250
    /**
251
     * remove a list of users from a group
252
     *
253
     * @param  int   $group_id ID of the group
254
     * @param  array $user_ids array of user-IDs
255
     * @return bool  success?
256
     */
257
    public function removeUsersFromGroup($group_id, $user_ids = [])
258
    {
259
        $criteria = new CriteriaCompo();
260
        $criteria->add(new Criteria('groupid', $group_id));
261
        $criteria2 = new CriteriaCompo();
262
        foreach ($user_ids as $uid) {
263
            $criteria2->add(new Criteria('uid', $uid), 'OR');
264
        }
265
        $criteria->add($criteria2);
266
267
        return $this->membershipHandler->deleteAll($criteria);
268
    }
269
270
    /**
271
     * get a list of users belonging to a group
272
     *
273
     * @param  int  $group_id ID of the group
274
     * @param  bool $asobject return the users as objects?
275
     * @param  int  $limit    number of users to return
276
     * @param  int  $start    index of the first user to return
277
     * @return array Array of {@link XoopsUser} objects (if $asobject is TRUE)
278
     *                        or of associative arrays matching the record structure in the database.
279
     */
280
    public function getUsersByGroup($group_id, $asobject = false, $limit = 0, $start = 0)
281
    {
282
        $user_ids = $this->membershipHandler->getUsersByGroup($group_id, $limit, $start);
283
        if (!$asobject) {
284
            return $user_ids;
285
        } else {
286
            $ret = [];
287
            foreach ($user_ids as $u_id) {
288
                $user = $this->getUser($u_id);
289
                if (is_object($user)) {
290
                    $ret[] = &$user;
291
                }
292
                unset($user);
293
            }
294
295
            return $ret;
296
        }
297
    }
298
299
    /**
300
     * get a list of groups that a user is member of
301
     *
302
     * @param  int  $user_id  ID of the user
303
     * @param  bool $asobject return groups as {@link XoopsGroup} objects or arrays?
304
     * @return array array of objects or arrays
305
     */
306
    public function getGroupsByUser($user_id, $asobject = false)
307
    {
308
        $group_ids = $this->membershipHandler->getGroupsByUser($user_id);
309
        if (!$asobject) {
310
            return $group_ids;
311
        } else {
312
            $ret = [];
313
            foreach ($group_ids as $g_id) {
314
                $ret[] = $this->getGroup($g_id);
315
            }
316
317
            return $ret;
318
        }
319
    }
320
321
    /**
322
     * log in a user
323
     *
324
     * @param  string    $uname username as entered in the login form
325
     * @param  string    $pwd   password entered in the login form
326
     *
327
     * @return XoopsUser|false logged in XoopsUser, FALSE if failed to log in
328
     */
329
    public function loginUser($uname, $pwd)
330
    {
331
        $db = XoopsDatabaseFactory::getDatabaseConnection();
332
        $uname = $db->escape($uname);
0 ignored issues
show
Bug introduced by
The method escape() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

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

332
        /** @scrutinizer ignore-call */ 
333
        $uname = $db->escape($uname);
Loading history...
333
        $pwd = $db->escape($pwd);
334
        $criteria = new Criteria('uname', $uname);
335
        $user = & $this->userHandler->getObjects($criteria, false);
336
        if (!$user || count($user) != 1) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $user of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
337
            return false;
338
        }
339
340
        $hash = $user[0]->pass();
341
        $type = substr($user[0]->pass(), 0, 1);
342
        // see if we have a crypt like signature, old md5 hash is just hex digits
343
        if ($type === '$') {
344
            if (!password_verify($pwd, $hash)) {
345
                return false;
346
            }
347
            // check if hash uses the best algorithm (i.e. after a PHP upgrade)
348
            $rehash = password_needs_rehash($hash, PASSWORD_DEFAULT);
349
        } else {
350
            if ($hash != md5($pwd)) {
351
                return false;
352
            }
353
            $rehash = true; // automatically update old style
354
        }
355
        // hash used an old algorithm, so make it stronger
356
        if ($rehash) {
357
            if ($this->getColumnCharacterLength('users', 'pass') < 255) {
358
                error_log('Upgrade required on users table!');
359
            } else {
360
                $user[0]->setVar('pass', password_hash($pwd, PASSWORD_DEFAULT));
361
                $this->userHandler->insert($user[0]);
362
            }
363
        }
364
        return $user[0];
365
    }
366
367
    /**
368
     * Get maximum character length for a table column
369
     *
370
     * @param string $table  database table
371
     * @param string $column table column
372
     *
373
     * @return int|null max length or null on error
374
     */
375
    public function getColumnCharacterLength($table, $column)
376
    {
377
        /** @var XoopsMySQLDatabase $db */
378
        $db = XoopsDatabaseFactory::getDatabaseConnection();
379
380
        $dbname = constant('XOOPS_DB_NAME');
381
        $table = $db->prefix($table);
382
383
        $sql = sprintf(
384
            'SELECT `CHARACTER_MAXIMUM_LENGTH` FROM `information_schema`.`COLUMNS` '
385
            . "WHERE TABLE_SCHEMA = '%s'AND TABLE_NAME = '%s' AND COLUMN_NAME = '%s'",
386
            $db->escape($dbname),
387
            $db->escape($table),
388
            $db->escape($column),
389
        );
390
391
        /** @var mysqli_result $result */
392
        $result = $db->query($sql);
393
        if ($db->isResultSet($result)) {
394
            $row = $db->fetchRow($result);
395
            if ($row) {
396
                $columnLength = $row[0];
397
                return (int) $columnLength;
398
            }
399
        }
400
        return null;
401
    }
402
403
    /**
404
     * count users matching certain conditions
405
     *
406
     * @param  CriteriaElement $criteria {@link CriteriaElement} object
407
     * @return int
408
     */
409
    public function getUserCount(?CriteriaElement $criteria = null)
410
    {
411
        return $this->userHandler->getCount($criteria);
412
    }
413
414
    /**
415
     * count users belonging to a group
416
     *
417
     * @param  int $group_id ID of the group
418
     * @return int
419
     */
420
    public function getUserCountByGroup($group_id)
421
    {
422
        return $this->membershipHandler->getCount(new Criteria('groupid', $group_id));
423
    }
424
425
    /**
426
     * updates a single field in a users record
427
     *
428
     * @param  XoopsUser $user       reference to the {@link XoopsUser} object
429
     * @param  string    $fieldName  name of the field to update
430
     * @param  string    $fieldValue updated value for the field
431
     * @return bool      TRUE if success or unchanged, FALSE on failure
432
     */
433
    public function updateUserByField(XoopsUser $user, $fieldName, $fieldValue)
434
    {
435
        $user->setVar($fieldName, $fieldValue);
436
437
        return $this->insertUser($user);
438
    }
439
440
    /**
441
     * updates a single field in a users record
442
     *
443
     * @param  string          $fieldName  name of the field to update
444
     * @param  string          $fieldValue updated value for the field
445
     * @param  CriteriaElement $criteria   {@link CriteriaElement} object
446
     * @return bool            TRUE if success or unchanged, FALSE on failure
447
     */
448
    public function updateUsersByField($fieldName, $fieldValue, ?CriteriaElement $criteria = null)
449
    {
450
        return $this->userHandler->updateAll($fieldName, $fieldValue, $criteria);
451
    }
452
453
    /**
454
     * activate a user
455
     *
456
     * @param  XoopsUser $user reference to the {@link XoopsUser} object
457
     * @return mixed      successful? false on failure
458
     */
459
    public function activateUser(XoopsUser $user)
460
    {
461
        if ($user->getVar('level') != 0) {
462
            return true;
463
        }
464
        $user->setVar('level', 1);
465
        $actkey = substr(md5(uniqid(mt_rand(), 1)), 0, 8);
466
        $user->setVar('actkey', $actkey);
467
468
        return $this->userHandler->insert($user, true);
469
    }
470
471
    protected function allowedSortMap()
472
    {
473
        // Maps both prefixed and non-prefixed column names for flexibility
474
        // This allows sorting by 'uid' or 'u.uid' while maintaining security
475
        return [
476
            'uid'            => 'u.uid',
477
            'uname'          => 'u.uname',
478
            'email'          => 'u.email',
479
            'user_regdate'   => 'u.user_regdate',
480
            'last_login'     => 'u.last_login',
481
            'user_avatar'    => 'u.user_avatar',
482
            'name'           => 'u.name',
483
            // Prefixed versions for explicit table references
484
            'u.uid'          => 'u.uid',
485
            'u.uname'        => 'u.uname',
486
            'u.email'        => 'u.email',
487
            'u.user_regdate' => 'u.user_regdate',
488
            'u.last_login'   => 'u.last_login',
489
            'u.user_avatar'  => 'u.user_avatar',
490
            'u.name'         => 'u.name',
491
        ];
492
    }
493
494
495
    /**
496
     * Get a list of users belonging to certain groups and matching criteria
497
     * Temporary solution
498
     *
499
     * @param  array           $groups    IDs of groups
500
     * @param  CriteriaElement $criteria  {@link CriteriaElement} object
501
     * @param  bool            $asobject  return the users as objects?
502
     * @param  bool            $id_as_key use the UID as key for the array if $asobject is TRUE
503
     * @return array           Array of {@link XoopsUser} objects (if $asobject is TRUE)
504
     *                                    or of associative arrays matching the record structure in the database.
505
     */
506
507
    public function getUsersByGroupLink(
508
        $groups,
509
        $criteria = null,
510
        $asobject = false,
511
        $id_as_key = false
512
    ) {
513
        // Type coercion for backwards compatibility
514
        $groups = is_array($groups) ? $groups : [$groups];
0 ignored issues
show
introduced by
The condition is_array($groups) is always true.
Loading history...
515
        $asobject = (bool)$asobject;
516
        $id_as_key = (bool)$id_as_key;
517
518
        // Debug configuration using only current XOOPS debug system
519
        // Check XOOPS debug mode - we only want PHP debugging (1=inline, 2=popup)
520
        $xoopsDebugMode = isset($GLOBALS['xoopsConfig']['debug_mode']) ? (int)$GLOBALS['xoopsConfig']['debug_mode'] : 0;
521
        $xoopsPhpDebugEnabled = ($xoopsDebugMode === 1 || $xoopsDebugMode === 2);
522
523
        // Check if debug is allowed for current user based on debugLevel
524
        $xoopsDebugAllowed = $xoopsPhpDebugEnabled;
525
        if ($xoopsPhpDebugEnabled && isset($GLOBALS['xoopsConfig']['debugLevel'])) {
526
            $debugLevel = (int)$GLOBALS['xoopsConfig']['debugLevel'];
527
            $xoopsUser = $GLOBALS['xoopsUser'] ?? null;
528
            $xoopsUserIsAdmin = isset($GLOBALS['xoopsUserIsAdmin']) ? $GLOBALS['xoopsUserIsAdmin'] : false;
529
530
            // Apply XOOPS debug level restrictions
531
            switch ($debugLevel) {
532
                case 2: // Admins only
533
                    $xoopsDebugAllowed = $xoopsUserIsAdmin;
534
                    break;
535
                case 1: // Members only
536
                    $xoopsDebugAllowed = ($xoopsUser !== null);
537
                    break;
538
                case 0: // All users
539
                default:
540
                    $xoopsDebugAllowed = true;
541
                    break;
542
            }
543
        }
544
545
        // Production safety check - use secure environment detection
546
        // Note: SERVER_NAME can be spoofed via Host header, so it's not secure for production detection
547
        // For security, set XOOPS_ENV=production in your server environment or use a config constant
548
        $isProd = false;
549
550
        if (defined('XOOPS_PRODUCTION') && XOOPS_PRODUCTION) {
0 ignored issues
show
Bug introduced by
The constant XOOPS_PRODUCTION was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
551
            // Most secure: use a defined constant set in configuration
552
            $isProd = true;
553
        } elseif (getenv('XOOPS_ENV') === 'production') {
554
            // Secure: use environment variable (not spoofable by clients)
555
            $isProd = true;
556
        } else {
557
            // Fallback: assume production unless explicitly in known development environments
558
            // This is more secure than the old approach - defaults to restrictive mode
559
            $isProd = true;
560
            // Only allow debug in explicitly known safe development indicators
561
            if ((defined('XOOPS_DEBUG') && XOOPS_DEBUG)
0 ignored issues
show
Bug introduced by
The constant XOOPS_DEBUG was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
562
                || (php_sapi_name() === 'cli')
563
                || (isset($_SERVER['SERVER_ADDR']) && $_SERVER['SERVER_ADDR'] === '127.0.0.1')) {
564
                $isProd = false;
565
            }
566
        }
567
568
        // Enable SQL logging only if XOOPS PHP debug is allowed and not in production
569
        $isDebug = $xoopsDebugAllowed && !$isProd;
570
571
        /**
572
         * Redact sensitive SQL literals in debug logs while preserving query structure
573
         * @param string $sql The SQL query to redact
574
         * @return string Redacted SQL query
575
         */
576
        $redactSql = static function (string $sql): string {
577
            // Replace quoted strings with placeholders
578
            $sql = preg_replace("/'[^']*'/", "'?'", $sql);
579
            $sql = preg_replace('/"[^"]*"/', '"?"', $sql);
580
            // Replace hex literals
581
            $sql = preg_replace("/x'[0-9A-Fa-f]+'/", "x'?'", $sql);
582
            // Replace large numbers (potential IDs) but keep small ones
583
            // Removed overzealous redaction of large numbers to preserve legitimate identifiers
584
            return $sql;
585
        };
586
587
        $ret           = [];
588
        $criteriaCompo = new CriteriaCompo();
589
        $select        = $asobject ? 'u.*' : 'u.uid';
590
        $sql = "SELECT {$select} FROM " . $this->userHandler->db->prefix('users') . ' u';
591
        $whereParts = [];
592
        $limit = 0;
0 ignored issues
show
Unused Code introduced by
The assignment to $limit is dead and can be removed.
Loading history...
593
        $start = 0;
0 ignored issues
show
Unused Code introduced by
The assignment to $start is dead and can be removed.
Loading history...
594
595
        // Sanitize and validate groups once - clean and efficient
596
        $validGroups = array_values(
597
            array_unique(
598
                array_filter(
599
            array_map('intval', $groups),
600
                static fn($id) => $id > 0
601
                )
602
            )
603
        );
604
605
        // Build group filtering with EXISTS subquery (no re-validation needed)
606
        if (!empty($validGroups)) {
607
            $group_in = '(' . implode(', ', $validGroups) . ')';
608
            $whereParts[] = 'EXISTS (SELECT 1 FROM ' . $this->membershipHandler->db->prefix('groups_users_link')
609
                            . " m WHERE m.uid = u.uid AND m.groupid IN {$group_in})";
610
        }
611
612
        // Initialize criteria-dependent variables
613
        $limit   = 0;
614
        $start   = 0;
615
        $orderBy = '';
616
617
        // Handle criteria - compatible with CriteriaElement and subclasses
618
        if ($criteria instanceof \CriteriaElement) {
619
            $criteriaCompo->add($criteria, 'AND');
620
            $sqlCriteria = trim($criteriaCompo->render());
621
622
            // Remove WHERE keyword if present
623
            $sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', $sqlCriteria ?? '');
624
            if ($sqlCriteria !== '') {
625
                $whereParts[] = $sqlCriteria;
626
        }
627
628
            // LIMIT/OFFSET
629
            $limit = (int)$criteria->getLimit();
630
            $start = (int)$criteria->getStart();
631
632
            // ORDER BY (whitelist)
633
            $sort  = trim((string)$criteria->getSort());
634
            $order = trim((string)$criteria->getOrder());
635
            if ($sort !== '') {
636
                $allowedSorts = $this->allowedSortMap();
637
                if (isset($allowedSorts[$sort])) {
638
                    $orderDirection = (strtoupper($order) === 'DESC') ? ' DESC' : ' ASC';
639
                    $orderBy        = ' ORDER BY ' . $allowedSorts[$sort] . $orderDirection;
640
                }
641
            }
642
        }
643
644
        // Emit WHERE once
645
        if (!empty($whereParts)) {
646
            $sql .= ' WHERE ' . implode(' AND ', $whereParts);
647
        }
648
649
        // Then ORDER BY (if any)
650
        $sql .= $orderBy;
651
652
653
        // Execute query with comprehensive error handling
654
        $result = $this->userHandler->db->query($sql, $limit, $start);
0 ignored issues
show
Bug introduced by
The method query() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

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

654
        /** @scrutinizer ignore-call */ 
655
        $result = $this->userHandler->db->query($sql, $limit, $start);
Loading history...
655
656
        if (!$this->userHandler->db->isResultSet($result)) {
657
            // Enhanced error logging with security considerations
658
            $logger = class_exists('XoopsLogger') ? \XoopsLogger::getInstance() : null;
659
                $error = $this->userHandler->db->error();
0 ignored issues
show
Bug introduced by
The method error() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

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

659
                /** @scrutinizer ignore-call */ 
660
                $error = $this->userHandler->db->error();
Loading history...
660
661
            $msg = "Database query failed in " . __METHOD__ . ": {$error}";
662
663
            if ($isDebug) {
664
                // Comprehensive log sanitizers to prevent injection and spoofing attacks
665
                $sanitizeLogValue = static function ($value): string {
666
                    $s = (string)$value;
667
                    // Strip ASCII control chars (including CR/LF) and DEL
668
                    $s = preg_replace('/[\x00-\x1F\x7F]/', '', $s);
669
                    // Strip Unicode bidi/isolation controls that can spoof log layout
670
                    // U+202A..U+202E (LRE..RLO) and U+2066..U+2069 (LRI..PDI)
671
                    $s = preg_replace(\XoopsMemberHandler::BIDI_CONTROL_REGEX, '', $s);
672
                    // Collapse excessive whitespace
673
                    $s = preg_replace('/\s+/', ' ', $s);
674
                    // Length cap with mbstring fallback
675
                    if (function_exists('mb_substr')) {
676
                        $s = mb_substr($s, 0, 256, 'UTF-8');
677
                    } else {
678
                        $s = substr($s, 0, 256);
679
                    }
680
                    return $s;
681
                };
682
683
                $sanitizeMethod = static function ($method) use ($sanitizeLogValue): string {
684
                    $m     = strtoupper($sanitizeLogValue($method));
685
                    $allow = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
686
                    return in_array($m, $allow, true) ? $m : 'OTHER';
687
                };
688
689
                $sanitizeUri = static function ($uri) use ($sanitizeLogValue): string {
690
                    $u     = (string)$uri;
691
                    $parts = parse_url($u);
692
                    $path  = $sanitizeLogValue($parts['path'] ?? '/');
693
                    // Redact sensitive query params
694
                    $qs = '';
695
                    if (!empty($parts['query'])) {
696
                        parse_str($parts['query'], $q);
697
                        $redact = self::SENSITIVE_PARAMS;
698
                        foreach ($q as $k => &$v) {
699
                            $kLower = strtolower((string)$k);
700
                            if (in_array($kLower, $redact, true)) {
701
                                $v = 'REDACTED';
702
                            } else {
703
                                $v = is_array($v) ? $sanitizeLogValue(json_encode($v)) : $sanitizeLogValue($v);
704
                            }
705
                        }
706
                        unset($v);
707
                        $qs = $sanitizeLogValue(http_build_query($q));
708
                    }
709
                    return $qs !== '' ? $path . '?' . $qs : $path;
710
                };
711
712
                // Add correlation context for easier debugging
713
                $context = [
714
                    'user_id'      => isset($GLOBALS['xoopsUser']) && $GLOBALS['xoopsUser'] ? (int)$GLOBALS['xoopsUser']->getVar('uid') : 'anonymous',
715
                    'uri'          => isset($_SERVER['REQUEST_URI']) ? $sanitizeUri($_SERVER['REQUEST_URI']) : 'cli',
716
                    'method'       => isset($_SERVER['REQUEST_METHOD']) ? $sanitizeMethod($_SERVER['REQUEST_METHOD']) : 'CLI',
717
                    'groups_count' => count($validGroups),
718
                ];
719
                $msg .= ' Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);
720
                $msg .= ' SQL: ' . $redactSql($sql);
721
            }
722
723
            if ($logger) {
724
                $logger->handleError(E_USER_WARNING, $msg, __FILE__, __LINE__);
725
            } else {
726
                // Enhanced fallback logging with file/line info
727
                error_log($msg . " in " . __FILE__ . " on line " . __LINE__);
728
            }
729
730
            return $ret;
731
        }
732
733
        // Process results with enhanced type safety
734
        while (false !== ($myrow = $this->userHandler->db->fetchArray($result))) {
0 ignored issues
show
Bug introduced by
The method fetchArray() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

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

734
        while (false !== ($myrow = $this->userHandler->db->/** @scrutinizer ignore-call */ fetchArray($result))) {
Loading history...
735
            if ($asobject) {
736
                $user = new XoopsUser();
737
                $user->assignVars($myrow);
738
                if ($id_as_key) {
739
                    $ret[(int)$myrow['uid']] = $user;
740
                } else {
741
                    $ret[] = $user;
742
                }
743
            } else {
744
                // Ensure consistent integer return for UIDs
745
                $ret[] = (int)$myrow['uid'];
746
            }
747
        }
748
749
        return $ret;
750
    }
751
752
    /**
753
     * Get count of users belonging to certain groups and matching criteria
754
     * Temporary solution
755
     *
756
     * @param  array           $groups IDs of groups
757
     * @param  CriteriaElement $criteria
758
     * @return int             count of users
759
     */
760
    public function getUserCountByGroupLink(array $groups, ?CriteriaElement $criteria = null)
761
    {
762
        $ret           = 0;
763
        $criteriaCompo = new CriteriaCompo();
764
        $sql = "SELECT COUNT(*) FROM " . $this->userHandler->db->prefix('users') . " u WHERE ";
765
        if (!empty($groups)) {
766
            $group_in = is_array($groups) ? '(' . implode(', ', $groups) . ')' : (array) $groups;
0 ignored issues
show
introduced by
The condition is_array($groups) is always true.
Loading history...
767
            $sql .= " EXISTS (SELECT * FROM " . $this->membershipHandler->db->prefix('groups_users_link')
768
                . " m " . "WHERE m.groupid IN {$group_in} and m.uid = u.uid) ";
769
        }
770
771
        if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) {
772
            $criteriaCompo->add($criteria, 'AND');
773
        }
774
        $sql_criteria = $criteriaCompo->render();
775
776
        if ($sql_criteria) {
777
            $sql .= ' AND ' . $sql_criteria;
778
        }
779
        $result = $this->userHandler->db->query($sql);
780
        if (!$this->userHandler->db->isResultSet($result)) {
781
            return $ret;
782
        }
783
        [$ret] = $this->userHandler->db->fetchRow($result);
0 ignored issues
show
Bug introduced by
The method fetchRow() does not exist on XoopsDatabase. Since it exists in all sub-types, consider adding an abstract or default implementation to XoopsDatabase. ( Ignorable by Annotation )

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

783
        /** @scrutinizer ignore-call */ 
784
        [$ret] = $this->userHandler->db->fetchRow($result);
Loading history...
784
785
        return (int) $ret;
786
    }
787
}
788