Audit fixes: security, dead code removal, API consolidation, JS dedup
Security: - Fix IDOR in delete/update comment (add ticket visibility check) - XSS defense-in-depth in DashboardView active filters - Replace innerHTML with DOM construction in toast.js - Remove redundant real_escape_string in check_duplicates - Add rate limiting to get_template, download_attachment, audit_log, saved_filters, user_preferences endpoints Bug fixes: - Session timeout now reads from config instead of hardcoded 18000 - TicketController uses $GLOBALS['config'] instead of duplicate .env parsing - Add DISCORD_WEBHOOK_URL to centralized config - Cleanup script uses hashmap for O(1) ticket ID lookups Dead code removal (~100 lines): - Remove dead getTicketComments() from TicketModel (wrong bind_param type) - Remove dead getCategories()/getTypes() from DashboardController - Remove ~80 lines dead Discord webhook code from update_ticket API Consolidation: - Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit) - Convert 6 API endpoints to use bootstrap - Extract escapeHtml/getTicketIdFromUrl into shared utils.js - Batch save for user preferences (1 request instead of 7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Apply rate limiting
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
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 dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
echo json_encode(['success' => 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
|
// Get request data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
$ticketId = $data['ticket_id'] ?? null;
|
||||||
@@ -41,9 +14,6 @@ if (!$ticketId) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use centralized database connection
|
|
||||||
$conn = Database::getConnection();
|
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
$userModel = new UserModel($conn);
|
$userModel = new UserModel($conn);
|
||||||
|
|||||||
@@ -5,31 +5,16 @@
|
|||||||
* Admin-only access
|
* Admin-only access
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.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
|
// Check admin status - audit log viewing is admin-only
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use centralized database connection
|
|
||||||
$conn = Database::getConnection();
|
|
||||||
|
|
||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
// GET - Fetch filtered audit logs or export to CSV
|
// GET - Fetch filtered audit logs or export to CSV
|
||||||
@@ -114,12 +99,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
49
api/bootstrap.php
Normal file
49
api/bootstrap.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* API Bootstrap - Common setup for API endpoints
|
||||||
|
*
|
||||||
|
* Provides: $conn, $currentUser, $userId, $isAdmin
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* require_once __DIR__ . '/bootstrap.php';
|
||||||
|
* // $conn, $currentUser, $userId, $isAdmin are now available
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Rate limiting (also starts session)
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Config and database
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Authentication check
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
@@ -5,22 +5,9 @@
|
|||||||
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Apply rate limiting
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
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 dirname(__DIR__) . '/helpers/ResponseHelper.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
|
// Only accept GET requests
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
ResponseHelper::error('Method not allowed', 405);
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
@@ -33,15 +20,12 @@ if (strlen($title) < 5) {
|
|||||||
ResponseHelper::success(['duplicates' => []]);
|
ResponseHelper::success(['duplicates' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use centralized database connection
|
|
||||||
$conn = Database::getConnection();
|
|
||||||
|
|
||||||
// Search for similar titles
|
// Search for similar titles
|
||||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||||
$duplicates = [];
|
$duplicates = [];
|
||||||
|
|
||||||
// Prepare search term for LIKE
|
// Prepare search term for LIKE
|
||||||
$searchTerm = '%' . $conn->real_escape_string($title) . '%';
|
$searchTerm = '%' . $title . '%';
|
||||||
|
|
||||||
// Get SOUNDEX of title
|
// Get SOUNDEX of title
|
||||||
$soundexTitle = soundex($title);
|
$soundexTitle = soundex($title);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
@@ -61,9 +62,22 @@ try {
|
|||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($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);
|
$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
|
// Delete comment
|
||||||
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
|
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
* Serves file downloads for ticket attachments
|
* 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__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
* Returns a ticket template by ID
|
* Returns a ticket template by ID
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
ErrorHandler::init();
|
ErrorHandler::init();
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,9 @@
|
|||||||
* Returns list of users for @mentions autocomplete
|
* Returns list of users for @mentions autocomplete
|
||||||
*/
|
*/
|
||||||
|
|
||||||
ini_set('display_errors', 0);
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
error_reporting(E_ALL);
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
||||||
RateLimitMiddleware::apply('api');
|
|
||||||
|
|
||||||
try {
|
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
|
// Get all users for mentions/assignment
|
||||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
||||||
|
|
||||||
|
|||||||
@@ -4,36 +4,9 @@
|
|||||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/SavedFiltersModel.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);
|
$filtersModel = new SavedFiltersModel($conn);
|
||||||
|
|
||||||
// GET - Fetch all saved filters or a specific filter
|
// 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'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterName = trim($data['filter_name']);
|
$filterName = trim($data['filter_name']);
|
||||||
@@ -83,7 +56,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (empty($filterName) || strlen($filterName) > 100) {
|
if (empty($filterName) || strlen($filterName) > 100) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterId = (int)$data['filter_id'];
|
$filterId = (int)$data['filter_id'];
|
||||||
@@ -117,14 +90,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle full filter update
|
// Handle full filter update
|
||||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterName = trim($data['filter_name']);
|
$filterName = trim($data['filter_name']);
|
||||||
@@ -148,7 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterId = (int)$data['filter_id'];
|
$filterId = (int)$data['filter_id'];
|
||||||
@@ -166,5 +139,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
@@ -64,6 +65,20 @@ try {
|
|||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($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
|
// Update comment
|
||||||
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
|
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
|
||||||
|
|
||||||
|
|||||||
@@ -15,27 +15,6 @@ try {
|
|||||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||||
require_once $configPath;
|
require_once $configPath;
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
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
|
// Load models directly with absolute paths
|
||||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
@@ -76,16 +55,14 @@ try {
|
|||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLog;
|
private $auditLog;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $envVars;
|
|
||||||
private $userId;
|
private $userId;
|
||||||
private $isAdmin;
|
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->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
$this->auditLog = new AuditLogModel($conn);
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->envVars = $envVars;
|
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
$this->isAdmin = $isAdmin;
|
$this->isAdmin = $isAdmin;
|
||||||
}
|
}
|
||||||
@@ -184,9 +161,6 @@ try {
|
|||||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord webhook disabled for updates - only send for new tickets
|
|
||||||
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $updateData['status'],
|
'status' => $updateData['status'],
|
||||||
@@ -194,87 +168,6 @@ try {
|
|||||||
'message' => 'Ticket updated successfully'
|
'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
|
// Use centralized database connection
|
||||||
@@ -300,7 +193,7 @@ try {
|
|||||||
$ticketId = (int)$data['ticket_id'];
|
$ticketId = (int)$data['ticket_id'];
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
$result = $controller->update($ticketId, $data);
|
$result = $controller->update($ticketId, $data);
|
||||||
|
|||||||
@@ -4,36 +4,9 @@
|
|||||||
* Handles GET (fetch preferences) and POST (update preference)
|
* Handles GET (fetch preferences) and POST (update preference)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.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);
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
// GET - Fetch all preferences for user
|
// GET - Fetch all preferences for user
|
||||||
@@ -48,19 +21,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST - Update a preference
|
// POST - Update preference(s)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$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)
|
// Validate preference key (whitelist)
|
||||||
$validKeys = [
|
$validKeys = [
|
||||||
'rows_per_page',
|
'rows_per_page',
|
||||||
@@ -71,10 +35,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'toast_duration'
|
'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)) {
|
if (!in_array($key, $validKeys)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +93,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
if (!isset($data['key'])) {
|
if (!isset($data['key'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -116,5 +109,3 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -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
|
* Toggle sidebar visibility on desktop
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -94,22 +94,20 @@ async function saveSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save each preference
|
// Batch save all preferences in one request
|
||||||
for (const [key, value] of Object.entries(prefs)) {
|
const response = await fetch('/api/user_preferences.php', {
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
method: 'POST',
|
||||||
method: 'POST',
|
credentials: 'same-origin',
|
||||||
credentials: 'same-origin',
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'X-CSRF-Token': window.CSRF_TOKEN
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
},
|
||||||
},
|
body: JSON.stringify({ preferences: prefs })
|
||||||
body: JSON.stringify({ key, value })
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`Failed to save ${key}`);
|
throw new Error('Failed to save preferences');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof toast !== 'undefined') {
|
if (typeof toast !== 'undefined') {
|
||||||
|
|||||||
@@ -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
|
* Toggle visibility groups field based on visibility selection
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,11 +30,22 @@ function displayToast(message, type, duration) {
|
|||||||
warning: '⚠'
|
warning: '⚠'
|
||||||
};
|
};
|
||||||
|
|
||||||
toast.innerHTML = `
|
const iconSpan = document.createElement('span');
|
||||||
<span class="toast-icon">[${icons[type] || 'ℹ'}]</span>
|
iconSpan.className = 'toast-icon';
|
||||||
<span class="toast-message">${message}</span>
|
iconSpan.textContent = `[${icons[type] || 'ℹ'}]`;
|
||||||
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
|
|
||||||
`;
|
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
|
// Add to document
|
||||||
document.body.appendChild(toast);
|
document.body.appendChild(toast);
|
||||||
|
|||||||
14
assets/js/utils.js
Normal file
14
assets/js/utils.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ $GLOBALS['config'] = [
|
|||||||
'ASSETS_URL' => '/assets', // Assets URL
|
'ASSETS_URL' => '/assets', // Assets URL
|
||||||
'API_URL' => '/api', // API URL
|
'API_URL' => '/api', // API URL
|
||||||
|
|
||||||
|
// Discord webhook
|
||||||
|
'DISCORD_WEBHOOK_URL' => $envVars['DISCORD_WEBHOOK_URL'] ?? null,
|
||||||
|
|
||||||
// Domain settings for external integrations (webhooks, links, etc.)
|
// Domain settings for external integrations (webhooks, links, etc.)
|
||||||
// Set APP_DOMAIN in .env to override
|
// Set APP_DOMAIN in .env to override
|
||||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||||
@@ -40,7 +43,7 @@ $GLOBALS['config'] = [
|
|||||||
)),
|
)),
|
||||||
|
|
||||||
// Session settings
|
// 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
|
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||||
|
|
||||||
// CSRF settings
|
// CSRF settings
|
||||||
|
|||||||
@@ -187,12 +187,5 @@ class DashboardController {
|
|||||||
return ['categories' => $categories, 'types' => $types];
|
return ['categories' => $categories, 'types' => $types];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCategories(): array {
|
|
||||||
return $this->getCategoriesAndTypes()['categories'];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTypes(): array {
|
|
||||||
return $this->getCategoriesAndTypes()['types'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -15,7 +15,6 @@ class TicketController {
|
|||||||
private $userModel;
|
private $userModel;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $templateModel;
|
private $templateModel;
|
||||||
private $envVars;
|
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn) {
|
||||||
@@ -26,26 +25,6 @@ class TicketController {
|
|||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->templateModel = new TemplateModel($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) {
|
public function view($id) {
|
||||||
@@ -217,13 +196,12 @@ class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function sendDiscordWebhook($ticketId, $ticketData) {
|
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");
|
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
|
||||||
|
|
||||||
// Create ticket URL using validated host
|
// Create ticket URL using validated host
|
||||||
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ class AuthMiddleware {
|
|||||||
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
||||||
ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
||||||
ini_set('session.use_strict_mode', 1);
|
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
|
ini_set('session.cookie_lifetime', 0); // Until browser closes
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
@@ -68,8 +69,9 @@ class AuthMiddleware {
|
|||||||
|
|
||||||
// Check if user is already authenticated in session
|
// Check if user is already authenticated in session
|
||||||
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
||||||
// Verify session hasn't expired (5 hour timeout)
|
// Verify session hasn't expired
|
||||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
|
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
||||||
|
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) {
|
||||||
// Log session expiration
|
// Log session expiration
|
||||||
$this->logSecurityEvent('session_expired', [
|
$this->logSecurityEvent('session_expired', [
|
||||||
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
||||||
|
|||||||
@@ -31,21 +31,6 @@ class TicketModel {
|
|||||||
return $result->fetch_assoc();
|
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 {
|
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
|
// Calculate offset
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ echo "Scanning uploads directory: $uploadsDir\n";
|
|||||||
// Get all valid ticket IDs from database
|
// Get all valid ticket IDs from database
|
||||||
$ticketIds = [];
|
$ticketIds = [];
|
||||||
$result = $conn->query("SELECT ticket_id FROM tickets");
|
$result = $conn->query("SELECT ticket_id FROM tickets");
|
||||||
|
if (!$result) {
|
||||||
|
die("Failed to query tickets: " . $conn->error . "\n");
|
||||||
|
}
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$ticketIds[] = $row['ticket_id'];
|
$ticketIds[$row['ticket_id']] = true;
|
||||||
}
|
}
|
||||||
echo "Found " . count($ticketIds) . " tickets in database\n";
|
echo "Found " . count($ticketIds) . " tickets in database\n";
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ foreach ($ticketDirs as $ticketDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if ticket exists
|
// Check if ticket exists
|
||||||
if (!in_array($ticketId, $ticketIds)) {
|
if (!isset($ticketIds[$ticketId])) {
|
||||||
// Ticket doesn't exist - entire folder is orphaned
|
// Ticket doesn't exist - entire folder is orphaned
|
||||||
$orphanedFolders[] = $ticketDir;
|
$orphanedFolders[] = $ticketDir;
|
||||||
$folderSize = 0;
|
$folderSize = 0;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
@@ -316,12 +317,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVisibilityGroups() {
|
function toggleVisibilityGroups() {
|
||||||
const visibility = document.getElementById('visibility').value;
|
const visibility = document.getElementById('visibility').value;
|
||||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
@@ -372,13 +373,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!empty($_GET['category'])) {
|
if (!empty($_GET['category'])) {
|
||||||
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
|
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . htmlspecialchars($_GET['category'])];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['type'])) {
|
if (!empty($_GET['type'])) {
|
||||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
|
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['assigned_to'])) {
|
if (!empty($_GET['assigned_to'])) {
|
||||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned To: ' . ($_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . $_GET['assigned_to'])];
|
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned To: ' . ($_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . htmlspecialchars($_GET['assigned_to']))];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['search'])) {
|
if (!empty($_GET['search'])) {
|
||||||
$activeFilters[] = ['type' => 'search', 'value' => $_GET['search'], 'label' => 'Search: "' . htmlspecialchars(substr($_GET['search'], 0, 20)) . (strlen($_GET['search']) > 20 ? '...' : '') . '"'];
|
$activeFilters[] = ['type' => 'search', 'value' => $_GET['search'], 'label' => 'Search: "' . htmlspecialchars(substr($_GET['search'], 0, 20)) . (strlen($_GET['search']) > 20 ? '...' : '') . '"'];
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user