From 01fbcda75d462ae15da337100116c88dbb6f5789 Mon Sep 17 00:00:00 2001
From: johnnyq
Date: Mon, 26 Oct 2026 19:54:36 -0400
Subject: [PATCH] Security sweep 2: API archive, unarchive, resolve and close
now check the client named in the call the same as create, update and delete
do (every call except read.php and time.php counts as a write); API asset,
contact, network and document create/update keep linked contacts, locations,
vendors, networks and folders within the record's own client; linking a
contact to an asset refuses another client's contact, and unlinking only
clears the contact it was linked to; agents limited to certain clients no
longer see other clients' names in the asset import template file name,
export filter summaries, or the messages from file moves, unlinks, ticket
asset removal, bulk project add and bulk interface network; guest quote
Accept and Decline post instead of being plain links and check the key with
hash_equals; exports that match nothing say so instead of showing a blank
page; about 30 handlers now stop with 'no longer exists' when their record
was just deleted, instead of showing warnings or an error page and writing
history for a record that is gone; restore_contact_note reads its own id
parameter; ticket prefix pattern escaped for the v flag; missing user agent
and array key parameters handled; CHANGELOG updated
---
CHANGELOG.md | 13 +++++
admin/post/users.php | 2 +
agent/post/asset.php | 63 +++++++++++++++++++++----
agent/post/asset_interface.php | 8 +++-
agent/post/certificate.php | 4 +-
agent/post/client.php | 2 +
agent/post/contact.php | 73 +++++++++++++++++++++++++----
agent/post/credential.php | 4 +-
agent/post/domain.php | 6 ++-
agent/post/expense.php | 2 +
agent/post/file.php | 6 +++
agent/post/income.php | 2 +
agent/post/invoice.php | 30 +++++++++++-
agent/post/location.php | 4 +-
agent/post/network.php | 6 ++-
agent/post/network_ip.php | 2 +
agent/post/payment.php | 8 ++++
agent/post/product.php | 4 +-
agent/post/project.php | 2 +-
agent/post/quote.php | 30 ++++++++++++
agent/post/recurring_invoice.php | 24 +++++++++-
agent/post/service.php | 2 +-
agent/post/software.php | 4 +-
agent/post/ticket.php | 15 ++++--
agent/post/transaction.php | 2 +
agent/post/trip.php | 2 +
agent/post/vendor.php | 4 +-
api/v1/assets/asset_model.php | 6 +++
api/v1/contacts/contact_model.php | 3 ++
api/v1/documents/document_model.php | 3 ++
api/v1/enforce_api_rbac.php | 5 +-
api/v1/networks/network_model.php | 5 +-
api/v1/validate_api_key.php | 2 +-
client/join.php | 3 +-
client/login_microsoft.php | 2 +-
functions/contract_signature.php | 2 +-
functions/export.php | 38 +++++++++++++++
functions/portal_invite.php | 2 +-
functions/request.php | 2 +-
functions/settings.php | 2 +-
guest/guest_post.php | 20 ++++----
guest/guest_view_contract.php | 2 +-
guest/guest_view_quote.php | 15 +++---
guest/includes/inc_all_guest.php | 2 +-
includes/load_user_session.php | 2 +-
45 files changed, 373 insertions(+), 67 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d620d780b..2f3c4cb14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1411,6 +1411,15 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Clients: a client with a contract couldn't be deleted, because the database refused it.
- Contract templates: the after-hours rate of a new template was never saved, rates were cut to whole amounts, and net terms written as "Net 30", as the field suggested, were saved as 0.
- Contract templates: Edit showed the rates empty, so saving a template set them to 0.
+- API: a key acting as an agent limited to certain clients could archive or restore another client's contacts, credentials, documents, domains, locations and networks, archive or restore the client itself, and resolve or close its tickets, by naming that client in the call - only creating, editing and deleting checked the client named. Archiving a contact that way also archived its portal login.
+- API: an asset, contact, network or document made or edited through the API could be given another client's contact, location, vendor, network or folder, which then showed on this client's record.
+- Assets: linking a contact to an asset from the contact's page took another client's contact, whose name and details then showed on the asset to anyone who could open it.
+- Agents limited to certain clients could see the names of other clients' records: in the file name of the asset import template, the filter line of an export, and the messages after moving files to a folder, unlinking a credential, contact or asset, removing an asset from a ticket, and moving tickets into a project or interfaces onto a network in bulk.
+- Quotes: Accept and Decline on the page a client opens from a quote email were plain links, so a mail scanner or link preview that opens every link could answer the quote. They are now buttons.
+- Exports: exporting with filters that match nothing left a blank page. It now says nothing matches and stays on the list.
+- Assets: unlinking a contact from an asset with a page opened earlier took off whichever contact the asset had been given since.
+- Archiving, restoring or deleting an asset, contact, note, invoice, quote, payment or recurring invoice, or saving one, that someone else had just deleted showed an error page or warnings and wrote history for a record that was gone. It now says the record no longer exists.
+- Settings: the ticket prefix's letters-and-hyphens check didn't run in the browser - its pattern is refused by current browsers. ITFlow still checked it on saving.
### Developer Updates
@@ -2016,6 +2025,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run
- Tags' look: `.itflow-tag` draws `--itflow-tag-color` as a `color-mix()` wash and edge with the icon in it and the name in `--bs-body-color` (`--itflow-tag-ink`, 75% or 55% under `[data-bs-theme="dark"]`, is how much of the color the icon keeps), with neutral fallbacks before each `color-mix()`; `--itflow-tag-text` is no longer used by pills. `.itflow-search-tag-icon` and the new `.itflow-select-badge.is-soft` follow it. New `tagOptionLook($color, $icon)` in `functions/tags.php` (`selectOptionColor()` plus `data-soft="1"`) for a tag's `` - `tagOptionHtml()`, the Tags filters on Clients, Contacts, Assets, Credentials, Domains, Vendors and Locations, and the merge window; `js/itflow_select.js` puts an icon-and-color lead on the wash when its option says `data-soft="1"`, and `js/tag_picker.js` always does. Categories and ticket statuses keep their filled badges.
- Reply emails: database 3.4.8 adds `ticket_replies.ticket_reply_emailed` tinyint(1) (1 emailed, 0 not, NULL not known - a client's reply, or one from before the update the mail queue no longer had), `ticket_replies.ticket_reply_emailed_to` text (a JSON list of `{email, name}`, kept after the mail queue clears the emails) and `email_queue.email_ticket_reply_id` int(11), indexed. The update links the mail queue's Ticket update and Ticket resolved emails to the public reply on that ticket made in the 15 seconds before each was queued. `emailQueue()` takes a `ticket_reply_id` option, kept on each email, and so does `ticketMailSend()`; `ticketMailReply()` and `ticketMailResolved()` take `$reply_id` and mark the reply (both still return how many it went to). New in `functions/ticket.php`: `ticketReplyEmailReady()` (nothing is kept before 3.4.8 has run), `ticketReplyEmailed($reply_id, $emailed = true)` (who it went to, read back from the mail queue; false for a note), `ticketReplyEmailStates($rows)` (each recipient's `mailQueueState()` while the queue has the email) and `ticketReplyEmailHtml()`. `add_ticket_reply`, `bulk_ticket_reply`, `bulk_resolve_tickets` and the API's `ticket_replies/create.php` mark each reply they add, and `ticket_replies/read.php` returns the two new columns. New CSS `.ticket-badge-note`, `.ticket-reply-emailed` and `.ticket-reply-recipients`.
- Popups and editors: new `itflowDismissPopups(from)` in `js/itflow_select.js` closes what a click outside would - Bootstrap dropdowns (not those with `data-bs-auto-close` `false` or `inside`, nor one holding `from`), an open flatpickr calendar - and fires `itflow:dismiss` on `document`, on which `js/itflow_select.js`, `js/quick_pick.js` and `js/tag_picker.js` close their panel. It runs on `mousedown`/`touchstart` in every TinyMCE editor (through `tinymce.on('AddEditor')`, so the client portal's too) and when the focus goes into any other iframe (on `window` blur), since neither reaches the page. `.itflow-select-sheet-head` shows on a desktop too, as a slim heading: the field's label with Cancel on a single select (`close(false, true)`, nothing typed is added) or Done on a multi select; the tag picker keeps the Done in its footer there. The canned response picker has `[data-canned-close]`, which also focuses the reply.
+- Security sweep 2: `api/v1/enforce_api_rbac.php` counts every call but `read.php` and `time.php` as a write (`$is_write`), so `archive.php`, `unarchive.php`, `tickets/resolve.php` and `tickets/close.php` refuse a `client_id` the key's user can't reach (`apiUserCanAccessClient()`) as create, update and delete do - a list of the reads, so a new kind of write is checked by default. `api/v1/assets/asset_model.php`, `contacts/contact_model.php`, `networks/network_model.php` and `documents/document_model.php` pass the contact, location, vendor, network and folder through `assetClientOwnedId()` / `folderOwnedId()` against `$client_id`, as their forms do. `link_contact_to_asset` (`agent/post/contact.php`) refuses a contact `assetClientOwnedId('contact', ...)` doesn't own, and `unlink_asset_from_contact` only clears `asset_contact_id` when it is still that contact. Existing cross-client asset contacts: `SELECT asset_id FROM assets JOIN contacts ON contact_id = asset_contact_id WHERE contact_client_id <> asset_client_id`.
+- Security sweep 2: new in `functions/export.php` - `exportClientName($client_id)` and `exportRecordName($table, $id, $name_column, $client_column)` (the name for an export's filter line only when the agent can reach its client; a record of no client is named) and `guardExportHasRows($num_rows, $what)` ("No $what match - nothing to export." and back, called before the `$num_rows > 0` block of every export but projects, services and the two logs, which already did). `download_assets_csv_template` needs `module_support` and names the file after the client only when `hasClientAccess()`. The flash messages of `delete_ticket_additional_asset`, `bulk_add_ticket_project` and `bulk_edit_asset_interface_network` name the asset, project or network only once it was on the ticket or taken; the unlink handlers in `agent/post/asset.php` and `contact.php` name the other record only when `assetClientOwnedId()` owns it; `bulk_move_files` refuses a folder whose client the agent can't reach before naming it.
+- Security sweep 2: the guest quote's Accept and Decline post `quote_id`, `url_key` and `accept_quote` or `decline_quote` to `guest/guest_post.php`, which compares the key with `hash_equals()`; the GET form of `accept_quote` / `decline_quote` no longer answers. `contractSignatureByKey()`, `portalInviteByLink()` and `client/join.php` take a key or token that isn't a string as ''. `$_SERVER['HTTP_USER_AGENT'] ?? ''` in `api/v1/validate_api_key.php`, `includes/load_user_session.php`, `client/login_microsoft.php`, `guest/includes/inc_all_guest.php` and `isMobile()`.
+- Security sweep 2: the handlers that fetch their record and go on when it is gone return "That ... no longer exists." - `archive_asset`, `restore_asset` (both failed on `asset_history`'s foreign key), `delete_asset`, `edit_asset`, the asset and contact note handlers, `add_asset_note`, `add_contact_note`, `anonymize_contact`, `archive_contact`, `restore_contact`, `delete_contact`, `unlink_credential_from_contact`, `unlink_credential_from_asset`, `edit_invoice`, `add_invoice_copy`, `mark_invoice_sent`, `mark_invoice_non-billable`, `cancel_invoice`, `delete_invoice`, `delete_payment`, `refund_payment_stripe`, `add_quote_copy`, `edit_quote`, `mark_quote_sent`, `delete_quote`, `accept_quote`, `decline_quote`, `mark_quote_invoiced`, `add_invoice_recurring`, `edit_recurring_invoice`, `set_recurring_payment`, `delete_recurring_invoice` and `recurring_invoice_email_notify`. `restore_contact_note` read its id from `unarchive_contact_note`. The ticket prefix's `pattern` is `[A-Za-z\-]+` - an unescaped `-` in a class is an error under the `v` flag browsers now compile `pattern` with.
### Library Updates
diff --git a/admin/post/users.php b/admin/post/users.php
index 89c42de3f..4eea8c5ab 100644
--- a/admin/post/users.php
+++ b/admin/post/users.php
@@ -432,6 +432,8 @@ if (isExportRequest('export_users')) {
$num_rows = count($rows);
+ guardExportHasRows($num_rows, 'users');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/asset.php b/agent/post/asset.php
index 2cec18ff6..8d4f5a1f6 100644
--- a/agent/post/asset.php
+++ b/agent/post/asset.php
@@ -114,6 +114,10 @@ if (isset($_POST['edit_asset'])) {
// Get Existing Photo and assigned client_id
$sql = mysqli_query($mysqli,"SELECT asset_photo, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$existing_file_name = $row['asset_photo'] ?? '';
$client_id = intval($row['asset_client_id']);
@@ -183,6 +187,10 @@ if (isset($_GET['archive_asset'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -212,6 +220,10 @@ if (isset($_GET['restore_asset'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -241,6 +253,10 @@ if (isset($_GET['delete_asset'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -275,6 +291,10 @@ if (isset($_POST['add_asset_note'])) {
// Get Asset details for logging and alerting
$sql = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -304,6 +324,10 @@ if (isset($_GET['archive_asset_note'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_note_type, asset_id, asset_name, asset_client_id FROM asset_notes LEFT JOIN assets ON asset_id = asset_note_asset_id WHERE asset_note_id = $asset_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$asset_note_type = escapeSql($row['asset_note_type']);
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -332,6 +356,10 @@ if (isset($_GET['restore_asset_note'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_note_type, asset_id, asset_name, asset_client_id FROM asset_notes LEFT JOIN assets ON asset_id = asset_note_asset_id WHERE asset_note_id = $asset_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$asset_note_type = escapeSql($row['asset_note_type']);
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -360,6 +388,10 @@ if (isset($_GET['delete_asset_note'])) {
// Get Asset Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT asset_note_type, asset_id, asset_name, asset_client_id FROM asset_notes LEFT JOIN assets ON asset_id = asset_note_asset_id WHERE asset_note_id = $asset_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$asset_note_type = escapeSql($row['asset_note_type']);
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
@@ -388,6 +420,10 @@ if (isset($_POST['bulk_assign_asset_location'])) {
// Get Location name and client id for logging and alert
$sql = mysqli_query($mysqli,"SELECT location_name, location_client_id FROM locations WHERE location_id = $location_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('Pick a location to move them to.', 'error');
+ redirect();
+ }
$location_name = escapeSql($row['location_name']);
$client_id = intval($row['location_client_id']);
$location_client_id = $client_id;
@@ -575,6 +611,10 @@ if (isset($_POST['bulk_assign_asset_contact'])) {
// Get Contact name and client id for logging and Notification
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('Pick a contact to assign them to.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
@@ -907,19 +947,23 @@ if (isset($_GET['unlink_credential_from_asset'])) {
enforceUserPermission('module_support', 2);
enforceUserPermission('module_credential', 2);
- $asset_id = intval($_GET['asset_id']);
- $credential_id = intval($_GET['credential_id']);
+ $asset_id = intval($_GET['asset_id'] ?? 0);
+ $credential_id = intval($_GET['credential_id'] ?? 0);
// Get credential Name and Client ID for logging
$sql_credential = mysqli_query($mysqli,"SELECT credential_name, credential_client_id FROM credentials WHERE credential_id = $credential_id");
$row = mysqli_fetch_assoc($sql_credential);
+ if (!$row) {
+ flashAlert('That credential no longer exists.', 'error');
+ redirect();
+ }
$credential_name = escapeSql($row['credential_name']);
$client_id = intval($row['credential_client_id']);
enforceClientAccess();
// Get Asset Name for logging
- $asset_name = escapeSql(getFieldById('assets', $asset_id, 'asset_name'));
+ $asset_name = escapeSql(assetClientOwnedId('asset', $asset_id, $client_id) ? getFieldById('assets', $asset_id, 'asset_name') : 'its asset');
// Only if it is this asset's - an old link can't unpick a newer one
mysqli_query($mysqli,"UPDATE credentials SET credential_asset_id = 0 WHERE credential_id = $credential_id AND credential_asset_id = $asset_id");
@@ -1104,18 +1148,17 @@ if (isset($_POST["import_assets_csv"])) {
if (isset($_GET['download_assets_csv_template'])) {
+ enforceUserPermission('module_support');
+
$client_id = intval($_GET['download_assets_csv_template']);
- //get records from database
- $sql = mysqli_query($mysqli,"SELECT client_name FROM clients WHERE client_id = $client_id");
- $row = mysqli_fetch_assoc($sql);
-
- $client_name = $row['client_name'];
+ // Named for the client only when it's one this agent can reach - any id would otherwise say its client's name back
+ $client_name = $client_id && hasClientAccess($client_id) ? (string) getFieldById('clients', $client_id, 'client_name') : '';
$delimiter = ",";
$enclosure = '"';
$escape = '\\';
- $filename = toAlphanumeric($client_name) . "-Assets-Template.csv";
+ $filename = ($client_name !== '' ? toAlphanumeric($client_name) . '-' : '') . "Assets-Template.csv";
//create a file pointer
$f = fopen('php://memory', 'w');
@@ -1285,6 +1328,8 @@ if (isExportRequest('export_assets')) {
);
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'assets');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/asset_interface.php b/agent/post/asset_interface.php
index 3cda78b07..6edc3c9cd 100644
--- a/agent/post/asset_interface.php
+++ b/agent/post/asset_interface.php
@@ -246,8 +246,10 @@ if (isset($_POST['bulk_edit_asset_interface_network'])) {
$count++;
}
- flashAlert("Network set to " . ($network['network_name'] ?? 'none') . " on $count interface" . ($count === 1 ? '' : 's')
- . ($outside ? " - $outside ha" . ($outside === 1 ? 's an address' : 've addresses') . " outside " . $network['network'] : ''), $outside ? 'warning' : 'success');
+ // The network is named once an interface took it - one that fits none of them may be a client this agent can't see
+ $network_label = !$network ? 'none' : ($count ? escapeHtml($network['network_name']) : 'that network');
+ flashAlert("Network set to $network_label on $count interface" . ($count === 1 ? '' : 's')
+ . ($outside ? " - $outside ha" . ($outside === 1 ? 's an address' : 've addresses') . " outside " . escapeHtml($network['network']) : ''), $outside ? 'warning' : 'success');
redirect();
@@ -460,6 +462,8 @@ if (isExportRequest('export_asset_interfaces')) {
$num_rows = count($rows);
+ guardExportHasRows($num_rows, 'interfaces');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/certificate.php b/agent/post/certificate.php
index 65e99d23d..51cb381a2 100644
--- a/agent/post/certificate.php
+++ b/agent/post/certificate.php
@@ -372,7 +372,7 @@ if (isExportRequest('export_certificates')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (certificate_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR certificate_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND certificate_archived_at IS NULL)";
@@ -427,6 +427,8 @@ if (isExportRequest('export_certificates')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'certificates');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/client.php b/agent/post/client.php
index ce3abeec4..7ee325e6b 100644
--- a/agent/post/client.php
+++ b/agent/post/client.php
@@ -496,6 +496,8 @@ if (isExportRequest('export_clients')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'clients');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/contact.php b/agent/post/contact.php
index e2c6e185b..d87dbc735 100644
--- a/agent/post/contact.php
+++ b/agent/post/contact.php
@@ -217,6 +217,10 @@ if (isset($_POST['add_contact_note'])) {
// Get Contact details for logging and alerting
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That contact no longer exists.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
@@ -246,6 +250,10 @@ if (isset($_GET['archive_contact_note'])) {
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_note_type, contact_id, contact_name, contact_client_id FROM contact_notes LEFT JOIN contacts ON contact_id = contact_note_contact_id WHERE contact_note_id = $contact_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$contact_note_type = escapeSql($row['contact_note_type']);
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
@@ -269,11 +277,15 @@ if (isset($_GET['restore_contact_note'])) {
enforceUserPermission('module_client', 2);
- $contact_note_id = intval($_GET['unarchive_contact_note']);
+ $contact_note_id = intval($_GET['restore_contact_note']);
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_note_type, contact_id, contact_name, contact_client_id FROM contact_notes LEFT JOIN contacts ON contact_id = contact_note_contact_id WHERE contact_note_id = $contact_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$contact_note_type = escapeSql($row['contact_note_type']);
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
@@ -302,6 +314,10 @@ if (isset($_GET['delete_contact_note'])) {
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_note_type, contact_id, contact_name, contact_client_id FROM contact_notes LEFT JOIN contacts ON contact_id = contact_note_contact_id WHERE contact_note_id = $contact_note_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That note no longer exists.', 'error');
+ redirect();
+ }
$contact_note_type = escapeSql($row['contact_note_type']);
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
@@ -671,6 +687,10 @@ if (isset($_GET['anonymize_contact'])) {
// Get contact & client info
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_email, contact_phone, contact_client_id, contact_user_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That contact no longer exists.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$contact_first_name = explode(" ", $contact_name)[0];
@@ -927,6 +947,10 @@ if (isset($_GET['archive_contact'])) {
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id, contact_user_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That contact no longer exists.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
$contact_user_id = intval($row['contact_user_id']);
@@ -959,6 +983,10 @@ if (isset($_GET['restore_contact'])) {
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id, contact_user_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That contact no longer exists.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
$contact_user_id = intval($row['contact_user_id']);
@@ -991,6 +1019,10 @@ if (isset($_GET['delete_contact'])) {
// Get Contact Name and Client ID for logging and alert message
$sql = mysqli_query($mysqli,"SELECT contact_name, contact_client_id, contact_user_id FROM contacts WHERE contact_id = $contact_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That contact no longer exists.', 'error');
+ redirect();
+ }
$contact_name = escapeSql($row['contact_name']);
$client_id = intval($row['contact_client_id']);
$contact_user_id = intval($row['contact_user_id']);
@@ -1027,11 +1059,21 @@ if (isset($_POST['link_contact_to_asset'])) {
// Get Asset Name and Client ID for logging
$sql_asset = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql_asset);
+ if (!$row) {
+ flashAlert('Pick an asset to link.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
enforceClientAccess();
+ // Only one of the same client's people - another client's contact would show on this client's asset
+ if (!assetClientOwnedId('contact', $contact_id, $client_id)) {
+ flashAlert("That contact isn't one of this client's", 'error');
+ redirect();
+ }
+
// Get Contact Name for logging
$contact_name = escapeSql(getFieldById('contacts', $contact_id, 'contact_name'));
@@ -1051,21 +1093,26 @@ if (isset($_GET['unlink_asset_from_contact'])) {
enforceUserPermission('module_client', 2);
- $contact_id = intval($_GET['contact_id']);
- $asset_id = intval($_GET['asset_id']);
+ $contact_id = intval($_GET['contact_id'] ?? 0);
+ $asset_id = intval($_GET['asset_id'] ?? 0);
// Get asset Name and Client ID for logging
$sql_asset = mysqli_query($mysqli,"SELECT asset_name, asset_client_id FROM assets WHERE asset_id = $asset_id");
$row = mysqli_fetch_assoc($sql_asset);
+ if (!$row) {
+ flashAlert('That asset no longer exists.', 'error');
+ redirect();
+ }
$asset_name = escapeSql($row['asset_name']);
$client_id = intval($row['asset_client_id']);
enforceClientAccess();
// Get Contact Name for logging
- $contact_name = escapeSql(getFieldById('contacts', $contact_id, 'contact_name'));
+ $contact_name = escapeSql(assetClientOwnedId('contact', $contact_id, $client_id) ? getFieldById('contacts', $contact_id, 'contact_name') : 'its contact');
- mysqli_query($mysqli,"UPDATE assets SET asset_contact_id = 0 WHERE asset_id = $asset_id");
+ // Only if it is this contact's - an old link can't unpick a newer one
+ mysqli_query($mysqli,"UPDATE assets SET asset_contact_id = 0 WHERE asset_id = $asset_id AND asset_contact_id = $contact_id");
logAudit("Asset", "Unlink", "$session_name unlinked contact $contact_name from asset $asset_name", $client_id, $asset_id);
@@ -1126,19 +1173,23 @@ if (isset($_GET['unlink_credential_from_contact'])) {
enforceUserPermission('module_client', 2);
enforceUserPermission('module_credential', 2);
- $contact_id = intval($_GET['contact_id']);
- $credential_id = intval($_GET['credential_id']);
+ $contact_id = intval($_GET['contact_id'] ?? 0);
+ $credential_id = intval($_GET['credential_id'] ?? 0);
// Get credential Name and Client ID for logging
$sql_credential = mysqli_query($mysqli,"SELECT credential_name, credential_client_id FROM credentials WHERE credential_id = $credential_id");
$row = mysqli_fetch_assoc($sql_credential);
+ if (!$row) {
+ flashAlert('That credential no longer exists.', 'error');
+ redirect();
+ }
$credential_name = escapeSql($row['credential_name']);
$client_id = intval($row['credential_client_id']);
enforceClientAccess();
// Get Contact Name for logging
- $contact_name = escapeSql(getFieldById('contacts', $contact_id, 'contact_name'));
+ $contact_name = escapeSql(assetClientOwnedId('contact', $contact_id, $client_id) ? getFieldById('contacts', $contact_id, 'contact_name') : 'its contact');
// Only if it is this contact's - an old link can't unpick a newer one
mysqli_query($mysqli,"UPDATE credentials SET credential_contact_id = 0 WHERE credential_id = $credential_id AND credential_contact_id = $contact_id");
@@ -1188,7 +1239,7 @@ if (isExportRequest('export_contacts')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (contact_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR contact_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND contact_archived_at IS NULL)";
@@ -1214,7 +1265,7 @@ if (isExportRequest('export_contacts')) {
if (!empty($_POST['location'])) {
$filter_location_id = intval($_POST['location']);
$location_query = "AND (contact_location_id = $filter_location_id)";
- $filter_summary['Location'] = getFieldById('locations', $filter_location_id, 'location_name');
+ $filter_summary['Location'] = exportRecordName('locations', $filter_location_id, 'location_name', 'location_client_id');
} else {
// Default - any
$location_query = '';
@@ -1247,6 +1298,8 @@ if (isExportRequest('export_contacts')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'contacts');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/credential.php b/agent/post/credential.php
index 6a1f6548e..e122b2e51 100644
--- a/agent/post/credential.php
+++ b/agent/post/credential.php
@@ -322,7 +322,7 @@ if (isExportRequest('export_credentials')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (credential_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR credential_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND credential_archived_at IS NULL)";
@@ -368,6 +368,8 @@ if (isExportRequest('export_credentials')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'credentials');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/domain.php b/agent/post/domain.php
index 3527a8042..2703e2900 100644
--- a/agent/post/domain.php
+++ b/agent/post/domain.php
@@ -444,7 +444,7 @@ if (isExportRequest('export_domains')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (domain_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR domain_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND domain_archived_at IS NULL)";
@@ -475,7 +475,7 @@ if (isExportRequest('export_domains')) {
if (!empty($_POST['vendor'])) {
$vendor_filter = intval($_POST['vendor']);
$vendor_query = "AND $vendor_filter IN (domain_registrar, domain_dnshost, domain_webhost, domain_mailhost)";
- $filter_summary['Vendor'] = getFieldById('vendors', $vendor_filter, 'vendor_name');
+ $filter_summary['Vendor'] = exportRecordName('vendors', $vendor_filter, 'vendor_name', 'vendor_client_id');
}
// Search Filter
@@ -507,6 +507,8 @@ if (isExportRequest('export_domains')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'domains');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/expense.php b/agent/post/expense.php
index fdaf29a02..f8e8f4dd0 100644
--- a/agent/post/expense.php
+++ b/agent/post/expense.php
@@ -360,6 +360,8 @@ if (isExportRequest('export_expenses')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'expenses');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/file.php b/agent/post/file.php
index eb9820fd1..39c024e7f 100644
--- a/agent/post/file.php
+++ b/agent/post/file.php
@@ -385,6 +385,12 @@ if (isset($_POST['bulk_move_files'])) {
enforceUserPermission('module_support', 2);
$target_folder = intval($_POST['bulk_folder_id'] ?? 0);
+ // A folder of a client this agent can reach - another's name isn't said back
+ $folder_client_id = $target_folder ? intval(getFieldById('folders', $target_folder, 'folder_client_id')) : 0;
+ if ($target_folder && (!$folder_client_id || !hasClientAccess($folder_client_id))) {
+ flashAlert('That folder no longer exists.', 'error');
+ redirect();
+ }
$folder_name = folderDisplayName($target_folder);
$file_count = 0;
diff --git a/agent/post/income.php b/agent/post/income.php
index 1a67f22d4..983195c59 100644
--- a/agent/post/income.php
+++ b/agent/post/income.php
@@ -195,6 +195,8 @@ if (isExportRequest('export_income')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'income entries');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/invoice.php b/agent/post/invoice.php
index 2269f4f1a..8247d6ea0 100644
--- a/agent/post/invoice.php
+++ b/agent/post/invoice.php
@@ -67,6 +67,10 @@ if (isset($_POST['edit_invoice'])) {
// Get Invoice Number and Prefix and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT invoice_prefix, invoice_number, invoice_client_id FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['invoice_client_id']);
@@ -100,6 +104,10 @@ if (isset($_POST['add_invoice_copy'])) {
invoice_currency_code, invoice_discount_amount, invoice_note, invoice_number,
invoice_prefix, invoice_scope FROM clients, invoices WHERE client_id = invoice_client_id AND invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$client_net_terms = intval($row['client_net_terms']); // 0 is On Receipt
$invoice_scope = escapeSql($row['invoice_scope']);
$invoice_discount_amount = floatval($row['invoice_discount_amount']);
@@ -166,6 +174,10 @@ if (isset($_POST['mark_invoice_sent'])) {
// Get Invoice Number and Prefix and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT invoice_prefix, invoice_number, invoice_client_id FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['invoice_client_id']);
@@ -210,6 +222,10 @@ if (isset($_GET['mark_invoice_non-billable'])) {
// Get Invoice Number and Prefix and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT invoice_prefix, invoice_number, invoice_client_id FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['invoice_client_id']);
@@ -239,6 +255,10 @@ if (isset($_GET['cancel_invoice'])) {
// Get Invoice Number and Prefix and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT invoice_prefix, invoice_number, invoice_client_id FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['invoice_client_id']);
@@ -271,6 +291,10 @@ if (isset($_GET['delete_invoice'])) {
// Get Invoice Number and Prefix and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT invoice_prefix, invoice_number, invoice_client_id FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$client_id = intval($row['invoice_client_id']);
@@ -568,6 +592,8 @@ if (isExportRequest('export_invoices')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'invoices');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
@@ -712,8 +738,8 @@ if (isset($_GET['remove_ticket_from_invoice'])) {
enforceUserPermission('module_sales', 2);
- $invoice_id = intval($_GET['invoice_id']);
- $ticket_id = intval($_GET['ticket_id']);
+ $invoice_id = intval($_GET['invoice_id'] ?? 0);
+ $ticket_id = intval($_GET['ticket_id'] ?? 0);
$invoice = billingRow('invoice', $invoice_id);
$ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_prefix, ticket_number, ticket_client_id FROM tickets WHERE ticket_id = $ticket_id AND ticket_invoice_id = $invoice_id"));
diff --git a/agent/post/location.php b/agent/post/location.php
index cb8cc8c83..437ad2a07 100644
--- a/agent/post/location.php
+++ b/agent/post/location.php
@@ -386,7 +386,7 @@ if (isExportRequest('export_locations')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (location_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR location_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND location_archived_at IS NULL)";
@@ -433,6 +433,8 @@ if (isExportRequest('export_locations')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'locations');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/network.php b/agent/post/network.php
index 63fc9c5ac..ff7cc30fd 100644
--- a/agent/post/network.php
+++ b/agent/post/network.php
@@ -329,7 +329,7 @@ if (isExportRequest('export_networks')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND network_client_id = $filter_client_id";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR network_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND network_archived_at IS NULL)";
}
@@ -338,7 +338,7 @@ if (isExportRequest('export_networks')) {
if (!empty($_POST['location'])) {
$filter_location_id = intval($_POST['location']);
$location_query = "AND network_location_id = $filter_location_id";
- $filter_summary['Site'] = getFieldById('locations', $filter_location_id, 'location_name');
+ $filter_summary['Site'] = exportRecordName('locations', $filter_location_id, 'location_name', 'location_client_id');
}
$type_query = '';
@@ -373,6 +373,8 @@ if (isExportRequest('export_networks')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'networks');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/network_ip.php b/agent/post/network_ip.php
index c23e0817d..d8843983f 100644
--- a/agent/post/network_ip.php
+++ b/agent/post/network_ip.php
@@ -334,6 +334,8 @@ if (isExportRequest('export_network_ips')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'IP addresses');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/payment.php b/agent/post/payment.php
index faa9747c4..699b78af0 100644
--- a/agent/post/payment.php
+++ b/agent/post/payment.php
@@ -528,6 +528,10 @@ if (isset($_GET['delete_payment'])) {
LIMIT 1"
);
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That payment no longer exists.', 'error');
+ redirect();
+ }
$invoice_id = intval($row['payment_invoice_id']);
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
@@ -571,6 +575,10 @@ if (isset($_GET['refund_payment_stripe'])) {
LIMIT 1"
);
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That payment no longer exists.', 'error');
+ redirect();
+ }
$invoice_id = intval($row['payment_invoice_id']);
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
diff --git a/agent/post/product.php b/agent/post/product.php
index c5ddf9186..c751e333a 100644
--- a/agent/post/product.php
+++ b/agent/post/product.php
@@ -353,7 +353,7 @@ if (isExportRequest('export_products')) {
if (!empty($_POST['vendor'])) {
$filter_vendor_id = intval($_POST['vendor']);
$vendor_query = "AND product_vendor_id = $filter_vendor_id";
- $filter_summary['Vendor'] = getFieldById('vendors', $filter_vendor_id, 'vendor_name');
+ $filter_summary['Vendor'] = exportRecordName('vendors', $filter_vendor_id, 'vendor_name', 'vendor_client_id');
}
// Search Filter
@@ -380,6 +380,8 @@ if (isExportRequest('export_products')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'products');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/project.php b/agent/post/project.php
index bde677ef4..ce73f3d1c 100644
--- a/agent/post/project.php
+++ b/agent/post/project.php
@@ -507,7 +507,7 @@ if (isExportRequest('export_projects')) {
$filter_summary['Client'] = 'Internal';
} elseif (intval($client_filter)) {
$where[] = 'AND project_client_id = ' . intval($client_filter);
- $filter_summary['Client'] = getFieldById('clients', intval($client_filter), 'client_name');
+ $filter_summary['Client'] = exportClientName($client_filter);
}
}
diff --git a/agent/post/quote.php b/agent/post/quote.php
index 001b3120b..ffcc6756f 100644
--- a/agent/post/quote.php
+++ b/agent/post/quote.php
@@ -82,6 +82,10 @@ if (isset($_POST['add_quote_copy'])) {
$sql = mysqli_query($mysqli,"SELECT quote_amount, quote_category_id, quote_currency_code, quote_discount_amount, quote_note,
quote_number, quote_prefix, quote_scope FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$original_quote_prefix = escapeSql($row['quote_prefix']);
$original_quote_number = escapeSql($row['quote_number']);
$quote_discount_amount = floatval($row['quote_discount_amount']);
@@ -192,6 +196,10 @@ if (isset($_POST['edit_quote'])) {
// Get Quote Details for logging
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -221,6 +229,10 @@ if (isset($_GET['delete_quote'])) {
// Get Quote Details for logging
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -267,6 +279,10 @@ if (isset($_POST['mark_quote_sent'])) {
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -310,6 +326,10 @@ if (isset($_GET['accept_quote'])) {
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -340,6 +360,10 @@ if (isset($_GET['decline_quote'])) {
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -429,6 +453,10 @@ if (isset($_GET['mark_quote_invoiced'])) {
$sql = mysqli_query($mysqli,"SELECT quote_client_id, quote_number, quote_prefix FROM quotes WHERE quote_id = $quote_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That quote no longer exists.', 'error');
+ redirect();
+ }
$quote_prefix = escapeSql($row['quote_prefix']);
$quote_number = escapeSql($row['quote_number']);
$client_id = intval($row['quote_client_id']);
@@ -511,6 +539,8 @@ if (isExportRequest('export_quotes')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'quotes');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/recurring_invoice.php b/agent/post/recurring_invoice.php
index 6747bbcb6..3a14792de 100644
--- a/agent/post/recurring_invoice.php
+++ b/agent/post/recurring_invoice.php
@@ -18,6 +18,10 @@ if (isset($_POST['add_invoice_recurring'])) {
$sql = mysqli_query($mysqli,"SELECT invoice_amount, invoice_category_id, invoice_client_id, invoice_currency_code,
invoice_date, invoice_note, invoice_number, invoice_prefix, invoice_scope FROM invoices WHERE invoice_id = $invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That invoice no longer exists.', 'error');
+ redirect();
+ }
$invoice_prefix = escapeSql($row['invoice_prefix']);
$invoice_number = intval($row['invoice_number']);
$invoice_date = escapeSql(validateDate($row['invoice_date']));
@@ -131,6 +135,10 @@ if (isset($_POST['edit_recurring_invoice'])) {
// Get Recurring Invoice Details and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_client_id FROM recurring_invoices WHERE recurring_invoice_id = $recurring_invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That recurring invoice no longer exists.', 'error');
+ redirect();
+ }
$recurring_invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
$recurring_invoice_number = intval($row['recurring_invoice_number']);
$client_id = intval($row['recurring_invoice_client_id']);
@@ -166,6 +174,10 @@ if (isset($_GET['delete_recurring_invoice'])) {
// Get Recurring Invoice Details and Client ID for Logging
$sql = mysqli_query($mysqli,"SELECT recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_scope, recurring_invoice_client_id FROM recurring_invoices WHERE recurring_invoice_id = $recurring_invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That recurring invoice no longer exists.', 'error');
+ redirect();
+ }
$recurring_invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
$recurring_invoice_number = intval($row['recurring_invoice_number']);
$recurring_invoice_scope = escapeSql($row['recurring_invoice_scope']);
@@ -259,6 +271,10 @@ if (isset($_POST['set_recurring_payment'])) {
$sql = mysqli_query($mysqli, "SELECT recurring_invoice_amount, recurring_invoice_client_id, recurring_invoice_currency_code,
recurring_invoice_number, recurring_invoice_prefix FROM recurring_invoices WHERE recurring_invoice_id = $recurring_invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That recurring invoice no longer exists.', 'error');
+ redirect();
+ }
$client_id = intval($row['recurring_invoice_client_id']);
$recurring_invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
$recurring_invoice_number = intval($row['recurring_invoice_number']);
@@ -389,6 +405,8 @@ if (isExportRequest('export_recurring_invoices')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'recurring invoices');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
@@ -420,10 +438,14 @@ if (isset($_GET['recurring_invoice_email_notify'])) {
enforceUserPermission('module_sales', 2);
$recurring_invoice_email_notify = intval($_GET['recurring_invoice_email_notify']);
- $recurring_invoice_id = intval($_GET['recurring_invoice_id']);
+ $recurring_invoice_id = intval($_GET['recurring_invoice_id'] ?? 0);
$sql = mysqli_query($mysqli,"SELECT recurring_invoice_client_id, recurring_invoice_number, recurring_invoice_prefix FROM recurring_invoices WHERE recurring_invoice_id = $recurring_invoice_id");
$row = mysqli_fetch_assoc($sql);
+ if (!$row) {
+ flashAlert('That recurring invoice no longer exists.', 'error');
+ redirect();
+ }
$recurring_invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
$recurring_invoice_number = intval($row['recurring_invoice_number']);
$client_id = intval($row['recurring_invoice_client_id']);
diff --git a/agent/post/service.php b/agent/post/service.php
index 8c3d3870a..e88f52ddd 100644
--- a/agent/post/service.php
+++ b/agent/post/service.php
@@ -431,7 +431,7 @@ if (isExportRequest('export_services')) {
$where[] = 'AND clients.client_archived_at IS NULL';
if (intval($_POST['client'] ?? 0)) {
$where[] = 'AND service_client_id = ' . intval($_POST['client']);
- $filter_summary['Client'] = getFieldById('clients', intval($_POST['client']), 'client_name');
+ $filter_summary['Client'] = exportClientName($_POST['client']);
}
}
diff --git a/agent/post/software.php b/agent/post/software.php
index bc63c60fc..bac6ab3e4 100644
--- a/agent/post/software.php
+++ b/agent/post/software.php
@@ -483,7 +483,7 @@ if (isExportRequest('export_software')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND (software_client_id = $filter_client_id)";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR software_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND software_archived_at IS NULL)";
@@ -570,6 +570,8 @@ if (isExportRequest('export_software')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'licenses');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/ticket.php b/agent/post/ticket.php
index 17f08c36e..9174b17c0 100644
--- a/agent/post/ticket.php
+++ b/agent/post/ticket.php
@@ -524,13 +524,15 @@ if (isset($_GET['delete_ticket_additional_asset'])) {
mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id = $ticket_id AND asset_id = $asset_id");
+ // Named only when it was on this ticket - any other id would say another client's asset name back
if (mysqli_affected_rows($mysqli)) {
logTicketHistory($ticket_id, "$session_name removed additional asset " . escapeSql($asset_name));
logAudit("Ticket", "Edit", "$session_name removed asset " . escapeSql($asset_name) . " from ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_id);
+ flashAlert("Removed asset " . escapeHtml($asset_name) . " from the ticket", 'error');
+ } else {
+ flashAlert("That asset isn't on this ticket any more.", 'error');
}
- flashAlert("Removed asset " . escapeHtml($asset_name) . " from the ticket", 'error');
-
redirect();
}
@@ -949,7 +951,8 @@ if (isset($_POST['bulk_add_ticket_project'])) {
$count++;
}
- flashAlert(($project_input ? "$count ticket" . ($count === 1 ? '' : 's') . " added to project " . escapeHtml($project_name) . " " : "$count ticket" . ($count === 1 ? '' : 's') . " taken out of their project")
+ // The project is named once a ticket went into it - one that fits none of them may be a client this agent can't see
+ flashAlert(($project_input ? "$count ticket" . ($count === 1 ? '' : 's') . " added to " . ($count ? "project " . escapeHtml($project_name) . " " : 'the project') : "$count ticket" . ($count === 1 ? '' : 's') . " taken out of their project")
. ($skipped ? ", $skipped skipped - the project is another client's" : ''));
redirect();
@@ -1609,7 +1612,7 @@ if (isExportRequest('export_tickets')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND ticket_client_id = $filter_client_id";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
}
@@ -1703,7 +1706,7 @@ if (isExportRequest('export_tickets')) {
if (!empty($_POST['project']) && $_POST['project'] > '0') {
$filter_project_id = intval($_POST['project']);
$ticket_project_snippet = "AND ticket_project_id = $filter_project_id";
- $filter_summary['Project'] = getFieldById('projects', $filter_project_id, 'project_name');
+ $filter_summary['Project'] = exportRecordName('projects', $filter_project_id, 'project_name', 'project_client_id');
} else {
// Default - any, including tickets without a project
$ticket_project_snippet = '';
@@ -1760,6 +1763,8 @@ if (isExportRequest('export_tickets')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'tickets');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/transaction.php b/agent/post/transaction.php
index 45fbfeaec..15532686b 100644
--- a/agent/post/transaction.php
+++ b/agent/post/transaction.php
@@ -34,6 +34,8 @@ if (isExportRequest('export_transactions')) {
ORDER BY ledger.txn_date ASC, ledger.txn_created_at ASC, ledger.txn_order ASC, ledger.txn_id ASC");
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'transactions');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/trip.php b/agent/post/trip.php
index 26ca2c5a1..a2631d296 100644
--- a/agent/post/trip.php
+++ b/agent/post/trip.php
@@ -306,6 +306,8 @@ if (isExportRequest('export_trips')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'trips');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/agent/post/vendor.php b/agent/post/vendor.php
index d7a48e9f4..efd1f519f 100644
--- a/agent/post/vendor.php
+++ b/agent/post/vendor.php
@@ -443,7 +443,7 @@ if (isExportRequest('export_vendors')) {
if (!empty($_POST['client'])) {
$filter_client_id = intval($_POST['client']);
$client_query = "AND vendor_client_id = $filter_client_id";
- $filter_summary['Client'] = getFieldById('clients', $filter_client_id, 'client_name');
+ $filter_summary['Client'] = exportClientName($filter_client_id);
}
$archive_query = $archived ? "(client_archived_at IS NOT NULL OR vendor_archived_at IS NOT NULL)" : "(client_archived_at IS NULL AND vendor_archived_at IS NULL)";
} else {
@@ -491,6 +491,8 @@ if (isExportRequest('export_vendors')) {
$num_rows = mysqli_num_rows($sql);
+ guardExportHasRows($num_rows, 'vendors');
+
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
diff --git a/api/v1/assets/asset_model.php b/api/v1/assets/asset_model.php
index 4d1f4cce9..a3cec0ec2 100644
--- a/api/v1/assets/asset_model.php
+++ b/api/v1/assets/asset_model.php
@@ -159,3 +159,9 @@ if (isset($_POST['asset_network_id'])) {
} else {
$network = '0';
}
+
+// Only the client's own, as in the asset form - another client's would show on this one's asset, and the asset on theirs
+$vendor = assetClientOwnedId('vendor', $vendor, $client_id);
+$location = assetClientOwnedId('location', $location, $client_id);
+$contact = assetClientOwnedId('contact', $contact, $client_id);
+$network = assetClientOwnedId('network', $network, $client_id);
diff --git a/api/v1/contacts/contact_model.php b/api/v1/contacts/contact_model.php
index b4cd38f82..076006154 100644
--- a/api/v1/contacts/contact_model.php
+++ b/api/v1/contacts/contact_model.php
@@ -105,3 +105,6 @@ if (isset($_POST['contact_location_id'])) {
} else {
$location_id = '0';
}
+
+// Only one of the client's own sites, as in the contact form
+$location_id = assetClientOwnedId('location', $location_id, $client_id);
diff --git a/api/v1/documents/document_model.php b/api/v1/documents/document_model.php
index ea1d34d13..b34df0671 100644
--- a/api/v1/documents/document_model.php
+++ b/api/v1/documents/document_model.php
@@ -41,3 +41,6 @@ if (isset($_POST['document_folder_id'])) {
} else {
$folder = 0;
}
+
+// Only one of the client's own folders, as in the document form
+$folder = folderOwnedId($folder, $client_id);
diff --git a/api/v1/enforce_api_rbac.php b/api/v1/enforce_api_rbac.php
index b18ddbd16..d55d3260a 100644
--- a/api/v1/enforce_api_rbac.php
+++ b/api/v1/enforce_api_rbac.php
@@ -200,7 +200,10 @@ if ($resource_module[$resource] !== '' && lookupUserPermission($resource_module[
// permission at all, just an optional filter applied inside apiClientScopeSql().
$client_id = intval($_POST['client_id'] ?? $_GET['client_id'] ?? 0);
$client_id_supplied = isset($_POST['client_id']) || isset($_GET['client_id']);
-$is_write = in_array($operation_file, ['create.php', 'update.php', 'delete.php'], true);
+// Everything but a read is a write - archive, unarchive, resolve and close act on one
+// client's record just as update does, so they name a client the user can reach too.
+// Listing the reads, not the writes, keeps a new kind of write from slipping past.
+$is_write = !in_array($operation_file, ['read.php', 'time.php'], true);
if ($is_write && !apiUserCanAccessClient($client_id)) {
// Writes act on a single client the caller names (client_id 0 = a global record).
// The user must be able to access it - this also blocks a restricted user from
diff --git a/api/v1/networks/network_model.php b/api/v1/networks/network_model.php
index 4f8800e8d..96a87173c 100644
--- a/api/v1/networks/network_model.php
+++ b/api/v1/networks/network_model.php
@@ -80,4 +80,7 @@ if (isset($_POST['network_location_id'])) {
$location_id = intval($network_row['network_location_id']);
} else {
$location_id = 0;
-}
\ No newline at end of file
+}
+
+// Only one of the client's own sites, as in the network form
+$location_id = assetClientOwnedId('location', $location_id, $client_id);
diff --git a/api/v1/validate_api_key.php b/api/v1/validate_api_key.php
index 2ad732ad3..a1c3acf23 100644
--- a/api/v1/validate_api_key.php
+++ b/api/v1/validate_api_key.php
@@ -22,7 +22,7 @@ $_POST = json_decode(file_get_contents('php://input'), true);
// Get IP & UA
$ip = escapeSql(getIP());
-$user_agent = escapeSql($_SERVER['HTTP_USER_AGENT']);
+$user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? '');
// Temp Added this to work with the new logAction function
$session_ip = $ip;
diff --git a/client/join.php b/client/join.php
index 302685ea1..f06698870 100644
--- a/client/join.php
+++ b/client/join.php
@@ -36,7 +36,8 @@ $company_name = (string) ($company['company_name'] ?? '');
$methods = portalSignInMethods();
$invite_id = intval($_POST['invite'] ?? ($_GET['invite'] ?? 0));
-$token = (string) ($_POST['token'] ?? ($_GET['token'] ?? ''));
+$token = $_POST['token'] ?? ($_GET['token'] ?? '');
+$token = is_string($token) ? $token : '';
$link = portalInviteByLink($invite_id, $token);
$invite = $link['row'];
$error = '';
diff --git a/client/login_microsoft.php b/client/login_microsoft.php
index 2ab4a1f77..6728cd09c 100644
--- a/client/login_microsoft.php
+++ b/client/login_microsoft.php
@@ -13,7 +13,7 @@ require_once __DIR__ . "/../includes/session_init.php";
require_once "../includes/inc_set_timezone.php";
$session_ip = escapeSql(getIP());
-$session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT']);
+$session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? '');
$sql_settings = mysqli_query($mysqli, "SELECT config_azure_client_id, config_azure_client_secret FROM settings WHERE company_id = 1");
$settings = mysqli_fetch_assoc($sql_settings);
diff --git a/functions/contract_signature.php b/functions/contract_signature.php
index a9f30c7c2..a11073c75 100644
--- a/functions/contract_signature.php
+++ b/functions/contract_signature.php
@@ -308,7 +308,7 @@ function contractSignatureRow($contract_signature_id) {
/** The round a link opens - its id and key, matched exactly - or null. */
function contractSignatureByKey($contract_signature_id, $key) {
- $key = (string) $key;
+ $key = is_string($key) ? $key : '';
$round = contractSignatureRow($contract_signature_id);
if (!$round || $key === '' || (string) $round['contract_signature_url_key'] === '' || !hash_equals((string) $round['contract_signature_url_key'], $key)) {
return null;
diff --git a/functions/export.php b/functions/export.php
index 2208585f1..dfd2e01c5 100644
--- a/functions/export.php
+++ b/functions/export.php
@@ -564,6 +564,18 @@ function isExportRequest($trigger) {
return isset($_POST[$trigger]) && in_array($_POST[$trigger], ['csv', 'pdf'], true);
}
+/*
+ * Filters that match nothing say so and go back to the list - the export form
+ * posts straight to post.php, so returning nothing left a blank page there.
+ * Call it once the row count is known, before beginExport().
+ */
+function guardExportHasRows($num_rows, $what) {
+ if ($num_rows < 1) {
+ flashAlert("No $what match - nothing to export.", 'error');
+ redirect();
+ }
+}
+
/*
* PDF is capped - see EXPORT_PDF_MAX_ROWS. Call this after the row count is known
* and before beginExport(); it redirects rather than returning on refusal.
@@ -751,6 +763,32 @@ function formatExportDateRange($date_from, $date_to) {
return $date_from . ' to ' . $date_to;
}
+/*
+ * The name of a client an export is filtered to, for its summary - only a
+ * client this agent can reach; '' otherwise, so a made-up filter can't be
+ * used to read another client's name (the rows are scoped all the same).
+ */
+function exportClientName($client_id) {
+ $client_id = intval($client_id);
+ return $client_id && hasClientAccess($client_id) ? (string) getFieldById('clients', $client_id, 'client_name') : '';
+}
+
+/**
+ * The same for a record an export is filtered to (a site, a project, a vendor) - its $name_column
+ * when it belongs to no client (the company's own, which every agent sees) or to one this agent can reach.
+ */
+function exportRecordName($table, $id, $name_column, $client_column) {
+ $id = intval($id);
+ if (!$id) {
+ return '';
+ }
+ $client_id = getFieldById($table, $id, $client_column);
+ if ($client_id === null || $client_id === false || $client_id === '' || (intval($client_id) && !hasClientAccess($client_id))) {
+ return '';
+ }
+ return (string) getFieldById($table, $id, $name_column);
+}
+
/*
* Human-readable one-liner of the same thing, for the PDF subtitle.
*/
diff --git a/functions/portal_invite.php b/functions/portal_invite.php
index e31898cf5..3a928d7c8 100644
--- a/functions/portal_invite.php
+++ b/functions/portal_invite.php
@@ -217,7 +217,7 @@ function portalInviteNewToken($invite_id) {
* client archived since): ['row' => row or null, 'state' => ...].
*/
function portalInviteByLink($invite_id, $token) {
- $token = (string) $token;
+ $token = is_string($token) ? $token : '';
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
return ['row' => null, 'state' => 'gone'];
}
diff --git a/functions/request.php b/functions/request.php
index 6725c5817..2b76de0c9 100644
--- a/functions/request.php
+++ b/functions/request.php
@@ -98,7 +98,7 @@ function getOS($user_os) {
function isMobile()
{
// Check if the user agent is a mobile device
- return preg_match('/(android|avantgo|blackberry|bolt|boost|cricket|docomo|fone|hiptop|mini|opera mini|palm|phone|pie|tablet|up.browser|up.link|webos|wos)/i', $_SERVER['HTTP_USER_AGENT']);
+ return preg_match('/(android|avantgo|blackberry|bolt|boost|cricket|docomo|fone|hiptop|mini|opera mini|palm|phone|pie|tablet|up.browser|up.link|webos|wos)/i', $_SERVER['HTTP_USER_AGENT'] ?? '');
}
// Redirect Function
diff --git a/functions/settings.php b/functions/settings.php
index 1f3880543..c99d9a8eb 100644
--- a/functions/settings.php
+++ b/functions/settings.php
@@ -917,7 +917,7 @@ function settingsNumberingFields($kind, $prefix, $number) {
ob_start(); ?>
= settingsInput($numbering['prefix'], $prefix, ['icon' => 'fa-barcode', 'required' => true, 'maxlength' => 200, 'label' => 'Prefix',
- 'pattern' => $kind === 'ticket' ? '[A-Za-z-]+' : '[^\s]+', 'title' => $kind === 'ticket' ? 'Only letters and hyphens' : 'No spaces']) ?>
+ 'pattern' => $kind === 'ticket' ? '[A-Za-z\\-]+' : '[^\s]+', 'title' => $kind === 'ticket' ? 'Only letters and hyphens' : 'No spaces']) ?>
= settingsInput($numbering['number'], $number, ['icon' => 'fa-hashtag', 'type' => 'number', 'required' => true, 'min' => 1, 'label' => 'Next number']) ?>
This contract link isn\'t working It may have been mistyped, or the contract deleted. Ask whoever sent it for a new link.
';
diff --git a/guest/guest_view_quote.php b/guest/guest_view_quote.php
index 9378978b7..10fc0ecab 100644
--- a/guest/guest_view_quote.php
+++ b/guest/guest_view_quote.php
@@ -77,16 +77,19 @@ $paper = [
Good until = escapeHtml($quote['quote_expire']) ?>= $days_left === 0 ? ' - today is the last day' : ' - ' . $days_left . ' day' . ($days_left === 1 ? '' : 's') . ' left' ?>.
Let us know whether to go ahead.
-
+
+
diff --git a/guest/includes/inc_all_guest.php b/guest/includes/inc_all_guest.php
index 5bb9505ab..860bfd884 100644
--- a/guest/includes/inc_all_guest.php
+++ b/guest/includes/inc_all_guest.php
@@ -10,7 +10,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/session_init.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/inc_set_timezone.php';
$ip = escapeSql(getIP());
-$user_agent = escapeSql($_SERVER['HTTP_USER_AGENT']);
+$user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? '');
$os = escapeSql(getOS($user_agent));
$browser = escapeSql(getWebBrowser($user_agent));
diff --git a/includes/load_user_session.php b/includes/load_user_session.php
index 9847140da..569cebdba 100644
--- a/includes/load_user_session.php
+++ b/includes/load_user_session.php
@@ -1,7 +1,7 @@