Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 74544ac5b4 Merge development into main: LDAP avatar lookups now use LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m21s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:59:21 -04:00
jaredandClaude Sonnet 5 863f84f37e Switch LDAP avatar lookups to LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m56s
Lint / Deploy (push) Successful in 3s
api/user_avatar.php connected via ldap://$ldapHost:$ldapPort — never
ldaps://, and there was no ldap_start_tls() call anywhere in the
codebase. LDAP_BIND_PW was sent over the wire unencrypted on every
avatar fetch.

Switched to ldaps://, and changed LDAP_HOST/LDAP_PORT's defaults to
ldap.lotusguild.org:6360 (lldap's LDAPS listener) instead of the bare
IP on port 3890 (plaintext). PHP's ldap extension verifies the server
cert's hostname by default, so a bare IP won't validate against the
LDAPS cert (issued for *.lotusguild.org) — LDAP_HOST has to be a
hostname the cert covers. This is deliberately not configurable back to
plaintext ldap://.

Infra change (pve-infra, separate repo/commit): added a Pi-hole
split-horizon override so ldap.lotusguild.org resolves internally to
the real LDAP server's LAN IP — its existing public DNS record points
elsewhere (an unrelated host), and there was no internal-only DNS entry
for it before this.

Verified against the real lldap server (pct 147, LDAPS on 6360, a live
Let's Encrypt *.lotusguild.org cert): confirmed the Pi-hole override
resolves correctly from hosts using it as their resolver, then ran the
exact ldap_connect/ldap_bind sequence via `php -r` directly on the
production tinker_tickets host (10.10.10.45) with a deliberately wrong
bind password — got "Invalid credentials" (a real LDAP protocol
response), not a transport/TLS error, proving the full connect + TLS
handshake + hostname verification + bind path works end-to-end in the
actual deployment environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:56:00 -04:00
jaredandClaude Sonnet 5 b73a4c792c Merge development into main: dashboard status chart excludes Closed (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:26:37 -04:00
jaredandClaude Sonnet 5 78ee5fdf48 Exclude Closed tickets from the dashboard status breakdown chart (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 6s
StatsModel::getAllStats()'s by_status breakdown grouped every status
including Closed, unlike by_priority and by_category which already
filter status != 'Closed'. Closed tickets accumulate indefinitely, so
over time the status donut chart's Closed slice comes to dominate it,
squeezing Open/Pending/In Progress down to barely-visible slivers —
exactly the breakdown the chart exists to show at a glance.

Filtered by_status the same way the other two breakdowns already are.
The separate open_tickets/closed_tickets KPI counts are unaffected (a
different query); Closed just no longer appears as a chart segment.

Note: this issue was labeled 'invalid' with no explanation in the body
or comments — checked and it's Gitea's generic stock label (description
"Something is wrong"), not a documented decision that the request itself
was wrong. The described problem is real and reproducible in the code,
so implementing it as filed.

Verified against real MariaDB: seeded 3 open-ish tickets (Open, Pending,
In Progress) and 3 Closed. by_status now returns only the 3 active
statuses; open_tickets/closed_tickets KPI counts are unchanged at 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:23:33 -04:00
jaredandClaude Sonnet 5 786674abf3 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
2026-09-11 22:19:57 -04:00
jaredandClaude Sonnet 5 dcf9b0cfa1 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
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
2026-09-11 22:17:35 -04:00
jaredandClaude Sonnet 5 80169de16d Fix User Activity report's Tickets Assigned column to use assignment date (#49)
The "Tickets Assigned" column filtered by tickets.created_at, so a ticket
created outside the selected date range but assigned to a user within it
never counted, while one created in-range but assigned/reassigned later
counted as if the assignment happened in-range — filtered by the wrong
date field for what the column claims to measure.

tickets has no assigned_at column, so derive the count from audit_log's
'assign' events (already logged by both the single-ticket and bulk-assign
paths) instead, filtered by the event's own created_at. COUNT(DISTINCT
entity_id) so a ticket reassigned more than once to the same user within
the range still counts once.

Verified against real MariaDB: seeded one ticket created outside the test
range but assigned inside it, and one created inside the range but
assigned outside it. The old query counted the wrong one; the new query
correctly flips to count the one actually assigned within the range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:25 -04:00
13 changed files with 190 additions and 32 deletions
+7 -3
View File
@@ -74,10 +74,14 @@ TRUSTED_PROXIES=
; Timezone (default: America/New_York) ; Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York
; LDAP / lldap (for user avatar lookups) ; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is
; lldap's default LDAPS port, NOT its plaintext port (3890), since
; LDAP_BIND_PW below would otherwise go over the wire unencrypted.
; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname
; verification is not skipped), not a bare IP.
LDAP_ENABLED=true LDAP_ENABLED=true
LDAP_HOST=10.10.10.39 LDAP_HOST=ldap.lotusguild.org
LDAP_PORT=3890 LDAP_PORT=6360
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com" LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW= LDAP_BIND_PW=
LDAP_BASE_DN="dc=example,dc=com" LDAP_BASE_DN="dc=example,dc=com"
+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 // Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) { if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record'); ResponseHelper::serverError('Failed to delete attachment record');
+15 -3
View File
@@ -69,9 +69,21 @@ try {
$conn->close(); $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 // Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $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) // Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir); $realUploadDir = realpath($uploadDir);
@@ -100,7 +112,7 @@ try {
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']; $inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers // 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 // Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']); $safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
@@ -138,7 +150,7 @@ try {
$rangeLength = $rangeEnd - $rangeStart + 1; $rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
header('Content-Type: ' . $attachment['mime_type']); header('Content-Type: ' . $servedMimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Cache-Control: private, max-age=3600'); header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff'); 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 // Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized(); ResponseHelper::unauthorized();
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
foreach ($attachments as &$att) { foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']); $att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']); $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([ ResponseHelper::success([
@@ -278,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file'); 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/')) { if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType); stripImageMetadata($targetPath, $mimeType);
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
} }
// Sanitize original filename // Sanitize original filename
@@ -298,12 +371,16 @@ try {
$originalFilename, $originalFilename,
$file['size'], $file['size'],
$mimeType, $mimeType,
$_SESSION['user']['user_id'] $_SESSION['user']['user_id'],
$thumbnailFilename
); );
if (!$attachmentId) { if (!$attachmentId) {
// Clean up file if database insert fails // Clean up file (and any thumbnail) if database insert fails
unlink($targetPath); unlink($targetPath);
if ($thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to save attachment record'); ResponseHelper::serverError('Failed to save attachment record');
} }
@@ -330,13 +407,17 @@ try {
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']), 'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType, 'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType), 'icon' => AttachmentModel::getFileIcon($mimeType),
'has_thumbnail' => $thumbnailFilename !== null,
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'], 'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s') 'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully'); ], 'File uploaded successfully');
} catch (Exception $e) { } catch (Exception $e) {
// Clean up file on error // Clean up file (and any thumbnail) on error
if (file_exists($targetPath)) { if (file_exists($targetPath)) {
unlink($targetPath); unlink($targetPath);
} }
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to process attachment'); ResponseHelper::serverError('Failed to process attachment');
} }
+4 -1
View File
@@ -113,7 +113,10 @@ $avatarData = null;
$ldapQueryOk = false; // true only if the LDAP lookup completed without error $ldapQueryOk = false; // true only if the LDAP lookup completed without error
try { try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort"); // LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below,
// and lldap's plaintext port (3890) would put it on the wire unencrypted.
// lldap's LDAPS listener defaults to port 6360 (see config.php).
$ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort");
if (!$ldap) { if (!$ldap) {
throw new RuntimeException("ldap_connect failed"); throw new RuntimeException("ldap_connect failed");
} }
+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 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 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 const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}"> ? `<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>` </a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`; : `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
+13 -3
View File
@@ -154,9 +154,19 @@ $GLOBALS['config'] = [
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below 'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups) // LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39', // (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890), // its plaintext port 3890. The bind password must never go over the
// wire unencrypted, so this is not configurable back to a plaintext
// ldap:// connection.
//
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
// hostname by default and a mismatch fails the connection. Pi-hole has a
// split-horizon override so ldap.lotusguild.org resolves internally to
// the real LDAP server IP (its public DNS record points elsewhere).
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com', 'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '', 'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com', 'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
+13 -3
View File
@@ -388,9 +388,19 @@ switch (true) {
GROUP BY user_id GROUP BY user_id
) cm ON u.user_id = cm.user_id ) cm ON u.user_id = cm.user_id
LEFT JOIN ( LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned -- Assignment date, not ticket creation date: a ticket created
FROM tickets -- outside the range but assigned within it should count, and
WHERE DATE(created_at) BETWEEN ? 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 GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to ) ta ON u.user_id = ta.assigned_to
LEFT JOIN ( 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 * 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) $sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $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(); $result = $stmt->execute();
if ($result) { if ($result) {
+1 -1
View File
@@ -148,7 +148,7 @@ class StatsModel
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets t WHERE ($visSQL) GROUP BY status FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY status
UNION ALL UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category"; FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
+8 -6
View File
@@ -59,10 +59,11 @@ try {
exit(1); exit(1);
} }
// Prepared lookup: does any attachment row reference this stored filename? // Prepared lookup: does any attachment row reference this stored filename,
// Stored filenames are globally unique (uniqid), so filename alone is sufficient // either as the original file or as its generated preview thumbnail? Both
// and safe — a match in any ticket means the file is a real attachment. // are globally unique (uniqid-derived), so filename alone is sufficient and
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1'); // 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) { if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error); logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1); exit(1);
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
continue; continue;
} }
// Keep the file if any attachment row references it. // Keep the file if any attachment row references it (as the
$lookup->bind_param('s', $filename); // original or as its thumbnail).
$lookup->bind_param('ss', $filename, $filename);
$lookup->execute(); $lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0; $hasRow = $lookup->get_result()->num_rows > 0;
+5 -2
View File
@@ -280,8 +280,11 @@ include __DIR__ . '/layout_header.php';
// default: with no `status` param the controller falls back to the viewer's // default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting // default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds // list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans // by_priority, by_status, and by_category all with `status != 'Closed'`
// every status — so only the priority and category charts pin the open set. // closed tickets accumulate indefinitely and would otherwise dominate every
// breakdown over time — so chartPriority/chartCategory pin the open set
// explicitly, while chartStatus's clicked label is itself already one of
// the non-Closed statuses.
function openStatuses() { function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed']; var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(','); return all.filter(function(s) { return s !== 'Closed'; }).join(',');