From d11cb989bf17155cb0d0ae47991d77da375d81e1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 12:26:39 -0400 Subject: [PATCH] Fix API correctness: external API stub/collision, recurring dates, CSV, audit - create_ticket_api.php: remove the wrong CREATE TABLE stub that broke a fresh DB; generate collision-safe ticket_ids so a genuine id collision isn't misreported as a duplicate and a hw alert dropped; stop leaking raw DB errors; correct a reopen comment that falsely claimed refreshed sensor data - manage_recurring.php: fix next-run so create/edit no longer skips the current period (monthly day-of-month this month, daily today if time not passed, correct ISO weekday, month-length clamp); only recompute on schedule changes to avoid double-fire - export_tickets.php, audit_log.php: neutralize CSV formula injection - revoke_api_key.php, generate_api_key.php: correct HTTP status codes and stop the catch clobbering specific 4xx codes - health.php: stop leaking PHP version / extension names / paths to unauthenticated callers - watch_ticket.php: define $data before use - manage_templates/recurring/custom_fields: add audit logging for CRUD; add recurring_ticket + custom_field to the audit entity whitelist Co-Authored-By: Claude Opus 4.8 --- api/audit_log.php | 20 +++++- api/custom_fields.php | 25 +++++++ api/export_tickets.php | 18 ++++- api/generate_api_key.php | 33 +++++++-- api/health.php | 10 ++- api/manage_recurring.php | 149 ++++++++++++++++++++++++++++++++------- api/manage_templates.php | 36 +++++++++- api/revoke_api_key.php | 34 +++++++-- api/watch_ticket.php | 5 +- create_ticket_api.php | 58 +++++++++------ models/AuditLogModel.php | 2 +- 11 files changed, 322 insertions(+), 68 deletions(-) diff --git a/api/audit_log.php b/api/audit_log.php index dc0248d..59a71dd 100644 --- a/api/audit_log.php +++ b/api/audit_log.php @@ -9,6 +9,22 @@ require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; +/** + * Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that + * a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula. + * + * @param mixed $value + * @return string + */ +function auditCsvSafeCell($value): string +{ + $value = (string)$value; + if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) { + return "'" . $value; + } + return $value; +} + // Check admin status - audit log viewing is admin-only if (!$isAdmin) { http_response_code(403); @@ -69,7 +85,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $details = json_encode($log['details']); } - fputcsv($output, [ + fputcsv($output, array_map('auditCsvSafeCell', [ $log['audit_id'] ?? ($log['log_id'] ?? ''), $log['created_at'], $log['display_name'] ?? $log['username'] ?? 'N/A', @@ -78,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $log['entity_id'] ?? 'N/A', $log['ip_address'] ?? 'N/A', $details - ]); + ])); } fclose($output); diff --git a/api/custom_fields.php b/api/custom_fields.php index 50ca414..2ca16a9 100644 --- a/api/custom_fields.php +++ b/api/custom_fields.php @@ -15,6 +15,7 @@ try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CustomFieldModel.php'; + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication if (session_status() === PHP_SESSION_NONE) { @@ -50,6 +51,8 @@ try { header('Content-Type: application/json'); $model = new CustomFieldModel($conn); + $auditLog = new AuditLogModel($conn); + $currentUserId = $_SESSION['user']['user_id']; $method = $_SERVER['REQUEST_METHOD']; $id = isset($_GET['id']) ? (int)$_GET['id'] : null; $category = isset($_GET['category']) ? $_GET['category'] : null; @@ -75,6 +78,13 @@ try { exit; } $result = $model->createDefinition($data); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'create', 'custom_field', (string)($result['field_id'] ?? ''), [ + 'field_name' => $data['field_name'] ?? null, + 'field_label' => $data['field_label'] ?? null, + 'field_type' => $data['field_type'] ?? null + ]); + } echo json_encode($result); break; @@ -92,6 +102,14 @@ try { exit; } $result = $model->updateDefinition($id, $data); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'update', 'custom_field', (string)$id, [ + 'entity' => 'custom_field', + 'field_name' => $data['field_name'] ?? null, + 'field_label' => $data['field_label'] ?? null, + 'field_type' => $data['field_type'] ?? null + ]); + } echo json_encode($result); break; @@ -102,7 +120,14 @@ try { exit; } + $toDelete = $model->getDefinition($id); $result = $model->deleteDefinition($id); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'delete', 'custom_field', (string)$id, [ + 'entity' => 'custom_field', + 'field_name' => $toDelete['field_name'] ?? 'unknown' + ]); + } echo json_encode($result); break; diff --git a/api/export_tickets.php b/api/export_tickets.php index 549dd42..5972694 100644 --- a/api/export_tickets.php +++ b/api/export_tickets.php @@ -15,6 +15,22 @@ error_reporting(E_ALL); require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; RateLimitMiddleware::apply('api'); +/** + * Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that + * a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula. + * + * @param mixed $value + * @return string + */ +function exportCsvSafeCell($value): string +{ + $value = (string)$value; + if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) { + return "'" . $value; + } + return $value; +} + try { // Include required files require_once dirname(__DIR__) . '/config/config.php'; @@ -124,7 +140,7 @@ try { $ticket['updated_at'], $ticket['description'] ]; - fputcsv($output, $row); + fputcsv($output, array_map('exportCsvSafeCell', $row)); } fclose($output); diff --git a/api/generate_api_key.php b/api/generate_api_key.php index faca757..77f6dec 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -24,11 +24,13 @@ try { session_start(); } if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { + http_response_code(401); throw new Exception("Authentication required"); } // Check admin privileges if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) { + http_response_code(403); throw new Exception("Admin privileges required"); } @@ -51,6 +53,7 @@ try { // Get request data $input = json_decode(file_get_contents('php://input'), true); if (!$input) { + http_response_code(400); throw new Exception("Invalid request data"); } @@ -58,10 +61,12 @@ try { $expiresInDays = $input['expires_in_days'] ?? null; if (empty($keyName)) { + http_response_code(400); throw new Exception("Key name is required"); } if (strlen($keyName) > 100) { + http_response_code(400); throw new Exception("Key name must be 100 characters or less"); } @@ -69,6 +74,7 @@ try { if ($expiresInDays !== null && $expiresInDays !== '') { $expiresInDays = (int)$expiresInDays; if ($expiresInDays < 1 || $expiresInDays > 3650) { + http_response_code(400); throw new Exception("Expiration must be between 1 and 3650 days"); } } else { @@ -110,11 +116,26 @@ try { ]); } catch (Exception $e) { ob_end_clean(); - error_log("Generate API key error: " . $e->getMessage()); header('Content-Type: application/json'); - http_response_code(isset($conn) ? 400 : 500); - echo json_encode([ - 'success' => false, - 'error' => 'An internal error occurred' - ]); + + // Preserve any specific status set before the throw (401/403/400/...); + // only fall back to 500 when nothing more specific was set. + $code = http_response_code(); + if (!is_int($code) || $code < 400) { + $code = 500; + } + http_response_code($code); + + if ($code >= 500) { + error_log("Generate API key error: " . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'error' => 'An internal error occurred' + ]); + } else { + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } } diff --git a/api/health.php b/api/health.php index 6f712f5..908eb31 100644 --- a/api/health.php +++ b/api/health.php @@ -135,11 +135,19 @@ $responseTime = round((microtime(true) - $startTime) * 1000, 2); // Set status code http_response_code($healthy ? 200 : 503); +// This endpoint is unauthenticated, so expose only a coarse per-component status +// and never the diagnostic messages (they leak PHP_VERSION, exact missing +// extension names, and filesystem paths to anonymous callers). +$publicChecks = []; +foreach ($checks as $name => $check) { + $publicChecks[$name] = ['status' => $check['status']]; +} + // Return response echo json_encode([ 'status' => $healthy ? 'healthy' : 'unhealthy', 'timestamp' => date('c'), 'response_time_ms' => $responseTime, - 'checks' => $checks, + 'checks' => $publicChecks, 'version' => '1.0.0' ], JSON_PRETTY_PRINT); diff --git a/api/manage_recurring.php b/api/manage_recurring.php index 96c91c9..79c196e 100644 --- a/api/manage_recurring.php +++ b/api/manage_recurring.php @@ -15,6 +15,7 @@ try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/RecurringTicketModel.php'; + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication if (session_status() === PHP_SESSION_NONE) { @@ -52,6 +53,7 @@ try { header('Content-Type: application/json'); $model = new RecurringTicketModel($conn); + $auditLog = new AuditLogModel($conn); $method = $_SERVER['REQUEST_METHOD']; $id = isset($_GET['id']) ? (int)$_GET['id'] : null; $action = isset($_GET['action']) ? $_GET['action'] : null; @@ -70,6 +72,12 @@ try { case 'POST': if ($action === 'toggle' && $id) { $result = $model->toggleActive($id); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [ + 'entity' => 'recurring_ticket', + 'action' => 'toggle_active' + ]); + } echo json_encode($result); } else { $data = json_decode(file_get_contents('php://input'), true); @@ -90,6 +98,14 @@ try { $data['created_by'] = $currentUserId; $result = $model->create($data); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'create', 'recurring_ticket', (string)($result['recurring_id'] ?? ''), [ + 'title_template' => $data['title_template'], + 'schedule_type' => $data['schedule_type'], + 'schedule_day' => $data['schedule_day'] ?? null, + 'schedule_time' => $data['schedule_time'] ?? '09:00' + ]); + } echo json_encode($result); } break; @@ -106,16 +122,49 @@ try { exit; } - // Recalculate next run time if schedule changed - $nextRun = calculateNextRun( - $data['schedule_type'], - $data['schedule_day'] ?? null, - $data['schedule_time'] ?? '09:00' - ); - $data['next_run_at'] = $nextRun; + $existing = $model->getById($id); + if (!$existing) { + echo json_encode(['success' => false, 'error' => 'Recurring ticket not found']); + exit; + } + + $newDay = $data['schedule_day'] ?? null; + $newTime = $data['schedule_time'] ?? '09:00'; + + // Only the schedule fields affect when the next occurrence fires. + $scheduleChanged = + (string)$existing['schedule_type'] !== (string)$data['schedule_type'] + || (string)($existing['schedule_day'] ?? '') !== (string)($newDay ?? '') + || substr((string)$existing['schedule_time'], 0, 5) !== substr((string)$newTime, 0, 5); + + $existingNextFuture = !empty($existing['next_run_at']) + && strtotime($existing['next_run_at']) > time(); + + // Recompute only when the schedule actually changed (or the stored + // next_run is already in the past). Editing an unrelated field (e.g. + // title) must NOT move next_run_at backwards past an occurrence that + // may already have fired, which would double-create a ticket. + if ($scheduleChanged || !$existingNextFuture) { + $data['next_run_at'] = calculateNextRun( + $data['schedule_type'], + $newDay, + $newTime + ); + } else { + $data['next_run_at'] = $existing['next_run_at']; + } $data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1; $result = $model->update($id, $data); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [ + 'entity' => 'recurring_ticket', + 'title_template' => $data['title_template'] ?? null, + 'schedule_type' => $data['schedule_type'], + 'schedule_day' => $newDay, + 'schedule_time' => $newTime + ]); + } echo json_encode($result); break; @@ -125,7 +174,14 @@ try { exit; } + $toDelete = $model->getById($id); $result = $model->delete($id); + if (!empty($result['success'])) { + $auditLog->log($currentUserId, 'delete', 'recurring_ticket', (string)$id, [ + 'entity' => 'recurring_ticket', + 'title_template' => $toDelete['title_template'] ?? 'unknown' + ]); + } echo json_encode($result); break; @@ -139,36 +195,77 @@ try { echo json_encode(['success' => false, 'error' => 'An internal error occurred']); } -function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) +/** + * Compute the SOONEST FUTURE occurrence matching the schedule. + * + * Returns 'Y-m-d H:i:s' in the app-configured timezone. The current period is + * NOT skipped: a schedule whose time today/this-month is still in the future + * fires then, not one period later. + * + * @param string $scheduleType daily|weekly|monthly + * @param int|null $scheduleDay 1-7 (ISO, 1=Mon..7=Sun) weekly; 1-31 monthly + * @param string $scheduleTime HH:MM or HH:MM:SS + * @param DateTime|null $now Injected "now" for testing + */ +function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime, ?DateTime $now = null) { - $now = new DateTime(); - $time = $scheduleTime ?: '09:00'; + $tz = new DateTimeZone($GLOBALS['config']['TIMEZONE'] ?? date_default_timezone_get()); + $now = $now ? $now : new DateTime('now', $tz); + + $parts = explode(':', $scheduleTime ?: '09:00'); + $hour = (int)($parts[0] ?? 9); + $minute = (int)($parts[1] ?? 0); + $second = (int)($parts[2] ?? 0); + + $next = clone $now; switch ($scheduleType) { - case 'daily': - $next = new DateTime('tomorrow ' . $time); - break; - case 'weekly': - $days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; - $dayName = $days[(int)$scheduleDay] ?? 'Monday'; - $next = new DateTime("next {$dayName} " . $time); + $targetDow = (int)$scheduleDay; + if ($targetDow < 1 || $targetDow > 7) { + $targetDow = 1; + } + $next->setTime($hour, $minute, $second); + $currentDow = (int)$next->format('N'); // 1=Mon .. 7=Sun + $daysAhead = ($targetDow - $currentDow + 7) % 7; + // Same weekday but the time already passed today -> next week. + if ($daysAhead === 0 && $next <= $now) { + $daysAhead = 7; + } + if ($daysAhead > 0) { + $next->modify("+{$daysAhead} day"); + $next->setTime($hour, $minute, $second); + } break; case 'monthly': $day = max(1, min(31, (int)$scheduleDay)); - $next = new DateTime(); - $next->modify('first day of next month'); - // Clamp to last day of target month (handles Feb, 30-day months) - $daysInMonth = (int)$next->format('t'); - $day = min($day, $daysInMonth); - $next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day); - $parts = explode(':', $time . ':00'); // ensure at least H:M - $next->setTime((int)$parts[0], (int)$parts[1], 0); + // This month first, clamped to the month's length (e.g. day 31 -> Feb 28/29). + $daysInMonth = (int)$now->format('t'); + $next->setDate((int)$now->format('Y'), (int)$now->format('n'), min($day, $daysInMonth)); + $next->setTime($hour, $minute, $second); + if ($next <= $now) { + // Already passed this month -> first day of next month, then clamp. + $firstNext = clone $now; + $firstNext->modify('first day of next month'); + $daysInMonth = (int)$firstNext->format('t'); + $next->setDate( + (int)$firstNext->format('Y'), + (int)$firstNext->format('n'), + min($day, $daysInMonth) + ); + $next->setTime($hour, $minute, $second); + } break; + case 'daily': default: - $next = new DateTime('tomorrow ' . $time); + $next->setTime($hour, $minute, $second); + if ($next <= $now) { + $next->modify('+1 day'); + $next->setTime($hour, $minute, $second); + } + break; } return $next->format('Y-m-d H:i:s'); diff --git a/api/manage_templates.php b/api/manage_templates.php index abd9f00..e89067d 100644 --- a/api/manage_templates.php +++ b/api/manage_templates.php @@ -14,6 +14,7 @@ RateLimitMiddleware::apply('api'); try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication if (session_status() === PHP_SESSION_NONE) { @@ -48,6 +49,8 @@ try { header('Content-Type: application/json'); + $auditLog = new AuditLogModel($conn); + $currentUserId = $_SESSION['user']['user_id']; $method = $_SERVER['REQUEST_METHOD']; $id = isset($_GET['id']) ? (int)$_GET['id'] : null; @@ -110,7 +113,13 @@ try { ); if ($stmt->execute()) { - echo json_encode(['success' => true, 'template_id' => $conn->insert_id]); + $newTemplateId = $conn->insert_id; + $auditLog->log($currentUserId, 'create', 'template', (string)$newTemplateId, [ + 'template_name' => $templateName, + 'category' => $category, + 'type' => $type + ]); + echo json_encode(['success' => true, 'template_id' => $newTemplateId]); } else { error_log("Template creation failed: " . $stmt->error); echo json_encode(['success' => false, 'error' => 'Failed to create template']); @@ -161,7 +170,15 @@ try { $id ); - echo json_encode(['success' => $stmt->execute()]); + $updated = $stmt->execute(); + if ($updated) { + $auditLog->log($currentUserId, 'update', 'template', (string)$id, [ + 'template_name' => $templateName, + 'category' => $category, + 'type' => $type + ]); + } + echo json_encode(['success' => $updated]); $stmt->close(); break; @@ -171,9 +188,22 @@ try { exit; } + // Capture the name before deletion for the audit record. + $nameStmt = $conn->prepare("SELECT template_name FROM ticket_templates WHERE template_id = ?"); + $nameStmt->bind_param('i', $id); + $nameStmt->execute(); + $delRow = $nameStmt->get_result()->fetch_assoc(); + $nameStmt->close(); + $stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?"); $stmt->bind_param('i', $id); - echo json_encode(['success' => $stmt->execute()]); + $deleted = $stmt->execute(); + if ($deleted) { + $auditLog->log($currentUserId, 'delete', 'template', (string)$id, [ + 'template_name' => $delRow['template_name'] ?? 'unknown' + ]); + } + echo json_encode(['success' => $deleted]); $stmt->close(); break; diff --git a/api/revoke_api_key.php b/api/revoke_api_key.php index e7f8d40..fe2bb7f 100644 --- a/api/revoke_api_key.php +++ b/api/revoke_api_key.php @@ -24,11 +24,13 @@ try { session_start(); } if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { + http_response_code(401); throw new Exception("Authentication required"); } // Check admin privileges if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) { + http_response_code(403); throw new Exception("Admin privileges required"); } @@ -51,12 +53,14 @@ try { // Get request data $input = json_decode(file_get_contents('php://input'), true); if (!$input) { + http_response_code(400); throw new Exception("Invalid request data"); } $keyId = (int)($input['key_id'] ?? 0); if ($keyId <= 0) { + http_response_code(400); throw new Exception("Valid key ID is required"); } @@ -68,10 +72,12 @@ try { $keyInfo = $apiKeyModel->getKeyById($keyId); if (!$keyInfo) { + http_response_code(404); throw new Exception("API key not found"); } if (!$keyInfo['is_active']) { + http_response_code(409); throw new Exception("API key is already revoked"); } @@ -79,6 +85,7 @@ try { $success = $apiKeyModel->revokeKey($keyId); if (!$success) { + http_response_code(500); throw new Exception("Failed to revoke API key"); } @@ -103,11 +110,26 @@ try { ]); } catch (Exception $e) { ob_end_clean(); - error_log("Revoke API key error: " . $e->getMessage()); header('Content-Type: application/json'); - http_response_code(isset($conn) ? 400 : 500); - echo json_encode([ - 'success' => false, - 'error' => 'An internal error occurred' - ]); + + // Preserve any specific status set before the throw (401/403/404/409/...); + // only fall back to 500 when nothing more specific was set. + $code = http_response_code(); + if (!is_int($code) || $code < 400) { + $code = 500; + } + http_response_code($code); + + if ($code >= 500) { + error_log("Revoke API key error: " . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'error' => 'An internal error occurred' + ]); + } else { + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); + } } diff --git a/api/watch_ticket.php b/api/watch_ticket.php index 375dc69..10c7f3e 100644 --- a/api/watch_ticket.php +++ b/api/watch_ticket.php @@ -10,12 +10,13 @@ require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; +$data = json_decode(file_get_contents('php://input'), true) ?? []; + $ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] - : (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0); + : (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'] ?? ''; diff --git a/create_ticket_api.php b/create_ticket_api.php index 99eab78..3eeffed 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -73,18 +73,6 @@ try { $userId = $systemUser['user_id']; -// Create tickets table with hash column if not exists -$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets ( - id INT AUTO_INCREMENT PRIMARY KEY, - ticket_id VARCHAR(9) NOT NULL, - title VARCHAR(255) NOT NULL, - hash VARCHAR(64) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY unique_hash (hash) -)"; - -$conn->query($createTableSQL); - // Parse input regardless of content-type header $rawInput = file_get_contents('php://input'); $data = json_decode($rawInput, true); @@ -371,7 +359,8 @@ if ($existing) { $reopenStmt->close(); $commentText = "**Issue recurred — ticket reopened automatically.**\n\n" . - "hwmonDaemon detected this condition again. Current sensor data is in the ticket description above."; + "hwmonDaemon detected this condition again. The ticket description reflects the " + . "original report; see this comment's timestamp for when the issue recurred."; $commentStmt = $conn->prepare( "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)" ); @@ -404,13 +393,40 @@ if ($existing) { exit; } -// No existing ticket — create a new one -// Use random_int range 100000000-999999999 to avoid leading-zero IDs -try { - $ticket_id = (string)random_int(100000000, 999999999); -} catch (Exception $e) { - $ticket_id = (string)mt_rand(100000000, 999999999); +// No existing ticket — create a new one. +// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same +// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That +// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and +// is correctly reported as a duplicate rather than a dropped hardware alert. +$ticket_id = null; +$maxAttempts = 50; +$attempts = 0; +do { + try { + $candidateId = sprintf('%09d', random_int(100000000, 999999999)); + } catch (Exception $e) { + $candidateId = sprintf('%09d', mt_rand(100000000, 999999999)); + } + + $idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1"); + $idCheckStmt->bind_param("s", $candidateId); + $idCheckStmt->execute(); + $idExists = $idCheckStmt->get_result()->num_rows > 0; + $idCheckStmt->close(); + + if (!$idExists) { + $ticket_id = $candidateId; + } + $attempts++; +} while ($ticket_id === null && $attempts < $maxAttempts); + +if ($ticket_id === null) { + error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts'); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + exit; } + $insertStmt = $conn->prepare( "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -469,5 +485,7 @@ if ($inserted) { 'message' => 'Ticket created successfully', ]); } else { - echo json_encode(['success' => false, 'error' => $conn->error]); + error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); } diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php index 2da878d..f0eea92 100644 --- a/models/AuditLogModel.php +++ b/models/AuditLogModel.php @@ -27,7 +27,7 @@ class AuditLogModel private const VALID_ENTITY_TYPES = [ 'ticket', 'comment', 'user', 'api_key', 'security', 'template', 'attachment', 'ticket_attachments', 'group', - 'dependency', 'workflow_transition' + 'dependency', 'workflow_transition', 'recurring_ticket', 'custom_field' ]; public function __construct($conn)