false, 'error' => 'Authentication required']); exit; } // CSRF Protection for all state-changing methods (any non-GET/HEAD request) require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) { $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { http_response_code(403); header('Content-Type: application/json'); echo json_encode([ 'success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken() ]); exit; } // Rotate token after successful validation $newCsrfToken = CsrfMiddleware::rotateToken(); } $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; // Use centralized database connection $conn = Database::getConnection(); // Get POST data $data = json_decode(file_get_contents('php://input'), true); if (!$data) { http_response_code(400); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']); exit; } $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; if (!ctype_digit($ticketId) || (int)$ticketId <= 0) { http_response_code(400); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']); exit; } // Reject empty/whitespace-only comments $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; if ($commentTextRaw === '') { http_response_code(400); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']); exit; } // Never trust a client-supplied display name — always attribute the comment to // the authenticated session user. $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; // Verify user can access the ticket before allowing a comment $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById($ticketId); if (!$ticket) { http_response_code(404); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Ticket not found']); exit; } if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) { http_response_code(403); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Access denied']); exit; } // Initialize models $commentModel = new CommentModel($conn); $auditLog = new AuditLogModel($conn); // If replying, the parent comment must belong to this same (accessible) ticket. if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') { $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']); if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) { http_response_code(400); ob_end_clean(); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Invalid parent comment']); exit; } } // Extract @mentions from comment text $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); $mentionedUsers = []; if (!empty($mentions)) { $mentionedUsers = $commentModel->getMentionedUsers($mentions); } // Add comment with user tracking $result = $commentModel->addComment($ticketId, $data, $userId); // Log comment creation to audit log if ($result['success'] && isset($result['comment_id'])) { $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); // Log mentions to audit log foreach ($mentionedUsers as $mentionedUser) { $auditLog->log( $userId, 'mention', 'user', (string)$mentionedUser['user_id'], [ 'ticket_id' => $ticketId, 'comment_id' => $result['comment_id'], 'mentioned_username' => $mentionedUser['username'] ] ); } // Matrix notifications $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; $commentText = $data['comment_text'] ?? ''; $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'); $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); if (!empty($mentionedMatrixIds)) { NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); } } // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { NotificationHelper::sendCommentNotification( $ticketId, $ticketTitle, $commentText, $authorDisplay, $ticketVisibility !== 'public', $ticketVisibility ); } // Notify watchers of the new comment NotificationHelper::notifyWatchers( $conn, $ticketId, $ticketTitle, 'comment_added', ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')], (int)$userId, $ticketVisibility ); // Add mentioned users to result for frontend $result['mentions'] = array_map(function ($u) { return $u['username']; }, $mentionedUsers); } // Add user info to result for frontend avatar rendering if ($result['success']) { $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; $result['user_id'] = $userId; if (isset($newCsrfToken)) { $result['csrf_token'] = $newCsrfToken; } } // Discard any unexpected output ob_end_clean(); // Return JSON response if ($result['success']) { http_response_code(201); } header('Content-Type: application/json'); echo json_encode($result); } catch (Exception $e) { // Discard any unexpected output ob_end_clean(); // Log error details but don't expose to client error_log("Add comment API error: " . $e->getMessage()); // Return error response http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'success' => false, 'error' => 'An internal error occurred' ]); }