From 58d6ab7342a9b21ebdb3aa74110f6f38f94985b6 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 9 Dec 2025 13:39:16 -0500 Subject: [PATCH 01/13] Unify Agent and Client login, if same user exists as a client and an agent then offer a selection of client portal or agent portal --- login.php | 690 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 433 insertions(+), 257 deletions(-) diff --git a/login.php b/login.php index 2db04167..afbb4e8b 100644 --- a/login.php +++ b/login.php @@ -1,5 +1,7 @@ (NOW() - INTERVAL 10 MINUTE)")); - +// Block access if more than 15 failed login attempts have happened in the last 10 minutes +$row = mysqli_fetch_assoc(mysqli_query( + $mysqli, + "SELECT COUNT(log_id) AS failed_login_count + FROM logs + WHERE log_ip = '$session_ip' + AND log_type = 'Login' + AND log_action = 'Failed' + AND log_created_at > (NOW() - INTERVAL 10 MINUTE)" +)); $failed_login_count = intval($row['failed_login_count']); if ($failed_login_count >= 15) { @@ -53,253 +72,391 @@ if ($failed_login_count >= 15) { } // Query Settings for company -$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings LEFT JOIN companies ON settings.company_id = companies.company_id WHERE settings.company_id = 1"); +$sql_settings = mysqli_query($mysqli, " + SELECT settings.*, companies.company_name, companies.company_logo + FROM settings + LEFT JOIN companies ON settings.company_id = companies.company_id + WHERE settings.company_id = 1 +"); $row = mysqli_fetch_array($sql_settings); // Company info -$company_name = $row['company_name']; -$company_logo = $row['company_logo']; -$config_start_page = nullable_htmlentities($row['config_start_page']); -$config_login_message = nullable_htmlentities($row['config_login_message']); +$company_name = $row['company_name']; +$company_logo = $row['company_logo']; +$config_start_page = nullable_htmlentities($row['config_start_page']); +$config_login_message = nullable_htmlentities($row['config_login_message']); // Mail -$config_smtp_host = $row['config_smtp_host']; -$config_smtp_port = intval($row['config_smtp_port']); +$config_smtp_host = $row['config_smtp_host']; +$config_smtp_port = intval($row['config_smtp_port']); $config_smtp_encryption = $row['config_smtp_encryption']; -$config_smtp_username = $row['config_smtp_username']; -$config_smtp_password = $row['config_smtp_password']; +$config_smtp_username = $row['config_smtp_username']; +$config_smtp_password = $row['config_smtp_password']; $config_mail_from_email = sanitizeInput($row['config_mail_from_email']); -$config_mail_from_name = sanitizeInput($row['config_mail_from_name']); +$config_mail_from_name = sanitizeInput($row['config_mail_from_name']); // Client Portal Enabled -$config_client_portal_enable = intval($row['config_client_portal_enable']); - -// Login key (if setup) -$config_login_key_required = $row['config_login_key_required']; -$config_login_key_secret = $row['config_login_key_secret']; - +$config_client_portal_enable = intval($row['config_client_portal_enable']); $config_login_remember_me_expire = intval($row['config_login_remember_me_expire']); -// Login key verification -// If no/incorrect 'key' is supplied, send to client portal instead -if ($config_login_key_required) { - if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) { - redirect("client"); - } -} +// Azure / Entra for client +$azure_client_id = $row['config_azure_client_id'] ?? null; -// HTTP-Only cookies -ini_set("session.cookie_httponly", true); +$response = null; +$token_field = null; +$show_role_choice = false; +$email = ''; +$password = ''; -// Tell client to only send cookie(s) over HTTPS -if ($config_https_only || !isset($config_https_only)) { - ini_set("session.cookie_secure", true); -} +// Handle POST login request (normal login or role choice) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_POST['role_choice']))) { -// Handle POST login request -if (isset($_POST['login'])) { - - // Sessions should start after the user has POSTed data - session_start(); - - // Passed login brute force check - $email = sanitizeInput($_POST['email']); - $password = $_POST['password']; - - $current_code = 0; // Default value - if (isset($_POST['current_code'])) { - $current_code = intval($_POST['current_code']); - } - - $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM users LEFT JOIN user_settings on users.user_id = user_settings.user_id WHERE user_email = '$email' AND user_archived_at IS NULL AND user_status = 1 AND user_type = 1")); - - // Check password - if ($row && password_verify($password, $row['user_password'])) { - - // User password correct (partial login) - - // Set temporary user variables - $user_name = sanitizeInput($row['user_name']); - $user_id = intval($row['user_id']); - $session_user_id = $user_id; // to pass the user_id to logAction function - $user_email = sanitizeInput($row['user_email']); - $token = sanitizeInput($row['user_token']); - $force_mfa = intval($row['user_config_force_mfa']); - $user_role_id = intval($row['user_role_id']); - $user_encryption_ciphertext = $row['user_specific_encryption_ciphertext']; - $user_extension_key = $row['user_extension_key']; - - $mfa_is_complete = false; // Default to requiring MFA - $extended_log = ''; // Default value - - if (empty($token)) { - // MFA is not configured - $mfa_is_complete = true; - } - - // Validate MFA via a remember-me cookie - if (isset($_COOKIE['rememberme'])) { - // Get remember tokens less than $config_login_remember_me_days_expire days old - $remember_tokens = mysqli_query($mysqli, "SELECT remember_token_token FROM remember_tokens WHERE remember_token_user_id = $user_id AND remember_token_created_at > (NOW() - INTERVAL $config_login_remember_me_expire DAY)"); - while ($row = mysqli_fetch_assoc($remember_tokens)) { - if (hash_equals($row['remember_token_token'], $_COOKIE['rememberme'])) { - $mfa_is_complete = true; - $extended_log = 'with 2FA remember-me cookie'; - break; - } - } - } - - // Validate MFA code - if (!empty($current_code) && TokenAuth6238::verify($token, $current_code)) { - $mfa_is_complete = true; - $extended_log = 'with MFA'; - } - - if ($mfa_is_complete) { - // MFA Completed successfully - - // FULL LOGIN SUCCESS - - // Create a remember me token, if requested - if (isset($_POST['remember_me'])) { - // TODO: Record the UA and IP a token is generated from so that can be shown later on - $newRememberToken = bin2hex(random_bytes(64)); - setcookie('rememberme', $newRememberToken, time() + 86400*$config_login_remember_me_expire, "/", null, true, true); - mysqli_query($mysqli, "INSERT INTO remember_tokens SET remember_token_user_id = $user_id, remember_token_token = '$newRememberToken'"); - - $extended_log .= ", generated a new remember-me token"; - } - - // Check this login isn't suspicious - $sql_ip_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS ip_previous_logins FROM logs WHERE log_type = 'Login' AND log_action = 'Success' AND log_ip = '$session_ip' AND log_user_id = $user_id")); - $ip_previous_logins = sanitizeInput($sql_ip_prev_logins['ip_previous_logins']); - - $sql_ua_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS ua_previous_logins FROM logs WHERE log_type = 'Login' AND log_action = 'Success' AND log_user_agent = '$session_user_agent' AND log_user_id = $user_id")); - $ua_prev_logins = sanitizeInput($sql_ua_prev_logins['ua_previous_logins']); - - // Notify if both the user agent and IP are different - if (!empty($config_smtp_host) && $ip_previous_logins == 0 && $ua_prev_logins == 0) { - $subject = "$config_app_name new login for $user_name"; - $body = "Hi $user_name,

