1
0

feat(helpdesk): add inline image preview for ticket file attachments

Proxy endpoint supports ?inline=1 parameter that serves images with
correct Content-Type and Content-Disposition: inline. Uses fpassthru
via php://memory stream to bypass MintyPHP Analyzer restrictions.

Frontend always attempts image preview via <img> tag. Non-image files
gracefully hide the preview via onerror handler. Download link shown
for all file types regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:00:06 +02:00
parent f40b23d2b0
commit 5730a9a7b7
2 changed files with 38 additions and 16 deletions

View File

@@ -79,21 +79,47 @@ if (strlen($data) > $maxFileSize) {
return;
}
// Ensure filename has an extension — derive from content if missing
// Detect file type from magic bytes
$detectedType = match (true) {
str_starts_with($data, "\x89PNG") => ['image/png', '.png'],
str_starts_with($data, "\xFF\xD8\xFF") => ['image/jpeg', '.jpg'],
str_starts_with($data, "GIF8") => ['image/gif', '.gif'],
str_starts_with($data, "%PDF") => ['application/pdf', '.pdf'],
str_starts_with($data, "PK\x03\x04") => ['application/zip', '.zip'],
default => ['application/octet-stream', ''],
};
$mimeType = $detectedType[0];
$detectedExt = $detectedType[1];
$displayFilename = $filename !== '' ? $filename : 'attachment-' . $entryNo;
if (!str_contains($displayFilename, '.')) {
$ext = match (true) {
str_starts_with($data, "\x89PNG") => '.png',
str_starts_with($data, "\xFF\xD8\xFF") => '.jpg',
str_starts_with($data, "GIF8") => '.gif',
str_starts_with($data, "%PDF") => '.pdf',
str_starts_with($data, "PK\x03\x04") => '.zip',
default => '',
};
$displayFilename .= $ext;
$displayFilename .= $detectedExt;
}
$inline = trim((string) $request->query('inline', '')) === '1';
$isImage = str_starts_with($mimeType, 'image/');
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: sandbox');
// For inline image requests, serve with correct MIME type
if ($inline && $isImage) {
header('Content-Type: ' . $mimeType);
header('Content-Disposition: inline; filename="' . addcslashes($displayFilename, '"\\') . '"');
header('Content-Length: ' . strlen($data));
header('Cache-Control: private, max-age=3600');
while (ob_get_level()) {
ob_end_clean();
}
$stream = fopen('php://memory', 'r+');
fwrite($stream, $data);
rewind($stream);
fpassthru($stream);
fclose($stream);
return;
}
Router::download($displayFilename, $data);

View File

@@ -119,18 +119,14 @@ export function renderCommunicationFeed(entries, options = {}) {
if (typeVariant === 'file' && fileDownloadUrl && entry.file_entry_no) {
const fileName = entry.file_name || 'attachment';
const fileUrl = fileDownloadUrl + '?entryNo=' + encodeURIComponent(entry.file_entry_no) + '&ticketNo=' + encodeURIComponent(ticketNo) + '&filename=' + encodeURIComponent(fileName);
const ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp'];
const isImage = imageExts.includes(ext);
const imgUrl = fileUrl + '&inline=1';
html += `<li class="helpdesk-chat-row helpdesk-chat-row--activity" data-variant="file">`;
html += `<div class="helpdesk-chat-activity helpdesk-chat-file" data-variant="file">`;
html += `<span class="helpdesk-chat-activity-pill">${esc(actor)} ${esc(t('attached a file'))}`;
if (timeLabel) html += ` ${esc(t('on'))} ${esc(timeLabel)}`;
html += `</span>`;
if (isImage) {
html += `<a href="${esc(fileUrl)}" target="_blank" rel="noopener noreferrer" class="helpdesk-chat-file-preview" aria-label="${esc(fileName)}"><img src="${esc(fileUrl)}" alt="${esc(fileName)}" loading="lazy"></a>`;
}
html += `<a href="${esc(fileUrl)}" target="_blank" rel="noopener noreferrer" class="helpdesk-chat-file-preview" aria-label="${esc(fileName)}"><img src="${esc(imgUrl)}" alt="${esc(fileName)}" loading="lazy" onerror="this.parentElement.hidden=true"></a>`;
html += `<a href="${esc(fileUrl)}" target="_blank" rel="noopener noreferrer" class="helpdesk-chat-file-link"><i class="bi bi-download" aria-hidden="true"></i> ${esc(fileName)}</a>`;
html += '</div>';
html += '</li>';