Fix visibility enforcement and register missing API routes

Security fixes:
- add_comment.php: verify canUserAccessTicket() before allowing comment creation
- assign_ticket.php: use canUserAccessTicket() to prevent info leakage via 403 vs 404
- check_duplicates.php: apply getVisibilityFilter() so confidential ticket titles are not exposed in duplicate search results
- ticket_dependencies.php: verify ticket access on GET before returning dependency data

Route registration:
- Register 7 previously missing API endpoints in index.php: custom_fields, saved_filters, audit_log, user_preferences, download_attachment, clone_ticket, health

Frontend:
- ticket.js: fill empty catch block and empty else block in addComment() with proper error toasts

Documentation:
- README.md: document all API endpoints and update project structure listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:39:02 -04:00
parent ce95e555d5
commit 164c2d231a
7 changed files with 90 additions and 6 deletions

View File

@@ -204,6 +204,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user | | `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket | | `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template | | `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments | | `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations | | `/api/bulk_operation.php` | POST | Perform bulk operations |
@@ -220,6 +221,12 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) | | `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
| `/api/manage_templates.php` | CRUD | Templates (admin) | | `/api/manage_templates.php` | CRUD | Templates (admin) |
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) | | `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
| `/api/user_preferences.php` | GET/POST | User preferences |
| `/api/audit_log.php` | GET | Audit log entries (admin) |
| `/api/bootstrap.php` | GET | Bootstrap config/user data for front-end |
| `/api/health.php` | GET | Health check |
## Project Structure ## Project Structure
@@ -228,8 +235,12 @@ tinker_tickets/
├── api/ ├── api/
│ ├── add_comment.php # POST: Add comment │ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user │ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── audit_log.php # GET: Audit log entries (admin)
│ ├── bootstrap.php # GET: Bootstrap data (config/user for front-end)
│ ├── bulk_operation.php # POST: Bulk operations (admin only) │ ├── bulk_operation.php # POST: Bulk operations (admin only)
│ ├── check_duplicates.php # GET: Check for duplicate tickets │ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── clone_ticket.php # POST: Clone an existing ticket
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
│ ├── delete_attachment.php # POST/DELETE: Delete attachment │ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin) │ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download with visibility check │ ├── download_attachment.php # GET: Download with visibility check
@@ -237,23 +248,26 @@ tinker_tickets/
│ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template │ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list │ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin) │ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin) │ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin) │ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin) │ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── saved_filters.php # CRUD: Saved filter combinations
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies │ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin) │ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation) │ ├── update_ticket.php # POST: Update ticket (workflow validation)
── upload_attachment.php # GET/POST: List or upload attachments ── upload_attachment.php # GET/POST: List or upload attachments
│ └── user_preferences.php # GET/POST: User preferences
├── assets/ ├── assets/
│ ├── css/ │ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template) │ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling │ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling │ │ └── ticket.css # Ticket view styling
│ ├── js/ │ ├── js/
│ │ ├── advanced-search.js # Advanced search modal │ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit) │ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template) │ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar │ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys) │ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe) │ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
@@ -292,6 +306,8 @@ tinker_tickets/
│ ├── UserPreferencesModel.php # User preferences │ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows │ └── WorkflowModel.php # Status transition workflows
├── scripts/ ├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads │ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ └── create_dependencies_table.php # Create ticket_dependencies table │ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage ├── uploads/ # File attachment storage

View File

@@ -28,6 +28,7 @@ try {
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication via session // Check authentication via session
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
@@ -71,6 +72,24 @@ try {
exit; exit;
} }
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models // Initialize models
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);

View File

@@ -25,9 +25,9 @@ $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn); $userModel = new UserModel($conn);
// Verify ticket exists // Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;

View File

@@ -7,6 +7,7 @@
require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Only accept GET requests // Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
// Get SOUNDEX of title // Get SOUNDEX of title
$soundexTitle = soundex($title); $soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive) // First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at $sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets FROM tickets
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
OR SOUNDEX(title) = ? OR SOUNDEX(title) = ?
) )
AND status != 'Closed' AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 10"; LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $searchTerm, $soundexTitle); if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();

View File

@@ -67,6 +67,7 @@ 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/DependencyModel.php'; require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
} }
$userId = $_SESSION['user']['user_id']; $userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
// CSRF Protection for POST/DELETE // CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') { if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
try { try {
$dependencyModel = new DependencyModel($conn); $dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) { } catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage()); error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components'); ResponseHelper::serverError('Failed to initialize required components');
@@ -116,6 +119,12 @@ switch ($method) {
ResponseHelper::error('Ticket ID required'); ResponseHelper::error('Ticket ID required');
} }
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try { try {
$dependencies = $dependencyModel->getDependencies($ticketId); $dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId); $dependents = $dependencyModel->getDependentTickets($ticketId);

View File

@@ -188,9 +188,11 @@ function addComment() {
commentsList.insertBefore(commentDiv, commentsList.firstChild); commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else { } else {
lt.toast.error(data.error || 'Failed to add comment');
} }
}) })
.catch(error => { .catch(error => {
lt.toast.error('Error adding comment: ' + error.message);
}); });
} }

View File

@@ -146,6 +146,34 @@ switch (true) {
require_once 'api/check_duplicates.php'; require_once 'api/check_duplicates.php';
break; break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
// Admin Routes - require admin privileges // Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets': case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) { if (!$currentUser || !$currentUser['is_admin']) {