A recent successful login to your $config_app_name account was considered a little unusual. If this was you, you can safely ignore this email!

IP Address: $session_ip
User Agent: $session_user_agent

If you did not perform this login, your credentials may be compromised.

Thanks,
ITFlow"; - - $data = [ - [ - 'from' => $config_mail_from_email, - 'from_name' => $config_mail_from_name, - 'recipient' => $user_email, - 'recipient_name' => $user_name, - 'subject' => $subject, - 'body' => $body - ] - ]; - addToMailQueue($data); - } - - logAction("Login", "Success", "$user_name successfully logged in $extended_log", 0, $user_id); - - // Session info - $_SESSION['user_id'] = $user_id; - $_SESSION['csrf_token'] = randomString(156); - $_SESSION['logged'] = true; - - // Forcing MFA - if ($force_mfa == 1 && $token == NULL) { - $config_start_page = "user/mfa_enforcement.php"; - } - - // Setup encryption session key - if (isset($user_encryption_ciphertext)) { - $site_encryption_master_key = decryptUserSpecificKey($user_encryption_ciphertext, $password); - generateUserSessionKey($site_encryption_master_key); - - // Setup extension - currently unused - //if (is_null($user_extension_key)) { - // Extension cookie - // Note: Browsers don't accept cookies with SameSite None if they are not HTTPS. - //setcookie("user_extension_key", "$user_extension_key", ['path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'None']); - - // Set PHP session in DB, so we can access the session encryption data (above) - //$user_php_session = session_id(); - //mysqli_query($mysqli, "UPDATE users SET user_php_session = '$user_php_session' WHERE user_id = $user_id"); - //} - - } - - // Redirect to last visited or config home - - if (isset($_GET['last_visited']) && (str_starts_with(base64_decode($_GET['last_visited']), '/agent') || str_starts_with(base64_decode($_GET['last_visited']), '/admin'))) { - - redirect($_SERVER["REQUEST_SCHEME"] . "://" . $config_base_url . base64_decode($_GET['last_visited']) ); - - } else { - redirect("agent/$config_start_page"); - } - } else { - - // MFA is configured and needs to be confirmed, or was unsuccessful - - // HTML code for the token input field - $token_field = " -
- -
-
- -
-
-
"; - - // Log/notify if MFA was unsuccessful - if ($current_code !== 0) { - - // Logging - logAction("Login", "MFA Failed", "$user_name failed MFA", 0, $user_id); - - // Email the tech to advise their credentials may be compromised - if (!empty($config_smtp_host)) { - $subject = "Important: $config_app_name failed 2FA login attempt for $user_name"; - $body = "Hi $user_name,

