Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 47s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m31s
Lint / Deploy (push) Successful in 4s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
258 lines
9.4 KiB
PHP
258 lines
9.4 KiB
PHP
<?php
|
|
|
|
// Disable error display in the output
|
|
ini_set('display_errors', 0);
|
|
error_reporting(E_ALL);
|
|
|
|
// Apply rate limiting
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api');
|
|
|
|
// Start output buffering to capture any errors
|
|
ob_start();
|
|
|
|
try {
|
|
// Include required files with proper error handling
|
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
|
|
|
if (!file_exists($configPath)) {
|
|
throw new Exception("Config file not found: $configPath");
|
|
}
|
|
|
|
if (!file_exists($commentModelPath)) {
|
|
throw new Exception("CommentModel file not found: $commentModelPath");
|
|
}
|
|
|
|
require_once $configPath;
|
|
require_once $commentModelPath;
|
|
require_once $auditLogModelPath;
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
|
|
|
// Check authentication via session
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
ob_end_clean();
|
|
http_response_code(401);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => 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;
|
|
}
|
|
|
|
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
|
// and keeps stored comment_text free of leading whitespace that could shift a
|
|
// markdown-enabled comment's first line out of column 0 on reload.
|
|
$data['comment_text'] = $commentTextRaw;
|
|
|
|
// 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.
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// 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'
|
|
]);
|
|
}
|