Attachment 'thumbnails' are the full-size original image — no actual resized thumbnail is ever generated #98

Closed
opened 2026-09-01 00:13:38 -04:00 by jared · 1 comment
Owner

Severity: Medium (performance/UX)

assets/js/ticket.js (~lines 1052-1057) points both the grid <img> thumbnail and the lightbox <a href> at the same download_attachment.php?...&inline=1 URL serving the original file. There is no server-side image resizing anywhere in the app.

Impact: A multi-MB photo attachment costs a full multi-MB download just to render a small preview thumbnail in the grid. Opening the attachments tab on a ticket with several large photos is bandwidth- and load-time-expensive; loading="lazy" only defers off-screen images, it doesn't reduce per-image transfer size.

Fix: Generate a resized thumbnail (e.g. GD imagecreatefromstring() + resize, with an explicit dimension/pixel-count cap before processing to guard against a decompression-bomb-style crafted image causing memory exhaustion) at upload time, and serve that for the grid view — reserve the full-size original for the lightbox/actual download.

**Severity:** Medium (performance/UX) `assets/js/ticket.js` (~lines 1052-1057) points both the grid `<img>` thumbnail and the lightbox `<a href>` at the same `download_attachment.php?...&inline=1` URL serving the original file. There is no server-side image resizing anywhere in the app. **Impact:** A multi-MB photo attachment costs a full multi-MB download just to render a small preview thumbnail in the grid. Opening the attachments tab on a ticket with several large photos is bandwidth- and load-time-expensive; `loading="lazy"` only defers off-screen images, it doesn't reduce per-image transfer size. **Fix:** Generate a resized thumbnail (e.g. GD `imagecreatefromstring()` + resize, with an explicit dimension/pixel-count cap before processing to guard against a decompression-bomb-style crafted image causing memory exhaustion) at upload time, and serve that for the grid view — reserve the full-size original for the lightbox/actual download.
jared added the performancepriority/medium labels 2026-09-08 10:15:50 -04:00
Author
Owner

Fixed in commit dcf9b0c (merged to main in 786674a).

The attachment grid's 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.

Fix: 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). The thumbnail's filename is stored 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 via ?thumb=1 when one exists; the attachments grid now requests it for the preview, while the lightbox link is unchanged and still opens the full-size original. delete_attachment.php removes the thumbnail 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 correctly.

Fixed in commit dcf9b0c (merged to main in 786674a). 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. Fix: 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). The thumbnail's filename is stored 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 via ?thumb=1 when one exists; the attachments grid now requests it for the <img> preview, while the lightbox link is unchanged and still opens the full-size original. delete_attachment.php removes the thumbnail 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 correctly.
jared closed this issue 2026-09-11 22:20:13 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/tinker_tickets#98