Total Complexity | 127 |
Total Lines | 888 |
Duplicated Lines | 0 % |
Changes | 15 | ||
Bugs | 0 | Features | 0 |
Complex classes like XoopsMemberHandler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use XoopsMemberHandler, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
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) |
||
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 (no manual escaping of password) |
||
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 (!$users || 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` |
||
399 | WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s AND `COLUMN_NAME` = %s', |
||
400 | $db->$quoteFn($dbname), |
||
401 | $db->$quoteFn($table), |
||
402 | $db->$quoteFn($column) |
||
403 | ); |
||
404 | |||
405 | /** @var mysqli_result|resource|false $result */ |
||
406 | $result = $db->query($sql); |
||
407 | if ($db->isResultSet($result)) { |
||
408 | $row = $db->fetchRow($result); |
||
409 | if ($row) { |
||
410 | return (int)$row[0]; |
||
411 | } |
||
412 | } |
||
413 | return null; |
||
414 | } |
||
415 | |||
416 | /** |
||
417 | * Count users matching certain conditions |
||
418 | * @param CriteriaElement|null $criteria {@link CriteriaElement} object |
||
419 | * @return int Number of users |
||
420 | */ |
||
421 | public function getUserCount($criteria = null) |
||
422 | { |
||
423 | return $this->userHandler->getCount($criteria); |
||
424 | } |
||
425 | |||
426 | /** |
||
427 | * Count users belonging to a group |
||
428 | * @param int $group_id ID of the group |
||
429 | * @return int Number of users in the group |
||
430 | */ |
||
431 | public function getUserCountByGroup($group_id) |
||
432 | { |
||
433 | return $this->membershipHandler->getCount(new Criteria('groupid', (int)$group_id)); |
||
434 | } |
||
435 | |||
436 | /** |
||
437 | * Update a single field in a user's record |
||
438 | * @param XoopsUser $user Reference to the {@link XoopsUser} object |
||
439 | * @param string $fieldName Name of the field to update |
||
440 | * @param mixed $fieldValue Updated value for the field |
||
441 | * @return bool TRUE if success or unchanged, FALSE on failure |
||
442 | */ |
||
443 | public function updateUserByField(XoopsUser $user, $fieldName, $fieldValue) |
||
444 | { |
||
445 | $user->setVar($fieldName, $fieldValue); |
||
446 | return $this->insertUser($user); |
||
447 | } |
||
448 | |||
449 | /** |
||
450 | * Update a single field for multiple users |
||
451 | * @param string $fieldName Name of the field to update |
||
452 | * @param mixed $fieldValue Updated value for the field |
||
453 | * @param CriteriaElement|null $criteria {@link CriteriaElement} object |
||
454 | * @return bool TRUE if success or unchanged, FALSE on failure |
||
455 | */ |
||
456 | public function updateUsersByField($fieldName, $fieldValue, $criteria = null) |
||
457 | { |
||
458 | return $this->userHandler->updateAll($fieldName, $fieldValue, $criteria); |
||
459 | } |
||
460 | |||
461 | /** |
||
462 | * Activate a user |
||
463 | * @param XoopsUser $user Reference to the {@link XoopsUser} object |
||
464 | * @return bool TRUE on success, FALSE on failure |
||
465 | */ |
||
466 | public function activateUser(XoopsUser $user) |
||
467 | { |
||
468 | if ($user->getVar('level') != 0) { |
||
469 | return true; |
||
470 | } |
||
471 | $user->setVar('level', 1); |
||
472 | |||
473 | // Generate more secure activation key |
||
474 | $actkey = $this->generateSecureToken(8); |
||
475 | $user->setVar('actkey', $actkey); |
||
476 | |||
477 | return $this->userHandler->insert($user, true); |
||
478 | } |
||
479 | |||
480 | /** |
||
481 | * Get a list of users belonging to certain groups and matching criteria |
||
482 | * @param int|array $groups IDs of groups |
||
483 | * @param CriteriaElement|null $criteria {@link CriteriaElement} object |
||
484 | * @param bool $asobject Return the users as objects? |
||
485 | * @param bool $id_as_key Use the UID as key for the array if $asobject is TRUE |
||
486 | * @return array Array of {@link XoopsUser} objects or user IDs |
||
487 | */ |
||
488 | public function getUsersByGroupLink($groups, $criteria = null, $asobject = false, $id_as_key = false) |
||
489 | { |
||
490 | $groups = (array)$groups; |
||
491 | $validGroups = $this->sanitizeIds($groups); |
||
492 | |||
493 | // If groups were specified but none are valid, return empty array |
||
494 | if (!empty($groups) && empty($validGroups)) { |
||
495 | return []; |
||
496 | } |
||
497 | |||
498 | $isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment(); |
||
499 | |||
500 | /** @var XoopsMySQLDatabase $db */ |
||
501 | $db = $this->userHandler->db; |
||
502 | $select = $asobject ? 'u.*' : 'u.uid'; |
||
503 | $sql = "SELECT {$select} FROM " . $db->prefix('users') . ' u'; |
||
504 | $whereParts = []; |
||
505 | |||
506 | if (!empty($validGroups)) { |
||
507 | $linkTable = $db->prefix('groups_users_link'); |
||
508 | $group_in = '(' . implode(', ', $validGroups) . ')'; |
||
509 | $whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})"; |
||
510 | } |
||
511 | |||
512 | $limit = 0; |
||
513 | $start = 0; |
||
514 | $orderBy = ''; |
||
515 | |||
516 | if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) { |
||
517 | $criteriaCompo = new CriteriaCompo(); |
||
518 | $criteriaCompo->add($criteria, 'AND'); |
||
519 | $sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render())); |
||
520 | if ($sqlCriteria !== '') { |
||
521 | $whereParts[] = $sqlCriteria; |
||
522 | } |
||
523 | |||
524 | $limit = (int)$criteria->getLimit(); |
||
525 | $start = (int)$criteria->getStart(); |
||
526 | |||
527 | // Apply safe sorting |
||
528 | $sort = trim((string)$criteria->getSort()); |
||
529 | $order = trim((string)$criteria->getOrder()); |
||
530 | if ($sort !== '') { |
||
531 | $allowed = $this->getAllowedSortFields(); |
||
532 | if (isset($allowed[$sort])) { |
||
533 | $orderBy = ' ORDER BY ' . $allowed[$sort]; |
||
534 | $orderBy .= (strtoupper($order) === 'DESC') ? ' DESC' : ' ASC'; |
||
535 | } |
||
536 | } |
||
537 | } |
||
538 | |||
539 | if (!empty($whereParts)) { |
||
540 | $sql .= ' WHERE ' . implode(' AND ', $whereParts); |
||
541 | } |
||
542 | $sql .= $orderBy; |
||
543 | |||
544 | $result = $db->query($sql, $limit, $start); |
||
545 | if (!$db->isResultSet($result)) { |
||
546 | $this->logDatabaseError('Query failed in getUsersByGroupLink', $sql, [ |
||
547 | 'groups_count' => count($validGroups), |
||
548 | 'has_criteria' => isset($criteria) |
||
549 | ], $isDebug); |
||
550 | return []; |
||
551 | } |
||
552 | |||
553 | $ret = []; |
||
554 | /** @var array $myrow */ |
||
555 | while (false !== ($myrow = $db->fetchArray($result))) { |
||
556 | if ($asobject) { |
||
557 | $user = new XoopsUser(); |
||
558 | $user->assignVars($myrow); |
||
559 | if ($id_as_key) { |
||
560 | $ret[(int)$myrow['uid']] = $user; |
||
561 | } else { |
||
562 | $ret[] = $user; |
||
563 | } |
||
564 | } else { |
||
565 | $ret[] = (int)$myrow['uid']; |
||
566 | } |
||
567 | } |
||
568 | |||
569 | return $ret; |
||
570 | } |
||
571 | |||
572 | /** |
||
573 | * Get count of users belonging to certain groups and matching criteria |
||
574 | * @param array $groups IDs of groups |
||
575 | * @param CriteriaElement|null $criteria {@link CriteriaElement} object |
||
576 | * @return int Count of users |
||
577 | */ |
||
578 | public function getUserCountByGroupLink(array $groups, $criteria = null) |
||
579 | { |
||
580 | $validGroups = $this->sanitizeIds($groups); |
||
581 | |||
582 | // If groups were specified but none are valid, return 0 |
||
583 | if (!empty($groups) && empty($validGroups)) { |
||
584 | return 0; |
||
585 | } |
||
586 | |||
587 | $isDebug = $this->isDebugAllowed() && !$this->isProductionEnvironment(); |
||
588 | |||
589 | /** @var XoopsMySQLDatabase $db */ |
||
590 | $db = $this->userHandler->db; |
||
591 | $sql = 'SELECT COUNT(*) FROM ' . $db->prefix('users') . ' u'; |
||
592 | $whereParts = []; |
||
593 | |||
594 | if (!empty($validGroups)) { |
||
595 | $linkTable = $db->prefix('groups_users_link'); |
||
596 | $group_in = '(' . implode(', ', $validGroups) . ')'; |
||
597 | $whereParts[] = "EXISTS (SELECT 1 FROM {$linkTable} m WHERE m.uid = u.uid AND m.groupid IN {$group_in})"; |
||
598 | } |
||
599 | |||
600 | if (isset($criteria) && is_subclass_of($criteria, 'CriteriaElement')) { |
||
601 | $criteriaCompo = new CriteriaCompo(); |
||
602 | $criteriaCompo->add($criteria, 'AND'); |
||
603 | $sqlCriteria = preg_replace('/^\s*WHERE\s+/i', '', trim($criteriaCompo->render())); |
||
604 | if ($sqlCriteria !== '') { |
||
605 | $whereParts[] = $sqlCriteria; |
||
606 | } |
||
607 | } |
||
608 | |||
609 | if (!empty($whereParts)) { |
||
610 | $sql .= ' WHERE ' . implode(' AND ', $whereParts); |
||
611 | } |
||
612 | |||
613 | $result = $db->query($sql); |
||
614 | if (!$db->isResultSet($result)) { |
||
615 | $this->logDatabaseError('Query failed in getUserCountByGroupLink', $sql, [ |
||
616 | 'groups_count' => count($validGroups), |
||
617 | 'has_criteria' => isset($criteria) |
||
618 | ], $isDebug); |
||
619 | return 0; |
||
620 | } |
||
621 | |||
622 | list($count) = $db->fetchRow($result); |
||
623 | return (int)$count; |
||
624 | } |
||
625 | |||
626 | // ========================================================================= |
||
627 | // Private Helper Methods |
||
628 | // ========================================================================= |
||
629 | |||
630 | /** |
||
631 | * Create a safe IN criteria for IDs |
||
632 | * @param string $field Field name |
||
633 | * @param mixed $value Single value or array of values |
||
634 | * @return CriteriaElement |
||
635 | */ |
||
636 | private function createSafeInCriteria($field, $value) |
||
641 | } |
||
642 | |||
643 | /** |
||
644 | * Sanitize an array of IDs |
||
645 | * @param array $ids Array of potential IDs |
||
646 | * @return array Array of valid integer IDs |
||
647 | */ |
||
648 | private function sanitizeIds(array $ids) |
||
649 | { |
||
650 | return array_values(array_filter( |
||
651 | array_map('intval', array_filter($ids, function($v) { |
||
652 | return is_int($v) || (is_string($v) && ctype_digit($v)); |
||
653 | })), |
||
654 | function($v) { return $v > 0; } |
||
655 | )); |
||
656 | } |
||
657 | |||
658 | /** |
||
659 | * Get allowed sort fields for SQL queries |
||
660 | * @return array Map of allowed field names to SQL columns |
||
661 | */ |
||
662 | private function getAllowedSortFields() |
||
663 | { |
||
664 | return [ |
||
665 | // Non-prefixed (backward compatibility) |
||
666 | 'uid' => 'u.uid', |
||
667 | 'uname' => 'u.uname', |
||
668 | 'email' => 'u.email', |
||
669 | 'user_regdate' => 'u.user_regdate', |
||
670 | 'last_login' => 'u.last_login', |
||
671 | 'user_avatar' => 'u.user_avatar', |
||
672 | 'name' => 'u.name', |
||
673 | 'posts' => 'u.posts', |
||
674 | 'level' => 'u.level', |
||
675 | // Prefixed (explicit) |
||
676 | 'u.uid' => 'u.uid', |
||
677 | 'u.uname' => 'u.uname', |
||
678 | 'u.email' => 'u.email', |
||
679 | 'u.user_regdate' => 'u.user_regdate', |
||
680 | 'u.last_login' => 'u.last_login', |
||
681 | 'u.user_avatar' => 'u.user_avatar', |
||
682 | 'u.name' => 'u.name', |
||
683 | 'u.posts' => 'u.posts', |
||
684 | 'u.level' => 'u.level' |
||
685 | ]; |
||
686 | } |
||
687 | |||
688 | /** |
||
689 | * Timing-safe string comparison (PHP 7.4 compatible) |
||
690 | * @param string $expected Expected string |
||
691 | * @param string $actual Actual string |
||
692 | * @return bool TRUE if strings are equal |
||
693 | */ |
||
694 | private function hashEquals($expected, $actual) |
||
713 | } |
||
714 | |||
715 | /** |
||
716 | * Generate a secure random token |
||
717 | * @param int $length Token length |
||
718 | * @return string Random token |
||
719 | */ |
||
720 | private function generateSecureToken($length = 16) |
||
721 | { |
||
722 | if (function_exists('random_bytes')) { |
||
723 | // PHP 7.0+ |
||
724 | return substr(bin2hex(random_bytes($length)), 0, $length); |
||
725 | } elseif (function_exists('openssl_random_pseudo_bytes')) { |
||
726 | // PHP 5.3+ with OpenSSL |
||
727 | return substr(bin2hex(openssl_random_pseudo_bytes($length)), 0, $length); |
||
728 | } else { |
||
729 | // Fallback to less secure method |
||
730 | return substr(md5(uniqid(mt_rand(), true)), 0, $length); |
||
731 | } |
||
732 | } |
||
733 | |||
734 | /** |
||
735 | * Check if debug output is allowed |
||
736 | * @return bool TRUE if debugging is allowed |
||
737 | */ |
||
738 | private function isDebugAllowed() |
||
739 | { |
||
740 | $mode = (int)($GLOBALS['xoopsConfig']['debug_mode'] ?? 0); |
||
741 | if (!in_array($mode, [1, 2], true)) { |
||
742 | return false; |
||
743 | } |
||
744 | |||
745 | $level = (int)($GLOBALS['xoopsConfig']['debugLevel'] ?? 0); |
||
746 | $user = $GLOBALS['xoopsUser'] ?? null; |
||
747 | $isAdmin = (bool)($GLOBALS['xoopsUserIsAdmin'] ?? false); |
||
748 | |||
749 | switch ($level) { |
||
750 | case 2: |
||
751 | return $isAdmin; |
||
752 | case 1: |
||
753 | return $user !== null; |
||
754 | default: |
||
755 | return true; |
||
756 | } |
||
757 | } |
||
758 | |||
759 | /** |
||
760 | * Check if running in production environment |
||
761 | * @return bool TRUE if in production |
||
762 | */ |
||
763 | private function isProductionEnvironment() |
||
764 | { |
||
765 | // Check explicit production flag |
||
766 | if (defined('XOOPS_PRODUCTION') && XOOPS_PRODUCTION) { |
||
767 | return true; |
||
768 | } |
||
769 | |||
770 | // Check environment variable |
||
771 | if (getenv('XOOPS_ENV') === 'production') { |
||
772 | return true; |
||
773 | } |
||
774 | |||
775 | // Check for development indicators |
||
776 | if ((defined('XOOPS_DEBUG') && XOOPS_DEBUG) || |
||
777 | (php_sapi_name() === 'cli') || |
||
778 | (isset($_SERVER['SERVER_ADDR']) && in_array($_SERVER['SERVER_ADDR'], ['127.0.0.1', '::1'], true))) { |
||
779 | return false; |
||
780 | } |
||
781 | |||
782 | // Default to production for safety |
||
783 | return true; |
||
784 | } |
||
785 | |||
786 | /** |
||
787 | * Redact sensitive information from SQL queries |
||
788 | * @param string $sql SQL query string |
||
789 | * @return string Redacted SQL query |
||
790 | */ |
||
791 | private function redactSql($sql) |
||
792 | { |
||
793 | // Redact quoted strings |
||
794 | $sql = preg_replace("/'[^']*'/", "'?'", $sql); |
||
795 | $sql = preg_replace('/"[^"]*"/', '"?"', $sql); |
||
796 | $sql = preg_replace("/x'[0-9A-Fa-f]+'/", "x'?'", $sql); |
||
797 | return $sql; |
||
798 | } |
||
799 | |||
800 | /** |
||
801 | * Sanitize string for logging |
||
802 | * @param string $str String to sanitize |
||
803 | * @return string Sanitized string |
||
804 | */ |
||
805 | private function sanitizeForLog($str) |
||
818 | } |
||
819 | |||
820 | /** |
||
821 | * Log database errors with context |
||
822 | * @param string $message Error message |
||
823 | * @param string $sql SQL query that failed |
||
824 | * @param array $context Additional context |
||
825 | * @param bool $isDebug Whether debug mode is enabled |
||
826 | */ |
||
827 | private function logDatabaseError($message, $sql, array $context, $isDebug) |
||
868 | } |
||
869 | } |
||
870 | |||
871 | /** |
||
872 | * Sanitize request URI for logging |
||
873 | * @return string Sanitized URI |
||
874 | */ |
||
875 | private function sanitizeRequestUri() |
||
876 | { |
||
877 | if (!isset($_SERVER['REQUEST_URI'])) { |
||
878 | return 'cli'; |
||
879 | } |
||
880 | |||
881 | $parts = parse_url($_SERVER['REQUEST_URI']); |
||
882 | $path = $this->sanitizeForLog($parts['path'] ?? '/'); |
||
883 | |||
884 | if (isset($parts['query'])) { |
||
885 | parse_str($parts['query'], $queryParams); |
||
886 | foreach ($queryParams as $key => &$value) { |
||
887 | if (in_array(strtolower($key), self::SENSITIVE_PARAMS, true)) { |
||
888 | $value = 'REDACTED'; |
||
889 | } else { |
||
890 | $value = is_array($value) ? '[ARRAY]' : $this->sanitizeForLog((string)$value); |
||
891 | } |
||
892 | } |
||
893 | unset($value); |
||
894 | $queryString = http_build_query($queryParams); |
||
895 | return $queryString ? $path . '?' . $queryString : $path; |
||
896 | } |
||
897 | |||
898 | return $path; |
||
899 | } |
||
900 | |||
901 | /** |
||
902 | * Log security-related events |
||
903 | * @param string $event Event description |
||
904 | * @param array $context Additional context |
||
905 | */ |
||
906 | private function logSecurityEvent($event, array $context = []) |
||
922 | } |
||
923 | } |
||
924 | } |
||
925 |
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.