- Database.php: pin MySQL session time_zone to the configured named zone (mysql.time_zone tables now loaded on the DB) with a fixed-offset fallback, so NOW()/TIMESTAMP and PHP agree regardless of the DB server's SYSTEM tz. Best-effort, never fatals the connection. - NotificationHelper: redact comment-body previews for internal/ confidential tickets in sendCommentNotification and notifyWatchers so they are not leaked to the shared Matrix notify list (new $visibility param; callers wired in the API batch). - config.php: die with a clear error if parse_ini_file fails instead of silently falling back to insecure defaults (empty DB pass / proxies). - CacheHelper: create cache dir 0700 and cache files 0600 so other local users cannot read or poison security-relevant cached data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
11 KiB
PHP
278 lines
11 KiB
PHP
<?php
|
|
|
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
|
|
|
class NotificationHelper
|
|
{
|
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
|
|
|
private static function fire(array $payload): void
|
|
{
|
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
|
if (empty($webhookUrl)) {
|
|
return;
|
|
}
|
|
|
|
$ch = curl_init($webhookUrl);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
$id = $payload['ticket_id'] ?? '?';
|
|
if ($curlError) {
|
|
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
|
|
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
|
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
|
|
}
|
|
}
|
|
|
|
private static function notifyUsers(): array
|
|
{
|
|
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
|
}
|
|
|
|
// ─── Public event methods ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* New ticket created (manual or automated/API).
|
|
*/
|
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
|
{
|
|
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
|
|
|
self::fire([
|
|
'event' => 'ticket_created',
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketData['title'] ?? 'Untitled',
|
|
'priority' => (int)($ticketData['priority'] ?? 4),
|
|
'category' => $ticketData['category'] ?? 'General',
|
|
'type' => $ticketData['type'] ?? 'Issue',
|
|
'status' => $ticketData['status'] ?? 'Open',
|
|
'source' => $source,
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'trigger' => $trigger,
|
|
'notify_users' => self::notifyUsers(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Ticket status changed.
|
|
*
|
|
* @param string|int $ticketId
|
|
* @param string $oldStatus
|
|
* @param string $newStatus
|
|
* @param string $ticketTitle
|
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
|
*/
|
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
|
{
|
|
self::fire([
|
|
'event' => 'status_changed',
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketTitle,
|
|
'old_status' => $oldStatus,
|
|
'new_status' => $newStatus,
|
|
'changed_by' => $changedByDisplay,
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'notify_users' => self::notifyUsers(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* New comment posted (non-mention; use sendMentionNotification for @mentions).
|
|
*
|
|
* @param string|int $ticketId
|
|
* @param string $ticketTitle
|
|
* @param string $commentText Plain text (first 200 chars will be sent)
|
|
* @param string|null $authorDisplay Display name of commenter
|
|
* @param bool $isInternal True if the comment is internal-only
|
|
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
|
* 'confidential'. For non-public tickets the
|
|
* comment text preview is redacted so it is
|
|
* never leaked to the shared notify list.
|
|
*/
|
|
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false, string $visibility = 'public'): void
|
|
{
|
|
$notifyUsers = self::notifyUsers();
|
|
if (empty($notifyUsers)) {
|
|
return;
|
|
}
|
|
|
|
// The shared notify list may include users without access to non-public
|
|
// tickets, so never post the comment body for internal/confidential
|
|
// tickets — only that activity occurred.
|
|
$preview = $visibility === 'public'
|
|
? mb_strimwidth($commentText, 0, 200, '…')
|
|
: null;
|
|
|
|
self::fire([
|
|
'event' => 'comment_added',
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketTitle,
|
|
'author' => $authorDisplay,
|
|
'preview' => $preview,
|
|
'is_internal' => $isInternal,
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'notify_users' => $notifyUsers,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @mention detected in a comment.
|
|
*
|
|
* @param string|int $ticketId
|
|
* @param string $ticketTitle
|
|
* @param string $commentText
|
|
* @param string|null $authorDisplay
|
|
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
|
*/
|
|
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
|
|
{
|
|
if (empty($mentionedMatrixIds)) {
|
|
return;
|
|
}
|
|
|
|
self::fire([
|
|
'event' => 'mention',
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketTitle,
|
|
'author' => $authorDisplay,
|
|
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'notify_users' => $mentionedMatrixIds,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Notify all watchers of a ticket about an update event.
|
|
*
|
|
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
|
|
* and fires the appropriate event notification with them in notify_users.
|
|
*
|
|
* @param \mysqli $conn
|
|
* @param string|int $ticketId
|
|
* @param string $ticketTitle
|
|
* @param string $event One of: status_changed, comment_added, assigned
|
|
* @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.
|
|
*/
|
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
|
{
|
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
|
if (!$webhookUrl || !$domain) {
|
|
return;
|
|
}
|
|
|
|
// Don't leak comment/body content to the shared notify list for
|
|
// non-public tickets — keep only the fact that activity occurred.
|
|
if ($visibility !== 'public' && isset($extraData['preview'])) {
|
|
$extraData['preview'] = null;
|
|
}
|
|
|
|
// Fetch watcher usernames, excluding the actor so they don't notify
|
|
// themselves. Notifications are best-effort: if the watchers table is
|
|
// absent or the query fails, skip silently rather than fataling the
|
|
// request that already committed its DB change. mysqli may either throw
|
|
// (default exception mode) or return false, so handle both.
|
|
$usernames = [];
|
|
try {
|
|
if ($excludeUserId !== null) {
|
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
|
$stmt = $conn->prepare($sql);
|
|
} else {
|
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
|
$stmt = $conn->prepare($sql);
|
|
}
|
|
if (!$stmt) {
|
|
return;
|
|
}
|
|
if ($excludeUserId !== null) {
|
|
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
|
} else {
|
|
$stmt->bind_param("i", $ticketId);
|
|
}
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
$stmt->close();
|
|
|
|
while ($row = $result->fetch_assoc()) {
|
|
$usernames[] = $row['username'];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('NotificationHelper::notifyWatchers watcher lookup failed: ' . $e->getMessage());
|
|
return;
|
|
}
|
|
|
|
if (empty($usernames)) {
|
|
return;
|
|
}
|
|
|
|
// Resolve to Matrix IDs — skip users without Synapse accounts
|
|
$matrixIds = SynapseHelper::resolveUsernames($usernames);
|
|
if (empty($matrixIds)) {
|
|
return;
|
|
}
|
|
|
|
// Remove the global notify list duplicates and build payload
|
|
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
|
|
|
$payload = array_merge($extraData, [
|
|
'event' => $event,
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketTitle,
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'notify_users' => array_values($allNotify),
|
|
]);
|
|
|
|
self::fire($payload);
|
|
}
|
|
|
|
/**
|
|
* Ticket assigned (or reassigned) to a user.
|
|
*
|
|
* @param string|int $ticketId
|
|
* @param string $ticketTitle
|
|
* @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
|
|
*/
|
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
|
{
|
|
$notifyUsers = self::notifyUsers();
|
|
// Also notify the assignee directly if we know their Matrix ID
|
|
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
|
$notifyUsers[] = $assigneeMatrix;
|
|
}
|
|
if (empty($notifyUsers)) {
|
|
return;
|
|
}
|
|
|
|
self::fire([
|
|
'event' => 'assigned',
|
|
'ticket_id' => $ticketId,
|
|
'title' => $ticketTitle,
|
|
'assignee' => $assigneeName,
|
|
'changed_by' => $changedByDisplay,
|
|
'url' => UrlHelper::ticketUrl($ticketId),
|
|
'notify_users' => $notifyUsers,
|
|
]);
|
|
}
|
|
}
|