Merge development into main: visibility-notification pruning + CSRF UX + custom field type validation (#48, #50, #57, #73, #86)
- Prune watchers when a ticket's visibility is tightened (#73) - Re-check ticket visibility before surfacing in-app notifications (#48) - Use lt.api instead of raw fetch() in notification bell (#57) - Auto-retry once after CSRF token resync in lt.api (#86) - Validate field_type against the allowed enum in custom field definitions (#50)
This commit is contained in:
+40
-1
@@ -15,8 +15,10 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
|
||||
$all[] = $row;
|
||||
}
|
||||
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||
$all = array_slice($all, 0, 30);
|
||||
|
||||
// Re-check current ticket visibility before surfacing anything: a
|
||||
// notification's audit_log entry reflects historical activity, but the
|
||||
// ticket's visibility (or the user's group/watcher standing) may have
|
||||
// tightened since. Without this, a notification still discloses the
|
||||
// ticket's title and that activity occurred to someone who currently
|
||||
// shouldn't see it, even though the ticket view's own access check would
|
||||
// correctly reject them from opening it.
|
||||
$candidateTicketIds = [];
|
||||
foreach ($all as $row) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = ($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id'];
|
||||
if ($tid) {
|
||||
$candidateTicketIds[(string)$tid] = true;
|
||||
}
|
||||
}
|
||||
$ticketsById = !empty($candidateTicketIds)
|
||||
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
|
||||
: [];
|
||||
|
||||
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id']);
|
||||
$ticket = $ticketsById[$tid] ?? null;
|
||||
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
|
||||
});
|
||||
|
||||
$all = array_slice(array_values($all), 0, 30);
|
||||
|
||||
// Format for response
|
||||
$notifications = [];
|
||||
|
||||
+10
-1
@@ -2801,7 +2801,7 @@
|
||||
};
|
||||
|
||||
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
|
||||
async function _apiFetchAuth(method, url, body) {
|
||||
async function _apiFetchAuth(method, url, body, retried) {
|
||||
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
|
||||
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
||||
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
|
||||
@@ -2821,6 +2821,15 @@
|
||||
// Resync CSRF token from any response body that carries a fresh one
|
||||
// (bootstrap rotates on success and returns the current token on rejection).
|
||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
|
||||
// shorter than the session idle timeout (5h), so this is a routine,
|
||||
// recoverable case (an hour of inactivity, or a write in another tab
|
||||
// rotating the shared token) rather than a real rejection — resyncing
|
||||
// above already has the fresh token, so silently resending once succeeds
|
||||
// transparently instead of surfacing a confusing error on the first try.
|
||||
if (resp.status === 403 && !retried && data && data.csrf_token) {
|
||||
return _apiFetchAuth(method, url, body, true);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
err.data = data;
|
||||
|
||||
@@ -8,6 +8,9 @@ class CustomFieldModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
// Must match custom_field_definitions.field_type's enum() in the schema.
|
||||
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
|
||||
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
@@ -87,6 +90,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function createDefinition($data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
@@ -129,6 +136,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
|
||||
@@ -773,9 +773,68 @@ class TicketModel
|
||||
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if ($result) {
|
||||
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any watchers who no longer qualify for a ticket's access rules
|
||||
* after its visibility was tightened. Without this, a user watching a
|
||||
* ticket that's later made confidential/internal (and who isn't
|
||||
* creator/assignee/admin/in the new visibility_groups) keeps receiving
|
||||
* Matrix notifications about a ticket canUserAccessTicket() would now
|
||||
* reject them from opening directly.
|
||||
*/
|
||||
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
|
||||
{
|
||||
$ticket = $this->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
return;
|
||||
}
|
||||
// getTicketById() reflects the just-committed UPDATE, but set these
|
||||
// explicitly so pruning is correct even if a caller reorders things.
|
||||
$ticket['visibility'] = $visibility;
|
||||
$ticket['visibility_groups'] = $visibilityGroups;
|
||||
|
||||
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
|
||||
FROM ticket_watchers tw
|
||||
JOIN users u ON tw.user_id = u.user_id
|
||||
WHERE tw.ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
$stmt->close();
|
||||
|
||||
$toRemove = [];
|
||||
foreach ($watchers as $watcher) {
|
||||
$watcherUser = [
|
||||
'user_id' => $watcher['user_id'],
|
||||
'is_admin' => $watcher['is_admin'],
|
||||
'groups' => $watcher['groups'],
|
||||
];
|
||||
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
|
||||
$toRemove[] = $watcher['user_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($toRemove)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
|
||||
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
|
||||
$delStmt = $this->conn->prepare($delSql);
|
||||
$types = 's' . str_repeat('i', count($toRemove));
|
||||
$delStmt->bind_param($types, $ticketId, ...$toRemove);
|
||||
$delStmt->execute();
|
||||
$delStmt->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a ticket and all its associated records.
|
||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||
|
||||
@@ -235,8 +235,7 @@
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
return lt.api.get('/api/notifications.php')
|
||||
.then(function(data) { renderNotifications(data); return true; })
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||
@@ -251,11 +250,7 @@
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
fetch('/api/notifications.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||
body: JSON.stringify({ action: 'mark_read' })
|
||||
}).then(loadNotifications);
|
||||
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user