From be505b73126ba00a9d518e4b510210430f72895a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 20 Jan 2026 09:55:01 -0500 Subject: [PATCH] Implement comprehensive improvement plan (Phases 1-6) Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 +- api/add_comment.php | 31 ++ api/assign_ticket.php | 15 + api/bulk_operation.php | 4 + api/check_duplicates.php | 117 ++++ api/custom_fields.php | 111 ++++ api/delete_attachment.php | 102 ++++ api/download_attachment.php | 101 ++++ api/export_tickets.php | 148 +++++ api/get_users.php | 82 ++- api/manage_recurring.php | 169 ++++++ api/manage_templates.php | 153 ++++++ api/manage_workflows.php | 147 +++++ api/ticket_dependencies.php | 143 +++++ api/update_ticket.php | 75 +-- api/upload_attachment.php | 201 +++++++ assets/css/dashboard.css | 166 ++++++ assets/css/ticket.css | 322 ++++++++++- assets/js/dashboard.js | 200 ++++++- assets/js/markdown.js | 199 ++++++- assets/js/ticket.js | 642 +++++++++++++++++++++- config/config.php | 44 +- controllers/DashboardController.php | 24 +- controllers/TicketController.php | 1 - create_ticket_api.php | 4 +- cron/create_recurring_tickets.php | 135 +++++ helpers/ResponseHelper.php | 116 ++++ index.php | 185 ++++++- middleware/RateLimitMiddleware.php | 127 +++++ middleware/SecurityHeadersMiddleware.php | 30 + migrations/014_add_additional_indexes.sql | 23 + migrations/015_ticket_dependencies.sql | 15 + migrations/016_ticket_attachments.sql | 18 + migrations/017_recurring_tickets.sql | 29 + migrations/018_custom_fields.sql | 39 ++ models/AttachmentModel.php | 212 +++++++ models/AuditLogModel.php | 104 ++++ models/CommentModel.php | 46 +- models/CustomFieldModel.php | 230 ++++++++ models/DependencyModel.php | 254 +++++++++ models/RecurringTicketModel.php | 210 +++++++ models/StatsModel.php | 186 +++++++ models/TicketModel.php | 68 +-- uploads/.htaccess | 30 + views/CreateTicketView.php | 65 +++ views/DashboardView.php | 85 ++- views/TicketView.php | 72 +++ views/admin/AuditLogView.php | 157 ++++++ views/admin/CustomFieldsView.php | 254 +++++++++ views/admin/RecurringTicketsView.php | 283 ++++++++++ views/admin/TemplatesView.php | 241 ++++++++ views/admin/UserActivityView.php | 135 +++++ views/admin/WorkflowDesignerView.php | 255 +++++++++ 53 files changed, 6640 insertions(+), 169 deletions(-) create mode 100644 api/check_duplicates.php create mode 100644 api/custom_fields.php create mode 100644 api/delete_attachment.php create mode 100644 api/download_attachment.php create mode 100644 api/export_tickets.php create mode 100644 api/manage_recurring.php create mode 100644 api/manage_templates.php create mode 100644 api/manage_workflows.php create mode 100644 api/ticket_dependencies.php create mode 100644 api/upload_attachment.php create mode 100644 cron/create_recurring_tickets.php create mode 100644 helpers/ResponseHelper.php create mode 100644 middleware/RateLimitMiddleware.php create mode 100644 middleware/SecurityHeadersMiddleware.php create mode 100644 migrations/014_add_additional_indexes.sql create mode 100644 migrations/015_ticket_dependencies.sql create mode 100644 migrations/016_ticket_attachments.sql create mode 100644 migrations/017_recurring_tickets.sql create mode 100644 migrations/018_custom_fields.sql create mode 100644 models/AttachmentModel.php create mode 100644 models/CustomFieldModel.php create mode 100644 models/DependencyModel.php create mode 100644 models/RecurringTicketModel.php create mode 100644 models/StatsModel.php create mode 100644 uploads/.htaccess create mode 100644 views/admin/AuditLogView.php create mode 100644 views/admin/CustomFieldsView.php create mode 100644 views/admin/RecurringTicketsView.php create mode 100644 views/admin/TemplatesView.php create mode 100644 views/admin/UserActivityView.php create mode 100644 views/admin/WorkflowDesignerView.php diff --git a/.gitignore b/.gitignore index 7ce1d67..5cb251f 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -debug.log \ No newline at end of file +debug.log +.claude +settings.local.json \ No newline at end of file diff --git a/api/add_comment.php b/api/add_comment.php index 343fc52..4fc53f2 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -3,6 +3,10 @@ ini_set('display_errors', 0); error_reporting(E_ALL); +// Apply rate limiting +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); + // Start output buffering to capture any errors ob_start(); @@ -70,12 +74,39 @@ try { $commentModel = new CommentModel($conn); $auditLog = new AuditLogModel($conn); + // Extract @mentions from comment text + $mentions = $commentModel->extractMentions($data['comment_text'] ?? ''); + $mentionedUsers = []; + if (!empty($mentions)) { + $mentionedUsers = $commentModel->getMentionedUsers($mentions); + } + // Add comment with user tracking $result = $commentModel->addComment($ticketId, $data, $userId); // Log comment creation to audit log if ($result['success'] && isset($result['comment_id'])) { $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); + + // Log mentions to audit log + foreach ($mentionedUsers as $mentionedUser) { + $auditLog->log( + $userId, + 'mention', + 'user', + (string)$mentionedUser['user_id'], + [ + 'ticket_id' => $ticketId, + 'comment_id' => $result['comment_id'], + 'mentioned_username' => $mentionedUser['username'] + ] + ); + } + + // Add mentioned users to result for frontend + $result['mentions'] = array_map(function($u) { + return $u['username']; + }, $mentionedUsers); } // Add user display name to result for frontend diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 78ae531..ec07c58 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -1,8 +1,13 @@ connect_error) { $ticketModel = new TicketModel($conn); $auditLogModel = new AuditLogModel($conn); +$userModel = new UserModel($conn); if ($assignedTo === null || $assignedTo === '') { // Unassign ticket @@ -58,6 +64,15 @@ if ($assignedTo === null || $assignedTo === '') { $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); } } else { + // Validate assigned_to is a valid user ID + $assignedTo = (int)$assignedTo; + $targetUser = $userModel->getUserById($assignedTo); + if (!$targetUser) { + echo json_encode(['success' => false, 'error' => 'Invalid user ID']); + $conn->close(); + exit; + } + // Assign ticket $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); if ($success) { diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 8d06ce5..3ffc7cb 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -1,4 +1,8 @@ []]); +} + +// Create database connection +$conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] +); + +if ($conn->connect_error) { + ResponseHelper::serverError('Database connection failed'); +} + +// 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) . '%'; + +// Get SOUNDEX of title +$soundexTitle = soundex($title); + +// First, search for exact substring matches (case-insensitive) +$sql = "SELECT ticket_id, title, status, priority, created_at + FROM tickets + WHERE ( + title LIKE ? + OR SOUNDEX(title) = ? + ) + AND status != 'Closed' + ORDER BY created_at DESC + LIMIT 10"; + +$stmt = $conn->prepare($sql); +$stmt->bind_param("ss", $searchTerm, $soundexTitle); +$stmt->execute(); +$result = $stmt->get_result(); + +while ($row = $result->fetch_assoc()) { + // Calculate similarity score + $similarity = 0; + + // Check for exact substring match + if (stripos($row['title'], $title) !== false) { + $similarity = 90; + } + // Check SOUNDEX match + elseif (soundex($row['title']) === $soundexTitle) { + $similarity = 70; + } + // Check word overlap + else { + $titleWords = array_map('strtolower', preg_split('/\s+/', $title)); + $rowWords = array_map('strtolower', preg_split('/\s+/', $row['title'])); + $matchingWords = array_intersect($titleWords, $rowWords); + $similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60; + } + + if ($similarity >= 30) { + $duplicates[] = [ + 'ticket_id' => $row['ticket_id'], + 'title' => $row['title'], + 'status' => $row['status'], + 'priority' => $row['priority'], + 'created_at' => $row['created_at'], + 'similarity' => round($similarity) + ]; + } +} + +$stmt->close(); + +// Sort by similarity descending +usort($duplicates, function($a, $b) { + return $b['similarity'] - $a['similarity']; +}); + +// Limit to top 5 +$duplicates = array_slice($duplicates, 0, 5); + +$conn->close(); + +ResponseHelper::success(['duplicates' => $duplicates]); diff --git a/api/custom_fields.php b/api/custom_fields.php new file mode 100644 index 0000000..4710e34 --- /dev/null +++ b/api/custom_fields.php @@ -0,0 +1,111 @@ + false, 'error' => 'Authentication required']); + exit; + } + + // Check admin privileges for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Admin privileges required']); + exit; + } + + // CSRF Protection for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + header('Content-Type: application/json'); + + $model = new CustomFieldModel($conn); + $method = $_SERVER['REQUEST_METHOD']; + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + $category = isset($_GET['category']) ? $_GET['category'] : null; + + switch ($method) { + case 'GET': + if ($id) { + $field = $model->getDefinition($id); + echo json_encode(['success' => (bool)$field, 'field' => $field]); + } else { + // Get all definitions, optionally filtered by category + $activeOnly = !isset($_GET['include_inactive']); + $fields = $model->getAllDefinitions($category, $activeOnly); + echo json_encode(['success' => true, 'fields' => $fields]); + } + break; + + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $result = $model->createDefinition($data); + echo json_encode($result); + break; + + case 'PUT': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $data = json_decode(file_get_contents('php://input'), true); + $result = $model->updateDefinition($id, $data); + echo json_encode($result); + break; + + case 'DELETE': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $result = $model->deleteDefinition($id); + echo json_encode($result); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + } + + $conn->close(); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/delete_attachment.php b/api/delete_attachment.php new file mode 100644 index 0000000..2921ce0 --- /dev/null +++ b/api/delete_attachment.php @@ -0,0 +1,102 @@ +logCsrfFailure($_SESSION['user']['user_id'] ?? null, 'delete_attachment'); + ResponseHelper::forbidden('Invalid CSRF token'); +} + +// Get attachment ID +$attachmentId = $input['attachment_id'] ?? null; +if (!$attachmentId || !is_numeric($attachmentId)) { + ResponseHelper::error('Valid attachment ID is required'); +} + +$attachmentId = (int)$attachmentId; + +try { + $attachmentModel = new AttachmentModel(); + + // Get attachment details + $attachment = $attachmentModel->getAttachment($attachmentId); + if (!$attachment) { + ResponseHelper::notFound('Attachment not found'); + } + + // Check permission + $isAdmin = $_SESSION['user']['is_admin'] ?? false; + if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) { + ResponseHelper::forbidden('You do not have permission to delete this attachment'); + } + + // Delete the file + $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; + $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; + + if (file_exists($filePath)) { + if (!unlink($filePath)) { + ResponseHelper::serverError('Failed to delete file'); + } + } + + // Delete from database + if (!$attachmentModel->deleteAttachment($attachmentId)) { + ResponseHelper::serverError('Failed to delete attachment record'); + } + + // Log the deletion + $auditLog = new AuditLogModel(); + $auditLog->log( + $_SESSION['user']['user_id'], + 'attachment_delete', + 'ticket_attachments', + $attachmentId, + json_encode([ + 'ticket_id' => $attachment['ticket_id'], + 'filename' => $attachment['original_filename'], + 'size' => $attachment['file_size'] + ]), + null + ); + + ResponseHelper::success([], 'Attachment deleted successfully'); + +} catch (Exception $e) { + ResponseHelper::serverError('Failed to delete attachment'); +} diff --git a/api/download_attachment.php b/api/download_attachment.php new file mode 100644 index 0000000..8fcb4a0 --- /dev/null +++ b/api/download_attachment.php @@ -0,0 +1,101 @@ + false, 'error' => 'Authentication required']); + exit; +} + +// Get attachment ID +$attachmentId = $_GET['id'] ?? null; +if (!$attachmentId || !is_numeric($attachmentId)) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']); + exit; +} + +$attachmentId = (int)$attachmentId; + +try { + $attachmentModel = new AttachmentModel(); + + // Get attachment details + $attachment = $attachmentModel->getAttachment($attachmentId); + if (!$attachment) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Attachment not found']); + exit; + } + + // Build file path + $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; + $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; + + // Check if file exists + if (!file_exists($filePath)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'File not found on server']); + exit; + } + + // Determine if we should display inline or force download + $inline = isset($_GET['inline']) && $_GET['inline'] === '1'; + $inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']; + + // Set headers + $disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment'; + + // Sanitize filename for Content-Disposition + $safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']); + + header('Content-Type: ' . $attachment['mime_type']); + header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); + header('Content-Length: ' . $attachment['file_size']); + header('Cache-Control: private, max-age=3600'); + header('X-Content-Type-Options: nosniff'); + + // Prevent PHP from timing out on large files + set_time_limit(0); + + // Clear output buffer + if (ob_get_level()) { + ob_end_clean(); + } + + // Stream file + $handle = fopen($filePath, 'rb'); + if ($handle === false) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Failed to open file']); + exit; + } + + while (!feof($handle)) { + echo fread($handle, 8192); + flush(); + } + + fclose($handle); + exit; + +} catch (Exception $e) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Failed to download attachment']); + exit; +} diff --git a/api/export_tickets.php b/api/export_tickets.php new file mode 100644 index 0000000..3cca27f --- /dev/null +++ b/api/export_tickets.php @@ -0,0 +1,148 @@ + false, 'error' => 'Authentication required']); + exit; + } + + $currentUser = $_SESSION['user']; + + // Create database connection + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + // Get filter parameters + $status = isset($_GET['status']) ? $_GET['status'] : null; + $category = isset($_GET['category']) ? $_GET['category'] : null; + $type = isset($_GET['type']) ? $_GET['type'] : null; + $search = isset($_GET['search']) ? trim($_GET['search']) : null; + $format = isset($_GET['format']) ? $_GET['format'] : 'csv'; + + // Initialize model + $ticketModel = new TicketModel($conn); + + // Get all tickets (no pagination for export) + $result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search); + $tickets = $result['tickets']; + + if ($format === 'csv') { + // CSV Export + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"'); + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); + + // Create output stream + $output = fopen('php://output', 'w'); + + // Add BOM for Excel UTF-8 compatibility + fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + // CSV Headers + $headers = [ + 'Ticket ID', + 'Title', + 'Status', + 'Priority', + 'Category', + 'Type', + 'Created By', + 'Assigned To', + 'Created At', + 'Updated At', + 'Description' + ]; + fputcsv($output, $headers); + + // CSV Data + foreach ($tickets as $ticket) { + $row = [ + $ticket['ticket_id'], + $ticket['title'], + $ticket['status'], + 'P' . $ticket['priority'], + $ticket['category'], + $ticket['type'], + $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System', + $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned', + $ticket['created_at'], + $ticket['updated_at'], + $ticket['description'] + ]; + fputcsv($output, $row); + } + + fclose($output); + exit; + + } elseif ($format === 'json') { + // JSON Export + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"'); + + echo json_encode([ + 'exported_at' => date('c'), + 'total_tickets' => count($tickets), + 'tickets' => array_map(function($t) { + return [ + 'ticket_id' => $t['ticket_id'], + 'title' => $t['title'], + 'status' => $t['status'], + 'priority' => $t['priority'], + 'category' => $t['category'], + 'type' => $t['type'], + 'description' => $t['description'], + 'created_by' => $t['creator_display_name'] ?? $t['creator_username'], + 'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'], + 'created_at' => $t['created_at'], + 'updated_at' => $t['updated_at'] + ]; + }, $tickets) + ], JSON_PRETTY_PRINT); + exit; + + } else { + header('Content-Type: application/json'); + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']); + exit; + } + +} catch (Exception $e) { + header('Content-Type: application/json'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage() + ]); +} diff --git a/api/get_users.php b/api/get_users.php index a5a12f6..83f367b 100644 --- a/api/get_users.php +++ b/api/get_users.php @@ -1,33 +1,57 @@ false, 'error' => 'Not authenticated']); - exit; +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); + +try { + require_once dirname(__DIR__) . '/config/config.php'; + + // Check authentication + session_start(); + if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => 'Authentication required']); + exit; + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + header('Content-Type: application/json'); + + // Get all active users for mentions + $sql = "SELECT user_id, username, display_name FROM users WHERE is_active = 1 ORDER BY display_name, username"; + $result = $conn->query($sql); + + $users = []; + while ($row = $result->fetch_assoc()) { + $users[] = [ + 'user_id' => $row['user_id'], + 'username' => $row['username'], + 'display_name' => $row['display_name'] + ]; + } + + echo json_encode(['success' => true, 'users' => $users]); + + $conn->close(); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); } - -// Create database connection -$conn = new mysqli( - $GLOBALS['config']['DB_HOST'], - $GLOBALS['config']['DB_USER'], - $GLOBALS['config']['DB_PASS'], - $GLOBALS['config']['DB_NAME'] -); - -if ($conn->connect_error) { - echo json_encode(['success' => false, 'error' => 'Database connection failed']); - exit; -} - -// Get all users -$userModel = new UserModel($conn); -$users = $userModel->getAllUsers(); - -$conn->close(); - -echo json_encode(['success' => true, 'users' => $users]); diff --git a/api/manage_recurring.php b/api/manage_recurring.php new file mode 100644 index 0000000..53a23f7 --- /dev/null +++ b/api/manage_recurring.php @@ -0,0 +1,169 @@ + false, 'error' => 'Authentication required']); + exit; + } + + // Check admin privileges + if (!$_SESSION['user']['is_admin']) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Admin privileges required']); + exit; + } + + $currentUserId = $_SESSION['user']['user_id']; + + // CSRF Protection for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + header('Content-Type: application/json'); + + $model = new RecurringTicketModel($conn); + $method = $_SERVER['REQUEST_METHOD']; + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + $action = isset($_GET['action']) ? $_GET['action'] : null; + + switch ($method) { + case 'GET': + if ($id) { + $recurring = $model->getById($id); + echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]); + } else { + $all = $model->getAll(true); + echo json_encode(['success' => true, 'recurring_tickets' => $all]); + } + break; + + case 'POST': + if ($action === 'toggle' && $id) { + $result = $model->toggleActive($id); + echo json_encode($result); + } else { + $data = json_decode(file_get_contents('php://input'), true); + + // Calculate next run time + $nextRun = calculateNextRun( + $data['schedule_type'], + $data['schedule_day'] ?? null, + $data['schedule_time'] ?? '09:00' + ); + + $data['next_run_at'] = $nextRun; + $data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1; + $data['created_by'] = $currentUserId; + + $result = $model->create($data); + echo json_encode($result); + } + break; + + case 'PUT': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $data = json_decode(file_get_contents('php://input'), true); + + // 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; + $data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1; + + $result = $model->update($id, $data); + echo json_encode($result); + break; + + case 'DELETE': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $result = $model->delete($id); + echo json_encode($result); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + } + + $conn->close(); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} + +function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) { + $now = new DateTime(); + $time = $scheduleTime ?: '09:00'; + + switch ($scheduleType) { + case 'daily': + $next = new DateTime('tomorrow ' . $time); + break; + + case 'weekly': + $days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + $dayName = $days[$scheduleDay] ?? 'Monday'; + $next = new DateTime("next {$dayName} " . $time); + break; + + case 'monthly': + $day = max(1, min(28, (int)$scheduleDay)); + $next = new DateTime(); + $next->modify('first day of next month'); + $next->setDate($next->format('Y'), $next->format('m'), $day); + list($h, $m) = explode(':', $time); + $next->setTime((int)$h, (int)$m, 0); + break; + + default: + $next = new DateTime('tomorrow ' . $time); + } + + return $next->format('Y-m-d H:i:s'); +} diff --git a/api/manage_templates.php b/api/manage_templates.php new file mode 100644 index 0000000..e5c6439 --- /dev/null +++ b/api/manage_templates.php @@ -0,0 +1,153 @@ + false, 'error' => 'Authentication required']); + exit; + } + + // Check admin privileges for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Admin privileges required']); + exit; + } + + // CSRF Protection for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + header('Content-Type: application/json'); + + $method = $_SERVER['REQUEST_METHOD']; + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + + switch ($method) { + case 'GET': + if ($id) { + // Get single template + $stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $result = $stmt->get_result(); + $template = $result->fetch_assoc(); + $stmt->close(); + echo json_encode(['success' => true, 'template' => $template]); + } else { + // Get all templates + $result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name"); + $templates = []; + while ($row = $result->fetch_assoc()) { + $templates[] = $row; + } + echo json_encode(['success' => true, 'templates' => $templates]); + } + break; + + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + + $stmt = $conn->prepare("INSERT INTO ticket_templates + (template_name, title_template, description_template, category, type, priority, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?)"); + $stmt->bind_param('sssssii', + $data['template_name'], + $data['title_template'], + $data['description_template'], + $data['category'], + $data['type'], + $data['priority'] ?? 4, + $data['is_active'] ?? 1 + ); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'template_id' => $conn->insert_id]); + } else { + echo json_encode(['success' => false, 'error' => $stmt->error]); + } + $stmt->close(); + break; + + case 'PUT': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $data = json_decode(file_get_contents('php://input'), true); + + $stmt = $conn->prepare("UPDATE ticket_templates SET + template_name = ?, title_template = ?, description_template = ?, + category = ?, type = ?, priority = ?, is_active = ? + WHERE template_id = ?"); + $stmt->bind_param('sssssiii', + $data['template_name'], + $data['title_template'], + $data['description_template'], + $data['category'], + $data['type'], + $data['priority'] ?? 4, + $data['is_active'] ?? 1, + $id + ); + + echo json_encode(['success' => $stmt->execute()]); + $stmt->close(); + break; + + case 'DELETE': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?"); + $stmt->bind_param('i', $id); + echo json_encode(['success' => $stmt->execute()]); + $stmt->close(); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + } + + $conn->close(); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/manage_workflows.php b/api/manage_workflows.php new file mode 100644 index 0000000..b433bec --- /dev/null +++ b/api/manage_workflows.php @@ -0,0 +1,147 @@ + false, 'error' => 'Authentication required']); + exit; + } + + // Check admin privileges + if (!$_SESSION['user']['is_admin']) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Admin privileges required']); + exit; + } + + // CSRF Protection for write operations + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; + $csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + if (!CsrfMiddleware::validateToken($csrfToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']); + exit; + } + } + + $conn = new mysqli( + $GLOBALS['config']['DB_HOST'], + $GLOBALS['config']['DB_USER'], + $GLOBALS['config']['DB_PASS'], + $GLOBALS['config']['DB_NAME'] + ); + + if ($conn->connect_error) { + throw new Exception("Database connection failed"); + } + + header('Content-Type: application/json'); + + $method = $_SERVER['REQUEST_METHOD']; + $id = isset($_GET['id']) ? (int)$_GET['id'] : null; + + switch ($method) { + case 'GET': + if ($id) { + // Get single transition + $stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $result = $stmt->get_result(); + $transition = $result->fetch_assoc(); + $stmt->close(); + echo json_encode(['success' => true, 'transition' => $transition]); + } else { + // Get all transitions + $result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status"); + $transitions = []; + while ($row = $result->fetch_assoc()) { + $transitions[] = $row; + } + echo json_encode(['success' => true, 'transitions' => $transitions]); + } + break; + + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + + $stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active) + VALUES (?, ?, ?, ?, ?)"); + $stmt->bind_param('ssiii', + $data['from_status'], + $data['to_status'], + $data['requires_comment'] ?? 0, + $data['requires_admin'] ?? 0, + $data['is_active'] ?? 1 + ); + + if ($stmt->execute()) { + echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]); + } else { + echo json_encode(['success' => false, 'error' => $stmt->error]); + } + $stmt->close(); + break; + + case 'PUT': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $data = json_decode(file_get_contents('php://input'), true); + + $stmt = $conn->prepare("UPDATE status_transitions SET + from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ? + WHERE transition_id = ?"); + $stmt->bind_param('ssiiii', + $data['from_status'], + $data['to_status'], + $data['requires_comment'] ?? 0, + $data['requires_admin'] ?? 0, + $data['is_active'] ?? 1, + $id + ); + + echo json_encode(['success' => $stmt->execute()]); + $stmt->close(); + break; + + case 'DELETE': + if (!$id) { + echo json_encode(['success' => false, 'error' => 'ID required']); + exit; + } + + $stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?"); + $stmt->bind_param('i', $id); + echo json_encode(['success' => $stmt->execute()]); + $stmt->close(); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); + } + + $conn->close(); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/api/ticket_dependencies.php b/api/ticket_dependencies.php new file mode 100644 index 0000000..6bd6f51 --- /dev/null +++ b/api/ticket_dependencies.php @@ -0,0 +1,143 @@ +connect_error) { + ResponseHelper::serverError('Database connection failed'); +} + +$dependencyModel = new DependencyModel($conn); +$auditLog = new AuditLogModel($conn); + +$method = $_SERVER['REQUEST_METHOD']; + +switch ($method) { + case 'GET': + // Get dependencies for a ticket + $ticketId = $_GET['ticket_id'] ?? null; + + if (!$ticketId) { + ResponseHelper::error('Ticket ID required'); + } + + $dependencies = $dependencyModel->getDependencies($ticketId); + $dependents = $dependencyModel->getDependentTickets($ticketId); + + ResponseHelper::success([ + 'dependencies' => $dependencies, + 'dependents' => $dependents + ]); + break; + + case 'POST': + // Add a new dependency + $data = json_decode(file_get_contents('php://input'), true); + + $ticketId = $data['ticket_id'] ?? null; + $dependsOnId = $data['depends_on_id'] ?? null; + $type = $data['dependency_type'] ?? 'blocks'; + + if (!$ticketId || !$dependsOnId) { + ResponseHelper::error('Both ticket_id and depends_on_id are required'); + } + + $result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId); + + if ($result['success']) { + // Log to audit + $auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [ + 'ticket_id' => $ticketId, + 'depends_on_id' => $dependsOnId, + 'type' => $type + ]); + + ResponseHelper::created($result); + } else { + ResponseHelper::error($result['error']); + } + break; + + case 'DELETE': + // Remove a dependency + $data = json_decode(file_get_contents('php://input'), true); + + $dependencyId = $data['dependency_id'] ?? null; + + // Alternative: delete by ticket IDs + if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) { + $ticketId = $data['ticket_id']; + $dependsOnId = $data['depends_on_id']; + $type = $data['dependency_type'] ?? 'blocks'; + + $result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type); + + if ($result) { + $auditLog->log($userId, 'delete', 'dependency', null, [ + 'ticket_id' => $ticketId, + 'depends_on_id' => $dependsOnId, + 'type' => $type + ]); + ResponseHelper::success([], 'Dependency removed'); + } else { + ResponseHelper::error('Failed to remove dependency'); + } + } elseif ($dependencyId) { + $result = $dependencyModel->removeDependency($dependencyId); + + if ($result) { + $auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId); + ResponseHelper::success([], 'Dependency removed'); + } else { + ResponseHelper::error('Failed to remove dependency'); + } + } else { + ResponseHelper::error('Dependency ID or ticket IDs required'); + } + break; + + default: + ResponseHelper::error('Method not allowed', 405); +} + +$conn->close(); diff --git a/api/update_ticket.php b/api/update_ticket.php index f369ba4..ee76630 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -3,23 +3,18 @@ error_reporting(E_ALL); ini_set('display_errors', 0); // Don't display errors in the response -// Define a debug log function -function debug_log($message) { - file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND); -} +// Apply rate limiting +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); // Start output buffering to capture any errors ob_start(); try { - debug_log("Script started"); - // Load config $configPath = dirname(__DIR__) . '/config/config.php'; - debug_log("Loading config from: $configPath"); require_once $configPath; - debug_log("Config loaded successfully"); - + // Load environment variables (for Discord webhook) $envPath = dirname(__DIR__) . '/.env'; $envVars = []; @@ -38,7 +33,6 @@ try { $envVars[$key] = $value; } } - debug_log("Environment variables loaded"); } // Load models directly with absolute paths @@ -47,12 +41,10 @@ try { $auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php'; $workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php'; - debug_log("Loading models from: $ticketModelPath and $commentModelPath"); require_once $ticketModelPath; require_once $commentModelPath; require_once $auditLogModelPath; require_once $workflowModelPath; - debug_log("Models loaded successfully"); // Check authentication via session session_start(); @@ -75,7 +67,6 @@ try { $currentUser = $_SESSION['user']; $userId = $currentUser['user_id']; $isAdmin = $currentUser['is_admin'] ?? false; - debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")"); // Updated controller class that handles partial updates class ApiTicketController { @@ -98,8 +89,6 @@ try { } public function update($id, $data) { - debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data)); - // First, get the current ticket data to fill in missing fields $currentTicket = $this->ticketModel->getTicketById($id); if (!$currentTicket) { @@ -108,9 +97,7 @@ try { 'error' => 'Ticket not found' ]; } - - debug_log("Current ticket data: " . json_encode($currentTicket)); - + // Merge current data with updates, keeping existing values for missing fields $updateData = [ 'ticket_id' => $id, @@ -121,9 +108,7 @@ try { 'status' => $data['status'] ?? $currentTicket['status'], 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] ]; - - debug_log("Merged update data: " . json_encode($updateData)); - + // Validate required fields if (empty($updateData['title'])) { return [ @@ -155,14 +140,10 @@ try { ]; } } - - debug_log("Validation passed, calling ticketModel->updateTicket"); // Update ticket with user tracking $result = $this->ticketModel->updateTicket($updateData, $this->userId); - debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false')); - if ($result) { // Log ticket update to audit log if ($this->userId) { @@ -188,12 +169,10 @@ try { private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) { if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) { - debug_log("Discord webhook URL not configured, skipping webhook"); return; } - + $webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL']; - debug_log("Sending Discord webhook to: $webhookUrl"); // Determine what fields actually changed $changes = []; @@ -211,7 +190,6 @@ try { } if (empty($changes)) { - debug_log("No actual changes detected, skipping webhook"); return; } @@ -248,16 +226,13 @@ try { $payload = [ 'embeds' => [$embed] ]; - - debug_log("Discord payload: " . json_encode($payload)); - + // 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_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $webhookResult = curl_exec($ch); @@ -265,18 +240,11 @@ try { $curlError = curl_error($ch); curl_close($ch); - if ($curlError) { - debug_log("Discord webhook cURL error: $curlError"); - } else { - debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult"); - } + // Silently handle errors - webhook is optional } } - - debug_log("Controller defined successfully"); - + // Create database connection - debug_log("Creating database connection"); $conn = new mysqli( $GLOBALS['config']['DB_HOST'], $GLOBALS['config']['DB_USER'], @@ -287,8 +255,7 @@ try { if ($conn->connect_error) { throw new Exception("Database connection failed: " . $conn->connect_error); } - debug_log("Database connection successful"); - + // Check request method if ($_SERVER['REQUEST_METHOD'] !== 'POST') { throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']); @@ -297,9 +264,7 @@ try { // Get POST data $input = file_get_contents('php://input'); $data = json_decode($input, true); - debug_log("Received raw input: " . $input); - debug_log("Decoded data: " . json_encode($data)); - + if (!$data) { throw new Exception("Invalid JSON data received: " . $input); } @@ -309,17 +274,12 @@ try { } $ticketId = (int)$data['ticket_id']; - debug_log("Processing ticket ID: $ticketId"); - + // Initialize controller - debug_log("Initializing controller"); $controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin); - debug_log("Controller initialized"); - + // Update ticket - debug_log("Calling controller update method"); $result = $controller->update($ticketId, $data); - debug_log("Update completed with result: " . json_encode($result)); // Close database connection $conn->close(); @@ -330,12 +290,8 @@ try { // Return response header('Content-Type: application/json'); echo json_encode($result); - debug_log("Response sent successfully"); - + } catch (Exception $e) { - debug_log("Error: " . $e->getMessage()); - debug_log("Stack trace: " . $e->getTraceAsString()); - // Discard any output that might have been generated ob_end_clean(); @@ -346,6 +302,5 @@ try { 'success' => false, 'error' => $e->getMessage() ]); - debug_log("Error response sent"); } ?> \ No newline at end of file diff --git a/api/upload_attachment.php b/api/upload_attachment.php new file mode 100644 index 0000000..9a2b359 --- /dev/null +++ b/api/upload_attachment.php @@ -0,0 +1,201 @@ +getAttachments($ticketId); + + // Add formatted file size and icon to each attachment + foreach ($attachments as &$att) { + $att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']); + $att['icon'] = AttachmentModel::getFileIcon($att['mime_type']); + } + + ResponseHelper::success(['attachments' => $attachments]); + } catch (Exception $e) { + ResponseHelper::serverError('Failed to load attachments'); + } +} + +// Only accept POST requests for uploads +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + ResponseHelper::error('Method not allowed', 405); +} + +// Verify CSRF token +$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; +if (!CsrfMiddleware::validateToken($csrfToken)) { + require_once dirname(__DIR__) . '/models/AuditLogModel.php'; + $auditLog = new AuditLogModel(); + $auditLog->logCsrfFailure($_SESSION['user']['user_id'] ?? null, 'upload_attachment'); + ResponseHelper::forbidden('Invalid CSRF token'); +} + +// Get ticket ID +$ticketId = $_POST['ticket_id'] ?? ''; +if (empty($ticketId)) { + ResponseHelper::error('Ticket ID is required'); +} + +// Validate ticket ID format +if (!preg_match('/^[A-Z]{3}\d{6}$/', $ticketId)) { + ResponseHelper::error('Invalid ticket ID format'); +} + +// Check if file was uploaded +if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) { + ResponseHelper::error('No file uploaded'); +} + +$file = $_FILES['file']; + +// Check for upload errors +if ($file['error'] !== UPLOAD_ERR_OK) { + $errorMessages = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive', + UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'File upload stopped by extension' + ]; + $message = $errorMessages[$file['error']] ?? 'Unknown upload error'; + ResponseHelper::error($message); +} + +// Check file size +$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default +if ($file['size'] > $maxSize) { + ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')'); +} + +// Get MIME type +$finfo = new finfo(FILEINFO_MIME_TYPE); +$mimeType = $finfo->file($file['tmp_name']); + +// Validate file type +if (!AttachmentModel::isAllowedType($mimeType)) { + ResponseHelper::error('File type not allowed: ' . $mimeType); +} + +// Create upload directory if it doesn't exist +$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; +if (!is_dir($uploadDir)) { + if (!mkdir($uploadDir, 0755, true)) { + ResponseHelper::serverError('Failed to create upload directory'); + } +} + +// Create ticket subdirectory +$ticketDir = $uploadDir . '/' . $ticketId; +if (!is_dir($ticketDir)) { + if (!mkdir($ticketDir, 0755, true)) { + ResponseHelper::serverError('Failed to create ticket upload directory'); + } +} + +// Generate unique filename +$extension = pathinfo($file['name'], PATHINFO_EXTENSION); +$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension); +$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : ''); +$targetPath = $ticketDir . '/' . $uniqueFilename; + +// Move uploaded file +if (!move_uploaded_file($file['tmp_name'], $targetPath)) { + ResponseHelper::serverError('Failed to move uploaded file'); +} + +// Sanitize original filename +$originalFilename = basename($file['name']); +$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename); +if (empty($originalFilename)) { + $originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : ''); +} + +// Save to database +try { + $attachmentModel = new AttachmentModel(); + $attachmentId = $attachmentModel->addAttachment( + $ticketId, + $uniqueFilename, + $originalFilename, + $file['size'], + $mimeType, + $_SESSION['user']['user_id'] + ); + + if (!$attachmentId) { + // Clean up file if database insert fails + unlink($targetPath); + ResponseHelper::serverError('Failed to save attachment record'); + } + + // Log the upload + $auditLog = new AuditLogModel(); + $auditLog->log( + $_SESSION['user']['user_id'], + 'attachment_upload', + 'ticket_attachments', + $attachmentId, + null, + json_encode([ + 'ticket_id' => $ticketId, + 'filename' => $originalFilename, + 'size' => $file['size'], + 'mime_type' => $mimeType + ]) + ); + + ResponseHelper::created([ + 'attachment_id' => $attachmentId, + 'filename' => $originalFilename, + 'file_size' => $file['size'], + 'file_size_formatted' => AttachmentModel::formatFileSize($file['size']), + 'mime_type' => $mimeType, + 'icon' => AttachmentModel::getFileIcon($mimeType), + 'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'], + 'uploaded_at' => date('Y-m-d H:i:s') + ], 'File uploaded successfully'); + +} catch (Exception $e) { + // Clean up file on error + if (file_exists($targetPath)) { + unlink($targetPath); + } + ResponseHelper::serverError('Failed to process attachment'); +} diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 4a9160e..3d1bcd2 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -3000,3 +3000,169 @@ code.inline-code { margin-top: 0.25rem; font-style: italic; } + +/* ===== QUICK ACTIONS ===== */ +.quick-actions-cell { + text-align: center; + white-space: nowrap; +} + +.quick-actions { + display: flex; + gap: 0.25rem; + justify-content: center; + opacity: 0.5; + transition: opacity 0.2s ease; +} + +tr:hover .quick-actions { + opacity: 1; +} + +.quick-action-btn { + background: transparent; + border: 1px solid var(--terminal-green); + color: var(--terminal-green); + padding: 0.25rem 0.5rem; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + border-radius: 0; +} + +.quick-action-btn:hover { + background: var(--terminal-green); + color: var(--bg-primary); + box-shadow: var(--glow-green); +} + +/* ===== DASHBOARD STATS WIDGETS ===== */ +.stats-widgets { + margin-bottom: 1.5rem; +} + +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.stat-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + transition: all 0.3s ease; +} + +.stat-card:hover { + border-color: var(--terminal-amber); + box-shadow: var(--glow-amber); +} + +.stat-icon { + font-size: 1.5rem; + opacity: 0.9; +} + +.stat-content { + flex: 1; +} + +.stat-value { + font-size: 1.5rem; + font-weight: bold; + color: var(--terminal-green); + line-height: 1; + text-shadow: var(--glow-green); +} + +.stat-label { + font-size: 0.8rem; + color: var(--terminal-green-dim, #008822); + margin-top: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Stat card color variations */ +.stat-critical .stat-value { + color: var(--priority-1); + text-shadow: var(--glow-red); +} + +.stat-critical { + border-color: var(--priority-1); +} + +.stat-unassigned .stat-value { + color: var(--terminal-amber); + text-shadow: var(--glow-amber); +} + +.stat-unassigned { + border-color: var(--terminal-amber); +} + +/* Mobile responsive stats */ +@media (max-width: 768px) { + .stats-row { + grid-template-columns: repeat(2, 1fr); + } + + .stat-card { + padding: 0.75rem; + } + + .stat-value { + font-size: 1.25rem; + } + + .stat-icon { + font-size: 1.25rem; + } +} + +@media (max-width: 480px) { + .stats-row { + grid-template-columns: 1fr; + } +} + +/* ===== EXPORT DROPDOWN ===== */ +.export-dropdown { + position: relative; + display: inline-block; +} + +.export-dropdown-content { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-primary); + border: 2px solid var(--terminal-green); + min-width: 120px; + z-index: 100; + box-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.export-dropdown:hover .export-dropdown-content { + display: block; +} + +.export-dropdown-content a { + display: block; + padding: 0.5rem 1rem; + color: var(--terminal-green); + text-decoration: none; + font-family: var(--font-mono); + transition: all 0.2s ease; +} + +.export-dropdown-content a:hover { + background: rgba(0, 255, 65, 0.1); + color: var(--terminal-amber); +} diff --git a/assets/css/ticket.css b/assets/css/ticket.css index d78d681..e1e6740 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -1172,10 +1172,330 @@ body.dark-mode .editable { .ticket-tabs { flex-direction: column; } - + .tab-btn { width: 100%; border: 2px solid var(--terminal-green); margin-bottom: 5px; } } + +/* ===== ATTACHMENT STYLES ===== */ + +/* Upload Zone */ +.upload-zone { + border: 3px dashed var(--terminal-green); + border-radius: 0; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: var(--bg-primary); +} + +.upload-zone:hover { + border-color: var(--terminal-amber); + background: rgba(0, 255, 65, 0.05); +} + +.upload-zone.drag-over { + border-color: var(--terminal-amber); + background: rgba(241, 196, 15, 0.1); + box-shadow: 0 0 20px rgba(241, 196, 15, 0.3); +} + +.upload-zone-content { + color: var(--terminal-green); + font-family: var(--font-mono); +} + +.upload-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.upload-hint { + font-size: 0.85rem; + color: var(--terminal-green-dim); + margin-top: 0.5rem; +} + +/* Progress Bar */ +.progress-bar { + width: 100%; + height: 20px; + border: 2px solid var(--terminal-green); + background: var(--bg-primary); + position: relative; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--terminal-green); + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.2) 50%, + transparent 100% + ); + animation: progress-shimmer 1.5s infinite; +} + +@keyframes progress-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +/* Attachments Grid */ +.attachments-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.attachment-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--terminal-green); + background: var(--bg-primary); + transition: all 0.2s ease; +} + +.attachment-item:hover { + border-color: var(--terminal-amber); + background: rgba(0, 255, 65, 0.05); +} + +.attachment-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.attachment-info { + flex: 1; + min-width: 0; +} + +.attachment-name { + font-family: var(--font-mono); + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.attachment-name a { + color: var(--terminal-green); + text-decoration: none; +} + +.attachment-name a:hover { + color: var(--terminal-amber); + text-decoration: underline; +} + +.attachment-meta { + font-size: 0.8rem; + color: var(--terminal-green-dim); + font-family: var(--font-mono); + margin-top: 0.25rem; +} + +.attachment-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.btn-small { + padding: 0.25rem 0.5rem; + font-size: 0.85rem; +} + +.btn-danger { + color: var(--priority-1); + border-color: var(--priority-1); +} + +.btn-danger:hover { + background: var(--priority-1); + color: white; +} + +/* Mobile responsiveness for attachments */ +@media (max-width: 768px) { + .attachment-item { + flex-wrap: wrap; + } + + .attachment-info { + flex-basis: calc(100% - 4rem); + } + + .attachment-actions { + flex-basis: 100%; + justify-content: flex-end; + margin-top: 0.5rem; + } +} + +/* ===== @MENTION HIGHLIGHTING STYLES ===== */ + +/* Mention styling in comments */ +.mention { + color: var(--terminal-cyan); + background: rgba(0, 255, 255, 0.1); + padding: 0.1rem 0.3rem; + border-radius: 0; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.mention:hover { + background: rgba(0, 255, 255, 0.2); + text-shadow: 0 0 5px var(--terminal-cyan); +} + +.mention::before { + content: '@'; +} + +/* Mention Autocomplete Dropdown */ +.mention-autocomplete { + position: absolute; + background: var(--bg-primary); + border: 2px solid var(--terminal-green); + border-radius: 0; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + min-width: 200px; + box-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.mention-autocomplete.active { + display: block; +} + +.mention-option { + padding: 0.5rem 1rem; + cursor: pointer; + font-family: var(--font-mono); + color: var(--terminal-green); + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.mention-option:hover, +.mention-option.selected { + background: rgba(0, 255, 65, 0.1); + color: var(--terminal-amber); +} + +.mention-option .mention-username { + font-weight: bold; +} + +.mention-option .mention-displayname { + color: var(--terminal-green-dim); + font-size: 0.85rem; +} + +/* ===== RICH TEXT EDITOR TOOLBAR ===== */ + +.editor-toolbar { + display: flex; + gap: 0.25rem; + padding: 0.5rem; + border: 2px solid var(--terminal-green); + border-bottom: none; + background: var(--bg-secondary); + flex-wrap: wrap; +} + +.editor-toolbar button { + padding: 0.4rem 0.6rem; + background: var(--bg-primary); + border: 1px solid var(--terminal-green); + color: var(--terminal-green); + font-family: var(--font-mono); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 32px; +} + +.editor-toolbar button:hover { + background: rgba(0, 255, 65, 0.1); + color: var(--terminal-amber); + border-color: var(--terminal-amber); +} + +.editor-toolbar button:active { + background: rgba(0, 255, 65, 0.2); +} + +.editor-toolbar .toolbar-separator { + width: 1px; + background: var(--terminal-green-dim); + margin: 0 0.5rem; +} + +/* Connect toolbar to textarea */ +.editor-with-toolbar textarea { + border-top: none; +} + +/* ===== EXPORT BUTTON ===== */ + +.export-dropdown { + position: relative; + display: inline-block; +} + +.export-dropdown-content { + display: none; + position: absolute; + right: 0; + background: var(--bg-primary); + border: 2px solid var(--terminal-green); + min-width: 150px; + z-index: 100; + box-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.export-dropdown:hover .export-dropdown-content { + display: block; +} + +.export-dropdown-content a { + display: block; + padding: 0.5rem 1rem; + color: var(--terminal-green); + text-decoration: none; + font-family: var(--font-mono); + transition: all 0.2s ease; +} + +.export-dropdown-content a:hover { + background: rgba(0, 255, 65, 0.1); + color: var(--terminal-amber); +} diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index b628880..5469563 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -1,3 +1,10 @@ +// XSS prevention helper +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + // Main initialization document.addEventListener('DOMContentLoaded', function() { console.log('DOM loaded, initializing dashboard...'); @@ -999,6 +1006,10 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel }; const icon = icons[type] || icons.warning; + // Escape user-provided content to prevent XSS + const safeTitle = escapeHtml(title); + const safeMessage = escapeHtml(message); + const modalHtml = `