From 7c8c93c01c5d4d686fef253a3c86b7f6095acd26 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 29 Jul 2026 17:58:04 -0400 Subject: [PATCH] Feature allow agents to attach files to tickets in the app --- agent/modals/ticket/ticket_add.php | 7 ++- agent/post/ticket.php | 53 +++++++++++++++++++ agent/ticket.php | 12 +++-- client/post.php | 38 +------------- functions/files.php | 83 ++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 40 deletions(-) diff --git a/agent/modals/ticket/ticket_add.php b/agent/modals/ticket/ticket_add.php index 51934ce1..17401afa 100644 --- a/agent/modals/ticket/ticket_add.php +++ b/agent/modals/ticket/ticket_add.php @@ -20,7 +20,7 @@ ob_start(); × -
+ @@ -203,6 +203,11 @@ ob_start(); +
+ + +
+
diff --git a/agent/post/ticket.php b/agent/post/ticket.php index 76c0a3fd..beb75f41 100644 --- a/agent/post/ticket.php +++ b/agent/post/ticket.php @@ -89,6 +89,9 @@ if (isset($_POST['add_ticket'])) { addTasksFromTicketTemplate($ticket_id, $ticket_template_id); } + // Store any attached files against the ticket itself + saveTicketAttachments($ticket_id, null); + // Add Watchers if (isset($_POST['watchers'])) { foreach ($_POST['watchers'] as $watcher) { @@ -1957,12 +1960,62 @@ if (isset($_POST['add_ticket_reply'])) { flashAlert("Ticket updated"); } + // Store any attached files. They hang off the reply when there is one, and off + // the ticket itself when a file was uploaded without any accompanying text. + saveTicketAttachments($ticket_id, $ticket_reply_id ?: null); + logAudit("Ticket", "Reply", "$session_name replied to ticket $ticket_prefix$ticket_number - $ticket_subject and was a $ticket_reply_type reply", $client_id, $ticket_id); redirect(); } +if (isset($_GET['delete_ticket_attachment'])) { + + validateCSRFToken(); + + enforceUserPermission('module_support', 3); + + $attachment_id = intval($_GET['delete_ticket_attachment']); + + $sql = mysqli_query($mysqli, "SELECT ticket_attachment_name, ticket_attachment_reference_name, ticket_attachment_ticket_id FROM ticket_attachments WHERE ticket_attachment_id = $attachment_id LIMIT 1"); + + if (mysqli_num_rows($sql) !== 1) { + flashAlert("Attachment not found", 'error'); + redirect(); + } + + $row = mysqli_fetch_assoc($sql); + $attachment_name = escapeSql($row['ticket_attachment_name']); + $attachment_reference_name = $row['ticket_attachment_reference_name']; + $ticket_id = intval($row['ticket_attachment_ticket_id']); + + $client_id = intval(getFieldById('tickets', $ticket_id, 'ticket_client_id')); + + // Don't Enforce Client Access if Ticket doesn't have an assigned client + if ($client_id) { + enforceClientAccess(); + } + + // Resolve the path and confirm it is still inside uploads before unlinking, + // the same guard the download endpoint applies + $uploads_base = realpath(__DIR__ . "/../../uploads"); + $file_path = realpath(__DIR__ . "/../../uploads/tickets/$ticket_id/$attachment_reference_name"); + + if ($file_path !== false && $uploads_base !== false && strpos($file_path, $uploads_base) === 0) { + unlink($file_path); + } + + mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_id = $attachment_id"); + + logAudit("Ticket", "Delete", "$session_name deleted ticket attachment $attachment_name", $client_id, $ticket_id); + + flashAlert("Attachment $attachment_name deleted", 'error'); + + redirect(); + +} + if (isset($_POST['edit_ticket_reply'])) { validateCSRFToken(); diff --git a/agent/ticket.php b/agent/ticket.php index 063330de..6c44647a 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -596,7 +596,7 @@ if (isset($_GET['ticket_id'])) { - [View][Download] + [View][Download]= 3) { ?>'>[Delete] @@ -611,7 +611,7 @@ if (isset($_GET['ticket_id'])) { = 2 && empty($ticket_resolved_at) && empty($ticket_closed_at)) { ?> - + @@ -642,6 +642,12 @@ if (isset($_GET['ticket_id'])) {
+
+ + + Stored against this reply, or against the ticket itself if you submit without any text. Attachments are not included in the emailed copy of a public reply. +
+
@@ -830,7 +836,7 @@ if (isset($_GET['ticket_id'])) { - [View][Download] + [View][Download]= 3) { ?>'>[Delete] diff --git a/client/post.php b/client/post.php index 0fbf969f..4b64d9f6 100644 --- a/client/post.php +++ b/client/post.php @@ -142,42 +142,8 @@ if (isset($_POST['add_ticket_comment'])) { } - // Store any attached any files - if (!empty($_FILES)) { - - // Define & create directories, as required - mkdirMissing('../uploads/tickets/'); - $upload_file_dir = "../uploads/tickets/" . $ticket_id . "/"; - mkdirMissing($upload_file_dir); - - for ($i = 0; $i < count($_FILES['file']['name']); $i++) { - // Extract file details for this iteration - $single_file = [ - 'name' => $_FILES['file']['name'][$i], - 'type' => $_FILES['file']['type'][$i], - 'tmp_name' => $_FILES['file']['tmp_name'][$i], - 'error' => $_FILES['file']['error'][$i], - 'size' => $_FILES['file']['size'][$i] - ]; - - if ($ticket_attachment_ref_name = checkFileUpload($single_file, array('jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx', 'odt', 'csv', 'xls', 'xlsx', 'ods', 'pptx', 'odp', 'zip', 'tar', 'gz', 'xml', 'msg', 'json', 'wav', 'mp3', 'ogg', 'mov', 'mp4', 'av1', 'ovpn'))) { - - $file_tmp_path = $_FILES['file']['tmp_name'][$i]; - - $file_name = escapeSql($_FILES['file']['name'][$i]); - $extarr = explode('.', $_FILES['file']['name'][$i]); - $file_extension = escapeSql(strtolower(end($extarr))); - - // Define destination file path - $dest_path = $upload_file_dir . $ticket_attachment_ref_name; - - move_uploaded_file($file_tmp_path, $dest_path); - - mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$file_name', ticket_attachment_reference_name = '$ticket_attachment_ref_name', ticket_attachment_reply_id = $ticket_reply_id, ticket_attachment_ticket_id = $ticket_id"); - } - - } - } + // Store any attached files against this reply + saveTicketAttachments($ticket_id, $ticket_reply_id, 'file'); // Custom action/notif handler triggerCustomAction('ticket_reply_client', $ticket_id); diff --git a/functions/files.php b/functions/files.php index 202a493e..93d67f60 100644 --- a/functions/files.php +++ b/functions/files.php @@ -166,3 +166,86 @@ function cleanupUnusedImages(string $html, string $folderFsPath, string $folderW } } } + +/* + * Ticket attachment uploads + * + * Shared by the agent ticket page, the new ticket modal and the client portal + * reply form, so the allowed extension list has one definition rather than one + * per caller. + * + * Files land in uploads/tickets// under an unguessable reference name + * and are only ever served back through the ticket_attachment.php endpoints, + * which re-check permissions and force a safe Content-Type. + * + * Pass $reply_id to attach to a specific reply, or null to attach to the ticket + * itself - the ticket page reads reply_id IS NULL as "belongs to the ticket". + * + * Returns the number of files stored. Anything the extension allow-list or + * checkFileUpload() rejects is skipped silently, as it always has been. + */ +function saveTicketAttachments($ticket_id, $reply_id = null, $field_name = 'attachments') { + + global $mysqli; + + $ticket_id = intval($ticket_id); + + if (!$ticket_id || empty($_FILES[$field_name]) || !isset($_FILES[$field_name]['name'])) { + return 0; + } + + $allowed_extensions = array( + 'jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx', + 'odt', 'csv', 'xls', 'xlsx', 'ods', 'pptx', 'odp', 'zip', 'tar', 'gz', + 'xml', 'msg', 'json', 'wav', 'mp3', 'ogg', 'mov', 'mp4', 'av1', 'ovpn' + ); + + // A single-file input posts scalars, a multiple one posts arrays - normalize + $names = $_FILES[$field_name]['name']; + if (!is_array($names)) { + return 0; + } + + mkdirMissing('../uploads/tickets/'); + $upload_file_dir = "../uploads/tickets/" . $ticket_id . "/"; + mkdirMissing($upload_file_dir); + + if ($reply_id === null) { + $reply_id_sql = 'NULL'; + } else { + $reply_id_sql = intval($reply_id); + } + + $files_stored = 0; + + for ($i = 0; $i < count($names); $i++) { + + $single_file = [ + 'name' => $_FILES[$field_name]['name'][$i], + 'type' => $_FILES[$field_name]['type'][$i], + 'tmp_name' => $_FILES[$field_name]['tmp_name'][$i], + 'error' => $_FILES[$field_name]['error'][$i], + 'size' => $_FILES[$field_name]['size'][$i] + ]; + + $attachment_reference_name = checkFileUpload($single_file, $allowed_extensions); + + if (!$attachment_reference_name) { + continue; + } + + $destination_path = $upload_file_dir . $attachment_reference_name; + + if (!move_uploaded_file($single_file['tmp_name'], $destination_path)) { + continue; + } + + $attachment_name = escapeSql($single_file['name']); + + mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$attachment_name', ticket_attachment_reference_name = '$attachment_reference_name', ticket_attachment_reply_id = $reply_id_sql, ticket_attachment_ticket_id = $ticket_id"); + + $files_stored++; + } + + return $files_stored; +}