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