loadHTML('' . $html);
libxml_clear_errors();
$imgs = $dom->getElementsByTagName('img');
foreach ($imgs as $img) {
$src = $img->getAttribute('src');
// Match base64 images
if (preg_match('/^data:image\/([a-zA-Z0-9+]+);base64,(.*)$/s', $src, $matches)) {
$savedAny = true; // <-- We are actually saving at least 1 image
// Create folder ONLY when needed
if (!$folderCreated) {
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
$folderCreated = true;
}
$mimeType = strtolower($matches[1]);
$base64 = $matches[2];
$binary = base64_decode($base64);
if ($binary === false) {
continue;
}
// Extension mapping
switch ($mimeType) {
case 'jpeg':
case 'jpg': $ext = 'jpg'; break;
case 'png': $ext = 'png'; break;
case 'gif': $ext = 'gif'; break;
case 'webp': $ext = 'webp'; break;
default: $ext = 'png';
}
// Secure random filename
$uid = bin2hex(random_bytes(16));
$filename = "img_{$uid}.{$ext}";
$filePath = $targetDir . $filename;
if (file_put_contents($filePath, $binary) !== false) {
$webPath = "/" . $baseWebPath . $ownerId . "/" . $filename;
$img->setAttribute('src', $webPath);
}
}
}
// If no images were processed, return original HTML immediately
if (!$savedAny) {
return $html;
}
// Extract body content only
$body = $dom->getElementsByTagName('body')->item(0);
if ($body) {
$innerHTML = '';
foreach ($body->childNodes as $child) {
$innerHTML .= $dom->saveHTML($child);
}
return $innerHTML;
}
return $html;
}
function cleanupUnusedImages(string $html, string $folderFsPath, string $folderWebPath) {
$folderFsPath = rtrim($folderFsPath, '/\\') . '/';
$folderWebPath = rtrim($folderWebPath, '/\\') . '/';
if (!is_dir($folderFsPath)) {
return; // no folder = nothing to delete
}
// 1. Get all files currently on disk
$filesOnDisk = glob($folderFsPath . "*");
// 2. Find all
preg_match_all('/
]+src=["\']([^"\']+)["\']/i', $html, $matches);
$htmlImagePaths = $matches[1] ?? [];
// Normalize paths: keep only filenames belonging to this template folder
$referencedFiles = [];
foreach ($htmlImagePaths as $src) {
if (strpos($src, $folderWebPath) !== false) {
$filename = basename($src);
$referencedFiles[] = $filename;
}
}
// 3. Delete any physical file not referenced in the HTML
foreach ($filesOnDisk as $filePath) {
$filename = basename($filePath);
if (!in_array($filename, $referencedFiles)) {
unlink($filePath);
}
}
}
/*
* 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 a list of what was stored - ['name' => original name, 'path' => path
* relative to the app root, 'size' => bytes] - so a caller can pass the same
* files to the mail queue. 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);
$stored_attachments = [];
if (!$ticket_id || empty($_FILES[$field_name]) || !isset($_FILES[$field_name]['name'])) {
return $stored_attachments;
}
$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 $stored_attachments;
}
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);
}
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");
// Path is relative to the app root, not the caller, so the mail cron can
// resolve it from its own directory
$stored_attachments[] = [
'name' => $single_file['name'],
'path' => "uploads/tickets/$ticket_id/$attachment_reference_name",
'size' => (int) $single_file['size']
];
}
return $stored_attachments;
}
/*
* Splits a stored attachment list into what the mail queue will carry and what it
* will not, against MAX_EMAIL_ATTACHMENT_BYTES applied to the message as a whole.
*
* Oversized files stay on the ticket - the recipient can still download them from
* the portal - and the caller is expected to say so rather than leaving the agent
* to assume everything went out.
*
* Returns ['send' => [...], 'skipped' => [...]] in the input order.
*/
function filterEmailableAttachments($attachments) {
$result = ['send' => [], 'skipped' => []];
$running_total = 0;
foreach ($attachments as $attachment) {
$size = intval($attachment['size'] ?? 0);
if ($size > 0 && $running_total + $size <= MAX_EMAIL_ATTACHMENT_BYTES) {
$running_total += $size;
$result['send'][] = $attachment;
} else {
$result['skipped'][] = $attachment;
}
}
return $result;
}
/*
* FontAwesome icon name for a file extension.
*
* Lived in client/functions.php until agent/files.php needed the same mapping
* for its gallery view. The portal reaches this file through the root
* functions.php it already loads, so moving it up costs the portal nothing and
* leaves one list of extensions rather than two to drift apart.
*/
function getFileIcon($file_extension) {
$file_extension = strtolower($file_extension);
// Document icons
if (in_array($file_extension, ['pdf'])) {
return 'file-pdf';
} elseif (in_array($file_extension, ['doc', 'docx'])) {
return 'file-word';
} elseif (in_array($file_extension, ['xls', 'xlsx'])) {
return 'file-excel';
} elseif (in_array($file_extension, ['ppt', 'pptx'])) {
return 'file-powerpoint';
} elseif (in_array($file_extension, ['txt', 'md', 'rtf'])) {
return 'file-alt';
} elseif (in_array($file_extension, ['zip', 'rar', '7z', 'tar', 'gz'])) {
return 'file-archive';
} elseif (in_array($file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'])) {
return 'file-image';
} elseif (in_array($file_extension, ['mp4', 'avi', 'mov', 'wmv', 'flv'])) {
return 'file-video';
} elseif (in_array($file_extension, ['mp3', 'wav', 'ogg', 'flac'])) {
return 'file-audio';
} elseif (in_array($file_extension, ['html', 'htm', 'css', 'js', 'php', 'py', 'java'])) {
return 'file-code';
} else {
return 'file';
}
}
/*
* MIME types safe to render inline in a browser.
*
* Anything not on this list is served Content-Disposition: attachment - HTML
* and SVG in particular are stored-XSS vectors when a browser renders them from
* our own origin. Four file-serving entry points enforced it from four
* identical copies of the array; the gallery preview in agent/files.php has to
* agree with them about what it can frame, so the list is defined once here.
*
* application/json is on the list because a browser renders it as text, not as
* markup, and every serving point sends X-Content-Type-Options: nosniff so it
* cannot be re-interpreted as HTML.
*/
function getInlineViewableMimeTypes() {
return [
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json"
];
}
/*
* Tidies raw text into something worth showing in a preview tile.
*
* Two callers, two different messes, one clean-up:
*
* documents - document_content_raw is TinyMCE HTML that has been through
* strip_tags(), which removes the TAGS and leaves the ENTITIES.
* A paragraph of "Hello world & friends" arrives with
* those sequences intact, and escaping it for output turned them
* into a literal on screen. Decode first, escape last.
*
* text files - whatever bytes are on disk: a UTF-8 BOM that renders as a
* stray glyph, CRLF line endings, stray control characters, and
* the possibility that a file claiming text/plain is not text.
*
* Returns '' when there is nothing worth showing, so callers fall back to the
* file-type icon rather than printing noise.
*/
function cleanTextExcerpt($text, $length = 400) {
$text = (string) $text;
// A BOM is invisible metadata to a text editor and a stray glyph in HTML
$text = preg_replace('/^\xEF\xBB\xBF/', '', $text);
// Entities left behind by strip_tags(), plus TinyMCE's non-breaking spaces,
// which are U+00A0 once decoded and read as odd gaps
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace("\xC2\xA0", ' ', $text);
// Drop anything that is not valid UTF-8 rather than letting a half-decoded
// byte render as a replacement character
if (!mb_check_encoding($text, 'UTF-8')) {
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
}
// Control characters, keeping the two that carry meaning in a preview.
// No /u modifier on purpose: these are all single-byte ASCII, and every
// continuation byte of a multi-byte character is >= 0x80, so a byte-wise
// strip cannot damage one. With /u the whole call returns null the moment
// the subject holds a stray invalid byte, blanking the excerpt instead of
// cleaning it.
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Stripped HTML leaves long runs of blank lines and indentation
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace('/\n{2,}/', "\n", $text);
$text = trim($text);
// Cut on a character boundary - a byte-wise substr can split a multi-byte
// character and leave a broken glyph at the end of every tile
if (mb_strlen($text, 'UTF-8') > $length) {
$text = mb_substr($text, 0, $length, 'UTF-8') . '...';
}
return (string) $text;
}
/*
* First few hundred characters of a text file, for a grid tile preview.
*
* Reads a bounded chunk rather than the whole file - a 40MB log has no business
* being loaded into a page that shows two dozen tiles - and re-applies the same
* realpath containment check the file-serving endpoints use, because the caller
* is building a path from a database column.
*
* Returns '' when the file is missing, unreadable, escapes uploads/, or does
* not look like text at all. The last case matters: a mislabelled binary served
* as text/plain would otherwise fill the tile with garbage.
*/
function getFileTextExcerpt($client_id, $file_reference_name, $length = 400) {
$client_id = intval($client_id);
$uploads_base = realpath(__DIR__ . "/../uploads");
$file_path = realpath(__DIR__ . "/../uploads/clients/$client_id/$file_reference_name");
if ($file_path === false || $uploads_base === false || strpos($file_path, $uploads_base) !== 0) {
return '';
}
if (!is_file($file_path) || !is_readable($file_path)) {
return '';
}
$handle = fopen($file_path, 'rb');
if ($handle === false) {
return '';
}
// Read well past the target so cleaning and the character-boundary cut
// still have a full excerpt to work with
$raw = fread($handle, $length * 4);
fclose($handle);
if ($raw === false || $raw === '') {
return '';
}
// Does this actually look like text? Count the bytes no text file should
// carry; a few percent is a mislabelled binary, not a stray character.
$control_bytes = strlen(preg_replace('/[^\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $raw));
if ($control_bytes > 0 && ($control_bytes / strlen($raw)) > 0.05) {
return '';
}
return cleanTextExcerpt($raw, $length);
}