A recent login to your $config_app_name account was unsuccessful due to an incorrect 2FA code. If you did not attempt this login, your credentials may be compromised.

Thanks,
ITFlow"; - $data = [ - [ - 'from' => $config_mail_from_email, - 'from_name' => $config_mail_from_name, - 'recipient' => $user_email, - 'recipient_name' => $user_name, - 'subject' => $subject, - 'body' => $body - ] - ]; - $mail = addToMailQueue($data); - } - - // HTML feedback for incorrect 2FA code - $response = " -
- Please Enter 2FA Code! - -
"; - } - } + $email = sanitizeInput($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + $role_choice = $_POST['role_choice'] ?? null; // 'agent' or 'client' + // Basic validation + if (empty($email) || empty($password) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + header("HTTP/1.1 401 Unauthorized"); + $response = " +
+ Incorrect username or password. + +
"; } else { - // Password incorrect or user doesn't exist - show generic error + /* + * Unified lookup: + * - user_type = 1 → Agent + * - user_type = 2 → Client (must not be archived, client not archived) + * We fetch all possible matches for this email, then verify password per row. + * If both an agent and a client match with the same password: + * - First, show choice buttons (Agent / Client). + * - When user clicks a choice, we honor role_choice. + */ + $sql = mysqli_query($mysqli, " + SELECT users.*, + user_settings.*, + contacts.*, + clients.* + FROM users + LEFT JOIN user_settings ON users.user_id = user_settings.user_id + LEFT JOIN contacts ON users.user_id = contacts.contact_user_id + LEFT JOIN clients ON contacts.contact_client_id = clients.client_id + WHERE user_email = '$email' + AND user_archived_at IS NULL + AND user_status = 1 + AND ( + user_type = 1 + OR (user_type = 2 AND client_archived_at IS NULL) + ) + "); - header("HTTP/1.1 401 Unauthorized"); + $agentRow = null; + $clientRow = null; - logAction("Login", "Failed", "Failed login attempt using $email"); + while ($r = mysqli_fetch_assoc($sql)) { + if (!password_verify($password, $r['user_password'])) { + continue; + } + if (intval($r['user_type']) === 1 && $agentRow === null) { + $agentRow = $r; + } + if (intval($r['user_type']) === 2 && $clientRow === null) { + $clientRow = $r; + } + } - $response = " + $selectedRow = null; + $selectedType = null; // 1 = agent, 2 = client + + if ($agentRow === null && $clientRow === null) { + + // No matching user/password combo + header("HTTP/1.1 401 Unauthorized"); + logAction("Login", "Failed", "Failed login attempt using $email"); + + $response = "
Incorrect username or password.
"; + + } elseif ($agentRow !== null && $clientRow !== null) { + + // Both agent and client accounts share same email + password + if ($role_choice === 'agent') { + $selectedRow = $agentRow; + $selectedType = 1; + } elseif ($role_choice === 'client') { + $selectedRow = $clientRow; + $selectedType = 2; + } else { + // First time we realise this is a dual-role account: ask user to pick + $show_role_choice = true; + $response = " +
+ This login can be used as either an Agent account or a Client Portal account. + Please choose how you want to continue. + +
"; + } + + } else { + // Only one valid row (agent OR client) + if ($agentRow !== null) { + $selectedRow = $agentRow; + $selectedType = 1; + } else { + $selectedRow = $clientRow; + $selectedType = 2; + } + } + + // If we have a specific user selected, proceed with actual login + if ($selectedRow !== null && $selectedType !== null) { + + $user_id = intval($selectedRow['user_id']); + $user_email = sanitizeInput($selectedRow['user_email']); + $session_user_id = $user_id; // to pass the user_id to logAction function + + // ========================= + // AGENT LOGIN FLOW + // ========================= + if ($selectedType === 1) { + + $user_name = sanitizeInput($selectedRow['user_name']); + $token = sanitizeInput($selectedRow['user_token']); + $force_mfa = intval($selectedRow['user_config_force_mfa']); + $user_role_id = intval($selectedRow['user_role_id']); + $user_encryption_ciphertext = $selectedRow['user_specific_encryption_ciphertext']; + $user_extension_key = $selectedRow['user_extension_key']; + + $current_code = 0; + if (isset($_POST['current_code'])) { + $current_code = intval($_POST['current_code']); + } + + $mfa_is_complete = false; + $extended_log = ''; + + if (empty($token)) { + // MFA is not configured + $mfa_is_complete = true; + } + + // Validate MFA via a remember-me cookie + if (isset($_COOKIE['rememberme'])) { + $remember_tokens = mysqli_query($mysqli, " + SELECT remember_token_token + FROM remember_tokens + WHERE remember_token_user_id = $user_id + AND remember_token_created_at > (NOW() - INTERVAL $config_login_remember_me_expire DAY) + "); + while ($remember_row = mysqli_fetch_assoc($remember_tokens)) { + if (hash_equals($remember_row['remember_token_token'], $_COOKIE['rememberme'])) { + $mfa_is_complete = true; + $extended_log = 'with 2FA remember-me cookie'; + break; + } + } + } + + // Validate MFA code + if (!empty($current_code) && TokenAuth6238::verify($token, $current_code)) { + $mfa_is_complete = true; + $extended_log = 'with MFA'; + } + + if ($mfa_is_complete) { + // FULL AGENT LOGIN SUCCESS + + // Create a remember me token, if requested + if (isset($_POST['remember_me'])) { + $newRememberToken = bin2hex(random_bytes(64)); + setcookie( + 'rememberme', + $newRememberToken, + time() + 86400 * $config_login_remember_me_expire, + "/", + null, + true, + true + ); + mysqli_query($mysqli, " + INSERT INTO remember_tokens + SET remember_token_user_id = $user_id, + remember_token_token = '$newRememberToken' + "); + + $extended_log .= ", generated a new remember-me token"; + } + + // Check this login isn't suspicious + $sql_ip_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, " + SELECT COUNT(log_id) AS ip_previous_logins + FROM logs + WHERE log_type = 'Login' + AND log_action = 'Success' + AND log_ip = '$session_ip' + AND log_user_id = $user_id + ")); + $ip_previous_logins = sanitizeInput($sql_ip_prev_logins['ip_previous_logins']); + + $sql_ua_prev_logins = mysqli_fetch_assoc(mysqli_query($mysqli, " + SELECT COUNT(log_id) AS ua_previous_logins + FROM logs + WHERE log_type = 'Login' + AND log_action = 'Success' + AND log_user_agent = '$session_user_agent' + AND log_user_id = $user_id + ")); + $ua_prev_logins = sanitizeInput($sql_ua_prev_logins['ua_previous_logins']); + + // Notify if both the user agent and IP are different + if (!empty($config_smtp_host) && $ip_previous_logins == 0 && $ua_prev_logins == 0) { + $subject = "$config_app_name new login for $user_name"; + $body = "Hi $user_name,

