Verified, high-confidence fixes from a project-wide review: - markdown.js: escape " and ' in the HTML-escape step. User-controlled image/link URLs and alt text were interpolated into "..." attributes without quote escaping, allowing attribute breakout and injected event handlers (stored XSS, only mitigated by CSP). Flagged independently by two reviewers. - cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a mangled crontab example inside the docblock contained */ which closed the comment early, causing a fatal parse error — both cron jobs never ran. Rewrote the docblocks without a literal */. - update_ticket.php: validate visibility BEFORE the core DB write so an invalid payload can't leave the ticket updated while the request reports failure (which also skipped the audit delta and stats cache invalidation). - watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6 list); use an unbounded COUNT(*) so it matches the POST path. - notifications.php: "assigned to me" LIKE pattern lacked a trailing delimiter, so user 12 also matched 120/123/etc.; anchor with }. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
3.8 KiB
PHP
120 lines
3.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Watch / Unwatch Ticket API
|
|
*
|
|
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
|
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
|
*/
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
|
|
$ticketId = isset($_GET['ticket_id'])
|
|
? (int)$_GET['ticket_id']
|
|
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
$ticketId = (int)($data['ticket_id'] ?? 0);
|
|
$action = $data['action'] ?? '';
|
|
|
|
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
|
exit;
|
|
}
|
|
|
|
$ticketModel = new TicketModel($conn);
|
|
$ticket = $ticketModel->getTicketById($ticketId);
|
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
|
http_response_code(404);
|
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
|
exit;
|
|
}
|
|
|
|
if ($action === 'watch') {
|
|
$stmt = $conn->prepare(
|
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
|
);
|
|
$stmt->bind_param("ii", $ticketId, $userId);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
} else {
|
|
$stmt = $conn->prepare(
|
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
|
);
|
|
$stmt->bind_param("ii", $ticketId, $userId);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
|
|
// Return updated state
|
|
$countStmt = $conn->prepare(
|
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
|
);
|
|
$countStmt->bind_param("i", $ticketId);
|
|
$countStmt->execute();
|
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
|
$countStmt->close();
|
|
|
|
apiRespond([
|
|
'success' => true,
|
|
'watching' => $action === 'watch',
|
|
'watcher_count' => $count,
|
|
]);
|
|
}
|
|
|
|
// GET — return current watch state for this user
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|
http_response_code(405);
|
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
|
exit;
|
|
}
|
|
|
|
if ($ticketId <= 0) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
|
exit;
|
|
}
|
|
|
|
$watchingStmt = $conn->prepare(
|
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
|
);
|
|
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
|
$watchingStmt->execute();
|
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
|
$watchingStmt->close();
|
|
|
|
// Fetch watcher list (up to 6) with display names for avatar group
|
|
$watchersStmt = $conn->prepare(
|
|
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
|
|
FROM ticket_watchers tw
|
|
JOIN users u ON tw.user_id = u.user_id
|
|
WHERE tw.ticket_id = ?
|
|
ORDER BY tw.created_at ASC
|
|
LIMIT 6"
|
|
);
|
|
$watchersStmt->bind_param("i", $ticketId);
|
|
$watchersStmt->execute();
|
|
$watchersResult = $watchersStmt->get_result();
|
|
$watchers = [];
|
|
while ($row = $watchersResult->fetch_assoc()) {
|
|
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
|
}
|
|
$watchersStmt->close();
|
|
|
|
// True watcher count (the list above is capped at 6 for the avatar group)
|
|
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
|
$countStmt->bind_param("i", $ticketId);
|
|
$countStmt->execute();
|
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
|
$countStmt->close();
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'watching' => $watching,
|
|
'watcher_count' => $count,
|
|
'watchers' => $watchers,
|
|
]);
|