mirror of
https://github.com/itflow-org/itflow
synced 2026-09-01 12:25:11 +00:00
Setting a PIN containing < or > silently cleared it: the length check ran before escapeSql(), whose strip_tags() then emptied the value, and the UPDATE stored the blank while flashing success. Length is now checked after sanitising. Password and PIN changes require the current password. SSO contacts are exempt - no local password to check, and the IdP already did it. New index on logs(log_user_id, log_client_id) for the portal profile and activity pages, which were scanning the whole table twice per profile view. admin/audit_logs.php's date filter rewritten as a half-open range so KEY log_created_at is usable - DATE(log_created_at) BETWEEN made it non-sargable. Portal statement page and PDF now render in the client's currency, matching the guest view and the emailed statement. Quick Send asks for confirmation; confirm-link extended to submit buttons. Portal audit entries logged an empty name - client/post.php used , which only exists agent-side.
188 lines
6.5 KiB
PHP
188 lines
6.5 KiB
PHP
<?php
|
|
/*
|
|
* Client Portal
|
|
* Functions
|
|
*/
|
|
|
|
/*
|
|
* Verifies a contact has access to a particular ticket ID, and that the ticket is in the correct state (open/closed) to perform an action
|
|
*/
|
|
function verifyContactTicketAccess($requested_ticket_id, $expected_ticket_state) {
|
|
|
|
// Access the global variables
|
|
global $mysqli, $session_contact_id, $session_contact_primary, $session_contact_is_technical_contact, $session_client_id;
|
|
|
|
// Setup
|
|
if ($expected_ticket_state == "Closed") {
|
|
// Closed tickets
|
|
$ticket_state_snippet = "ticket_status = 5";
|
|
} else {
|
|
// Open (working/hold) tickets
|
|
$ticket_state_snippet = "ticket_status != 5";
|
|
}
|
|
|
|
// Verify the contact has access to the provided ticket ID
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM tickets WHERE ticket_id = $requested_ticket_id AND $ticket_state_snippet AND ticket_client_id = $session_client_id LIMIT 1"));
|
|
if ($row) {
|
|
$ticket_id = $row['ticket_id'];
|
|
|
|
if (intval($ticket_id) && ($session_contact_id == $row['ticket_contact_id'] || $session_contact_primary == 1 || $session_contact_is_technical_contact)) {
|
|
// Client is ticket owner, primary contact, or a technical contact
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Client is NOT ticket owner or primary/tech contact
|
|
return false;
|
|
|
|
}
|
|
|
|
/*
|
|
* Portal access control - single source of truth for what a logged-in contact can do.
|
|
* Primary contacts have full access; others are gated by their billing / technical flags.
|
|
* Capabilities are named by area so the rule for one can change without touching callers.
|
|
*/
|
|
function contactCan($capability) {
|
|
global $session_contact_primary, $session_contact_is_billing_contact, $session_contact_is_technical_contact;
|
|
|
|
// Primary contacts can do everything in the portal
|
|
if ($session_contact_primary == 1) {
|
|
return true;
|
|
}
|
|
|
|
switch ($capability) {
|
|
case 'accounting': // invoices, quotes, recurring invoices, saved payment methods
|
|
return (bool) $session_contact_is_billing_contact;
|
|
|
|
case 'itdoc': // assets, certificates, domains, documents, files
|
|
case 'contacts': // view / manage contacts
|
|
return (bool) $session_contact_is_technical_contact;
|
|
|
|
default: // unknown capability -> deny (fail closed)
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Enforce a capability at the top of a page or handler - bounce the contact out if they lack it.
|
|
*/
|
|
function enforceContactCan($capability) {
|
|
if (!contactCan($capability)) {
|
|
redirect("post.php?logout");
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Confirms the person at the keyboard is the account holder, before a change
|
|
* that would let someone who hijacked a session take the account over or
|
|
* defeat phone verification.
|
|
*
|
|
* Returns true for SSO contacts without checking anything: there is no local
|
|
* password to compare against, and the identity provider has already done this
|
|
* work. Gating them on a password they do not have would just lock them out.
|
|
*/
|
|
function portalReauthenticate($current_password) {
|
|
global $mysqli, $session_user_id;
|
|
|
|
if (($_SESSION['login_method'] ?? 'local') !== 'local') {
|
|
return true;
|
|
}
|
|
|
|
if (empty($current_password)) {
|
|
return false;
|
|
}
|
|
|
|
$sql = mysqli_query($mysqli, "SELECT user_password FROM users WHERE user_id = $session_user_id LIMIT 1");
|
|
$row = mysqli_fetch_assoc($sql);
|
|
|
|
if (!$row || empty($row['user_password'])) {
|
|
return false;
|
|
}
|
|
|
|
return password_verify($current_password, $row['user_password']);
|
|
}
|
|
|
|
/*
|
|
* A timestamp a person can read, in the company's configured date and time
|
|
* format rather than the raw DATETIME the database hands back.
|
|
*
|
|
* Today and yesterday are named instead of dated, because on an activity list
|
|
* the recent rows are the ones being scanned and "Today at 4:12 PM" answers
|
|
* "was that just now?" faster than a date does. Anything older gets the full
|
|
* date, since by then the date is the useful part.
|
|
*
|
|
* Returns HTML-escaped output - callers print it directly.
|
|
*/
|
|
function portalDateTime($datetime) {
|
|
global $config_date_format, $config_time_format;
|
|
|
|
if (empty($datetime)) {
|
|
return '';
|
|
}
|
|
|
|
$timestamp = strtotime($datetime);
|
|
if ($timestamp === false) {
|
|
return escapeHtml($datetime);
|
|
}
|
|
|
|
$time = date($config_time_format, $timestamp);
|
|
$day = date('Y-m-d', $timestamp);
|
|
|
|
if ($day === date('Y-m-d')) {
|
|
return escapeHtml("Today at $time");
|
|
}
|
|
|
|
if ($day === date('Y-m-d', strtotime('-1 day'))) {
|
|
return escapeHtml("Yesterday at $time");
|
|
}
|
|
|
|
return escapeHtml(date($config_date_format, $timestamp) . " at $time");
|
|
}
|
|
|
|
/*
|
|
* The empty state for the portal's list pages.
|
|
*
|
|
* Every list page here renders a header row and then a while loop, so a client
|
|
* with no assets, no quotes or no documents used to get a table with nothing
|
|
* under it - indistinguishable from a page that failed to load. This says so
|
|
* instead.
|
|
*
|
|
* Default is neutral, for a list that simply has nothing in it yet. Pass
|
|
* 'success' where empty is genuinely good news (nothing owed, nothing unpaid).
|
|
*/
|
|
function portalEmptyState($message, $icon = 'fa-inbox', $type = 'secondary') {
|
|
return '<div class="alert alert-' . $type . '"><i class="fa fa-fw ' . $icon . ' me-2"></i>' . $message . '</div>';
|
|
}
|
|
|
|
/*
|
|
* Returns appropriate FontAwesome icon for file extension
|
|
*/
|
|
function getFileIcon($file_extension) {
|
|
$file_extension = strtolower($file_extension);
|
|
|
|
// Document icons
|
|
if (in_array($file_extension, ['pdf'])) {
|
|
return 'file-pdf';
|
|
} elseif (in_array($file_extension, ['doc', 'docx'])) {
|
|
return 'file-word';
|
|
} elseif (in_array($file_extension, ['xls', 'xlsx'])) {
|
|
return 'file-excel';
|
|
} elseif (in_array($file_extension, ['ppt', 'pptx'])) {
|
|
return 'file-powerpoint';
|
|
} elseif (in_array($file_extension, ['txt', 'md', 'rtf'])) {
|
|
return 'file-alt';
|
|
} elseif (in_array($file_extension, ['zip', 'rar', '7z', 'tar', 'gz'])) {
|
|
return 'file-archive';
|
|
} elseif (in_array($file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'])) {
|
|
return 'file-image';
|
|
} elseif (in_array($file_extension, ['mp4', 'avi', 'mov', 'wmv', 'flv'])) {
|
|
return 'file-video';
|
|
} elseif (in_array($file_extension, ['mp3', 'wav', 'ogg', 'flac'])) {
|
|
return 'file-audio';
|
|
} elseif (in_array($file_extension, ['html', 'htm', 'css', 'js', 'php', 'py', 'java'])) {
|
|
return 'file-code';
|
|
} else {
|
|
return 'file';
|
|
}
|
|
}
|