Fix performAdvancedSearch ReferenceError, settings save, sort reset, notifications 500, CSP

DashboardView.php: wrap performAdvancedSearch in a closure so it is
resolved at event-fire time rather than listener-registration time
(advanced-search.js loads later via pageScripts so the bare identifier
reference caused ReferenceError).

DashboardView.php: reset sort URL to page=1 so sorting all pages
instead of staying on the current page.

dashboard.js: add missing save-settings and close-settings cases to
the click delegation handler (were removed in a prior session under
the assumption they were in dashboard.js, but they were not).

notifications.php: replace JSON_EXTRACT-based comment join (not
universally supported) with a two-step PHP filter: fetch owner/watcher
ticket IDs first, then filter raw comment rows in PHP. Also fix the
status change LIKE pattern to match the actual logTicketUpdate format
{"status": {"from": ..., "to": ...}}.

SecurityHeadersMiddleware.php: add https://cdn.jsdelivr.net to
connect-src so Chart.js source maps load without CSP violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:53:06 -04:00
parent df6c4de196
commit ac05b212b2
4 changed files with 51 additions and 13 deletions
+38 -9
View File
@@ -65,28 +65,57 @@ $assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close(); $stmt->close();
// Query 2: Comments on tickets I own or watch (events from other users) // Query 2: Comments on tickets I own or watch (events from other users)
// Comments are logged as action_type='create', entity_type='comment', with ticket_id in details JSON. // Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
$commentSql = "SELECT DISTINCT // Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
// Step A: ticket IDs the current user owns or watches
$myTicketIds = [];
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
$stmt = $conn->prepare($myTicketsSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$mtResult = $stmt->get_result();
while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; }
$stmt->close();
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
$stmt = $conn->prepare($watchedSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$wResult = $stmt->get_result();
while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; }
$stmt->close();
// Step B: fetch recent comment audit events not by the current user
$commentSql = "SELECT
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at, al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id LEFT JOIN users u ON al.user_id = u.user_id
INNER JOIN tickets t ON t.ticket_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) AS UNSIGNED)
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
WHERE al.action_type = 'create' WHERE al.action_type = 'create'
AND al.entity_type = 'comment' AND al.entity_type = 'comment'
AND al.user_id != ? AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT 15"; LIMIT 50";
$stmt = $conn->prepare($commentSql); $stmt = $conn->prepare($commentSql);
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId); $stmt->bind_param('i', $userId);
$stmt->execute(); $stmt->execute();
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close(); $stmt->close();
// Step C: filter to only comments on tickets the current user owns/watches
$commentRows = [];
foreach ($rawCommentRows as $rawRow) {
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
$tid = (int)($d['ticket_id'] ?? 0);
if ($tid > 0 && isset($myTicketIds[$tid])) {
$commentRows[] = $rawRow;
if (count($commentRows) >= 15) break;
}
}
// Query 3: Status changes on watched tickets (from other users) // Query 3: Status changes on watched tickets (from other users)
$statusSql = "SELECT DISTINCT $statusSql = "SELECT DISTINCT
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at, al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
@@ -98,7 +127,7 @@ $statusSql = "SELECT DISTINCT
AND al.entity_type = 'ticket' AND al.entity_type = 'ticket'
AND al.user_id != ? AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE '%\"field\":\"status\"%' AND al.details LIKE '%"status":%'
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
LIMIT 10"; LIMIT 10";
+6
View File
@@ -198,6 +198,12 @@ document.addEventListener('DOMContentLoaded', function() {
case 'open-settings-modal': case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal(); if (typeof openSettingsModal === 'function') openSettingsModal();
break; break;
case 'close-settings':
if (typeof closeSettingsModal === 'function') closeSettingsModal();
break;
case 'save-settings':
if (typeof saveSettings === 'function') saveSettings();
break;
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected // Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
case 'manual-refresh': case 'manual-refresh':
if (window.lt && lt.autoRefresh) lt.autoRefresh.now(); if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
+1 -1
View File
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
// Content Security Policy - restricts where resources can be loaded from // Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces // Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes // All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';"); header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
// Prevent clickjacking by disallowing framing // Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY"); header("X-Frame-Options: DENY");
+6 -3
View File
@@ -586,7 +586,7 @@ include __DIR__ . '/layout_header.php';
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc'; $newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : ''; $sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : ''; $ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]); $sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8'); $sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
?> ?>
<th scope="col" class="<?= $sortClass ?>" <th scope="col" class="<?= $sortClass ?>"
@@ -1150,9 +1150,12 @@ document.addEventListener('change', function (e) {
} }
}); });
// Advanced search form submit // Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
// (advanced-search.js loads later via pageScripts in layout_footer.php)
var advForm = document.getElementById('advancedSearchForm'); var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', performAdvancedSearch); if (advForm) advForm.addEventListener('submit', function(e) {
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
});
// ── Flatpickr date pickers on advanced search date fields ──────── // ── Flatpickr date pickers on advanced search date fields ────────
(function initFlatpickr() { (function initFlatpickr() {