README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.
Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.
Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.
For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.
Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
258 lines
9.5 KiB
PHP
258 lines
9.5 KiB
PHP
<?php
|
|
|
|
// Disable error display in the output
|
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|
ErrorHandler::init();
|
|
|
|
// 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'
|
|
]);
|
|
}
|