Add secure file handler for client ticket attachment and file view along with client and contact permission isolation, add the handler links in asset and contact

This commit is contained in:
johnnyq
2026-07-15 18:35:32 -04:00
parent c8f616ebee
commit 3838d052f0
9 changed files with 300 additions and 25 deletions

View File

@@ -941,7 +941,6 @@ if (isset($_GET['asset_id'])) {
$file_id = intval($row['file_id']);
$file_name = escapeHtml($row['file_name']);
$file_description = escapeHtml($row['file_description']);
$file_reference_name = escapeHtml($row['file_reference_name']);
$file_ext = escapeHtml($row['file_ext']);
if ($file_ext == 'pdf') {
$file_icon = "file-pdf";
@@ -972,7 +971,7 @@ if (isset($_GET['asset_id'])) {
?>
<tr>
<td><a class="text-dark" href="<?= "../uploads/clients/$client_id/$file_reference_name"; ?>" target="_blank" ><?= "$file_name<br><span class='text-secondary'>$file_description</span>"; ?></a></td>
<td><a class="text-dark" href="file.php?file_id=<?= $file_id; ?>&action=view" target="_blank" ><?= "$file_name<br><span class='text-secondary'>$file_description</span>"; ?></a></td>
<td><?= $file_created_at; ?></td>
<td class="text-center">
<a href="post.php?unlink_asset_from_file&asset_id=<?= $asset_id; ?>&file_id=<?= $file_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-secondary btn-sm" title="Unlink"><i class="fas fa-fw fa-unlink"></i></a>

View File

@@ -1047,7 +1047,6 @@ if (isset($_GET['contact_id'])) {
$file_description = escapeHtml($row['file_description']);
$file_size = escapeHtml($row['file_size']);
$file_size_KB = round($file_size / 1024);
$file_reference_name = escapeHtml($row['file_reference_name']);
$file_mime_type = escapeHtml($row['file_mime_type']);
$file_created_at = escapeHtml($row['file_created_at']);
@@ -1057,7 +1056,7 @@ if (isset($_GET['contact_id'])) {
<tr>
<td>
<div><a href="../uploads/clients/<?php echo $client_id; ?>/<?php echo $file_reference_name; ?>"><?php echo $file_name; ?></a></div>
<div><a href="file.php?file_id=<?= $file_id; ?>&action=view" target="_blank"><?= $file_name; ?></a></div>
<div class="text-secondary"><?php echo $file_description; ?></div>
</td>
<td><?php echo $file_mime_type; ?></td>

View File

@@ -225,7 +225,7 @@ $page_title = $row['document_name'];
?>
<div class="ml-2">
<a href="files.php?client_id=<?= $client_id ?>&folder_id=<?= $folder_id ?>&q=<?= $file_name ?>" target="_blank"><?= $file_name ?></a>
<a href="file.php?file_id=<?= $file_id ?>&action=view" target="_blank"><?= $file_name ?></a>
<a class="confirm-link" href="post.php?unlink_file_from_document&file_id=<?= $file_id ?>&document_id=<?= $document_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-unlink text-secondary float-right" title="Unlink File"></i>
</a>

View File

@@ -220,8 +220,6 @@ if (isset($_GET['client_id'])) {
$client_url = '';
}
enforceClientAccess();
ob_start();
?>
@@ -863,7 +861,6 @@ ob_start();
$file_name = escapeHtml($row['file_name']);
$file_mime_type = escapeHtml($row['file_mime_type']);
$file_description = escapeHtml($row['file_description']);
$file_reference_name = escapeHtml($row['file_reference_name']);
$file_ext = escapeHtml($row['file_ext']);
if ($file_ext == 'pdf') {
$file_icon = "file-pdf";
@@ -891,7 +888,11 @@ ob_start();
$file_created_at = escapeHtml($row['file_created_at']);
?>
<tr>
<td><a class="text-dark" href="<?php echo "../uploads/clients/$client_id/$file_reference_name"; ?>" target="_blank" ><?php echo "$file_name<br><span class='text-secondary'>$file_description</span>"; ?></a></td>
<td>
<a class="text-dark" href="file.php?file_id=<?=$file_id ?>&action=view" target="_blank" >
<?php echo "$file_name<br><span class='text-secondary'>$file_description</span>"; ?>
</a>
</td>
<td><?php echo $file_mime_type; ?></td>
<td><?php echo $file_created_at; ?></td>
</tr>

View File

@@ -821,11 +821,11 @@ ob_start();
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql_linked_files)) {
$file_id = intval($row['file_id']);
$file_name = escapeHtml($row['file_name']);
$file_description = escapeHtml($row['file_description']);
$file_size = escapeHtml($row['file_size']);
$file_size_KB = round($file_size / 1024);
$file_reference_name = escapeHtml($row['file_reference_name']);
$file_mime_type = escapeHtml($row['file_mime_type']);
$file_created_at = escapeHtml($row['file_created_at']);
@@ -833,7 +833,9 @@ ob_start();
?>
<tr>
<td>
<div><a href="../uploads/clients/<?= $client_id ?>/<?= $file_reference_name ?>" target="_blank"><?= $file_name ?></a></div>
<div>
<a href="file.php?file_id=<?= $file_id ?>&action=view" target="_blank"><?= $file_name ?></a>
</div>
<div class="text-secondary"><?= $file_description ?></div>
</td>
<td><?= $file_mime_type ?></td>

View File

@@ -76,13 +76,13 @@ if (mysqli_num_rows($sql_files) > 0) {
$file_row = mysqli_fetch_assoc($sql_files);
$file_id = intval($file_row['file_id']);
$file_name = escapeHtml($file_row['file_name']);
$file_reference_name = escapeHtml($file_row['file_reference_name']);
$file_ext = strtolower($file_row['file_ext']);
$file_size = intval($file_row['file_size']);
$file_mime_type = escapeHtml($file_row['file_mime_type']);
$file_size_formatted = formatBytes($file_size);
$file_path = "../uploads/clients/$session_client_id/$file_reference_name";
$file_view_url = "file.php?file_id=$file_id&action=view";
$file_download_url = "file.php?file_id=$file_id";
// For PDF files, display them inline
if ($file_ext == 'pdf') {
@@ -97,17 +97,17 @@ if (mysqli_num_rows($sql_files) > 0) {
<?php } ?>
</div>
<div class="col-auto">
<a href="<?php echo $file_path; ?>" target="_blank" class="btn btn-primary">
<a href="<?php echo $file_view_url; ?>" target="_blank" class="btn btn-primary">
<i class="fas fa-external-link-alt mr-2"></i>Open in New Tab
</a>
<a href="<?php echo $file_path; ?>" download="<?php echo $file_name; ?>" class="btn btn-secondary">
<a href="<?php echo $file_download_url; ?>" class="btn btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
</div>
</div>
</div>
<div class="card-body p-0">
<embed src="<?php echo $file_path; ?>" type="application/pdf" width="100%" height="800px" />
<embed src="<?php echo $file_view_url; ?>" type="application/pdf" width="100%" height="800px" />
</div>
</div>
<?php
@@ -125,17 +125,17 @@ if (mysqli_num_rows($sql_files) > 0) {
<?php } ?>
</div>
<div class="col-auto">
<a href="<?php echo $file_path; ?>" target="_blank" class="btn btn-primary">
<a href="<?php echo $file_view_url; ?>" target="_blank" class="btn btn-primary">
<i class="fas fa-external-link-alt mr-2"></i>View Full Size
</a>
<a href="<?php echo $file_path; ?>" download="<?php echo $file_name; ?>" class="btn btn-secondary">
<a href="<?php echo $file_download_url; ?>" class="btn btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
</div>
</div>
</div>
<div class="card-body text-center">
<img src="<?php echo $file_path; ?>" alt="<?php echo $file_name; ?>" class="img-fluid" style="max-height: 600px;">
<img src="<?php echo $file_view_url; ?>" alt="<?php echo $file_name; ?>" class="img-fluid" style="max-height: 600px;">
</div>
</div>
<?php
@@ -154,10 +154,10 @@ if (mysqli_num_rows($sql_files) > 0) {
<?php } ?>
</div>
<div class="col-auto">
<a href="<?php echo $file_path; ?>" target="_blank" class="btn btn-primary">
<a href="<?php echo $file_download_url; ?>" class="btn btn-primary">
<i class="fas fa-external-link-alt mr-2"></i>Open File
</a>
<a href="<?php echo $file_path; ?>" download="<?php echo $file_name; ?>" class="btn btn-secondary">
<a href="<?php echo $file_download_url; ?>" class="btn btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
</div>

131
client/file.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
/*
* ITFlow - GET /client/file.php
* Client Portal - streams a document-attached file for download or
* inline viewing without exposing the uploads directory location
*
* Files are only client-accessible through their link to a visible,
* unarchived document - matching the scoping in client/document.php
*/
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';
// Documents section is for primary / technical contacts only -
// same gate as client/document.php
if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) {
http_response_code(404);
exit("File not found");
}
// Require a file ID
if (!isset($_GET['file_id'])) {
http_response_code(400);
exit("File ID required");
}
$file_id = intval($_GET['file_id']);
// Disposition: download by default, inline only when explicitly requested
$disposition = "attachment";
if (isset($_GET['action']) && $_GET['action'] == "view") {
$disposition = "inline";
}
// Look up the file, enforcing the full visibility chain in the query:
// the file must belong to this client AND be attached to a document
// that is client-visible, this client's, and not archived.
// An out-of-scope file is indistinguishable from a nonexistent one.
$sql = mysqli_query($mysqli,
"SELECT f.file_name, f.file_reference_name
FROM files f
INNER JOIN document_files df ON f.file_id = df.file_id
INNER JOIN documents d ON df.document_id = d.document_id
WHERE f.file_id = $file_id
AND f.file_client_id = $session_client_id
AND d.document_client_id = $session_client_id
AND d.document_client_visible = 1
AND d.document_archived_at IS NULL
LIMIT 1"
);
if (mysqli_num_rows($sql) !== 1) {
http_response_code(404);
exit("File not found");
}
$row = mysqli_fetch_array($sql);
$file_name = $row['file_name'];
$file_name_escaped = escapeSql($row['file_name']);
$file_reference_name = $row['file_reference_name'];
// Build the on-disk path
$uploads_base = realpath(__DIR__ . "/../uploads");
$file_path = realpath(__DIR__ . "/../uploads/clients/$session_client_id/$file_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("File not found");
}
// Detect MIME from file content - files can be uploaded by portal
// contacts, so the stored MIME type is not trustworthy
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$file_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 = array(
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain"
);
if ($disposition == "inline" && !in_array($file_mime_type, $inline_allowed_mime_types, true)) {
$disposition = "attachment";
}
// Strip characters that would break the Content-Disposition header
$safe_file_name = str_replace(array('"', "\r", "\n"), "", basename($file_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) . $file_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("File", "Download", "Client contact $session_contact_name viewed file $file_name_escaped via portal", $session_client_id);
// Send the file
header("Content-Type: $file_mime_type");
header("Content-Disposition: $disposition; filename=\"$safe_file_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;

View File

@@ -127,13 +127,26 @@ if (isset($_GET['id']) && intval($_GET['id'])) {
<hr>
<?php echo $ticket_details ?>
<table class="table-sm">
<?php
while ($ticket_attachment = mysqli_fetch_assoc($sql_ticket_attachments)) {
$ticket_attachment_id = intval($ticket_attachment['ticket_attachment_id']);
$name = escapeHtml($ticket_attachment['ticket_attachment_name']);
$ref_name = escapeHtml($ticket_attachment['ticket_attachment_reference_name']);
echo "<hr><i class='fas fa-fw fa-paperclip text-secondary mr-1'></i>$name | <a href='../uploads/tickets/$ticket_id/$ref_name' download='$name'><i class='fas fa-fw fa-download mr-1'></i>Download</a> | <a target='_blank' href='../uploads/tickets/$ticket_id/$ref_name'><i class='fas fa-fw fa-external-link-alt mr-1'></i>View</a>";
?>
<tr>
<td><i class='fas fa-fw fa-paperclip text-secondary mr-1'></i><?= $name ?></td>
<td>
<a target='_blank' class='mr-1 ml-1' href='ticket_attachment.php?attachment_id=<?= $ticket_attachment_id; ?>&action=view'>[View]</a><a href='ticket_attachment.php?attachment_id=<?= $ticket_attachment_id; ?>'>[Download]</a>
</td>
</tr>
<?php
}
?>
</table>
</div>
</div>
@@ -319,13 +332,26 @@ if (isset($_GET['id']) && intval($_GET['id'])) {
<div class="card-body prettyContent">
<?php echo $ticket_reply; ?>
<table class="table-sm">
<?php
while ($ticket_attachment = mysqli_fetch_assoc($sql_ticket_reply_attachments)) {
$ticket_attachment_id = intval($ticket_attachment['ticket_attachment_id']);
$name = escapeHtml($ticket_attachment['ticket_attachment_name']);
$ref_name = escapeHtml($ticket_attachment['ticket_attachment_reference_name']);
echo "<hr><i class='fas fa-fw fa-paperclip text-secondary mr-1'></i>$name | <a href='../uploads/tickets/$ticket_id/$ref_name' download='$name'><i class='fas fa-fw fa-download mr-1'></i>Download</a> | <a target='_blank' href='../uploads/tickets/$ticket_id/$ref_name'><i class='fas fa-fw fa-external-link-alt mr-1'></i>View</a>";
?>
<tr>
<td><i class='fas fa-fw fa-paperclip text-secondary mr-1'></i><?= $name ?></td>
<td>
<a target='_blank' class='mr-1 ml-1' href='ticket_attachment.php?attachment_id=<?= $ticket_attachment_id; ?>&action=view'>[View]</a><a href='ticket_attachment.php?attachment_id=<?= $ticket_attachment_id; ?>'>[Download]</a>
</td>
</tr>
<?php
}
?>
</table>
</div>
</div>

View File

@@ -0,0 +1,117 @@
<?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
$sql = mysqli_query($mysqli, "SELECT * FROM ticket_attachments WHERE ticket_attachment_id = $attachment_id 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 = array(
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain"
);
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;