Fix data-layer bugs: bind_param fatals, ticket_id bindings, cache poisoning
- CustomFieldModel: assign ?? fallbacks to variables before bind_param
(by-reference args cannot be ?? expressions; fatal on PHP 8.2, custom
fields were uncreatable/uneditable)
- RecurringTicketModel::create: fix swapped bind type for schedule_type
(enum bound as int coerced 'daily' to 0, breaking the cron)
- TicketModel/CommentModel: bind varchar ticket_id as string not int so
the unique index is usable and leading-zero IDs match; ticket_watchers
(int column) left as integer
- TicketModel::deleteTicket: delete from custom_field_values (real table)
not the nonexistent ticket_custom_fields
- TicketModel search: honor literal '0'; never emit AGAINST('*') on
all-special-char input (fall back to LIKE)
- TicketModel::updateTicket: disambiguate not-found vs no-op vs genuine
optimistic-lock conflict on zero affected rows
- WorkflowModel: do not cache transitions/statuses on DB failure (a
transient error no longer blocks all status changes for the TTL)
- DependencyModel: filter linked tickets by visibility (new optional user
context params) to stop confidential metadata leaking via dependencies
- BulkOperationsModel: validate status/priority/assignee before mutating
- AuditLogModel: gate getClientIP forwarded headers on trusted proxies;
add missing action/entity types so audit-log filters work
- WorkflowModel: add transitionRequiresComment() accessor for enforcement
- CommentModel: stop leaking raw DB errors to clients (log instead)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+46
-31
@@ -9,7 +9,7 @@ class TicketModel
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getTicketById(int $id): ?array
|
||||
public function getTicketById(string $id): ?array
|
||||
{
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
@@ -24,7 +24,7 @@ class TicketModel
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
WHERE t.ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->bind_param("s", $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
@@ -82,18 +82,22 @@ class TicketModel
|
||||
$paramTypes .= str_repeat('s', count($types));
|
||||
}
|
||||
|
||||
// Search Functionality — use FULLTEXT when available, fall back to LIKE
|
||||
if ($search && !empty($search)) {
|
||||
if ($this->hasFulltextIndex()) {
|
||||
// Search Functionality — use FULLTEXT when available, fall back to LIKE.
|
||||
// Use a strict emptiness check so a literal "0" search is honored.
|
||||
if ($search !== null && $search !== '') {
|
||||
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
||||
$ftSearch = trim(preg_replace('/\s+/', ' ', preg_replace('/[+\-><()\~*"@]+/', ' ', $search)));
|
||||
if ($this->hasFulltextIndex() && $ftSearch !== '') {
|
||||
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
||||
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
||||
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
|
||||
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
|
||||
$ftSearch .= '*';
|
||||
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||
$searchTerm = "%$search%";
|
||||
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
|
||||
$paramTypes .= 'ssss';
|
||||
} else {
|
||||
// No FULLTEXT index, or the sanitized boolean query is empty (search was
|
||||
// only special chars) — fall back to LIKE instead of emitting invalid
|
||||
// AGAINST('*' ...) syntax.
|
||||
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||
$searchTerm = "%$search%";
|
||||
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
||||
@@ -308,7 +312,7 @@ class TicketModel
|
||||
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
$stmt->bind_param(
|
||||
"sissssisis",
|
||||
"sissssisss",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
@@ -322,7 +326,7 @@ class TicketModel
|
||||
);
|
||||
} else {
|
||||
$stmt->bind_param(
|
||||
"sissssisi",
|
||||
"sissssiss",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
@@ -343,20 +347,31 @@ class TicketModel
|
||||
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
||||
}
|
||||
|
||||
// Check for optimistic locking conflict
|
||||
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
|
||||
// Either ticket doesn't exist or was modified by someone else
|
||||
// Zero affected rows is ambiguous: the ticket may not exist, an optimistic
|
||||
// lock may have failed, or the row simply matched with no column changes
|
||||
// (identical resubmit). Disambiguate so we neither report a false conflict
|
||||
// nor silently "succeed" on a non-existent ticket.
|
||||
if ($affectedRows === 0) {
|
||||
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
||||
if ($ticket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||
'conflict' => true,
|
||||
'current_updated_at' => $ticket['updated_at']
|
||||
];
|
||||
} else {
|
||||
if (!$ticket) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
||||
}
|
||||
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
// Only a genuine concurrent modification changes updated_at. If it
|
||||
// still equals the expected value the WHERE matched but nothing
|
||||
// changed (e.g. identical data resubmitted within the same second),
|
||||
// which is not a conflict.
|
||||
if ($ticket['updated_at'] !== $expectedUpdatedAt) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||
'conflict' => true,
|
||||
'current_updated_at' => $ticket['updated_at']
|
||||
];
|
||||
}
|
||||
}
|
||||
// Ticket exists and no conflict: treat no-op update as success.
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||
@@ -516,9 +531,9 @@ class TicketModel
|
||||
}
|
||||
}
|
||||
|
||||
public function addComment(int $ticketId, array $commentData): array
|
||||
public function addComment(string $ticketId, array $commentData): array
|
||||
{
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -528,7 +543,7 @@ class TicketModel
|
||||
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
$stmt->bind_param(
|
||||
"issi",
|
||||
"sssi",
|
||||
$ticketId,
|
||||
$username,
|
||||
$commentData['comment_text'],
|
||||
@@ -557,11 +572,11 @@ class TicketModel
|
||||
* @param int $assignedBy User ID performing the assignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
|
||||
public function assignTicket(string $ticketId, int $userId, int $assignedBy): bool
|
||||
{
|
||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||
$stmt->bind_param("iis", $userId, $assignedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
@@ -574,11 +589,11 @@ class TicketModel
|
||||
* @param int $updatedBy User ID performing the unassignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function unassignTicket(int $ticketId, int $updatedBy): bool
|
||||
public function unassignTicket(string $ticketId, int $updatedBy): bool
|
||||
{
|
||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||
$stmt->bind_param("is", $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
@@ -733,7 +748,7 @@ class TicketModel
|
||||
* @param int $updatedBy User ID
|
||||
* @return bool
|
||||
*/
|
||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
||||
public function updateVisibility(string $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
||||
{
|
||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||
if (!in_array($visibility, $allowedVisibilities)) {
|
||||
@@ -752,7 +767,7 @@ class TicketModel
|
||||
|
||||
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
@@ -790,7 +805,7 @@ class TicketModel
|
||||
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
|
||||
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
|
||||
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
|
||||
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
|
||||
"DELETE FROM custom_field_values WHERE ticket_id = ?",
|
||||
];
|
||||
|
||||
foreach ($children as $sql) {
|
||||
|
||||
Reference in New Issue
Block a user