Files
itflow/client/file.php
johnnyq e29369926f Client portal stage 4: accounts - Your account rebuilt with details and an ask-us link, phone numbers, ticket email choices, password change needing the current password and 8 characters with an email notice, two-step sign-in with an authenticator app set up from a QR code, remembered browsers with Forget them, phone PIN and recent sign-ins with the device; activity rebuilt with signing-in and changes tabs and paging; sign-in asks portal users with two-step sign-in for a code, remembers the browser on request and locks an account after five wrong codes in ten minutes, matching portal users by contact_user_id; Admin > Client Portal can require two-step sign-in, sending anyone without it to set it up first, and agents can turn it off for a contact from the contact page; password links stored as SHA-256 and running out after an hour for Forgot password and a week for invites, the nightly run clearing only run-out links, Forgot password limited to one email per account every five minutes and ten requests per address in ten minutes, the reset page rebuilt with the password typed twice and a confirmation email, fixing the empty-token check that let anyone trigger the reset email; contacts choose every ticket email or replies and fixes only, and primary and technical contacts can hear about new tickets at their company or every update, honoured by agent, portal, email parser and recurring ticket emails; announcements for every client or one with tone and start and end dates on the portal home page, managed in Admin > Client Portal; View as contact opens the portal read only as a contact from the agent contact page, audited, with billing shown only when the agent's role reads sales and financial, nothing written or paid while viewing; agent contact page shows two-step status, last sign-in and ticket email choices; database 3.1.0 adds portal_announcements, contact ticket email columns and config_client_portal_require_mfa and hashes outstanding links; new functions/portal.php; changelog updated
2026-10-17 12:05:55 -04:00

126 lines
4.3 KiB
PHP

<?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 (!contactCan('itdoc')) {
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 = getInlineViewableMimeTypes();
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 - not for an agent viewing as the contact
if (!portalViewingAs()) {
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;