Harden recurring cron, bulk delete, error handling; fix semgrep CI

Continued fixes from the multi-agent review:

- recurring tickets cron: now that the parse error is fixed the job runs,
  exposing two latent bugs. (1) next_run_at was only advanced after the
  full success path, so any failure (e.g. a NULL created_by passed to the
  non-nullable assignTicket() $assignedBy -> TypeError) left it in the past
  and re-created a duplicate ticket every cron cycle. Added an atomic
  claimForRun() (conditional UPDATE gated on still-due) called BEFORE
  creation, which also prevents overlapping runs from double-creating.
  (2) The cron used a raw mysqli with no utf8mb4, corrupting non-ASCII
  content; it now uses Database::getConnection(). Also guard the assignment
  so created_by NULL falls back to the assignee.
- bulk delete: attachment files were unlinked inside the DB transaction, so
  an atomic-mode rollback restored rows but the files were already gone.
  deleteTicket() can now defer file removal to the caller, and
  BulkOperationsModel deletes files only after a successful commit.
- UserModel: back-tick the `groups` column (reserved word on MySQL 8.0.2+).
- create_ticket_api.php: stop leaking raw DB/exception messages to callers;
  log server-side and return a generic error. (Also includes a pre-existing
  working-tree tweak that adds title to the manual-ticket dedupe hash.)