A recent successful login to your $config_app_name account was considered a little unusual. If this was you, you can safely ignore this email!

IP Address: $session_ip
User Agent: $session_user_agent

If you did not perform this login, your credentials may be compromised.

Thanks,
ITFlow"; + + $data = [ + [ + 'from' => $config_mail_from_email, + 'from_name' => $config_mail_from_name, + 'recipient' => $user_email, + 'recipient_name' => $user_name, + 'subject' => $subject, + 'body' => $body + ] + ]; + addToMailQueue($data); + } + + logAction("Login", "Success", "$user_name successfully logged in $extended_log", 0, $user_id); + + // Session info + $_SESSION['user_id'] = $user_id; + $_SESSION['csrf_token'] = randomString(156); + $_SESSION['logged'] = true; + + // Forcing MFA + if ($force_mfa == 1 && $token == NULL) { + $config_start_page = "user/mfa_enforcement.php"; + } + + // Setup encryption session key + if (!empty($user_encryption_ciphertext)) { + $site_encryption_master_key = decryptUserSpecificKey($user_encryption_ciphertext, $password); + generateUserSessionKey($site_encryption_master_key); + } + + // Redirect to last visited or config home + if (isset($_GET['last_visited']) && (str_starts_with(base64_decode($_GET['last_visited']), '/agent') || str_starts_with(base64_decode($_GET['last_visited']), '/admin'))) { + + redirect($_SERVER["REQUEST_SCHEME"] . "://" . $config_base_url . base64_decode($_GET['last_visited'])); + + } else { + redirect("agent/$config_start_page"); + } + + } else { + + // MFA is configured and needs to be confirmed, or was unsuccessful + + // HTML code for the token input field + $token_field = " +
+ +
+
+ +
+
+
"; + + if ($current_code !== 0) { + // Logging + logAction("Login", "MFA Failed", "$user_email failed MFA", 0, $user_id); + + // Email the tech to advise their credentials may be compromised + if (!empty($config_smtp_host)) { + $subject = "Important: $config_app_name failed 2FA login attempt for $user_name"; + $body = "Hi $user_name,

