Merge development into main: user-activity fix + attachment thumbnails (#49, #98)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
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 1m48s
Lint / Deploy (push) Successful in 2s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
This commit is contained in:
2026-09-11 22:19:57 -04:00
co-authored by Claude Sonnet 5
8 changed files with 160 additions and 22 deletions
+9
View File
@@ -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');
+15 -3
View File
@@ -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');
+85 -4
View File
@@ -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');
}
+7 -2
View File
@@ -1238,10 +1238,15 @@ function renderAttachments(attachments, append, hasMore) {
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
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
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
<img src="${thumbUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
+13 -3
View File
@@ -388,9 +388,19 @@ switch (true) {
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
-- Assignment date, not ticket creation date: a ticket created
-- outside the range but assigned within it should count, and
-- one created in-range but assigned later shouldn't (until it
-- is). Derived from audit_log's 'assign' events since tickets
-- has no assigned_at column; COUNT(DISTINCT ...) so a ticket
-- reassigned more than once to the same user in-range still
-- counts once.
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(details, '$.assigned_to')) AS UNSIGNED) as assigned_to,
COUNT(DISTINCT entity_id) as tickets_assigned
FROM audit_log
WHERE action_type = 'assign' AND entity_type = 'ticket'
AND DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
+14
View File
@@ -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`;
+9 -4
View File
@@ -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) {
+8 -6
View File
@@ -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;