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 ? ` - ${lt.escHtml(att.original_filename)} + ${lt.escHtml(att.original_filename)} ` : `
${lt.escHtml(att.icon || '[ f ]')}
`; diff --git a/migrations/005_attachment_thumbnails.sql b/migrations/005_attachment_thumbnails.sql new file mode 100644 index 0000000..8dd08ff --- /dev/null +++ b/migrations/005_attachment_thumbnails.sql @@ -0,0 +1,14 @@ +-- Add a nullable thumbnail_filename column to ticket_attachments so an image +-- upload can store a separately-generated, resized preview alongside the +-- full-size original. NULL means no thumbnail exists (non-image, GD +-- unavailable at upload time, or an attachment uploaded before this existed) +-- and callers fall back to the full-size original. +-- +-- scripts/cleanup_orphan_uploads.php's orphan lookup is updated in the same +-- change to also match thumbnail_filename, so generated thumbnails aren't +-- swept up as orphans. +-- +-- Safe to re-run. + +ALTER TABLE `ticket_attachments` + ADD COLUMN IF NOT EXISTS `thumbnail_filename` varchar(255) DEFAULT NULL AFTER `filename`; diff --git a/models/AttachmentModel.php b/models/AttachmentModel.php index 3ca0c3e..7920553 100644 --- a/models/AttachmentModel.php +++ b/models/AttachmentModel.php @@ -68,14 +68,19 @@ class AttachmentModel /** * Add a new attachment record + * + * @param string|null $thumbnailFilename Stored filename of a generated preview + * thumbnail, or null if none was generated + * (non-image, GD unavailable, etc.) — callers + * fall back to the full-size original. */ - public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) + public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy, $thumbnailFilename = null) { - $sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?)"; + $sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?)"; $stmt = $this->conn->prepare($sql); - $stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy); + $stmt->bind_param("ssssisi", $ticketId, $filename, $thumbnailFilename, $originalFilename, $fileSize, $mimeType, $uploadedBy); $result = $stmt->execute(); if ($result) { diff --git a/scripts/cleanup_orphan_uploads.php b/scripts/cleanup_orphan_uploads.php index 267b1f0..df10f33 100644 --- a/scripts/cleanup_orphan_uploads.php +++ b/scripts/cleanup_orphan_uploads.php @@ -59,10 +59,11 @@ try { exit(1); } -// Prepared lookup: does any attachment row reference this stored filename? -// Stored filenames are globally unique (uniqid), so filename alone is sufficient -// and safe — a match in any ticket means the file is a real attachment. -$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1'); +// Prepared lookup: does any attachment row reference this stored filename, +// either as the original file or as its generated preview thumbnail? Both +// are globally unique (uniqid-derived), so filename alone is sufficient and +// safe — a match in any ticket means the file is a real, referenced file. +$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? OR thumbnail_filename = ? LIMIT 1'); if ($lookup === false) { logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error); exit(1); @@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) { continue; } - // Keep the file if any attachment row references it. - $lookup->bind_param('s', $filename); + // Keep the file if any attachment row references it (as the + // original or as its thumbnail). + $lookup->bind_param('ss', $filename, $filename); $lookup->execute(); $hasRow = $lookup->get_result()->num_rows > 0;