From bcc163bc77535c38a54c0c1630c6a433c66f0be1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Feb 2026 14:50:06 -0500 Subject: [PATCH] Audit fixes: security, dead code removal, API consolidation, JS dedup Security: - Fix IDOR in delete/update comment (add ticket visibility check) - XSS defense-in-depth in DashboardView active filters - Replace innerHTML with DOM construction in toast.js - Remove redundant real_escape_string in check_duplicates - Add rate limiting to get_template, download_attachment, audit_log, saved_filters, user_preferences endpoints Bug fixes: - Session timeout now reads from config instead of hardcoded 18000 - TicketController uses $GLOBALS['config'] instead of duplicate .env parsing - Add DISCORD_WEBHOOK_URL to centralized config - Cleanup script uses hashmap for O(1) ticket ID lookups Dead code removal (~100 lines): - Remove dead getTicketComments() from TicketModel (wrong bind_param type) - Remove dead getCategories()/getTypes() from DashboardController - Remove ~80 lines dead Discord webhook code from update_ticket API Consolidation: - Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit) - Convert 6 API endpoints to use bootstrap - Extract escapeHtml/getTicketIdFromUrl into shared utils.js - Batch save for user preferences (1 request instead of 7) Co-Authored-By: Claude Opus 4.6 --- api/assign_ticket.php | 32 +------- api/audit_log.php | 20 +---- api/bootstrap.php | 49 ++++++++++++ api/check_duplicates.php | 20 +---- api/delete_comment.php | 16 +++- api/download_attachment.php | 4 +- api/get_template.php | 3 + api/get_users.php | 18 +---- api/saved_filters.php | 43 ++--------- api/update_comment.php | 15 ++++ api/update_ticket.php | 111 +--------------------------- api/user_preferences.php | 75 +++++++++---------- assets/js/dashboard.js | 15 ---- assets/js/settings.js | 28 ++++--- assets/js/ticket.js | 19 ----- assets/js/toast.js | 21 ++++-- assets/js/utils.js | 14 ++++ config/config.php | 5 +- controllers/DashboardController.php | 7 -- controllers/TicketController.php | 26 +------ middleware/AuthMiddleware.php | 8 +- models/TicketModel.php | 15 ---- scripts/cleanup_orphan_uploads.php | 7 +- views/CreateTicketView.php | 7 +- views/DashboardView.php | 7 +- views/TicketView.php | 1 + 26 files changed, 197 insertions(+), 389 deletions(-) create mode 100644 api/bootstrap.php create mode 100644 assets/js/utils.js diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 786c450..9ee2b39 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -1,36 +1,9 @@ false, 'error' => 'Not authenticated']); - exit; -} - -// CSRF Protection -require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (!CsrfMiddleware::validateToken($csrfToken)) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); - exit; - } -} - -$userId = $_SESSION['user']['user_id']; - // Get request data $data = json_decode(file_get_contents('php://input'), true); $ticketId = $data['ticket_id'] ?? null; @@ -41,9 +14,6 @@ if (!$ticketId) { exit; } -// Use centralized database connection -$conn = Database::getConnection(); - $ticketModel = new TicketModel($conn); $auditLogModel = new AuditLogModel($conn); $userModel = new UserModel($conn); diff --git a/api/audit_log.php b/api/audit_log.php index 9861f00..3bf4ae4 100644 --- a/api/audit_log.php +++ b/api/audit_log.php @@ -5,31 +5,16 @@ * Admin-only access */ -require_once dirname(__DIR__) . '/config/config.php'; -require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; -session_start(); -header('Content-Type: application/json'); - -// Check authentication -if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => 'Not authenticated']); - exit; -} - // Check admin status - audit log viewing is admin-only -$isAdmin = $_SESSION['user']['is_admin'] ?? false; if (!$isAdmin) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'Admin access required']); exit; } -// Use centralized database connection -$conn = Database::getConnection(); - $auditLogModel = new AuditLogModel($conn); // GET - Fetch filtered audit logs or export to CSV @@ -114,12 +99,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']); } - $conn->close(); exit; } // Method not allowed http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); -$conn->close(); -?> diff --git a/api/bootstrap.php b/api/bootstrap.php new file mode 100644 index 0000000..ecac00e --- /dev/null +++ b/api/bootstrap.php @@ -0,0 +1,49 @@ + false, 'error' => 'Authentication required']); + exit; +} + +// CSRF protection for write requests +if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) { + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } +} + +header('Content-Type: application/json'); + +// Common variables +$currentUser = $_SESSION['user']; +$userId = $currentUser['user_id']; +$isAdmin = $currentUser['is_admin'] ?? false; +$conn = Database::getConnection(); diff --git a/api/check_duplicates.php b/api/check_duplicates.php index ef30852..efe14f2 100644 --- a/api/check_duplicates.php +++ b/api/check_duplicates.php @@ -5,22 +5,9 @@ * Searches for tickets with similar titles using LIKE and SOUNDEX */ -// Apply rate limiting -require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; -RateLimitMiddleware::apply('api'); - -session_start(); -require_once dirname(__DIR__) . '/config/config.php'; -require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; -header('Content-Type: application/json'); - -// Check authentication -if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { - ResponseHelper::unauthorized(); -} - // Only accept GET requests if ($_SERVER['REQUEST_METHOD'] !== 'GET') { ResponseHelper::error('Method not allowed', 405); @@ -33,15 +20,12 @@ if (strlen($title) < 5) { ResponseHelper::success(['duplicates' => []]); } -// Use centralized database connection -$conn = Database::getConnection(); - // Search for similar titles // Use both LIKE for substring matching and SOUNDEX for phonetic matching $duplicates = []; // Prepare search term for LIKE -$searchTerm = '%' . $conn->real_escape_string($title) . '%'; +$searchTerm = '%' . $title . '%'; // Get SOUNDEX of title $soundexTitle = soundex($title); diff --git a/api/delete_comment.php b/api/delete_comment.php index ea172b3..4bac547 100644 --- a/api/delete_comment.php +++ b/api/delete_comment.php @@ -18,6 +18,7 @@ try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; + require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication via session @@ -61,9 +62,22 @@ try { $commentModel = new CommentModel($conn); $auditLog = new AuditLogModel($conn); - // Get comment before deletion for audit log + // Get comment before deletion for audit log and access check $comment = $commentModel->getCommentById($commentId); + // Verify user can access the parent ticket + if ($comment) { + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($comment['ticket_id']); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + ob_end_clean(); + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; + } + } + // Delete comment $result = $commentModel->deleteComment($commentId, $userId, $isAdmin); diff --git a/api/download_attachment.php b/api/download_attachment.php index 012191e..1cab605 100644 --- a/api/download_attachment.php +++ b/api/download_attachment.php @@ -5,7 +5,9 @@ * Serves file downloads for ticket attachments */ -session_start(); +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); + require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/AttachmentModel.php'; diff --git a/api/get_template.php b/api/get_template.php index 6a1a35e..8c1cab8 100644 --- a/api/get_template.php +++ b/api/get_template.php @@ -4,6 +4,9 @@ * Returns a ticket template by ID */ +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); + require_once dirname(__DIR__) . '/helpers/ErrorHandler.php'; ErrorHandler::init(); diff --git a/api/get_users.php b/api/get_users.php index 897c0aa..905a89c 100644 --- a/api/get_users.php +++ b/api/get_users.php @@ -4,25 +4,9 @@ * Returns list of users for @mentions autocomplete */ -ini_set('display_errors', 0); -error_reporting(E_ALL); - -require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; -RateLimitMiddleware::apply('api'); +require_once __DIR__ . '/bootstrap.php'; try { - require_once dirname(__DIR__) . '/config/config.php'; - require_once dirname(__DIR__) . '/helpers/Database.php'; - - // Check authentication (session already started by RateLimitMiddleware) - if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => 'Authentication required']); - exit; - } - - header('Content-Type: application/json'); - // Get all users for mentions/assignment $result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username"); diff --git a/api/saved_filters.php b/api/saved_filters.php index 08ecf76..bfb99eb 100644 --- a/api/saved_filters.php +++ b/api/saved_filters.php @@ -4,36 +4,9 @@ * Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter) */ -require_once dirname(__DIR__) . '/config/config.php'; -require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/SavedFiltersModel.php'; -session_start(); -header('Content-Type: application/json'); - -// Check authentication -if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => 'Not authenticated']); - exit; -} - -// CSRF Protection -require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; -if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') { - $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (!CsrfMiddleware::validateToken($csrfToken)) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); - exit; - } -} - -$userId = $_SESSION['user']['user_id']; - -// Use centralized database connection -$conn = Database::getConnection(); - $filtersModel = new SavedFiltersModel($conn); // GET - Fetch all saved filters or a specific filter @@ -72,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); - exit; + exit; } $filterName = trim($data['filter_name']); @@ -83,7 +56,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($filterName) || strlen($filterName) > 100) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid filter name']); - exit; + exit; } try { @@ -103,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { if (!isset($data['filter_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_id']); - exit; + exit; } $filterId = (int)$data['filter_id']; @@ -117,14 +90,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); } - exit; + exit; } // Handle full filter update if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); - exit; + exit; } $filterName = trim($data['filter_name']); @@ -148,7 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['filter_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing filter_id']); - exit; + exit; } $filterId = (int)$data['filter_id']; @@ -166,5 +139,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { // Method not allowed http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); -$conn->close(); -?> diff --git a/api/update_comment.php b/api/update_comment.php index 28558f8..b092c48 100644 --- a/api/update_comment.php +++ b/api/update_comment.php @@ -18,6 +18,7 @@ try { require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/CommentModel.php'; + require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; // Check authentication via session @@ -64,6 +65,20 @@ try { $commentModel = new CommentModel($conn); $auditLog = new AuditLogModel($conn); + // Verify user can access the parent ticket + $comment = $commentModel->getCommentById($commentId); + if ($comment) { + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($comment['ticket_id']); + if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + ob_end_clean(); + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; + } + } + // Update comment $result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin); diff --git a/api/update_ticket.php b/api/update_ticket.php index cd9d131..37664c8 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -15,28 +15,7 @@ try { $configPath = dirname(__DIR__) . '/config/config.php'; require_once $configPath; require_once dirname(__DIR__) . '/helpers/Database.php'; - require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; - // Load environment variables (for Discord webhook) - $envPath = dirname(__DIR__) . '/.env'; - $envVars = []; - if (file_exists($envPath)) { - $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { - if (strpos($line, '=') !== false && strpos($line, '#') !== 0) { - list($key, $value) = explode('=', $line, 2); - $key = trim($key); - $value = trim($value); - // Remove surrounding quotes if present - if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') || - (substr($value, 0, 1) === "'" && substr($value, -1) === "'")) { - $value = substr($value, 1, -1); - } - $envVars[$key] = $value; - } - } - } - // Load models directly with absolute paths $ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; @@ -76,16 +55,14 @@ try { private $commentModel; private $auditLog; private $workflowModel; - private $envVars; private $userId; private $isAdmin; - public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) { + public function __construct($conn, $userId = null, $isAdmin = false) { $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); $this->auditLog = new AuditLogModel($conn); $this->workflowModel = new WorkflowModel($conn); - $this->envVars = $envVars; $this->userId = $userId; $this->isAdmin = $isAdmin; } @@ -184,9 +161,6 @@ try { $this->auditLog->logTicketUpdate($this->userId, $id, $data); } - // Discord webhook disabled for updates - only send for new tickets - // $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data); - return [ 'success' => true, 'status' => $updateData['status'], @@ -194,87 +168,6 @@ try { 'message' => 'Ticket updated successfully' ]; } - - private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) { - if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) { - return; - } - - $webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL']; - - // Determine what fields actually changed - $changes = []; - foreach ($changedFields as $field => $newValue) { - if ($field === 'ticket_id') continue; // Skip ticket_id - - $oldValue = $oldData[$field] ?? 'N/A'; - if ($oldValue != $newValue) { - $changes[] = [ - 'name' => ucfirst($field), - 'value' => "$oldValue → $newValue", - 'inline' => true - ]; - } - } - - if (empty($changes)) { - return; - } - - // Create ticket URL using validated host - $ticketUrl = UrlHelper::ticketUrl($ticketId); - - // Determine embed color based on priority - $colors = [ - 1 => 0xff4d4d, // Red - 2 => 0xffa726, // Orange - 3 => 0x42a5f5, // Blue - 4 => 0x66bb6a, // Green - 5 => 0x9e9e9e // Gray - ]; - $color = $colors[$newData['priority']] ?? 0x3498db; - - $embed = [ - 'title' => '🔄 Ticket Updated', - 'description' => "**#{$ticketId}** - " . $newData['title'], - 'color' => $color, - 'fields' => array_merge($changes, [ - [ - 'name' => '🔗 View Ticket', - 'value' => "[Click here to view]($ticketUrl)", - 'inline' => false - ] - ]), - 'footer' => [ - 'text' => 'Tinker Tickets' - ], - 'timestamp' => date('c') - ]; - - $payload = [ - 'embeds' => [$embed] - ]; - - // Send webhook - $ch = curl_init($webhookUrl); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - - $webhookResult = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - curl_close($ch); - - // Log webhook errors instead of silencing them - if ($curlError) { - error_log("Discord webhook cURL error for ticket #{$ticketId}: {$curlError}"); - } elseif ($httpCode !== 204 && $httpCode !== 200) { - error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}, Response: " . substr($webhookResult, 0, 200)); - } - } } // Use centralized database connection @@ -300,7 +193,7 @@ try { $ticketId = (int)$data['ticket_id']; // Initialize controller - $controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin); + $controller = new ApiTicketController($conn, $userId, $isAdmin); // Update ticket $result = $controller->update($ticketId, $data); diff --git a/api/user_preferences.php b/api/user_preferences.php index a908ddc..fcb3d2d 100644 --- a/api/user_preferences.php +++ b/api/user_preferences.php @@ -4,36 +4,9 @@ * Handles GET (fetch preferences) and POST (update preference) */ -require_once dirname(__DIR__) . '/config/config.php'; -require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/UserPreferencesModel.php'; -session_start(); -header('Content-Type: application/json'); - -// Check authentication -if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { - http_response_code(401); - echo json_encode(['success' => false, 'error' => 'Not authenticated']); - exit; -} - -// CSRF Protection -require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; -if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') { - $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; - if (!CsrfMiddleware::validateToken($csrfToken)) { - http_response_code(403); - echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); - exit; - } -} - -$userId = $_SESSION['user']['user_id']; - -// Use centralized database connection -$conn = Database::getConnection(); - $prefsModel = new UserPreferencesModel($conn); // GET - Fetch all preferences for user @@ -48,19 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { exit; } -// POST - Update a preference +// POST - Update preference(s) if ($_SERVER['REQUEST_METHOD'] === 'POST') { $data = json_decode(file_get_contents('php://input'), true); - if (!isset($data['key']) || !isset($data['value'])) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Missing key or value']); - exit; - } - - $key = trim($data['key']); - $value = $data['value']; - // Validate preference key (whitelist) $validKeys = [ 'rows_per_page', @@ -71,10 +35,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'toast_duration' ]; + // Support batch save: { preferences: { key: value, ... } } + if (isset($data['preferences']) && is_array($data['preferences'])) { + try { + foreach ($data['preferences'] as $key => $value) { + $key = trim($key); + if (!in_array($key, $validKeys)) continue; + $prefsModel->setPreference($userId, $key, $value); + if ($key === 'rows_per_page') { + setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/'); + } + } + echo json_encode(['success' => true]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to save preferences']); + } + exit; + } + + // Single preference: { key, value } + if (!isset($data['key']) || !isset($data['value'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Missing key or value']); + exit; + } + + $key = trim($data['key']); + $value = $data['value']; + if (!in_array($key, $validKeys)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid preference key']); - exit; + exit; } try { @@ -100,7 +93,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { if (!isset($data['key'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Missing key']); - exit; + exit; } try { @@ -116,5 +109,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { // Method not allowed http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); -$conn->close(); -?> diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index dcbaf64..aaca91f 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -1,18 +1,3 @@ -// XSS prevention helper -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats) -function getTicketIdFromUrl() { - const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/); - if (pathMatch) return pathMatch[1]; - const params = new URLSearchParams(window.location.search); - return params.get('id'); -} - /** * Toggle sidebar visibility on desktop */ diff --git a/assets/js/settings.js b/assets/js/settings.js index 3f33795..7b52e13 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -94,22 +94,20 @@ async function saveSettings() { }; try { - // Save each preference - for (const [key, value] of Object.entries(prefs)) { - const response = await fetch('/api/user_preferences.php', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': window.CSRF_TOKEN - }, - body: JSON.stringify({ key, value }) - }); + // Batch save all preferences in one request + const response = await fetch('/api/user_preferences.php', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.CSRF_TOKEN + }, + body: JSON.stringify({ preferences: prefs }) + }); - const result = await response.json(); - if (!result.success) { - throw new Error(`Failed to save ${key}`); - } + const result = await response.json(); + if (!result.success) { + throw new Error('Failed to save preferences'); } if (typeof toast !== 'undefined') { diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 16519ca..a3a7aa4 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -1,22 +1,3 @@ -// XSS prevention helper -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats) -function getTicketIdFromUrl() { - // Try new URL format first: /ticket/123456789 - const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/); - if (pathMatch) { - return pathMatch[1]; - } - // Fall back to query param: ?id=123456789 - const params = new URLSearchParams(window.location.search); - return params.get('id'); -} - /** * Toggle visibility groups field based on visibility selection */ diff --git a/assets/js/toast.js b/assets/js/toast.js index 5736bb5..ab9d3ef 100644 --- a/assets/js/toast.js +++ b/assets/js/toast.js @@ -30,11 +30,22 @@ function displayToast(message, type, duration) { warning: '⚠' }; - toast.innerHTML = ` - [${icons[type] || 'ℹ'}] - ${message} - [×] - `; + const iconSpan = document.createElement('span'); + iconSpan.className = 'toast-icon'; + iconSpan.textContent = `[${icons[type] || 'ℹ'}]`; + + const msgSpan = document.createElement('span'); + msgSpan.className = 'toast-message'; + msgSpan.textContent = message; + + const closeSpan = document.createElement('span'); + closeSpan.className = 'toast-close'; + closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;'; + closeSpan.textContent = '[×]'; + + toast.appendChild(iconSpan); + toast.appendChild(msgSpan); + toast.appendChild(closeSpan); // Add to document document.body.appendChild(toast); diff --git a/assets/js/utils.js b/assets/js/utils.js new file mode 100644 index 0000000..c40ab23 --- /dev/null +++ b/assets/js/utils.js @@ -0,0 +1,14 @@ +// XSS prevention helper +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats) +function getTicketIdFromUrl() { + const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/); + if (pathMatch) return pathMatch[1]; + const params = new URLSearchParams(window.location.search); + return params.get('id'); +} diff --git a/config/config.php b/config/config.php index 4cbde2a..34891f5 100644 --- a/config/config.php +++ b/config/config.php @@ -31,6 +31,9 @@ $GLOBALS['config'] = [ 'ASSETS_URL' => '/assets', // Assets URL 'API_URL' => '/api', // API URL + // Discord webhook + 'DISCORD_WEBHOOK_URL' => $envVars['DISCORD_WEBHOOK_URL'] ?? null, + // Domain settings for external integrations (webhooks, links, etc.) // Set APP_DOMAIN in .env to override 'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null, @@ -40,7 +43,7 @@ $GLOBALS['config'] = [ )), // Session settings - 'SESSION_TIMEOUT' => 3600, // 1 hour in seconds + 'SESSION_TIMEOUT' => 18000, // 5 hours in seconds 'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes // CSRF settings diff --git a/controllers/DashboardController.php b/controllers/DashboardController.php index 7dc3180..804a8bf 100644 --- a/controllers/DashboardController.php +++ b/controllers/DashboardController.php @@ -187,12 +187,5 @@ class DashboardController { return ['categories' => $categories, 'types' => $types]; } - private function getCategories(): array { - return $this->getCategoriesAndTypes()['categories']; - } - - private function getTypes(): array { - return $this->getCategoriesAndTypes()['types']; - } } ?> \ No newline at end of file diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 90c5626..0f70da3 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -15,7 +15,6 @@ class TicketController { private $userModel; private $workflowModel; private $templateModel; - private $envVars; private $conn; public function __construct($conn) { @@ -26,26 +25,6 @@ class TicketController { $this->userModel = new UserModel($conn); $this->workflowModel = new WorkflowModel($conn); $this->templateModel = new TemplateModel($conn); - - // Load environment variables for Discord webhook - $envPath = dirname(__DIR__) . '/.env'; - $this->envVars = []; - if (file_exists($envPath)) { - $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { - if (strpos($line, '=') !== false && strpos($line, '#') !== 0) { - list($key, $value) = explode('=', $line, 2); - $key = trim($key); - $value = trim($value); - // Remove surrounding quotes if present - if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') || - (substr($value, 0, 1) === "'" && substr($value, -1) === "'")) { - $value = substr($value, 1, -1); - } - $this->envVars[$key] = $value; - } - } - } } public function view($id) { @@ -217,13 +196,12 @@ class TicketController { } private function sendDiscordWebhook($ticketId, $ticketData) { - if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) { + $webhookUrl = $GLOBALS['config']['DISCORD_WEBHOOK_URL'] ?? null; + if (empty($webhookUrl)) { error_log("Discord webhook URL not configured, skipping webhook for ticket creation"); return; } - $webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL']; - // Create ticket URL using validated host $ticketUrl = UrlHelper::ticketUrl($ticketId); diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index ef88c93..779dc39 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -60,7 +60,8 @@ class AuthMiddleware { ini_set('session.cookie_secure', 1); // Requires HTTPS ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia ini_set('session.use_strict_mode', 1); - ini_set('session.gc_maxlifetime', 18000); // 5 hours + $sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000; + ini_set('session.gc_maxlifetime', $sessionTimeout); ini_set('session.cookie_lifetime', 0); // Until browser closes session_start(); @@ -68,8 +69,9 @@ class AuthMiddleware { // Check if user is already authenticated in session if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) { - // Verify session hasn't expired (5 hour timeout) - if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) { + // Verify session hasn't expired + $sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000; + if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) { // Log session expiration $this->logSecurityEvent('session_expired', [ 'username' => $_SESSION['user']['username'] ?? 'unknown', diff --git a/models/TicketModel.php b/models/TicketModel.php index 7935ed5..8d89491 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -31,21 +31,6 @@ class TicketModel { return $result->fetch_assoc(); } - public function getTicketComments(int $ticketId): array { - $sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC"; - $stmt = $this->conn->prepare($sql); - $stmt->bind_param("i", $ticketId); - $stmt->execute(); - $result = $stmt->get_result(); - - $comments = []; - while ($row = $result->fetch_assoc()) { - $comments[] = $row; - } - - return $comments; - } - public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array { // Calculate offset $offset = ($page - 1) * $limit; diff --git a/scripts/cleanup_orphan_uploads.php b/scripts/cleanup_orphan_uploads.php index 3f93144..ceaafce 100755 --- a/scripts/cleanup_orphan_uploads.php +++ b/scripts/cleanup_orphan_uploads.php @@ -32,8 +32,11 @@ echo "Scanning uploads directory: $uploadsDir\n"; // Get all valid ticket IDs from database $ticketIds = []; $result = $conn->query("SELECT ticket_id FROM tickets"); +if (!$result) { + die("Failed to query tickets: " . $conn->error . "\n"); +} while ($row = $result->fetch_assoc()) { - $ticketIds[] = $row['ticket_id']; + $ticketIds[$row['ticket_id']] = true; } echo "Found " . count($ticketIds) . " tickets in database\n"; @@ -63,7 +66,7 @@ foreach ($ticketDirs as $ticketDir) { } // Check if ticket exists - if (!in_array($ticketId, $ticketIds)) { + if (!isset($ticketIds[$ticketId])) { // Ticket doesn't exist - entire folder is orphaned $orphanedFolders[] = $ticketDir; $folderSize = 0; diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 41690e2..73809fb 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -13,6 +13,7 @@ $nonce = SecurityHeadersMiddleware::getNonce(); + + +