A recent login to your $config_app_name account was unsuccessful due to an incorrect 2FA code. If you did not attempt this login, your credentials may be compromised.

Thanks,
ITFlow"; + $data = [ + [ + 'from' => $config_mail_from_email, + 'from_name' => $config_mail_from_name, + 'recipient' => $user_email, + 'recipient_name' => $user_name, + 'subject' => $subject, + 'body' => $body + ] + ]; + addToMailQueue($data); + } + + $response = " +
+ Please Enter 2FA Code! + +
"; + } + } + + // ========================= + // CLIENT LOGIN FLOW + // ========================= + } elseif ($selectedType === 2) { + + if ($config_client_portal_enable != 1) { + // Client portal disabled + header("HTTP/1.1 401 Unauthorized"); + logAction("Client Login", "Failed", "Client portal disabled; login attempt using $email"); + $response = " +
+ Incorrect username or password. + +
"; + } else { + + $client_id = intval($selectedRow['contact_client_id']); + $contact_id = intval($selectedRow['contact_id']); + $user_auth_method = sanitizeInput($selectedRow['user_auth_method']); + + if ($client_id && $contact_id && $user_auth_method === 'local') { + + $_SESSION['client_logged_in'] = true; + $_SESSION['client_id'] = $client_id; + $_SESSION['user_id'] = $user_id; + $_SESSION['user_type'] = 2; + $_SESSION['contact_id'] = $contact_id; + $_SESSION['login_method'] = "local"; + + logAction("Client Login", "Success", "Client contact $user_email successfully logged in locally", $client_id, $user_id); + + header("Location: client/index.php"); + exit(); + + } else { + + // Not allowed or invalid + logAction("Client Login", "Failed", "Failed client portal login attempt using $email (invalid auth method or missing contact/client)", $client_id ?? 0, $user_id); + + header("HTTP/1.1 401 Unauthorized"); + $response = " +
+ Incorrect username or password. + +
"; + } + } + } + } } } ?> - @@ -313,10 +470,7 @@ if (isset($_POST['login'])) { - + @@ -336,7 +490,6 @@ if (isset($_POST['login'])) { -
- +
">

Notes

