Files
itflow/client/ticket_attachment.php
johnnyq c6eadf3fa3 Client portal stage 1, look, home and tickets: a new light shell with the company logo or initials on the theme colour, sections shown by what each contact may open (Home, Tickets, Billing for billing contacts, Your IT for technical contacts, custom links), New ticket and an account menu with a dark mode switch kept in a cookie, a bottom tab bar and slide-in menu on phones, and the welcome banner removed; a rebuilt home showing what needs the contact (tickets waiting on their reply or to confirm, approvals, past-due invoices), open tickets, balance with Pay invoices, renewals in the next 45 days, their devices with warranty status and the company phone and email; tickets rebuilt with Open, Waiting on you, Resolved, Closed and All tabs with counts, search, paging and the whole company's tickets for primary and technical contacts with a Mine filter, the old all-tickets page redirecting there; a ticket page with progress steps, the conversation oldest first, approvals, details alongside, replies with drag and drop attachments, It's fixed, close or reopen and rating; New Ticket with priority, category, device and attachments; fixes downloading files from internal notes, files on closed tickets not downloading, new tickets linking to another client's device or a non-ticket category, empty replies and tickets being accepted, PHP warnings on Pay invoices and Recurring invoices with no payment provider, and Bootstrap icons and the editor setup blocked by the portal CSP, which now allows data: images; new js/portal.js and portal helpers in client/functions.php, ticketPortalStatusHtml removed; changelog updated
2026-10-17 01:28:27 -04:00

115 lines
4.3 KiB
PHP

<?php
/*
* ITFlow - GET /client/ticket_attachment.php
* Client Portal - streams a ticket attachment for download or inline
* viewing without exposing the uploads directory on-disk location
*/
require_once '../config.php';
require_once '../includes/load_global_settings.php';
require_once '../functions.php';
require_once 'includes/check_login.php';
require_once 'functions.php';
// Require an attachment ID
if (!isset($_GET['attachment_id'])) {
http_response_code(400);
exit("Attachment ID required");
}
$attachment_id = intval($_GET['attachment_id']);
// Disposition: download by default, inline only when explicitly requested
$disposition = "attachment";
if (isset($_GET['action']) && $_GET['action'] == "view") {
$disposition = "inline";
}
// Look up the attachment - one on the ticket itself, or on a reply the client
// can read: never one on an internal note, or on a reply that was archived
$sql = mysqli_query($mysqli, "SELECT ticket_attachments.* FROM ticket_attachments
LEFT JOIN ticket_replies ON ticket_reply_id = ticket_attachment_reply_id
WHERE ticket_attachment_id = $attachment_id
AND (ticket_attachment_reply_id IS NULL OR (ticket_reply_type != 'Internal' AND ticket_reply_archived_at IS NULL))
LIMIT 1");
if (mysqli_num_rows($sql) !== 1) {
http_response_code(404);
exit("Attachment not found");
}
$row = mysqli_fetch_array($sql);
$ticket_id = intval($row['ticket_attachment_ticket_id']);
$attachment_name = $row['ticket_attachment_name'];
$attachment_name_escaped = escapeSql($row['ticket_attachment_name']);
$attachment_reference_name = $row['ticket_attachment_reference_name'];
// Verify the contact can access this ticket (any state - attachments
// on closed tickets remain downloadable)
// 404 (not 403) so out-of-scope attachments are indistinguishable from
// nonexistent ones
if (!verifyContactTicketAccess($ticket_id, "Any")) {
http_response_code(404);
exit("Attachment not found");
}
// Build the on-disk path
$uploads_base = realpath(__DIR__ . "/../uploads");
$file_path = realpath(__DIR__ . "/../uploads/tickets/$ticket_id/$attachment_reference_name");
// Path traversal guard - resolved path must stay inside uploads
if ($file_path === false || $uploads_base === false || strpos($file_path, $uploads_base) !== 0) {
http_response_code(404);
exit("Attachment not found");
}
// No MIME column on ticket_attachments - detect from file content.
// Attachments may come from email senders, so never trust a declared type.
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$attachment_mime_type = finfo_file($finfo, $file_path);
finfo_close($finfo);
// MIME types that are safe to render inline in the browser
// Everything else (esp. HTML/SVG - stored XSS risk) falls back to download
$inline_allowed_mime_types = getInlineViewableMimeTypes();
if ($disposition == "inline" && !in_array($attachment_mime_type, $inline_allowed_mime_types, true)) {
$disposition = "attachment";
}
// Strip characters that would break the Content-Disposition header
$safe_attachment_name = str_replace(array('"', "\r", "\n"), "", basename($attachment_name));
// Caching - allow the browser to reuse the file privately and revalidate cheaply
$file_mtime = filemtime($file_path);
$etag = '"' . md5($file_mtime . filesize($file_path) . $attachment_id) . '"';
header("Cache-Control: private, max-age=3600");
header("ETag: $etag");
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $file_mtime) . " GMT");
// Conditional GET - browser already has this version, send 304 and stop
if ((isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) == $etag)
|| (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $file_mtime)
) {
http_response_code(304);
exit;
}
// Audit log
logAudit("Ticket", "Download", "Client contact $session_contact_name viewed ticket attachment $attachment_name_escaped via portal", $session_client_id);
// Send the file
header("Content-Type: $attachment_mime_type");
header("Content-Disposition: $disposition; filename=\"$safe_attachment_name\"");
header("Content-Length: " . filesize($file_path));
header("X-Content-Type-Options: nosniff");
// Clear output buffers so large files stream instead of loading into memory
while (ob_get_level()) {
ob_end_clean();
}
readfile($file_path);
exit;