nilsteampassnet /
TeamPass
This project does not seem to handle request data directly as such no vulnerable execution paths were found.
include, or for example
via PHP's auto-loading mechanism.
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | /** |
||
| 6 | * Teampass - a collaborative passwords manager. |
||
| 7 | * --- |
||
| 8 | * This file is part of the TeamPass project. |
||
| 9 | * |
||
| 10 | * TeamPass is free software: you can redistribute it and/or modify it |
||
| 11 | * under the terms of the GNU General Public License as published by |
||
| 12 | * the Free Software Foundation, version 3 of the License. |
||
| 13 | * |
||
| 14 | * TeamPass is distributed in the hope that it will be useful, |
||
| 15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 17 | * GNU General Public License for more details. |
||
| 18 | * |
||
| 19 | * You should have received a copy of the GNU General Public License |
||
| 20 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
||
| 21 | * |
||
| 22 | * Certain components of this file may be under different licenses. For |
||
| 23 | * details, see the `licenses` directory or individual file headers. |
||
| 24 | * --- |
||
| 25 | * @file users.datatable.php |
||
| 26 | * @author Nils Laumaillé ([email protected]) |
||
| 27 | * @copyright 2009-2025 Teampass.net |
||
| 28 | * @license GPL-3.0 |
||
| 29 | * @see https://www.teampass.net |
||
| 30 | */ |
||
| 31 | |||
| 32 | |||
| 33 | use TeampassClasses\SessionManager\SessionManager; |
||
| 34 | use Symfony\Component\HttpFoundation\Request as SymfonyRequest; |
||
| 35 | use TeampassClasses\Language\Language; |
||
| 36 | use EZimuel\PHPSecureSession; |
||
| 37 | use TeampassClasses\PerformChecks\PerformChecks; |
||
| 38 | use TeampassClasses\ConfigManager\ConfigManager; |
||
| 39 | use TeampassClasses\NestedTree\NestedTree; |
||
| 40 | use voku\helper\AntiXSS; |
||
| 41 | |||
| 42 | // Load functions |
||
| 43 | require_once 'main.functions.php'; |
||
| 44 | |||
| 45 | // init |
||
| 46 | loadClasses('DB'); |
||
| 47 | $session = SessionManager::getSession(); |
||
| 48 | $request = SymfonyRequest::createFromGlobals(); |
||
| 49 | $lang = new Language($session->get('user-language') ?? 'english'); |
||
| 50 | $antiXss = new AntiXSS(); |
||
| 51 | |||
| 52 | // Load config |
||
| 53 | $configManager = new ConfigManager(); |
||
| 54 | $SETTINGS = $configManager->getAllSettings(); |
||
| 55 | |||
| 56 | // Do checks |
||
| 57 | // Instantiate the class with posted data |
||
| 58 | $checkUserAccess = new PerformChecks( |
||
| 59 | dataSanitizer( |
||
| 60 | [ |
||
| 61 | 'type' => htmlspecialchars($request->request->get('type', ''), ENT_QUOTES, 'UTF-8'), |
||
| 62 | ], |
||
| 63 | [ |
||
| 64 | 'type' => 'trim|escape', |
||
| 65 | ], |
||
| 66 | ), |
||
| 67 | [ |
||
| 68 | 'user_id' => returnIfSet($session->get('user-id'), null), |
||
| 69 | 'user_key' => returnIfSet($session->get('key'), null), |
||
| 70 | ] |
||
| 71 | ); |
||
| 72 | // Handle the case |
||
| 73 | echo $checkUserAccess->caseHandler(); |
||
| 74 | if ( |
||
| 75 | $checkUserAccess->userAccessPage('items') === false || |
||
| 76 | $checkUserAccess->checkSession() === false |
||
| 77 | ) { |
||
| 78 | // Not allowed page |
||
| 79 | $session->set('system-error_code', ERR_NOT_ALLOWED); |
||
| 80 | include $SETTINGS['cpassman_dir'] . '/error.php'; |
||
| 81 | exit; |
||
| 82 | } |
||
| 83 | |||
| 84 | // Define Timezone |
||
| 85 | date_default_timezone_set($SETTINGS['timezone'] ?? 'UTC'); |
||
| 86 | |||
| 87 | // Set header properties |
||
| 88 | header('Content-type: text/html; charset=utf-8'); |
||
| 89 | header('Cache-Control: no-cache, no-store, must-revalidate'); |
||
| 90 | |||
| 91 | // --------------------------------- // |
||
| 92 | |||
| 93 | // Configure AntiXSS to keep double-quotes |
||
| 94 | $antiXss->removeEvilAttributes(['style', 'onclick', 'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup', 'onmousemove', 'onkeydown', 'onkeyup', 'onkeypress', 'onchange', 'onblur', 'onfocus', 'onabort', 'onerror', 'onscroll']); |
||
| 95 | $antiXss->removeEvilHtmlTags(['script', 'iframe', 'embed', 'object', 'applet', 'link', 'style']); |
||
| 96 | |||
| 97 | // Load tree |
||
| 98 | $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title'); |
||
| 99 | |||
| 100 | // Build FUNCTIONS list |
||
| 101 | $params = $request->query->all(); |
||
| 102 | $rolesList = []; |
||
| 103 | $titles = DB::query('SELECT id,title FROM '.prefixTable('roles_title').' ORDER BY title ASC'); |
||
| 104 | foreach ($titles as $title) { |
||
| 105 | $rolesList[$title['id']] = ['id' => $title['id'], 'title' => $title['title']]; |
||
| 106 | } |
||
| 107 | |||
| 108 | $html = ''; |
||
| 109 | //Columns name |
||
| 110 | $aColumns = ['u.id', 'u.login', 'u.name', 'u.lastname', 'u.admin', 'u.read_only', 'u.gestionnaire', 'u.isAdministratedByRole', 'u.can_manage_all_users', 'u.can_create_root_folder', 'u.personal_folder', 'u.email', 'u.ga', 'fonction_id', 'u.mfa_enabled']; |
||
| 111 | $aSortTypes = ['asc', 'desc']; |
||
| 112 | //init SQL variables |
||
| 113 | $sWhere = $sOrder = $sLimit = ''; |
||
| 114 | |||
| 115 | /* BUILD QUERY */ |
||
| 116 | // Paging |
||
| 117 | $sLimit = ''; |
||
| 118 | if (isset($params['length']) && (int) $params['length'] !== -1) { |
||
| 119 | $start = filter_var($params['start'], FILTER_SANITIZE_NUMBER_INT); |
||
| 120 | $length = filter_var($params['length'], FILTER_SANITIZE_NUMBER_INT); |
||
| 121 | $sLimit = " LIMIT $start, $length"; |
||
| 122 | } |
||
| 123 | |||
| 124 | // Ordering |
||
| 125 | $sOrder = ''; |
||
| 126 | $order = $params['order'][0] ?? null; |
||
| 127 | if ($order && in_array($order['dir'], $aSortTypes)) { |
||
| 128 | $sOrder = 'ORDER BY '; |
||
| 129 | if (isset($order['column']) && preg_match('#^(asc|desc)$#i', $order['dir'])) { |
||
| 130 | $columnIndex = filter_var($order['column'], FILTER_SANITIZE_NUMBER_INT); |
||
| 131 | $dir = filter_var($order['dir'], FILTER_SANITIZE_FULL_SPECIAL_CHARS); |
||
| 132 | $sOrder .= $aColumns[$columnIndex] . ' ' . $dir . ', '; |
||
| 133 | } |
||
| 134 | |||
| 135 | $sOrder = substr_replace($sOrder, '', -2); |
||
| 136 | if ($sOrder === 'ORDER BY') { |
||
| 137 | $sOrder = ''; |
||
| 138 | } |
||
| 139 | } |
||
| 140 | |||
| 141 | /* |
||
| 142 | * Filtering |
||
| 143 | * NOTE this does not match the built-in DataTables filtering which does it |
||
| 144 | * word by word on any field. It's possible to do here, but concerned about efficiency |
||
| 145 | * on very large tables, and MySQL's regex functionality is very limited |
||
| 146 | */ |
||
| 147 | |||
| 148 | // exclude any deleted user |
||
| 149 | $sWhere = ' WHERE u.deleted_at IS NULL AND u.id NOT IN (9999991,9999997,9999998,9999999)'; |
||
| 150 | |||
| 151 | $letter = $request->query->filter('letter', '', FILTER_SANITIZE_FULL_SPECIAL_CHARS); |
||
| 152 | $searchValue = isset($params['search']) && isset($params['search']['value']) ? filter_var($params['search']['value'], FILTER_SANITIZE_FULL_SPECIAL_CHARS) : ''; |
||
| 153 | |||
| 154 | if ($letter !== '' && $letter !== 'None') { |
||
| 155 | $sWhere .= ' AND ('; |
||
| 156 | $sWhere .= $aColumns[1] . " LIKE '" . $letter . "%' OR "; |
||
| 157 | $sWhere .= $aColumns[2] . " LIKE '" . $letter . "%' OR "; |
||
| 158 | $sWhere .= $aColumns[3] . " LIKE '" . $letter . "%' "; |
||
| 159 | $sWhere .= ')'; |
||
| 160 | } elseif ($searchValue !== '') { |
||
| 161 | $sWhere .= ' AND ('; |
||
| 162 | $sWhere .= $aColumns[1] . " LIKE '" . $searchValue . "%' OR "; |
||
| 163 | $sWhere .= $aColumns[2] . " LIKE '" . $searchValue . "%' OR "; |
||
| 164 | $sWhere .= $aColumns[3] . " LIKE '" . $searchValue . "%' "; |
||
| 165 | $sWhere .= ')'; |
||
| 166 | } |
||
| 167 | |||
| 168 | // enlarge the query in case of Manager |
||
| 169 | if ((int) $session->get('user-admin') === 0 |
||
| 170 | && (int) $session->get('user-can_manage_all_users') === 0 |
||
| 171 | ) { |
||
| 172 | $sWhere .= ' AND '; |
||
| 173 | $arrUserRoles = array_filter($session->get('user-roles_array')); |
||
| 174 | if (count($arrUserRoles) > 0) { |
||
| 175 | $sWhere .= 'u.isAdministratedByRole IN ('.implode(',', $arrUserRoles).')'; |
||
| 176 | } |
||
| 177 | } |
||
| 178 | |||
| 179 | // Base query with LEFT JOIN (all roles sources) |
||
| 180 | $baseQuery = 'FROM '.prefixTable('users').' AS u |
||
| 181 | LEFT JOIN '.prefixTable('users_roles').' AS ur ON (u.id = ur.user_id)'; |
||
| 182 | |||
| 183 | // Count total records |
||
| 184 | $rows = DB::query( |
||
| 185 | 'SELECT u.id '.$baseQuery.$sWhere.' GROUP BY u.id' |
||
| 186 | ); |
||
| 187 | $iTotal = count($rows); |
||
| 188 | |||
| 189 | // Get paginated data with all fields |
||
| 190 | $rows = DB::query( |
||
| 191 | 'SELECT u.*, |
||
| 192 | GROUP_CONCAT(DISTINCT CASE WHEN ur.source = "manual" THEN ur.role_id END ORDER BY ur.role_id SEPARATOR ";") AS fonction_id, |
||
| 193 | GROUP_CONCAT(DISTINCT CASE WHEN ur.source = "ad" THEN ur.role_id END ORDER BY ur.role_id SEPARATOR ";") AS roles_from_ad_groups, |
||
| 194 | CASE |
||
| 195 | WHEN u.pw LIKE "$2y$10$%" THEN 1 |
||
| 196 | ELSE 0 |
||
| 197 | END AS pw_passwordlib |
||
| 198 | '.$baseQuery. |
||
| 199 | $sWhere. |
||
| 200 | ' GROUP BY u.id '. |
||
| 201 | $sOrder. |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 202 | $sLimit |
||
| 203 | ); |
||
| 204 | |||
| 205 | |||
| 206 | // Output |
||
| 207 | $sOutput = '{'; |
||
| 208 | $sOutput .= '"sEcho": '.(int) $request->query->filter('draw', FILTER_SANITIZE_NUMBER_INT).', '; |
||
| 209 | $sOutput .= '"iTotalRecords": '.$iTotal.', '; |
||
| 210 | $sOutput .= '"iTotalDisplayRecords": '.$iTotal.', '; |
||
| 211 | $sOutput .= '"aaData": '; |
||
| 212 | if (DB::count() > 0) { |
||
| 213 | $sOutput .= '['; |
||
| 214 | } else { |
||
| 215 | $sOutput .= ''; |
||
| 216 | } |
||
| 217 | |||
| 218 | foreach ($rows as $record) { |
||
| 219 | //Show user only if can be administrated by the adapted Roles manager |
||
| 220 | if ((int) $session->get('user-admin') === 1 |
||
| 221 | || in_array($record['isAdministratedByRole'], $session->get('user-roles_array')) |
||
| 222 | || ((int) $session->get('user-can_manage_all_users') === 1 && (int) $record['admin'] === 0 && (int) $record['id'] !== (int) $session->get('user-id')) |
||
| 223 | ) { |
||
| 224 | $showUserFolders = true; |
||
| 225 | } else { |
||
| 226 | $showUserFolders = false; |
||
| 227 | } |
||
| 228 | |||
| 229 | // Display Grid |
||
| 230 | if ($showUserFolders === true) { |
||
| 231 | // Get list of allowed functions |
||
| 232 | $listAlloFcts = ''; |
||
| 233 | if ((int) $record['admin'] !== 1) { |
||
| 234 | if (count($rolesList) > 0) { |
||
| 235 | foreach ($rolesList as $fonction) { |
||
| 236 | if (is_null($record['fonction_id']) === false && in_array($fonction['id'], explode(';', $record['fonction_id']))) { |
||
| 237 | $listAlloFcts .= '<i class="fa-solid fa-angle-right mr-1"></i>'.addslashes(filter_var($fonction['title'], FILTER_SANITIZE_FULL_SPECIAL_CHARS)).'<br />'; |
||
| 238 | } else if (isset($SETTINGS['enable_ad_users_with_ad_groups']) === true && (int) $SETTINGS['enable_ad_users_with_ad_groups'] === 1 && is_null($record['roles_from_ad_groups']) === false && in_array($fonction['id'], explode(';', $record['roles_from_ad_groups']))) { |
||
| 239 | $listAlloFcts .= '<i class="fa-solid fa-angle-right mr-1"></i><i>'.addslashes(filter_var($fonction['title'], FILTER_SANITIZE_FULL_SPECIAL_CHARS)).'</i><i class="fa-solid fa-rectangle-ad ml-1 infotip" title="'.$lang->get('ad_group').'"></i><br />'; |
||
| 240 | } |
||
| 241 | } |
||
| 242 | } |
||
| 243 | if (empty($listAlloFcts)) { |
||
| 244 | $listAlloFcts = '<i class="fas fa-exclamation-triangle text-danger infotip" title="'.$lang->get('user_alarm_no_function').'"></i>'; |
||
| 245 | } |
||
| 246 | } |
||
| 247 | |||
| 248 | $userDate = DB::queryFirstRow( |
||
| 249 | 'SELECT date FROM '.prefixTable('log_system ').' WHERE type = %s AND field_1 = %i', |
||
| 250 | 'user_mngt', |
||
| 251 | $record['id'] |
||
| 252 | ); |
||
| 253 | |||
| 254 | // Check for existing lock |
||
| 255 | $unlock_at = DB::queryFirstField( |
||
| 256 | 'SELECT MAX(unlock_at) |
||
| 257 | FROM ' . prefixTable('auth_failures') . ' |
||
| 258 | WHERE unlock_at > %s AND source = %s AND value = %s', |
||
| 259 | date('Y-m-d H:i:s', time()), |
||
| 260 | 'login', |
||
| 261 | $record['login'] |
||
| 262 | ); |
||
| 263 | |||
| 264 | // Get some infos about user |
||
| 265 | $userDisplayInfos = |
||
| 266 | (isset($userDate['date']) ? '<i class=\"fas fa-calendar-day infotip text-info ml-2\" title=\"'.$lang->get('creation_date').': '.date($SETTINGS['date_format'] . ' ' . $SETTINGS['time_format'], (int) $userDate['date']).'\"></i>' : '') |
||
| 267 | . |
||
| 268 | ((int) $record['last_connexion'] > 0 ? '<i class=\"far fa-clock infotip text-info ml-2\" title=\"'.$lang->get('index_last_seen').": ". |
||
| 269 | date($SETTINGS['date_format'] . ' ' . $SETTINGS['time_format'], (int) $record['last_connexion']).'\"></i>' : '') |
||
| 270 | . |
||
| 271 | ((int) $record['user_ip'] > 0 ? '<i class=\"fas fa-street-view infotip text-info ml-1\" title=\"'.$lang->get('ip').": ".($record['user_ip']).'\"></i>' : '') |
||
| 272 | . |
||
| 273 | (($record['auth_type'] === 'ldap' || $record['auth_type'] === 'oauth2') ? '<i class=\"far fa-address-book infotip text-warning ml-1\" title=\"'.$lang->get('managed_through_ad').'\"></i>' : '') |
||
| 274 | . |
||
| 275 | ((in_array($record['id'], [OTV_USER_ID, TP_USER_ID, SSH_USER_ID, API_USER_ID]) === false && (int) $record['admin'] !== 1 && ((int) $SETTINGS['duo'] === 1 || (int) $SETTINGS['google_authentication'] === 1)) ? |
||
| 276 | ((int) $record['mfa_enabled'] === 1 ? '' : '<i class=\"fa-solid fa-fingerprint infotip ml-1\" style=\"color:Tomato\" title=\"'.$lang->get('mfa_disabled_for_user').'\"></i>') : |
||
| 277 | '' |
||
| 278 | ) |
||
| 279 | . |
||
| 280 | (($unlock_at) ? '<i class=\"fas fa-solid text-red fa-lock infotip text-info ml-1\" title=\"'.$lang->get('bruteforce_unlock_at').$unlock_at.'\"></i>' : ''); |
||
| 281 | if ($request->query->filter('display_warnings', '', FILTER_VALIDATE_BOOLEAN) === true) { |
||
| 282 | $userDisplayInfos .= '<br>'. |
||
| 283 | ((in_array($record['id'], [OTV_USER_ID, TP_USER_ID, SSH_USER_ID, API_USER_ID]) === false && (int) $record['admin'] !== 1 && is_null($record['keys_recovery_time']) === true) ? |
||
| 284 | '<i class=\"fa-solid fa-download infotip ml-1\" style=\"color:Tomato\" title=\"'.$lang->get('recovery_keys_not_downloaded').'\"></i>' : |
||
| 285 | '' |
||
| 286 | ). |
||
| 287 | ((in_array($record['id'], [OTV_USER_ID, TP_USER_ID, SSH_USER_ID, API_USER_ID]) === false && (int) $record['pw_passwordlib'] === 1) ? '<i class=\"fa-solid fa-person-walking-luggage infotip ml-1\" style=\"color:Tomato\" title=\"Old password encryption. Shall login to initialize.\"></i>' : ''); |
||
| 288 | } |
||
| 289 | |||
| 290 | $sOutput .= '["<span data-id=\"'.$record['id'].'\" data-fullname=\"'. |
||
| 291 | (empty($record['name']) === false ? htmlentities($record['name'], ENT_QUOTES|ENT_SUBSTITUTE|ENT_DISALLOWED) : '').' '. |
||
| 292 | (empty($record['lastname']) === false ? htmlentities($record['lastname'], ENT_QUOTES|ENT_SUBSTITUTE|ENT_DISALLOWED) : ''). |
||
| 293 | '\" data-auth-type=\"'.$record['auth_type'].'\" data-special=\"'.$record['special'].'\" data-mfa-enabled=\"'.$record['mfa_enabled'].'\" data-otp-provided=\"'.(isset($record['otp_provided']) === true ? $record['otp_provided'] : '').'\"></span>", '; |
||
| 294 | //col2 |
||
| 295 | $sOutput .= '"'. |
||
| 296 | ((int) $record['disabled'] === 1 ? '<i class=\"fas fa-user-slash infotip text-danger mr-2\" title=\"'.$lang->get('account_is_locked').'\" id=\"user-disable-'.$record['id'].'\"></i>' |
||
| 297 | : ''). |
||
| 298 | '<span data-id=\"'.$record['id'].'\" data-field=\"login\" data-html=\"true\" id=\"user-login-'.$record['id'].'\">'.addslashes(str_replace("'", '‘', $record['login'])).'</span>'. |
||
| 299 | $userDisplayInfos. |
||
| 300 | (is_null($record['ongoing_process_id']) === false ? '<i class=\"fas fa-hourglass-half fa-beat-fade infotip text-warning ml-3\" title=\"'.$lang->get('task_in_progress_user_not_active').'\"></i>' : ''). |
||
| 301 | '" , '; |
||
| 302 | //col3 |
||
| 303 | $sOutput .= '"<span data-id=\"'.$record['id'].'\" data-field=\"name\" data-html=\"true\">'.addslashes($record['name'] === NULL ? '' : $record['name']).'</span>", '; |
||
| 304 | //col4 |
||
| 305 | $sOutput .= '"<span data-id=\"'.$record['id'].'\" data-field=\"lastname\" data-html=\"true\">'.addslashes($record['lastname'] === NULL ? '' : $record['lastname']).'</span>", '; |
||
| 306 | //col5 - MANAGED BY |
||
| 307 | $txt = '<span id=\"managedby-'.$record['id'].'\" data-id=\"'.$record['id'].'\" data-field=\"isAdministratedByRole\" data-html=\"true\">'; |
||
| 308 | $rows2 = DB::query( |
||
| 309 | 'SELECT title |
||
| 310 | FROM '.prefixTable('roles_title')." |
||
| 311 | WHERE id = '".$record['isAdministratedByRole']."' |
||
| 312 | ORDER BY title ASC" |
||
| 313 | ); |
||
| 314 | if (DB::count() > 0) { |
||
| 315 | foreach ($rows2 as $record2) { |
||
| 316 | $txt .= $lang->get('managers_of').' '.addslashes(str_replace("'", '‘', $record2['title'])).'<br />'; |
||
| 317 | } |
||
| 318 | } else { |
||
| 319 | $txt .= $lang->get('god'); |
||
| 320 | } |
||
| 321 | $sOutput .= '"'.$txt.'</span>", '; |
||
| 322 | //col6 |
||
| 323 | $sOutput .= '"<span data-id=\"'.$record['id'].'\" data-field=\"fonction_id\" data-html=\"true\">'.addslashes($listAlloFcts).'</span>", '; |
||
| 324 | // Get the user maximum privilege |
||
| 325 | if ((int) $record['admin'] === 1) { |
||
| 326 | $sOutput .= '"<i class=\"fa-solid fa-user-cog infotip\" title=\"'.$lang->get('god').'\"></i>", '; |
||
| 327 | } elseif ((int) $record['can_manage_all_users'] === 1) { |
||
| 328 | $sOutput .= '"<i class=\"fa-solid fa-user-graduate infotip\" title=\"'.$lang->get('human_resources').'\"></i>", '; |
||
| 329 | } elseif ((int) $record['gestionnaire'] === 1) { |
||
| 330 | $sOutput .= '"<i class=\"fa-solid fa-user-tie infotip\" title=\"'.$lang->get('gestionnaire').'\"></i>", '; |
||
| 331 | } elseif ((int) $record['read_only'] === 1) { |
||
| 332 | $sOutput .= '"<i class=\"fa-solid fa-book-reader infotip\" title=\"'.$lang->get('read_only_account').'\"></i>", '; |
||
| 333 | } else { |
||
| 334 | $sOutput .= '"<i class=\"fa-solid fa-user infotip\" title=\"'.$lang->get('user').'\"></i>", '; |
||
| 335 | } |
||
| 336 | //col12 |
||
| 337 | if ((int) $record['can_create_root_folder'] === 1) { |
||
| 338 | $sOutput .= '"<i class=\"fa-solid fa-toggle-on text-info\"></i>", '; |
||
| 339 | } else { |
||
| 340 | $sOutput .= '"<i class=\"fa-solid fa-toggle-off\"></i>", '; |
||
| 341 | } |
||
| 342 | |||
| 343 | //col13 |
||
| 344 | if ((int) $record['personal_folder'] === 1) { |
||
| 345 | $sOutput .= '"<i class=\"fa-solid fa-toggle-on text-info\"></i>"'; |
||
| 346 | } else { |
||
| 347 | $sOutput .= '"<i class=\"fa-solid fa-toggle-off\"></i>"'; |
||
| 348 | } |
||
| 349 | |||
| 350 | //Finish the line |
||
| 351 | $sOutput .= '],'; |
||
| 352 | } |
||
| 353 | } |
||
| 354 | |||
| 355 | if (count($rows) > 0) { |
||
| 356 | if (strrchr($sOutput, '[') !== '[') { |
||
| 357 | $sOutput = substr_replace($sOutput, '', -1); |
||
| 358 | } |
||
| 359 | $sOutput .= ']'; |
||
| 360 | } else { |
||
| 361 | $sOutput .= '[]'; |
||
| 362 | } |
||
| 363 | |||
| 364 | echo ($sOutput).'}'; |
||
| 365 |