From 442cd1d6f659fd0316d6b2473b27a93857abab83 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:49:06 -0400 Subject: [PATCH 1/4] Redact ticket title for non-public tickets in create/status-change Matrix notifications (#46) sendTicketNotification() and sendStatusChangeNotification() always sent the ticket title to the shared MATRIX_NOTIFY_USERS list regardless of visibility, unlike sendCommentNotification()/notifyWatchers() which already redact the comment/activity preview for non-public tickets. Creating or changing the status of a confidential ticket broadcast its title to a shared Matrix room, defeating the point of the Confidential visibility level. Added a shared redactedTitle() helper and threaded visibility through both functions (sendTicketNotification reads it from the existing $ticketData['visibility'] key; sendStatusChangeNotification takes a new optional parameter, wired up in both callers from the already-fetched ticket row). Verified end-to-end with a local HTTP server capturing the actual webhook payloads: public tickets pass the title through unchanged, confidential/internal tickets get the redacted placeholder. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- api/ticket_status_api.php | 3 ++- api/update_ticket.php | 3 ++- helpers/NotificationHelper.php | 31 +++++++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/api/ticket_status_api.php b/api/ticket_status_api.php index 144ce68..c7007dd 100644 --- a/api/ticket_status_api.php +++ b/api/ticket_status_api.php @@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) { $currentStatus, $newStatus, (string)$ticket['title'], - $keyName + $keyName, + $ticket['visibility'] ?? 'public' ); NotificationHelper::notifyWatchers( $conn, diff --git a/api/update_ticket.php b/api/update_ticket.php index de210be..0b51442 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -267,7 +267,8 @@ try { $currentTicket['status'], $updateData['status'], $updateData['title'], - $changedBy + $changedBy, + $currentTicket['visibility'] ?? 'public' ); NotificationHelper::notifyWatchers( $this->conn, diff --git a/helpers/NotificationHelper.php b/helpers/NotificationHelper.php index af4cf91..548c105 100644 --- a/helpers/NotificationHelper.php +++ b/helpers/NotificationHelper.php @@ -40,20 +40,40 @@ class NotificationHelper return array_values(array_filter(array_map('trim', explode(',', $raw)))); } + /** + * Redact a ticket title for the shared Matrix notify list when the + * ticket isn't public, matching how sendCommentNotification() and + * notifyWatchers() already redact comment/activity previews for the + * same list. + */ + private static function redactedTitle(string $title, string $visibility): string + { + return $visibility === 'public' ? $title : '(restricted ticket — title hidden)'; + } + // ─── Public event methods ───────────────────────────────────────────────── /** * New ticket created (manual or automated/API). + * + * $ticketData['visibility'] ('public', 'internal', or 'confidential') is + * used to redact the title sent to the shared MATRIX_NOTIFY_USERS list + * for non-public tickets, same as sendCommentNotification()'s preview + * redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon + * Bearer-API paths) that never set a non-default visibility. */ public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void { - preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m); + $visibility = $ticketData['visibility'] ?? 'public'; + $title = $ticketData['title'] ?? 'Untitled'; + + preg_match('/^\[([^\]]+)\]/', $title, $m); $source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual'); self::fire([ 'event' => 'ticket_created', 'ticket_id' => $ticketId, - 'title' => $ticketData['title'] ?? 'Untitled', + 'title' => self::redactedTitle($title, $visibility), 'priority' => (int)($ticketData['priority'] ?? 4), 'category' => $ticketData['category'] ?? 'General', 'type' => $ticketData['type'] ?? 'Issue', @@ -73,13 +93,16 @@ class NotificationHelper * @param string $newStatus * @param string $ticketTitle * @param string|null $changedByDisplay Display name of the user who changed status + * @param string $visibility Ticket visibility; non-public titles are + * redacted before being sent to the shared + * notify list, same as sendTicketNotification(). */ - public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void + public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void { self::fire([ 'event' => 'status_changed', 'ticket_id' => $ticketId, - 'title' => $ticketTitle, + 'title' => self::redactedTitle($ticketTitle, $visibility), 'old_status' => $oldStatus, 'new_status' => $newStatus, 'changed_by' => $changedByDisplay, From 90d798966b976964cd739fd82d898de3f9782dc3 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:49:17 -0400 Subject: [PATCH 2/4] Exclude shared notify list and redact title in notifyWatchers() for non-public tickets (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifyWatchers() only redacted the comment/activity preview for non-public tickets — the shared MATRIX_NOTIFY_USERS list was still merged into notify_users unconditionally, and the ticket title was never redacted at all. A status-change/comment notification for a confidential ticket with watchers still broadcast that ticket's title to the shared list, even though the function's own docblock intended to protect non-public tickets from it. For non-public tickets, the shared list is now excluded entirely (only actual watchers are notified) and the title is redacted via the same redactedTitle() helper added for #46. Verified against real MariaDB with a real watcher row: for a confidential ticket, the captured webhook payload has only the watcher's Matrix ID (no shared list) and a redacted title; for the same ticket made public, the shared list is included and the title passes through unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- helpers/NotificationHelper.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/helpers/NotificationHelper.php b/helpers/NotificationHelper.php index 548c105..532d8aa 100644 --- a/helpers/NotificationHelper.php +++ b/helpers/NotificationHelper.php @@ -189,11 +189,12 @@ class NotificationHelper * @param array $extraData Merged into the payload (old_status/new_status, author, etc.) * @param int|null $excludeUserId Don't notify the actor themselves * @param string $visibility Ticket visibility: 'public', 'internal', or - * 'confidential'. notify_users includes the - * shared list, which may contain users without - * access to non-public tickets, so any comment - * body preview in $extraData is redacted for - * non-public tickets. + * 'confidential'. The shared notify list may + * contain users without access to non-public + * tickets, so for those tickets it's excluded + * entirely (only actual watchers are notified) + * and both the title and any comment/body + * preview in $extraData are redacted. */ public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void { @@ -253,13 +254,17 @@ class NotificationHelper return; } - // Remove the global notify list duplicates and build payload - $allNotify = array_unique(array_merge($matrixIds, self::notifyUsers())); + // The shared notify list may include users without access to + // non-public tickets, so only mix it in for public tickets — for + // internal/confidential tickets, notify actual watchers only. + $allNotify = $visibility === 'public' + ? array_unique(array_merge($matrixIds, self::notifyUsers())) + : $matrixIds; $payload = array_merge($extraData, [ 'event' => $event, 'ticket_id' => $ticketId, - 'title' => $ticketTitle, + 'title' => self::redactedTitle($ticketTitle, $visibility), 'url' => UrlHelper::ticketUrl($ticketId), 'notify_users' => array_values($allNotify), ]); From 60bafae8a0688b442edce54db549ba22a5df0aba Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:49:34 -0400 Subject: [PATCH 3/4] Redact ticket title for non-public tickets in assignment notifications (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendAssignmentNotification() had the same missing-visibility gap as #46 in a separate function: assigning a user to a confidential/internal ticket broadcast the ticket title to the shared Matrix notify list unconditionally when MATRIX_NOTIFY_ASSIGNMENTS is enabled. Threaded visibility through using the same redactedTitle() helper added for #46, wired up from the already-fetched ticket row in assign_ticket.php. The assignee is still DMed directly regardless, since being assigned gives them standing access to the ticket — but because notify_users is one shared payload, they see the same redacted title as everyone else on it rather than a personalized one. Verified end-to-end with a local HTTP server capturing the webhook payload for both public and confidential tickets. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- api/assign_ticket.php | 3 ++- helpers/NotificationHelper.php | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api/assign_ticket.php b/api/assign_ticket.php index c616de5..1046909 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') { $ticket['title'] ?? "Ticket #{$ticketId}", $assigneeName, $assigneeMatrix, - $changedByDisplay + $changedByDisplay, + $ticket['visibility'] ?? 'public' ); } } diff --git a/helpers/NotificationHelper.php b/helpers/NotificationHelper.php index 532d8aa..179043f 100644 --- a/helpers/NotificationHelper.php +++ b/helpers/NotificationHelper.php @@ -280,8 +280,14 @@ class NotificationHelper * @param string|null $assigneeName Display name of new assignee * @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM) * @param string|null $changedByDisplay + * @param string $visibility Ticket visibility; non-public titles are + * redacted before being sent to the shared + * notify list, same as sendTicketNotification(). + * The assignee is DMed directly regardless, + * since they now have standing access to the + * ticket by virtue of being assigned to it. */ - public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void + public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void { $notifyUsers = self::notifyUsers(); // Also notify the assignee directly if we know their Matrix ID @@ -295,7 +301,7 @@ class NotificationHelper self::fire([ 'event' => 'assigned', 'ticket_id' => $ticketId, - 'title' => $ticketTitle, + 'title' => self::redactedTitle($ticketTitle, $visibility), 'assignee' => $assigneeName, 'changed_by' => $changedByDisplay, 'url' => UrlHelper::ticketUrl($ticketId), From 5709c3134f4bfb5a7362bbcb4a3a1a756f59acaa Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:49:42 -0400 Subject: [PATCH 4/4] Verify mentioned-user access before sending @mention notifications (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendMentionNotification(), called from add_comment.php, had no visibility check at all — unlike sendCommentNotification()/ notifyWatchers() which redact the comment preview for non-public tickets. Mentioning a user with zero standing access to a confidential ticket (not creator/assignee/admin, not in visibility_groups) sent them a Matrix DM with the full ticket title AND comment text — worse than #46 since it's delivered directly to an individual rather than diluted into a shared list. add_comment.php now filters mentioned users through canUserAccessTicket() before resolving Matrix IDs, skipping the notification entirely for anyone without access (one of the two options the issue names as acceptable). getMentionedUsers() needed to start selecting is_admin and groups alongside user_id/username/ display_name, since canUserAccessTicket() requires them. Verified against real MariaDB: a user mentioned on a confidential ticket they don't own/aren't assigned to is correctly denied, a user in the matching visibility_groups for an internal ticket is correctly allowed, and the same user is correctly denied on a different internal ticket whose group they're not in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- api/add_comment.php | 13 ++++++++++--- models/CommentModel.php | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/api/add_comment.php b/api/add_comment.php index 06538b7..6fecce9 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -177,9 +177,16 @@ try { $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; $ticketVisibility = $ticket['visibility'] ?? 'public'; - // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API - if (!empty($mentionedUsers)) { - $mentionedUsernames = array_column($mentionedUsers, 'username'); + // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API. + // Only notify mentioned users who actually have access to this ticket; + // otherwise a mention would DM them the ticket's title and comment text + // even though canUserAccessTicket() would deny them the ticket itself. + $accessibleMentionedUsers = array_filter( + $mentionedUsers, + fn($u) => $ticketModel->canUserAccessTicket($ticket, $u) + ); + if (!empty($accessibleMentionedUsers)) { + $mentionedUsernames = array_column($accessibleMentionedUsers, 'username'); $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); if (!empty($mentionedMatrixIds)) { NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); diff --git a/models/CommentModel.php b/models/CommentModel.php index 419f26b..57b816e 100644 --- a/models/CommentModel.php +++ b/models/CommentModel.php @@ -38,7 +38,7 @@ class CommentModel } $placeholders = str_repeat('?,', count($usernames) - 1) . '?'; - $sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)"; + $sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)"; $stmt = $this->conn->prepare($sql); $types = str_repeat('s', count($usernames));