Security / PHP Security (semgrep) (push) Successful in 1m14s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Has been skipped
XSS / security:
- markdown.js: sanitize footnote labels to a safe slug before using them in
id/href attributes. Labels are captured before the HTML-escape pass, so a
label like x"><img onerror=...> broke out → stored XSS (the earlier quote-
escape fix didn't cover this path). Verified neutralized.
- RateLimitMiddleware: only trust X-Forwarded-For / X-Real-IP when REMOTE_ADDR
is a configured trusted proxy, and use the rightmost (proxy-appended) entry.
Previously any client could rotate XFF to escape the per-IP rate limit.
- .env.example: document TRUSTED_PROXIES so fresh deploys aren't fail-open on
the Authelia forward-auth spoofing protection.
Correctness:
- notifications.php: my previous assigned-to LIKE fix anchored only on '}', so
BULK assignments (logged {"assigned_to":N,"bulk_operation_id":..}) produced
no "assigned to you" notification — now matches both '}' and ',' delimiters.
- notifications.php: implement the documented @mention notifications (query
action_type='mention' rows for the current user); they were never delivered.
- NotificationHelper::notifyWatchers: guard unchecked prepare() so a missing
ticket_watchers table can't fatal the request after its DB write committed.
- AuditLogModel::getTicketTimeline: JSON_UNQUOTE the extracted ticket_id so
comment events actually match (string vs JSON-number comparison never did).
- AuditLogModel/audit_log.php: CSV export no longer silently truncates to the
1000-row UI cap; uses a dedicated higher export limit.
- DashboardView: quick-preview drawer read .ticket-link from the title cell
(which has none), so the title was always blank — use the cell text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
9.2 KiB
PHP
243 lines
9.2 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Notifications API
|
|
*
|
|
* GET → returns recent notifications for the current user (last 7 days, max 30)
|
|
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
|
|
*
|
|
* Notifications are derived from audit_log:
|
|
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
|
|
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
|
|
* - Status changes on watched (via ticket_watchers)
|
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
|
*/
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
|
|
|
$prefsModel = new UserPreferencesModel($conn);
|
|
|
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
if (($data['action'] ?? '') === 'mark_read') {
|
|
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
|
|
apiRespond(['success' => true]);
|
|
} else {
|
|
http_response_code(400);
|
|
apiRespond(['success' => false, 'error' => 'Unknown action']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// ── GET: fetch notifications ──────────────────────────────────────
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|
http_response_code(405);
|
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
|
exit;
|
|
}
|
|
|
|
// Get last_seen timestamp (when user last marked all read)
|
|
$prefs = $prefsModel->getUserPreferences($userId);
|
|
$lastSeen = $prefs['notif_last_seen'] ?? null;
|
|
|
|
// Username for @mention detection
|
|
$myUsername = $currentUser['username'] ?? '';
|
|
|
|
// Query 1: Tickets assigned to me (events from other users)
|
|
$assignSql = "SELECT
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
|
FROM audit_log al
|
|
LEFT JOIN users u ON al.user_id = u.user_id
|
|
WHERE al.action_type = 'assign'
|
|
AND al.entity_type = 'ticket'
|
|
AND al.user_id != ?
|
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
|
AND (al.details LIKE ? OR al.details LIKE ?)
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 15";
|
|
|
|
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
|
// match 120/123/etc. Single assigns log {"assigned_to":5} (closing brace) while
|
|
// bulk assigns log {"assigned_to":5,"bulk_operation_id":N} (comma) — match both.
|
|
$assignId = (int)$userId;
|
|
$assignEnd = '%"assigned_to":' . $assignId . '}%';
|
|
$assignMid = '%"assigned_to":' . $assignId . ',%';
|
|
$stmt = $conn->prepare($assignSql);
|
|
$stmt->bind_param('iss', $userId, $assignEnd, $assignMid);
|
|
$stmt->execute();
|
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|
$stmt->close();
|
|
|
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
|
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
|
|
|
|
// Step A: ticket IDs the current user owns or watches
|
|
$myTicketIds = [];
|
|
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
|
|
$stmt = $conn->prepare($myTicketsSql);
|
|
$stmt->bind_param('ii', $userId, $userId);
|
|
$stmt->execute();
|
|
$mtResult = $stmt->get_result();
|
|
while ($mtRow = $mtResult->fetch_assoc()) {
|
|
$myTicketIds[(int)$mtRow['ticket_id']] = true;
|
|
$myTicketIds[$mtRow['ticket_id']] = true;
|
|
}
|
|
$stmt->close();
|
|
|
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
|
$stmt = $conn->prepare($watchedSql);
|
|
$stmt->bind_param('i', $userId);
|
|
$stmt->execute();
|
|
$wResult = $stmt->get_result();
|
|
while ($wRow = $wResult->fetch_assoc()) {
|
|
$myTicketIds[(int)$wRow['ticket_id']] = true;
|
|
$myTicketIds[$wRow['ticket_id']] = true;
|
|
}
|
|
$stmt->close();
|
|
|
|
// Step B: fetch recent comment audit events not by the current user
|
|
$commentSql = "SELECT
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
|
FROM audit_log al
|
|
LEFT JOIN users u ON al.user_id = u.user_id
|
|
WHERE al.action_type IN ('comment', 'create')
|
|
AND al.entity_type = 'comment'
|
|
AND al.user_id != ?
|
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 50";
|
|
|
|
$stmt = $conn->prepare($commentSql);
|
|
$stmt->bind_param('i', $userId);
|
|
$stmt->execute();
|
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|
$stmt->close();
|
|
|
|
// Step C: filter to only comments on tickets the current user owns/watches
|
|
$commentRows = [];
|
|
foreach ($rawCommentRows as $rawRow) {
|
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
|
$tidRaw = $d['ticket_id'] ?? 0;
|
|
$tid = (int)$tidRaw;
|
|
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
|
$commentRows[] = $rawRow;
|
|
if (count($commentRows) >= 15) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Query 3: Status changes on watched tickets (from other users)
|
|
$statusSql = "SELECT DISTINCT
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
|
FROM audit_log al
|
|
LEFT JOIN users u ON al.user_id = u.user_id
|
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
|
WHERE al.action_type = 'update'
|
|
AND al.entity_type = 'ticket'
|
|
AND al.user_id != ?
|
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
|
AND al.details LIKE '%\"status\":%'
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 10";
|
|
|
|
$stmt = $conn->prepare($statusSql);
|
|
$stmt->bind_param('ii', $userId, $userId);
|
|
$stmt->execute();
|
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|
$stmt->close();
|
|
|
|
// Query 4: @mentions of me (logged by add_comment.php as
|
|
// action_type='mention', entity_type='user', entity_id=<mentioned user_id>).
|
|
$mentionSql = "SELECT
|
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
|
FROM audit_log al
|
|
LEFT JOIN users u ON al.user_id = u.user_id
|
|
WHERE al.action_type = 'mention'
|
|
AND al.entity_type = 'user'
|
|
AND al.entity_id = ?
|
|
AND al.user_id != ?
|
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 15";
|
|
|
|
$mentionEntityId = (string)$userId;
|
|
$stmt = $conn->prepare($mentionSql);
|
|
$stmt->bind_param('si', $mentionEntityId, $userId);
|
|
$stmt->execute();
|
|
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|
$stmt->close();
|
|
|
|
// Merge, deduplicate by log_id, sort by created_at desc
|
|
$all = [];
|
|
$seen = [];
|
|
foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $row) {
|
|
$id = (int)$row['log_id'];
|
|
if (isset($seen[$id])) {
|
|
continue;
|
|
}
|
|
$seen[$id] = true;
|
|
$all[] = $row;
|
|
}
|
|
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
|
$all = array_slice($all, 0, 30);
|
|
|
|
// Format for response
|
|
$notifications = [];
|
|
foreach ($all as $row) {
|
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
|
// Comment rows: entity_id is the comment_id; real ticket_id is in details
|
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
|
? 'comment'
|
|
: $row['action_type'];
|
|
$ticketId = ($actionType === 'comment' || $actionType === 'mention')
|
|
? ($details['ticket_id'] ?? 0)
|
|
: $row['entity_id'];
|
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
|
|
|
// Build human-readable title
|
|
$title = match ($actionType) {
|
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
|
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
|
'update' => (function () use ($row, $details, $ticketId) {
|
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
|
})(),
|
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
|
};
|
|
|
|
$ticketTitle = $details['title'] ?? null;
|
|
if ($ticketTitle) {
|
|
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
|
|
}
|
|
|
|
$notifications[] = [
|
|
'log_id' => (int)$row['log_id'],
|
|
'ticket_id' => $ticketId,
|
|
'title' => $title,
|
|
'created_at' => $row['created_at'],
|
|
'is_read' => $isRead,
|
|
'action' => $actionType,
|
|
'url' => "/ticket/{$ticketId}",
|
|
];
|
|
}
|
|
|
|
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
|
|
|
|
apiRespond([
|
|
'success' => true,
|
|
'notifications' => $notifications,
|
|
'unread_count' => $unreadCount,
|
|
'last_seen' => $lastSeen,
|
|
]);
|