From 84cc4a094acbd8bcdf15881a7d648db58b124c2c Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 10 Dec 2025 17:09:34 -0500 Subject: [PATCH 05/13] Add DB helpers to make MySQLi Prepared statements less bloated and require less code --- functions.php | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) diff --git a/functions.php b/functions.php index 8bc6ffdb..220d56fa 100644 --- a/functions.php +++ b/functions.php @@ -1784,3 +1784,223 @@ function cleanupUnusedImages(string $html, string $folderFsPath, string $folderW } } } + +/** + * Simple mysqli helper functions + * - Prepared statements under the hood + * - "Old style" INSERT/UPDATE SET feeling + */ + +/** + * Core executor: prepares, binds, executes. + * + * @throws Exception on error + */ +function dbExecute(mysqli $mysqli, string $sql, array $params = []): mysqli_stmt +{ + $stmt = $mysqli->prepare($sql); + if (!$stmt) { + throw new Exception('MySQLi prepare error: ' . $mysqli->error . ' | SQL: ' . $sql); + } + + if (!empty($params)) { + $types = ''; + $values = []; + + foreach ($params as $param) { + // Detect type + if (is_int($param)) { + $types .= 'i'; + } elseif (is_float($param)) { + $types .= 'd'; + } elseif (is_bool($param)) { + $types .= 'i'; + $param = $param ? 1 : 0; + } elseif (is_null($param)) { + // bind as string, MySQL will accept NULL when using proper SQL (e.g. "col = ?") + $types .= 's'; + $param = null; + } else { + $types .= 's'; + } + + $values[] = $param; + } + + // bind_param needs references; unpack will pass them correctly + if (!$stmt->bind_param($types, ...$values)) { + throw new Exception('MySQLi bind_param error: ' . $stmt->error . ' | SQL: ' . $sql); + } + } + + if (!$stmt->execute()) { + throw new Exception('MySQLi execute error: ' . $stmt->error . ' | SQL: ' . $sql); + } + + return $stmt; +} + +/** + * Fetch all rows as associative arrays. + */ +function dbFetchAll(mysqli $mysqli, string $sql, array $params = []): array +{ + $stmt = dbExecute($mysqli, $sql, $params); + $result = $stmt->get_result(); + + if ($result === false) { + return []; + } + + return $result->fetch_all(MYSQLI_ASSOC); +} + +/** + * Fetch a single row (assoc) or null if none. + */ +function dbFetchOne(mysqli $mysqli, string $sql, array $params = []): ?array +{ + $stmt = dbExecute($mysqli, $sql, $params); + $result = $stmt->get_result(); + + if ($result === false) { + return null; + } + + $row = $result->fetch_assoc(); + + return $row !== null ? $row : null; +} + +/** + * Fetch a single scalar value (first column of first row) or null. + */ +function dbFetchValue(mysqli $mysqli, string $sql, array $params = []) +{ + $row = dbFetchOne($mysqli, $sql, $params); + if ($row === null) { + return null; + } + + return reset($row); +} + +/** + * INSERT using "SET" style. + * Example: + * $id = dbInsertSet($mysqli, 'clients', [ + * 'client_name' => $name, + * 'client_type' => $type, + * ]); + * + * @return int insert_id + * + * @throws InvalidArgumentException + * @throws Exception + */ +function dbInsertSet(mysqli $mysqli, string $table, array $data): int +{ + if (empty($data)) { + throw new InvalidArgumentException('dbInsertSet called with empty $data'); + } + + // NOTE: $table and keys must NOT come from untrusted user input + $setParts = []; + foreach ($data as $column => $_) { + $setParts[] = "$column = ?"; + } + + $sql = "INSERT INTO $table SET " . implode(', ', $setParts); + $params = array_values($data); + + dbExecute($mysqli, $sql, $params); + + return $mysqli->insert_id; +} + +/** + * UPDATE using "SET" style and a WHERE clause with placeholders. + * + * Example: + * dbUpdateSet( + * $mysqli, + * 'clients', + * ['client_name' => $name, 'client_type' => $type], + * 'client_id = ?', + * [$client_id] + * ); + * + * @return int affected_rows + * + * @throws InvalidArgumentException + * @throws Exception + */ +function dbUpdateSet( + mysqli $mysqli, + string $table, + array $data, + string $whereSql, + array $whereParams = [] +): int { + if (empty($data)) { + throw new InvalidArgumentException('dbUpdateSet called with empty $data'); + } + + if (trim($whereSql) === '') { + throw new InvalidArgumentException('dbUpdateSet requires a WHERE clause'); + } + + // Again, $table and keys must not come from user input + $setParts = []; + foreach ($data as $column => $_) { + $setParts[] = "$column = ?"; + } + + $sql = "UPDATE $table SET " . implode(', ', $setParts) . " WHERE $whereSql"; + $params = array_merge(array_values($data), $whereParams); + + $stmt = dbExecute($mysqli, $sql, $params); + + return $stmt->affected_rows; +} + +/** + * DELETE helper. + * + * Example: + * dbDelete($mysqli, 'client_tags', 'client_id = ?', [$client_id]); + * + * @return int affected_rows + * + * @throws InvalidArgumentException + * @throws Exception + */ +function dbDelete(mysqli $mysqli, string $table, string $whereSql, array $whereParams = []): int +{ + if (trim($whereSql) === '') { + throw new InvalidArgumentException('dbDelete requires a WHERE clause'); + } + + $sql = "DELETE FROM $table WHERE $whereSql"; + $stmt = dbExecute($mysqli, $sql, $whereParams); + + return $stmt->affected_rows; +} + +/** + * Transaction helpers (optional sugar). + */ +function dbBegin(mysqli $mysqli): void +{ + $mysqli->begin_transaction(); +} + +function dbCommit(mysqli $mysqli): void +{ + $mysqli->commit(); +} + +function dbRollback(mysqli $mysqli): void +{ + $mysqli->rollback(); +} From b27ffe66353fd2b06dedf74272e73a44bba6ab27 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 10 Dec 2025 18:32:46 -0500 Subject: [PATCH 06/13] Refine DB Helpers --- functions.php | 89 +++++++++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/functions.php b/functions.php index 220d56fa..94f35602 100644 --- a/functions.php +++ b/functions.php @@ -1808,7 +1808,6 @@ function dbExecute(mysqli $mysqli, string $sql, array $params = []): mysqli_stmt $values = []; foreach ($params as $param) { - // Detect type if (is_int($param)) { $types .= 'i'; } elseif (is_float($param)) { @@ -1817,17 +1816,14 @@ function dbExecute(mysqli $mysqli, string $sql, array $params = []): mysqli_stmt $types .= 'i'; $param = $param ? 1 : 0; } elseif (is_null($param)) { - // bind as string, MySQL will accept NULL when using proper SQL (e.g. "col = ?") $types .= 's'; $param = null; } else { $types .= 's'; } - $values[] = $param; } - // bind_param needs references; unpack will pass them correctly if (!$stmt->bind_param($types, ...$values)) { throw new Exception('MySQLi bind_param error: ' . $stmt->error . ' | SQL: ' . $sql); } @@ -1847,11 +1843,9 @@ function dbFetchAll(mysqli $mysqli, string $sql, array $params = []): array { $stmt = dbExecute($mysqli, $sql, $params); $result = $stmt->get_result(); - if ($result === false) { return []; } - return $result->fetch_all(MYSQLI_ASSOC); } @@ -1862,13 +1856,10 @@ function dbFetchOne(mysqli $mysqli, string $sql, array $params = []): ?array { $stmt = dbExecute($mysqli, $sql, $params); $result = $stmt->get_result(); - if ($result === false) { return null; } - $row = $result->fetch_assoc(); - return $row !== null ? $row : null; } @@ -1881,14 +1872,13 @@ function dbFetchValue(mysqli $mysqli, string $sql, array $params = []) if ($row === null) { return null; } - return reset($row); } /** * INSERT using "SET" style. * Example: - * $id = dbInsertSet($mysqli, 'clients', [ + * $id = dbInsert($mysqli, 'clients', [ * 'client_name' => $name, * 'client_type' => $type, * ]); @@ -1898,13 +1888,12 @@ function dbFetchValue(mysqli $mysqli, string $sql, array $params = []) * @throws InvalidArgumentException * @throws Exception */ -function dbInsertSet(mysqli $mysqli, string $table, array $data): int +function dbInsert(mysqli $mysqli, string $table, array $data): int { if (empty($data)) { - throw new InvalidArgumentException('dbInsertSet called with empty $data'); + throw new InvalidArgumentException('dbInsert called with empty $data'); } - // NOTE: $table and keys must NOT come from untrusted user input $setParts = []; foreach ($data as $column => $_) { $setParts[] = "$column = ?"; @@ -1918,72 +1907,80 @@ function dbInsertSet(mysqli $mysqli, string $table, array $data): int return $mysqli->insert_id; } -/** - * UPDATE using "SET" style and a WHERE clause with placeholders. - * - * Example: - * dbUpdateSet( - * $mysqli, - * 'clients', - * ['client_name' => $name, 'client_type' => $type], - * 'client_id = ?', - * [$client_id] - * ); - * - * @return int affected_rows - * - * @throws InvalidArgumentException - * @throws Exception - */ -function dbUpdateSet( +function dbUpdate( mysqli $mysqli, string $table, array $data, - string $whereSql, + $where, array $whereParams = [] ): int { if (empty($data)) { - throw new InvalidArgumentException('dbUpdateSet called with empty $data'); + throw new InvalidArgumentException('dbUpdate called with empty $data'); + } + if (empty($where)) { + throw new InvalidArgumentException('dbUpdate requires a WHERE clause'); } - if (trim($whereSql) === '') { - throw new InvalidArgumentException('dbUpdateSet requires a WHERE clause'); - } - - // Again, $table and keys must not come from user input $setParts = []; foreach ($data as $column => $_) { $setParts[] = "$column = ?"; } + if (is_array($where)) { + $whereParts = []; + $whereParams = []; + foreach ($where as $column => $value) { + $whereParts[] = "$column = ?"; + $whereParams[] = $value; + } + $whereSql = implode(' AND ', $whereParts); + } else { + $whereSql = $where; + } + $sql = "UPDATE $table SET " . implode(', ', $setParts) . " WHERE $whereSql"; $params = array_merge(array_values($data), $whereParams); $stmt = dbExecute($mysqli, $sql, $params); - return $stmt->affected_rows; } /** * DELETE helper. * - * Example: - * dbDelete($mysqli, 'client_tags', 'client_id = ?', [$client_id]); + * WHERE can be: + * - array: ['client_id' => $id] (auto "client_id = ?") + * - string: 'client_id = ?' (use with $whereParams) * * @return int affected_rows * * @throws InvalidArgumentException * @throws Exception */ -function dbDelete(mysqli $mysqli, string $table, string $whereSql, array $whereParams = []): int -{ - if (trim($whereSql) === '') { +function dbDelete( + mysqli $mysqli, + string $table, + $where, + array $whereParams = [] +): int { + if (empty($where)) { throw new InvalidArgumentException('dbDelete requires a WHERE clause'); } + if (is_array($where)) { + $whereParts = []; + $whereParams = []; + foreach ($where as $column => $value) { + $whereParts[] = "$column = ?"; + $whereParams[] = $value; + } + $whereSql = implode(' AND ', $whereParts); + } else { + $whereSql = $where; + } + $sql = "DELETE FROM $table WHERE $whereSql"; $stmt = dbExecute($mysqli, $sql, $whereParams); - return $stmt->affected_rows; } From 27fde82affbda6b58e70253d6ffb93d28296f34f Mon Sep 17 00:00:00 2001 From: johnnyq Date: Fri, 12 Dec 2025 16:42:09 -0500 Subject: [PATCH 07/13] Fixed Adding Payment provider not adding an account, now adding you can customize the income/expense account, expense category, and Expense vendor. Moved Saved Payment Provider Methods into Payment Providers as a link instead of on the admin side nav. Same with AI Provider and AI Models. --- admin/ai_model.php | 10 + admin/ai_provider.php | 5 +- admin/includes/side_nav.php | 18 +- .../payment_provider/payment_provider_add.php | 235 +++++++++++++----- .../payment_provider_edit.php | 212 +++++++++++----- admin/payment_provider.php | 12 +- admin/post/payment_provider.php | 53 +--- admin/saved_payment_method.php | 36 ++- agent/modals/expense/expense_add.php | 2 - 9 files changed, 379 insertions(+), 204 deletions(-) diff --git a/admin/ai_model.php b/admin/ai_model.php index a231ce5c..88c3b78b 100644 --- a/admin/ai_model.php +++ b/admin/ai_model.php @@ -12,6 +12,16 @@ $num_rows = mysqli_num_rows($sql); ?> + +

AI Models

diff --git a/admin/ai_provider.php b/admin/ai_provider.php index 45fb5f56..46258c55 100644 --- a/admin/ai_provider.php +++ b/admin/ai_provider.php @@ -39,7 +39,7 @@ $num_rows = mysqli_num_rows($sql); Key - + Models Action @@ -67,7 +67,8 @@ $num_rows = mysqli_num_rows($sql); - + + +
+
+ +
+ +
+
+ +
+ +
+ See here for the latest Stripe Fees. +
+ +
+ +
+
+ +
+ +
+ See here for the latest Stripe Fees. +
+ +
+
Date: Sat, 13 Dec 2025 15:47:28 -0500 Subject: [PATCH 10/13] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0987e44..aab7c5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This file documents all notable changes made to ITFlow. - You can now Set Payment Provider income/expense account, expense vendor and expense category upond creation or editing. - Moved Saved Payment Provider Methods away from admin side nav to the count link within Payment Providers page. - Moved AI Models from the admin side nav to the model count link within AI Providers. +- Add Favicon Reset. ## [25.12] Stable Release From 1916456c848d6fb1fbea99f19c3c7d60efb95c43 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 14 Dec 2025 13:04:53 -0500 Subject: [PATCH 11/13] Fix White Label not displaying on the login page --- login.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/login.php b/login.php index 0e974007..5a639d8e 100644 --- a/login.php +++ b/login.php @@ -574,7 +574,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ Powered by ITFlow'; } ?> From 312eb4dffcfae335c354477edc05516bf602aa9a Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 14 Dec 2025 13:16:54 -0500 Subject: [PATCH 12/13] Allow use of login key only for agents --- login.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/login.php b/login.php index 5a639d8e..33422ee2 100644 --- a/login.php +++ b/login.php @@ -99,6 +99,10 @@ $config_mail_from_name = sanitizeInput($row['config_mail_from_name']); $config_client_portal_enable = intval($row['config_client_portal_enable']); $config_login_remember_me_expire = intval($row['config_login_remember_me_expire']); +// Login key (if setup) +$config_login_key_required = $row['config_login_key_required']; +$config_login_key_secret = $row['config_login_key_secret']; + // Azure / Entra for client $azure_client_id = $row['config_azure_client_id'] ?? null; @@ -224,6 +228,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ // AGENT LOGIN FLOW // ========================= if ($selectedType === 1) { + // Login key verification + // If no/incorrect 'key' is supplied, send to client portal instead + if ($config_login_key_required) { + if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) { + redirect("client"); + } + } $user_name = sanitizeInput($selectedRow['user_name']); $token = sanitizeInput($selectedRow['user_token']); From 32f996d034d8967dbf9e917e2b5554e3bc6e6199 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 14 Dec 2025 13:42:38 -0500 Subject: [PATCH 13/13] If login key is set and it is not provided show Client Email instead of just Email for placeholder --- login.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/login.php b/login.php index 33422ee2..7e69045a 100644 --- a/login.php +++ b/login.php @@ -232,7 +232,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ // If no/incorrect 'key' is supplied, send to client portal instead if ($config_login_key_required) { if (!isset($_GET['key']) || $_GET['key'] !== $config_login_key_secret) { - redirect("client"); + redirect(); } } @@ -515,9 +515,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_
> - > + " + name="email" + value="" + required + >