diff --git a/api/delete_attachment.php b/api/delete_attachment.php
index 35896b4..d0225f2 100644
--- a/api/delete_attachment.php
+++ b/api/delete_attachment.php
@@ -94,6 +94,15 @@ try {
}
}
+ // Delete the generated preview thumbnail alongside the original, if one exists.
+ if (!empty($attachment['thumbnail_filename'])) {
+ $thumbPath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['thumbnail_filename'];
+ $realThumbPath = realpath($thumbPath);
+ if ($realThumbPath !== false && strncmp($realThumbPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) === 0) {
+ @unlink($realThumbPath);
+ }
+ }
+
// Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record');
diff --git a/api/download_attachment.php b/api/download_attachment.php
index 21ad217..f148279 100644
--- a/api/download_attachment.php
+++ b/api/download_attachment.php
@@ -69,9 +69,21 @@ try {
$conn->close();
+ // Serve the resized preview thumbnail instead of the full-size original
+ // when requested and one was actually generated at upload time; falls
+ // through to the full original otherwise (older attachments predating
+ // thumbnail generation, non-images, or a GD failure at upload time).
+ $wantsThumb = isset($_GET['thumb']) && $_GET['thumb'] === '1';
+ $servedFilename = $attachment['filename'];
+ $servedMimeType = $attachment['mime_type'];
+ if ($wantsThumb && !empty($attachment['thumbnail_filename'])) {
+ $servedFilename = $attachment['thumbnail_filename'];
+ $servedMimeType = 'image/jpeg';
+ }
+
// Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
- $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
+ $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $servedFilename;
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir);
@@ -100,7 +112,7 @@ try {
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers
- $disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
+ $disposition = ($inline && in_array($servedMimeType, $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
@@ -138,7 +150,7 @@ try {
$rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes');
- header('Content-Type: ' . $attachment['mime_type']);
+ header('Content-Type: ' . $servedMimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
diff --git a/api/upload_attachment.php b/api/upload_attachment.php
index 9d2ae49..963ff53 100644
--- a/api/upload_attachment.php
+++ b/api/upload_attachment.php
@@ -96,6 +96,72 @@ function stripImageMetadata(string $path, string $mimeType): void
}
}
+/**
+ * Generate a resized preview thumbnail for an uploaded image, saved as a JPEG
+ * alongside the original regardless of source format (a thumbnail is a small
+ * lossy preview, not an archival copy). Longest side capped at
+ * THUMBNAIL_MAX_DIMENSION; images already at or below that size are still
+ * re-encoded (cheap) rather than skipped, so the thumbnail is guaranteed to
+ * be a JPEG the grid can always request the same way.
+ *
+ * Best-effort like stripImageMetadata(): returns null on any failure
+ * (corrupt image, unsupported format, GD unavailable) rather than blocking
+ * the upload, and the caller falls back to serving the full-size original.
+ *
+ * @return string|null Basename of the generated thumbnail file, or null
+ */
+function generateThumbnail(string $path, string $mimeType, string $destDir): ?string
+{
+ if (!extension_loaded('gd')) {
+ return null;
+ }
+
+ // Same decompression-bomb guard as stripImageMetadata().
+ $dims = @getimagesize($path);
+ if ($dims === false) {
+ return null;
+ }
+ [$width, $height] = $dims;
+ if ($width * $height > 40_000_000) { // ~40 MP cap
+ return null;
+ }
+
+ $loaders = [
+ 'image/jpeg' => 'imagecreatefromjpeg',
+ 'image/png' => 'imagecreatefrompng',
+ 'image/gif' => 'imagecreatefromgif',
+ 'image/webp' => 'imagecreatefromwebp',
+ ];
+ $loader = $loaders[$mimeType] ?? null;
+ if ($loader === null || !function_exists($loader)) {
+ return null;
+ }
+
+ $source = @$loader($path);
+ if ($source === false) {
+ return null;
+ }
+
+ $maxDimension = 300;
+ $scale = min(1.0, $maxDimension / max($width, $height));
+ $thumbWidth = max(1, (int)round($width * $scale));
+ $thumbHeight = max(1, (int)round($height * $scale));
+
+ $thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
+ // Flatten transparency onto white — thumbnails are always opaque JPEGs.
+ $white = imagecolorallocate($thumb, 255, 255, 255);
+ imagefill($thumb, 0, 0, $white);
+ imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
+ imagedestroy($source);
+
+ $thumbFilename = pathinfo($path, PATHINFO_FILENAME) . '_thumb.jpg';
+ $thumbPath = rtrim($destDir, '/') . '/' . $thumbFilename;
+ $saved = imagejpeg($thumb, $thumbPath, 80);
+ imagedestroy($thumb);
+
+ return $saved ? $thumbFilename : null;
+}
+
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
+ $att['has_thumbnail'] = !empty($att['thumbnail_filename']);
+ unset($att['thumbnail_filename']); // internal storage detail, not needed by the client
}
ResponseHelper::success([
@@ -278,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
-// Strip EXIF/GPS metadata from image uploads before it's ever served back
+// Strip EXIF/GPS metadata from image uploads before it's ever served back,
+// then generate a resized preview thumbnail from the (now metadata-stripped)
+// original so the grid never has to transfer the full-size file just to
+// render a small preview.
+$thumbnailFilename = null;
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
+ $thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
}
// Sanitize original filename
@@ -298,12 +371,16 @@ try {
$originalFilename,
$file['size'],
$mimeType,
- $_SESSION['user']['user_id']
+ $_SESSION['user']['user_id'],
+ $thumbnailFilename
);
if (!$attachmentId) {
- // Clean up file if database insert fails
+ // Clean up file (and any thumbnail) if database insert fails
unlink($targetPath);
+ if ($thumbnailFilename !== null) {
+ @unlink($ticketDir . '/' . $thumbnailFilename);
+ }
ResponseHelper::serverError('Failed to save attachment record');
}
@@ -330,13 +407,17 @@ try {
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType),
+ 'has_thumbnail' => $thumbnailFilename !== null,
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
- // Clean up file on error
+ // Clean up file (and any thumbnail) on error
if (file_exists($targetPath)) {
unlink($targetPath);
}
+ if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
+ @unlink($ticketDir . '/' . $thumbnailFilename);
+ }
ResponseHelper::serverError('Failed to process attachment');
}
diff --git a/assets/js/ticket.js b/assets/js/ticket.js
index aae3d10..52cbdbd 100644
--- a/assets/js/ticket.js
+++ b/assets/js/ticket.js
@@ -1238,10 +1238,15 @@ function renderAttachments(attachments, append, hasMore) {
const uploadDate = `${lt.time.ago(att.uploaded_at)}`;
const isImage = /^image\//i.test(att.mime_type || '');
- const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
+ const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
+ // Grid preview requests the resized thumbnail (server falls back to the
+ // full-size original for attachments with none, e.g. uploaded before
+ // thumbnail generation existed); the lightbox link stays on the
+ // full-size original since that's what it displays when opened.
+ const thumbUrl = `${imgUrl}&thumb=1`;
const iconHtml = isImage
? `
-
+
`
: `