diff --git a/api/notifications.php b/api/notifications.php index ff22d70..b3d5dbd 100644 --- a/api/notifications.php +++ b/api/notifications.php @@ -15,8 +15,10 @@ require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/UserPreferencesModel.php'; +require_once dirname(__DIR__) . '/models/TicketModel.php'; $prefsModel = new UserPreferencesModel($conn); +$ticketModel = new TicketModel($conn); // ── POST: mark all read (update last_seen timestamp) ────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { @@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r $all[] = $row; } usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at'])); -$all = array_slice($all, 0, 30); + +// Re-check current ticket visibility before surfacing anything: a +// notification's audit_log entry reflects historical activity, but the +// ticket's visibility (or the user's group/watcher standing) may have +// tightened since. Without this, a notification still discloses the +// ticket's title and that activity occurred to someone who currently +// shouldn't see it, even though the ticket view's own access check would +// correctly reject them from opening it. +$candidateTicketIds = []; +foreach ($all as $row) { + $details = json_decode($row['details'] ?? '{}', true) ?? []; + $actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment') + ? 'comment' + : $row['action_type']; + $tid = ($actionType === 'comment' || $actionType === 'mention') + ? ($details['ticket_id'] ?? 0) + : $row['entity_id']; + if ($tid) { + $candidateTicketIds[(string)$tid] = true; + } +} +$ticketsById = !empty($candidateTicketIds) + ? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds)) + : []; + +$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) { + $details = json_decode($row['details'] ?? '{}', true) ?? []; + $actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment') + ? 'comment' + : $row['action_type']; + $tid = (string)(($actionType === 'comment' || $actionType === 'mention') + ? ($details['ticket_id'] ?? 0) + : $row['entity_id']); + $ticket = $ticketsById[$tid] ?? null; + return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser); +}); + +$all = array_slice(array_values($all), 0, 30); // Format for response $notifications = []; diff --git a/assets/js/base.js b/assets/js/base.js index 3547921..397c58f 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -2801,7 +2801,7 @@ }; // Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration) - async function _apiFetchAuth(method, url, body) { + async function _apiFetchAuth(method, url, body, retried) { if (_authAccess && auth.isExpiringSoon()) await auth.refresh(); const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess; @@ -2821,6 +2821,15 @@ // Resync CSRF token from any response body that carries a fresh one // (bootstrap rotates on success and returns the current token on rejection). if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; + // Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is + // shorter than the session idle timeout (5h), so this is a routine, + // recoverable case (an hour of inactivity, or a write in another tab + // rotating the shared token) rather than a real rejection — resyncing + // above already has the fresh token, so silently resending once succeeds + // transparently instead of surfacing a confusing error on the first try. + if (resp.status === 403 && !retried && data && data.csrf_token) { + return _apiFetchAuth(method, url, body, true); + } if (!resp.ok) { const err = new Error(data.error || data.message || 'HTTP ' + resp.status); err.data = data; diff --git a/models/CustomFieldModel.php b/models/CustomFieldModel.php index 4ebb3c0..cc80aa2 100644 --- a/models/CustomFieldModel.php +++ b/models/CustomFieldModel.php @@ -8,6 +8,9 @@ class CustomFieldModel { private $conn; + // Must match custom_field_definitions.field_type's enum() in the schema. + private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number']; + public function __construct($conn) { $this->conn = $conn; @@ -87,6 +90,10 @@ class CustomFieldModel */ public function createDefinition($data) { + if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) { + return ['success' => false, 'error' => 'Invalid field_type']; + } + $options = null; if (isset($data['field_options']) && !empty($data['field_options'])) { $options = json_encode($data['field_options']); @@ -129,6 +136,10 @@ class CustomFieldModel */ public function updateDefinition($fieldId, $data) { + if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) { + return ['success' => false, 'error' => 'Invalid field_type']; + } + $options = null; if (isset($data['field_options']) && !empty($data['field_options'])) { $options = json_encode($data['field_options']); diff --git a/models/TicketModel.php b/models/TicketModel.php index 7e05e42..5079224 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -773,9 +773,68 @@ class TicketModel $stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId); $result = $stmt->execute(); $stmt->close(); + + if ($result) { + $this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups); + } + return $result; } + /** + * Remove any watchers who no longer qualify for a ticket's access rules + * after its visibility was tightened. Without this, a user watching a + * ticket that's later made confidential/internal (and who isn't + * creator/assignee/admin/in the new visibility_groups) keeps receiving + * Matrix notifications about a ticket canUserAccessTicket() would now + * reject them from opening directly. + */ + private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void + { + $ticket = $this->getTicketById($ticketId); + if (!$ticket) { + return; + } + // getTicketById() reflects the just-committed UPDATE, but set these + // explicitly so pruning is correct even if a caller reorders things. + $ticket['visibility'] = $visibility; + $ticket['visibility_groups'] = $visibilityGroups; + + $sql = "SELECT tw.user_id, u.is_admin, u.`groups` + FROM ticket_watchers tw + JOIN users u ON tw.user_id = u.user_id + WHERE tw.ticket_id = ?"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param('s', $ticketId); + $stmt->execute(); + $watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + + $toRemove = []; + foreach ($watchers as $watcher) { + $watcherUser = [ + 'user_id' => $watcher['user_id'], + 'is_admin' => $watcher['is_admin'], + 'groups' => $watcher['groups'], + ]; + if (!$this->canUserAccessTicket($ticket, $watcherUser)) { + $toRemove[] = $watcher['user_id']; + } + } + + if (empty($toRemove)) { + return; + } + + $placeholders = implode(',', array_fill(0, count($toRemove), '?')); + $delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)"; + $delStmt = $this->conn->prepare($delSql); + $types = 's' . str_repeat('i', count($toRemove)); + $delStmt->bind_param($types, $ticketId, ...$toRemove); + $delStmt->execute(); + $delStmt->close(); + } + /** * Delete a ticket and all its associated records. * Admin-only operation. Removes comments, attachments, watchers, dependencies. diff --git a/views/layout_footer.php b/views/layout_footer.php index 144b561..8992dd6 100644 --- a/views/layout_footer.php +++ b/views/layout_footer.php @@ -235,8 +235,7 @@ } function loadNotifications() { - return fetch('/api/notifications.php', { credentials: 'same-origin' }) - .then(function(r) { return r.json(); }) + return lt.api.get('/api/notifications.php') .then(function(data) { renderNotifications(data); return true; }) .catch(function() { list.innerHTML = '
Could not load
'; @@ -251,11 +250,7 @@ if (clearBtn) { clearBtn.addEventListener('click', function() { - fetch('/api/notifications.php', { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' }, - body: JSON.stringify({ action: 'mark_read' }) - }).then(loadNotifications); + lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications); }); }