Files
tinker_tickets/api/add_comment.php
T
jaredandClaude Opus 4.8 d535557e5a
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 36s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 20s
Security / PHP Security (semgrep) (pull_request) Successful in 1m10s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
Strip trailing whitespace failing phpcs (unblocks CI/deploy)
CI has been red since the CSRF-drift changes landed a trailing space on the
'success' => false line in these two endpoints, which blocks the deploy job
(and therefore beta/prod). No logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:46:04 -04:00

246 lines
8.8 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;
}
// 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'
]);
}