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 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 09:55:01 -05:00
parent 8c7211d311
commit be505b7312
53 changed files with 6640 additions and 169 deletions

View File

@@ -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

View File

@@ -1,8 +1,13 @@
<?php
// 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__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json');
@@ -50,6 +55,7 @@ if ($conn->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) {

View File

@@ -1,4 +1,8 @@
<?php
// 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__) . '/models/BulkOperationsModel.php';

117
api/check_duplicates.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
/**
* Check for duplicate tickets API
*
* 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/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);
}
// Get title parameter
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// 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]);

111
api/custom_fields.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
/**
* Custom Fields Management API
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.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;
}
// 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()]);
}

102
api/delete_attachment.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
/**
* Delete Attachment API
*
* Handles deletion of ticket attachments
*/
// 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/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Only accept DELETE or POST requests
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
ResponseHelper::error('Method not allowed', 405);
}
// Get request body
$input = json_decode(file_get_contents('php://input'), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = array_merge($_POST, $input ?? []);
}
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$auditLog = new AuditLogModel();
$auditLog->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');
}

101
api/download_attachment.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
/**
* Download Attachment API
*
* Serves file downloads for ticket attachments
*/
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => 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;
}

148
api/export_tickets.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
/**
* Export Tickets API
*
* Exports tickets to CSV format with optional filtering
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
// Include required files
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['success' => 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()
]);
}

View File

@@ -1,33 +1,57 @@
<?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
/**
* Get Users API
* Returns list of users for @mentions autocomplete
*/
header('Content-Type: application/json');
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => 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]);

169
api/manage_recurring.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
/**
* Recurring Tickets Management API
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.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;
}
// 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');
}

153
api/manage_templates.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
/**
* Template Management API
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
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;
}
// 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()]);
}

147
api/manage_workflows.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
/**
* Workflow/Status Transitions Management API
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
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;
}
// 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()]);
}

143
api/ticket_dependencies.php Normal file
View File

@@ -0,0 +1,143 @@
<?php
/**
* Ticket Dependencies API
*
* GET: Get dependencies for a ticket
* POST: Add a new dependency
* DELETE: Remove a dependency
*/
// 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__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.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();
}
$userId = $_SESSION['user']['user_id'];
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
}
// 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');
}
$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();

View File

@@ -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");
}
?>

201
api/upload_attachment.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
/**
* Upload Attachment API
*
* Handles file uploads for ticket attachments
*/
// 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/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Handle GET requests to list attachments
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$ticketId = $_GET['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');
}
try {
$attachmentModel = new AttachmentModel();
$attachments = $attachmentModel->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');
}