- CI: semgrep install failed under PEP 668; add --break-system-packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 09:34:57 -04:00
co-authored by Claude Opus 4.8
parent 2b8d593ab0
commit 8a1c7e0089
7 changed files with 107 additions and 31 deletions
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq python3 python3-pip apt-get install -y -qq python3 python3-pip
pip3 install semgrep # Debian's Python is externally managed (PEP 668); the runner is
# ephemeral so installing system-wide is fine here.
pip3 install --break-system-packages semgrep
- name: Run semgrep - name: Run semgrep
run: | run: |
+11 -2
View File
@@ -45,9 +45,11 @@ $conn = new mysqli(
); );
if ($conn->connect_error) { if ($conn->connect_error) {
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
http_response_code(500);
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => 'Database connection failed: ' . $conn->connect_error 'error' => 'Internal server error'
]); ]);
exit; exit;
} }
@@ -199,6 +201,11 @@ function generateTicketHash($data)
)), )),
]; ];
// Manual tickets should be unique by title (so different software installs don't collide)
if ($sourceType === 'manual') {
$stableComponents['title'] = $title;
}
// Include hostname for node-specific issues // Include hostname for node-specific issues
if (!$isClusterWide) { if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname; $stableComponents['hostname'] = $hostname;
@@ -397,7 +404,9 @@ try {
// Race condition: another node inserted the same hash between our SELECT and INSERT // Race condition: another node inserted the same hash between our SELECT and INSERT
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']); echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else { } else {
echo json_encode(['success' => false, 'error' => $e->getMessage()]); error_log('create_ticket_api: insert failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
} }
exit; exit;
} }
+19 -18
View File
@@ -16,6 +16,7 @@ chdir(dirname(__DIR__));
// Include required files // Include required files
require_once 'config/config.php'; require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'models/RecurringTicketModel.php'; require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php'; require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php'; require_once 'models/AuditLogModel.php';
@@ -29,17 +30,9 @@ function logMessage($message)
logMessage("Starting recurring tickets cron job"); logMessage("Starting recurring tickets cron job");
try { try {
// Create database connection // Create database connection (Database::getConnection sets utf8mb4 so
$conn = new mysqli( // non-ASCII titles/descriptions aren't corrupted on insert).
$GLOBALS['config']['DB_HOST'], $conn = Database::getConnection();
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Initialize models // Initialize models
$recurringModel = new RecurringTicketModel($conn); $recurringModel = new RecurringTicketModel($conn);
@@ -57,6 +50,14 @@ try {
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']); logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
try { try {
// Claim the schedule FIRST (atomic advance of next_run_at). If another
// cron run already claimed it, or it's no longer due, skip it — this
// prevents duplicate-ticket floods if a later step throws.
if (!$recurringModel->claimForRun($recurring['recurring_id'])) {
logMessage("Skipped (already claimed or not due): " . $recurring['recurring_id']);
continue;
}
// Prepare ticket data // Prepare ticket data
$ticketData = [ $ticketData = [
'title' => processTemplate($recurring['title_template']), 'title' => processTemplate($recurring['title_template']),
@@ -74,9 +75,12 @@ try {
$ticketId = $result['ticket_id']; $ticketId = $result['ticket_id'];
logMessage("Created ticket: " . $ticketId); logMessage("Created ticket: " . $ticketId);
// Assign to user if specified // Assign to user if specified. assignTicket() requires a non-null
if ($recurring['assigned_to']) { // "assigned_by"; fall back to the assignee when created_by is null
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']); // (recurring schedules may have no creator).
if (!empty($recurring['assigned_to'])) {
$assignedBy = (int)($recurring['created_by'] ?? $recurring['assigned_to']);
$ticketModel->assignTicket($ticketId, (int)$recurring['assigned_to'], $assignedBy);
} }
// Log to audit // Log to audit
@@ -88,9 +92,6 @@ try {
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']] ['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
); );
// Update the recurring ticket's next run time
$recurringModel->updateAfterRun($recurring['recurring_id']);
$created++; $created++;
} else { } else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error')); logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
@@ -104,7 +105,7 @@ try {
logMessage("Completed: Created $created tickets, $errors errors"); logMessage("Completed: Created $created tickets, $errors errors");
$conn->close(); Database::close();
} catch (Exception $e) { } catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage()); logMessage("FATAL ERROR: " . $e->getMessage());
exit(1); exit(1);
+15 -1
View File
@@ -94,6 +94,10 @@ class BulkOperationsModel
// Start transaction for data consistency // Start transaction for data consistency
$this->conn->begin_transaction(); $this->conn->begin_transaction();
// Attachment files for deleted tickets are removed only AFTER a successful
// commit, so a rollback can't leave tickets with their files already gone.
$filesToDelete = [];
try { try {
foreach ($ticketIds as $ticketId) { foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId); $ticketId = trim($ticketId);
@@ -200,7 +204,7 @@ class BulkOperationsModel
break; break;
case 'bulk_delete': case 'bulk_delete':
$success = $ticketModel->deleteTicket($ticketId); $success = $ticketModel->deleteTicket($ticketId, $filesToDelete);
if ($success) { if ($success) {
$auditLogModel->log( $auditLogModel->log(
$operation['performed_by'], $operation['performed_by'],
@@ -249,6 +253,16 @@ class BulkOperationsModel
// Commit the transaction // Commit the transaction
$this->conn->commit(); $this->conn->commit();
// Now that the DB delete is durable, remove the physical files. Files
// are deleted first; directory entries (no trailing filename) last.
foreach ($filesToDelete as $path) {
if (is_dir($path)) {
@rmdir($path); // only succeeds if empty
} elseif (file_exists($path)) {
@unlink($path);
}
}
} catch (Exception $e) { } catch (Exception $e) {
// Rollback on any unexpected error // Rollback on any unexpected error
$this->conn->rollback(); $this->conn->rollback();
+38
View File
@@ -151,6 +151,44 @@ class RecurringTicketModel
return $items; return $items;
} }
/**
* Atomically claim a due schedule for processing.
*
* Advances next_run_at (and stamps last_run_at) in a single conditional
* UPDATE gated on the row still being active and due. Returns true only if
* THIS call won the claim. This must be done BEFORE creating the ticket so
* that:
* - two overlapping cron runs can't both process the same schedule, and
* - a failure in a later step (ticket create, assignment, audit) can't
* leave next_run_at in the past, which would re-fire — and re-create a
* duplicate ticket — on every subsequent cron run.
*
* @return bool true if the schedule was claimed by this call
*/
public function claimForRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets
SET last_run_at = NOW(), next_run_at = ?
WHERE recurring_id = ? AND is_active = 1 AND next_run_at <= NOW()";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$stmt->execute();
$claimed = $stmt->affected_rows > 0;
$stmt->close();
return $claimed;
}
/** /**
* Update last run and calculate next run time * Update last run and calculate next run time
*/ */
+13 -1
View File
@@ -740,9 +740,13 @@ class TicketModel
* Admin-only operation. Removes comments, attachments, watchers, dependencies. * Admin-only operation. Removes comments, attachments, watchers, dependencies.
* *
* @param string $ticketId Ticket ID * @param string $ticketId Ticket ID
* @param array|null &$deferredFiles When provided, attachment file paths to
* remove are appended here instead of being unlinked immediately, so a
* caller running inside a DB transaction can delete them only AFTER a
* successful commit (avoids destroying files for a rolled-back delete).
* @return bool Success status * @return bool Success status
*/ */
public function deleteTicket(string $ticketId): bool public function deleteTicket(string $ticketId, ?array &$deferredFiles = null): bool
{ {
// Collect attachment filenames before deleting DB rows // Collect attachment filenames before deleting DB rows
$attachmentFiles = []; $attachmentFiles = [];
@@ -804,6 +808,13 @@ class TicketModel
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads'); : (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId; $ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
if (is_dir($ticketDir)) { if (is_dir($ticketDir)) {
if ($deferredFiles !== null) {
// Defer physical deletion to the caller (post-commit).
foreach ($attachmentFiles as $filename) {
$deferredFiles[] = $ticketDir . '/' . basename($filename);
}
$deferredFiles[] = $ticketDir; // dir removed last, only if empty
} else {
foreach ($attachmentFiles as $filename) { foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename); $file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) { if (file_exists($file)) {
@@ -812,6 +823,7 @@ class TicketModel
} }
@rmdir($ticketDir); // Remove dir only if empty @rmdir($ticketDir); // Remove dir only if empty
} }
}
return true; return true;
} }
return false; return false;
+3 -3
View File
@@ -86,7 +86,7 @@ class UserModel
$user = $result->fetch_assoc(); $user = $result->fetch_assoc();
$updateStmt = $this->conn->prepare( $updateStmt = $this->conn->prepare(
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?" "UPDATE users SET display_name = ?, email = ?, `groups` = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
); );
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username); $updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
$updateStmt->execute(); $updateStmt->execute();
@@ -100,7 +100,7 @@ class UserModel
} else { } else {
// Create new user // Create new user
$insertStmt = $this->conn->prepare( $insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())" "INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
); );
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin); $insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute(); $insertStmt->execute();
@@ -300,7 +300,7 @@ class UserModel
return $cached; return $cached;
} }
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''"); $stmt = $this->conn->prepare("SELECT DISTINCT `groups` FROM users WHERE `groups` IS NOT NULL AND `groups` != ''");
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();