Generate real resized thumbnails for image attachments (#98)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
The attachment grid's <img> thumbnail pointed at the same download_attachment.php URL as the full-size original, so previewing a multi-MB photo attachment cost a full multi-MB download just to render a small grid preview. loading="lazy" only deferred off-screen images; it never reduced per-image transfer size. Generate a resized JPEG thumbnail (longest side capped at 300px) via GD at upload time, from the same metadata-stripped image stripImageMetadata() already produces, reusing its decompression-bomb guard (~40MP decode cap). Store the thumbnail's filename in a new nullable ticket_attachments. thumbnail_filename column (migration 005); NULL means no thumbnail exists (non-image, GD unavailable, or an attachment predating this change) and callers fall back to the full-size original. download_attachment.php serves the thumbnail when requested via ?thumb=1 and one exists, falling back to the original otherwise. The attachments grid now requests thumb=1 for its <img> preview; the lightbox link is unchanged and still opens the full-size original. delete_attachment.php removes the thumbnail file alongside the original, and cleanup_orphan_uploads.php's orphan lookup now also matches thumbnail_filename so generated thumbnails aren't swept up as orphans. Verified against real MariaDB + GD: a 1600x1200 test JPEG produced a 300x225 thumbnail at ~1.8KB vs. the 52KB original (~29x smaller); confirmed the serving logic picks the thumbnail for image attachments with one, falls back to the original for a non-image attachment even when thumb=1 is requested, and that the updated orphan-cleanup lookup matches both the original and thumbnail filename (and correctly finds neither for an unrelated filename). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user