Security hardening and performance improvements
- Add visibility check to attachment downloads (prevents unauthorized access) - Fix ticket ID collision with uniqueness verification loop - Harden CSP: replace unsafe-inline with nonce-based script execution - Add IP-based rate limiting (supplements session-based) - Add visibility checks to bulk operations - Validate internal visibility requires groups - Optimize user activity query (JOINs vs subqueries) - Update documentation with design decisions and security info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
105
Claude.md
105
Claude.md
@@ -17,6 +17,24 @@
|
|||||||
- Comment Edit/Delete (owner or admin can modify their comments)
|
- Comment Edit/Delete (owner or admin can modify their comments)
|
||||||
- Markdown Tables Support, Auto-linking URLs in Comments
|
- Markdown Tables Support, Auto-linking URLs in Comments
|
||||||
|
|
||||||
|
**Security Features** (January 2026):
|
||||||
|
- CSP with nonce-based script execution (no unsafe-inline)
|
||||||
|
- IP-based rate limiting (prevents session bypass attacks)
|
||||||
|
- Visibility checks on attachment downloads
|
||||||
|
- Unique ticket ID generation with collision prevention
|
||||||
|
- Internal visibility requires groups validation
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
**Not Planned / Out of Scope**:
|
||||||
|
- Email integration - Discord webhooks are the notification method for this system
|
||||||
|
- SLA management - Not required for internal infrastructure use
|
||||||
|
- Time tracking - Out of scope for current requirements
|
||||||
|
- OAuth2/External identity providers - Authelia is the only approved SSO method
|
||||||
|
- GraphQL API - REST API is sufficient for current needs
|
||||||
|
|
||||||
|
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface.
|
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface.
|
||||||
@@ -54,6 +72,7 @@ Controllers → Models → Database
|
|||||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||||
│ ├── 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 attachment (with visibility check)
|
||||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||||
│ ├── 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
|
||||||
@@ -93,8 +112,8 @@ Controllers → Models → Database
|
|||||||
├── middleware/
|
├── middleware/
|
||||||
│ ├── AuthMiddleware.php # Authelia SSO integration
|
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||||
│ ├── CsrfMiddleware.php # CSRF protection
|
│ ├── CsrfMiddleware.php # CSRF protection
|
||||||
│ ├── RateLimitMiddleware.php # API rate limiting
|
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||||
│ └── SecurityHeadersMiddleware.php # Security headers
|
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── ApiKeyModel.php # API key generation/validation
|
│ ├── ApiKeyModel.php # API key generation/validation
|
||||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||||
@@ -152,25 +171,41 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|
|||||||
|
|
||||||
### Core Tables
|
### Core Tables
|
||||||
|
|
||||||
- `tickets` - Core ticket data with assignment and visibility
|
| Table | Description |
|
||||||
- `ticket_comments` - Markdown-supported comments
|
|-------|-------------|
|
||||||
- `ticket_attachments` - File attachment metadata
|
| `tickets` | Core ticket data with assignment, visibility, and tracking |
|
||||||
- `ticket_dependencies` - Ticket relationships
|
| `ticket_comments` | Markdown-supported comments with user_id reference |
|
||||||
- `users` - User accounts synced from LLDAP (includes groups)
|
| `ticket_attachments` | File attachment metadata |
|
||||||
- `user_preferences` - User settings and preferences
|
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
|
||||||
- `audit_log` - Complete audit trail
|
| `users` | User accounts synced from LLDAP (includes groups) |
|
||||||
- `status_transitions` - Workflow configuration
|
| `user_preferences` | User settings and preferences |
|
||||||
- `ticket_templates` - Reusable ticket templates
|
| `audit_log` | Complete audit trail with indexed queries |
|
||||||
- `recurring_tickets` - Scheduled ticket definitions
|
| `status_transitions` | Workflow configuration |
|
||||||
- `custom_field_definitions` - Custom field schemas
|
| `ticket_templates` | Reusable ticket templates |
|
||||||
- `custom_field_values` - Custom field data per ticket
|
| `recurring_tickets` | Scheduled ticket definitions |
|
||||||
- `saved_filters` - User-saved dashboard filters
|
| `custom_field_definitions` | Custom field schemas per category |
|
||||||
- `bulk_operations` - Bulk operation tracking
|
| `custom_field_values` | Custom field data per ticket |
|
||||||
- `api_keys` - API key storage with hashes
|
| `saved_filters` | User-saved dashboard filters |
|
||||||
|
| `bulk_operations` | Bulk operation tracking |
|
||||||
|
| `api_keys` | API key storage with hashed keys |
|
||||||
|
|
||||||
### Ticket Visibility Columns
|
### tickets Table Key Columns
|
||||||
- `visibility` - ENUM('public', 'internal', 'confidential')
|
|
||||||
- `visibility_groups` - VARCHAR(500) comma-separated group names
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
|
||||||
|
| `visibility` | enum | 'public', 'internal', 'confidential' |
|
||||||
|
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
|
||||||
|
| `created_by` | int | Foreign key to users |
|
||||||
|
| `assigned_to` | int | Foreign key to users (nullable) |
|
||||||
|
| `updated_by` | int | Foreign key to users |
|
||||||
|
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
|
||||||
|
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
|
||||||
|
|
||||||
|
### Indexed Columns (for performance)
|
||||||
|
|
||||||
|
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
|
||||||
|
- `audit_log`: user_id, action_type, entity_type, created_at
|
||||||
|
|
||||||
## Dashboard Features
|
## Dashboard Features
|
||||||
|
|
||||||
@@ -187,9 +222,11 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|
|||||||
## Ticket Visibility Levels
|
## Ticket Visibility Levels
|
||||||
|
|
||||||
- **Public**: All authenticated users can view
|
- **Public**: All authenticated users can view
|
||||||
- **Internal**: Only users in specified groups can view
|
- **Internal**: Only users in specified groups can view (groups required)
|
||||||
- **Confidential**: Only creator, assignee, and admins can view
|
- **Confidential**: Only creator, assignee, and admins can view
|
||||||
|
|
||||||
|
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
|
||||||
|
|
||||||
## Important Notes for AI Assistants
|
## Important Notes for AI Assistants
|
||||||
|
|
||||||
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||||
@@ -205,6 +242,9 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|
|||||||
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
|
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
|
||||||
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
|
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
|
||||||
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||||
|
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
|
||||||
|
15. **Visibility validation**: Internal visibility requires groups; code validates this
|
||||||
|
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||||
|
|
||||||
## File Reference Quick Guide
|
## File Reference Quick Guide
|
||||||
|
|
||||||
@@ -212,14 +252,31 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.php` | Main router for all routes |
|
| `index.php` | Main router for all routes |
|
||||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
|
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
|
||||||
| `models/TicketModel.php` | Ticket CRUD, visibility filtering |
|
| `api/download_attachment.php` | File downloads with visibility check |
|
||||||
|
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||||
|
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||||
| `models/ApiKeyModel.php` | API key generation and validation |
|
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||||
|
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
|
||||||
|
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||||
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
||||||
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
||||||
| `assets/js/markdown.js` | Markdown parsing + ticket linking |
|
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||||
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
||||||
|
|
||||||
|
## Security Implementations
|
||||||
|
|
||||||
|
| Feature | Implementation |
|
||||||
|
|---------|---------------|
|
||||||
|
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||||
|
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
|
||||||
|
| CSRF Protection | Token-based with constant-time comparison |
|
||||||
|
| Session Security | Fixation prevention, secure cookies, timeout |
|
||||||
|
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||||
|
| File Security | Path traversal prevention, MIME validation |
|
||||||
|
| Visibility | Enforced on views, downloads, and bulk operations |
|
||||||
|
|
||||||
## Repository
|
## Repository
|
||||||
|
|
||||||
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||||
- **Production**: http://t.lotusguild.org
|
- **Production**: https://t.lotusguild.org
|
||||||
|
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||||
|
|
||||||
|
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
The following features are intentionally **not planned** for this system:
|
||||||
|
- **Email Integration**: Discord webhooks are the chosen notification method
|
||||||
|
- **SLA Management**: Not required for internal infrastructure use
|
||||||
|
- **Time Tracking**: Out of scope for current requirements
|
||||||
|
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### Dashboard & Ticket Management
|
### Dashboard & Ticket Management
|
||||||
@@ -104,12 +114,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `?` | Show keyboard shortcuts help |
|
| `?` | Show keyboard shortcuts help |
|
||||||
|
|
||||||
### Security Features
|
### Security Features
|
||||||
- **CSRF Protection**: Token-based protection on all forms
|
- **CSRF Protection**: Token-based protection with constant-time comparison
|
||||||
- **Rate Limiting**: API rate limiting to prevent abuse
|
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
||||||
- **Security Headers**: CSP, X-Frame-Options, X-Content-Type-Options
|
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
|
||||||
- **SQL Injection Prevention**: All queries use prepared statements
|
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
||||||
- **XSS Protection**: All output properly escaped
|
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
|
||||||
- **Audit Logging**: Complete audit trail of all actions
|
- **Audit Logging**: Complete audit trail of all actions
|
||||||
|
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
|
||||||
|
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ foreach ($ticketIds as $ticketId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create database connection (needed for visibility check)
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
$GLOBALS['config']['DB_HOST'],
|
$GLOBALS['config']['DB_HOST'],
|
||||||
@@ -69,6 +72,33 @@ if ($conn->connect_error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$bulkOpsModel = new BulkOperationsModel($conn);
|
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
|
||||||
|
// Verify user can access all tickets in the bulk operation
|
||||||
|
// (Admins can access all, but this is defense-in-depth)
|
||||||
|
$accessibleTicketIds = [];
|
||||||
|
$inaccessibleCount = 0;
|
||||||
|
$tickets = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
$ticketId = trim($ticketId);
|
||||||
|
$ticket = $tickets[$ticketId] ?? null;
|
||||||
|
|
||||||
|
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
$accessibleTicketIds[] = $ticketId;
|
||||||
|
} else {
|
||||||
|
$inaccessibleCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($accessibleTicketIds)) {
|
||||||
|
$conn->close();
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use only accessible ticket IDs
|
||||||
|
$ticketIds = $accessibleTicketIds;
|
||||||
|
|
||||||
// Create bulk operation record
|
// Create bulk operation record
|
||||||
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
||||||
@@ -90,11 +120,16 @@ if (isset($result['error'])) {
|
|||||||
'error' => $result['error']
|
'error' => $result['error']
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
|
||||||
|
if ($inaccessibleCount > 0) {
|
||||||
|
$message .= " ($inaccessibleCount skipped - no access)";
|
||||||
|
}
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'operation_id' => $operationId,
|
'operation_id' => $operationId,
|
||||||
'processed' => $result['processed'],
|
'processed' => $result['processed'],
|
||||||
'failed' => $result['failed'],
|
'failed' => $result['failed'],
|
||||||
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
|
'skipped' => $inaccessibleCount,
|
||||||
|
'message' => $message
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,26 +41,42 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the associated ticket exists (access control)
|
// Verify the associated ticket exists and user has access
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
$GLOBALS['config']['DB_HOST'],
|
$GLOBALS['config']['DB_HOST'],
|
||||||
$GLOBALS['config']['DB_USER'],
|
$GLOBALS['config']['DB_USER'],
|
||||||
$GLOBALS['config']['DB_PASS'],
|
$GLOBALS['config']['DB_PASS'],
|
||||||
$GLOBALS['config']['DB_NAME']
|
$GLOBALS['config']['DB_NAME']
|
||||||
);
|
);
|
||||||
if (!$conn->connect_error) {
|
if ($conn->connect_error) {
|
||||||
$ticketModel = new TicketModel($conn);
|
http_response_code(500);
|
||||||
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
header('Content-Type: application/json');
|
||||||
$conn->close();
|
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
if (!$ticket) {
|
|
||||||
http_response_code(404);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
||||||
|
|
||||||
|
if (!$ticket) {
|
||||||
|
$conn->close();
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this ticket based on visibility settings
|
||||||
|
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
$conn->close();
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
// Build file path
|
// Build file path
|
||||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ try {
|
|||||||
if (is_array($visibilityGroups)) {
|
if (is_array($visibilityGroups)) {
|
||||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate internal visibility requires groups
|
||||||
|
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Internal visibility requires at least one group to be specified'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
index.php
41
index.php
@@ -307,14 +307,45 @@ switch (true) {
|
|||||||
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
|
||||||
|
// This eliminates N+1 query pattern and runs much faster with many users
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
u.user_id, u.username, u.display_name, u.is_admin,
|
u.user_id, u.username, u.display_name, u.is_admin,
|
||||||
(SELECT COUNT(*) FROM tickets t WHERE t.created_by = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_created,
|
COALESCE(tc.tickets_created, 0) as tickets_created,
|
||||||
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND t.status = 'Closed' AND DATE(t.updated_at) BETWEEN ? AND ?) as tickets_resolved,
|
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
|
||||||
(SELECT COUNT(*) FROM ticket_comments tc WHERE tc.user_id = u.user_id AND DATE(tc.created_at) BETWEEN ? AND ?) as comments_added,
|
COALESCE(cm.comments_added, 0) as comments_added,
|
||||||
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_assigned,
|
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
|
||||||
(SELECT MAX(al.created_at) FROM audit_log al WHERE al.user_id = u.user_id) as last_activity
|
al.last_activity
|
||||||
FROM users u
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT created_by, COUNT(*) as tickets_created
|
||||||
|
FROM tickets
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY created_by
|
||||||
|
) tc ON u.user_id = tc.created_by
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT assigned_to, COUNT(*) as tickets_resolved
|
||||||
|
FROM tickets
|
||||||
|
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY assigned_to
|
||||||
|
) tr ON u.user_id = tr.assigned_to
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) as comments_added
|
||||||
|
FROM ticket_comments
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY user_id
|
||||||
|
) cm ON u.user_id = cm.user_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT assigned_to, COUNT(*) as tickets_assigned
|
||||||
|
FROM tickets
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY assigned_to
|
||||||
|
) ta ON u.user_id = ta.assigned_to
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, MAX(created_at) as last_activity
|
||||||
|
FROM audit_log
|
||||||
|
GROUP BY user_id
|
||||||
|
) al ON u.user_id = al.user_id
|
||||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
|
|||||||
@@ -2,21 +2,127 @@
|
|||||||
/**
|
/**
|
||||||
* Rate Limiting Middleware
|
* Rate Limiting Middleware
|
||||||
*
|
*
|
||||||
* Implements session-based rate limiting to prevent abuse.
|
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||||
|
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||||
*/
|
*/
|
||||||
class RateLimitMiddleware {
|
class RateLimitMiddleware {
|
||||||
// Default limits
|
// Default limits
|
||||||
const DEFAULT_LIMIT = 100; // requests per window
|
const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
const API_LIMIT = 60; // API requests per window
|
const API_LIMIT = 60; // API requests per window (session)
|
||||||
|
const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||||
|
const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||||
const WINDOW_SECONDS = 60; // 1 minute window
|
const WINDOW_SECONDS = 60; // 1 minute window
|
||||||
|
|
||||||
|
// Directory for IP rate limit storage
|
||||||
|
private static $rateLimitDir = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check rate limit for current request
|
* Get the rate limit storage directory
|
||||||
|
*
|
||||||
|
* @return string Path to rate limit storage directory
|
||||||
|
*/
|
||||||
|
private static function getRateLimitDir() {
|
||||||
|
if (self::$rateLimitDir === null) {
|
||||||
|
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
|
if (!is_dir(self::$rateLimitDir)) {
|
||||||
|
mkdir(self::$rateLimitDir, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$rateLimitDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the client's IP address
|
||||||
|
*
|
||||||
|
* @return string Client IP address
|
||||||
|
*/
|
||||||
|
private static function getClientIp() {
|
||||||
|
// Check for forwarded IP (behind proxy/load balancer)
|
||||||
|
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
// Take the first IP in a comma-separated list
|
||||||
|
$ips = explode(',', $_SERVER[$header]);
|
||||||
|
$ip = trim($ips[0]);
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check IP-based rate limit
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @return bool True if request is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
private static function checkIpRateLimit($type = 'default') {
|
||||||
|
$ip = self::getClientIp();
|
||||||
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||||
|
$ipHash = md5($ip . '_' . $type);
|
||||||
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||||
|
|
||||||
|
// Load existing rate data
|
||||||
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
$content = @file_get_contents($filePath);
|
||||||
|
if ($content !== false) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$rateData = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||||
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
$rateData['count']++;
|
||||||
|
|
||||||
|
// Save updated data
|
||||||
|
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
||||||
|
|
||||||
|
// Check if over limit
|
||||||
|
return $rateData['count'] <= $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old rate limit files (call periodically)
|
||||||
|
*/
|
||||||
|
public static function cleanupOldFiles() {
|
||||||
|
$dir = self::getRateLimitDir();
|
||||||
|
$files = glob($dir . '/*.json');
|
||||||
|
$now = time();
|
||||||
|
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($now - filemtime($file) > $maxAge) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for current request (both session and IP)
|
||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @return bool True if request is allowed, false if rate limited
|
||||||
*/
|
*/
|
||||||
public static function check($type = 'default') {
|
public static function check($type = 'default') {
|
||||||
|
// First check IP-based rate limit (prevents session bypass)
|
||||||
|
if (!self::checkIpRateLimit($type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check session-based rate limit
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -59,6 +165,11 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
*/
|
*/
|
||||||
public static function apply($type = 'default') {
|
public static function apply($type = 'default') {
|
||||||
|
// Periodically clean up old rate limit files (1% chance per request)
|
||||||
|
if (mt_rand(1, 100) === 1) {
|
||||||
|
self::cleanupOldFiles();
|
||||||
|
}
|
||||||
|
|
||||||
if (!self::check($type)) {
|
if (!self::check($type)) {
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -5,12 +5,29 @@
|
|||||||
* Applies security-related HTTP headers to all responses.
|
* Applies security-related HTTP headers to all responses.
|
||||||
*/
|
*/
|
||||||
class SecurityHeadersMiddleware {
|
class SecurityHeadersMiddleware {
|
||||||
|
private static $nonce = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate or retrieve the CSP nonce for this request
|
||||||
|
*
|
||||||
|
* @return string The nonce value
|
||||||
|
*/
|
||||||
|
public static function getNonce() {
|
||||||
|
if (self::$nonce === null) {
|
||||||
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
|
}
|
||||||
|
return self::$nonce;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply security headers to the response
|
* Apply security headers to the response
|
||||||
*/
|
*/
|
||||||
public static function apply() {
|
public static function apply() {
|
||||||
|
$nonce = self::getNonce();
|
||||||
|
|
||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
// Using nonce for inline scripts instead of unsafe-inline for better security
|
||||||
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||||
|
|
||||||
// Prevent clickjacking by disallowing framing
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
@@ -258,8 +258,35 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function createTicket($ticketData, $createdBy = null) {
|
public function createTicket($ticketData, $createdBy = null) {
|
||||||
// Generate ticket ID (9-digit format with leading zeros)
|
// Generate unique ticket ID (9-digit format with leading zeros)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
// Loop until we find an ID that doesn't exist to prevent collisions
|
||||||
|
$maxAttempts = 10;
|
||||||
|
$attempts = 0;
|
||||||
|
$ticket_id = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||||
|
|
||||||
|
// Check if this ID already exists
|
||||||
|
$checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1";
|
||||||
|
$checkStmt = $this->conn->prepare($checkSql);
|
||||||
|
$checkStmt->bind_param("s", $candidate_id);
|
||||||
|
$checkStmt->execute();
|
||||||
|
$checkResult = $checkStmt->get_result();
|
||||||
|
|
||||||
|
if ($checkResult->num_rows === 0) {
|
||||||
|
$ticket_id = $candidate_id;
|
||||||
|
}
|
||||||
|
$checkStmt->close();
|
||||||
|
$attempts++;
|
||||||
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||||
|
|
||||||
|
if ($ticket_id === null) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Failed to generate unique ticket ID after ' . $maxAttempts . ' attempts'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
@@ -280,8 +307,16 @@ class TicketModel {
|
|||||||
$visibility = 'public';
|
$visibility = 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear visibility_groups if not internal
|
// Validate internal visibility requires groups
|
||||||
if ($visibility !== 'internal') {
|
if ($visibility === 'internal') {
|
||||||
|
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Internal visibility requires at least one group to be specified'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear visibility_groups if not internal
|
||||||
$visibilityGroups = null;
|
$visibilityGroups = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,8 +564,13 @@ class TicketModel {
|
|||||||
$visibility = 'public';
|
$visibility = 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear visibility_groups if not internal
|
// Validate internal visibility requires groups
|
||||||
if ($visibility !== 'internal') {
|
if ($visibility === 'internal') {
|
||||||
|
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
|
||||||
|
return false; // Internal visibility requires groups
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear visibility_groups if not internal
|
||||||
$visibilityGroups = null;
|
$visibilityGroups = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
// This file contains the HTML template for creating a new ticket
|
// This file contains the HTML template for creating a new ticket
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -10,13 +13,10 @@
|
|||||||
<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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- END OUTER FRAME -->
|
<!-- END OUTER FRAME -->
|
||||||
|
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Duplicate detection with debounce
|
// Duplicate detection with debounce
|
||||||
let duplicateCheckTimeout = null;
|
let duplicateCheckTimeout = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// This file contains the HTML template for the dashboard
|
// This file contains the HTML template for the dashboard
|
||||||
// It receives $tickets, $totalTickets, $totalPages, $page, $status, $categories, $types variables from the controller
|
// It receives $tickets, $totalTickets, $totalPages, $page, $status, $categories, $types variables from the controller
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -10,16 +13,13 @@
|
|||||||
<title>Ticket Dashboard</title>
|
<title>Ticket Dashboard</title>
|
||||||
<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">
|
||||||
<script 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 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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
// Timezone configuration (from server)
|
// Timezone configuration (from server)
|
||||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<div id="boot-sequence" class="boot-overlay">
|
<div id="boot-sequence" class="boot-overlay">
|
||||||
<pre id="boot-text"></pre>
|
<pre id="boot-text"></pre>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
function showBootSequence() {
|
function showBootSequence() {
|
||||||
const bootText = document.getElementById('boot-text');
|
const bootText = document.getElementById('boot-text');
|
||||||
const bootOverlay = document.getElementById('boot-sequence');
|
const bootOverlay = document.getElementById('boot-sequence');
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div id="ascii-banner-container" class="banner-content"></div>
|
<div id="ascii-banner-container" class="banner-content"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
function toggleBanner() {
|
function toggleBanner() {
|
||||||
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
||||||
const icon = document.querySelector('.toggle-icon');
|
const icon = document.querySelector('.toggle-icon');
|
||||||
@@ -764,10 +764,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Admin dropdown toggle
|
// Admin dropdown toggle
|
||||||
function toggleAdminMenu(event) {
|
function toggleAdminMenu(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ function formatDetails($details, $actionType) {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -48,22 +52,19 @@ function formatDetails($details, $actionType) {
|
|||||||
<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 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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124e"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260124e"></script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// CSRF Token for AJAX requests
|
// CSRF Token for AJAX requests
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
// Timezone configuration (from server)
|
// Timezone configuration (from server)
|
||||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Store ticket data in a global variable (using json_encode for XSS safety)
|
// Store ticket data in a global variable (using json_encode for XSS safety)
|
||||||
window.ticketData = {
|
window.ticketData = {
|
||||||
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
||||||
@@ -445,7 +446,7 @@ function formatDetails($details, $actionType) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- END OUTER FRAME -->
|
<!-- END OUTER FRAME -->
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Initialize the ticket view
|
// Initialize the ticket view
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (typeof showTab === 'function') {
|
if (typeof showTab === 'function') {
|
||||||
@@ -455,7 +456,7 @@ function formatDetails($details, $actionType) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Ticket data already initialized in head, add id alias for compatibility
|
// Ticket data already initialized in head, add id alias for compatibility
|
||||||
window.ticketData.id = window.ticketData.ticket_id;
|
window.ticketData.id = window.ticketData.ticket_id;
|
||||||
console.log('Ticket data loaded:', window.ticketData);
|
console.log('Ticket data loaded:', window.ticketData);
|
||||||
@@ -597,6 +598,6 @@ function formatDetails($details, $actionType) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing API keys
|
// Admin view for managing API keys
|
||||||
// Receives $apiKeys from controller
|
// Receives $apiKeys from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,12 +14,9 @@
|
|||||||
<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">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script 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>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
|
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing custom fields
|
// Admin view for managing custom fields
|
||||||
// Receives $customFields from controller
|
// Receives $customFields from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,11 +14,8 @@
|
|||||||
<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">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -154,8 +154,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script 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>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
function showCreateModal() {
|
function showCreateModal() {
|
||||||
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
||||||
document.getElementById('fieldForm').reset();
|
document.getElementById('fieldForm').reset();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing recurring tickets
|
// Admin view for managing recurring tickets
|
||||||
// Receives $recurringTickets from controller
|
// Receives $recurringTickets from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,11 +14,8 @@
|
|||||||
<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">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -189,8 +189,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script 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>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
function showCreateModal() {
|
function showCreateModal() {
|
||||||
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
||||||
document.getElementById('recurringForm').reset();
|
document.getElementById('recurringForm').reset();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing ticket templates
|
// Admin view for managing ticket templates
|
||||||
// Receives $templates from controller
|
// Receives $templates from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,11 +14,8 @@
|
|||||||
<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">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -160,8 +160,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script 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>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
const templates = <?php echo json_encode($templates ?? []); ?>;
|
const templates = <?php echo json_encode($templates ?? []); ?>;
|
||||||
|
|
||||||
function showCreateModal() {
|
function showCreateModal() {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for workflow/status transitions designer
|
// Admin view for workflow/status transitions designer
|
||||||
// Receives $workflows from controller
|
// Receives $workflows from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -11,11 +14,8 @@
|
|||||||
<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">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||||
<script>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
|
||||||
echo CsrfMiddleware::getToken();
|
|
||||||
?>';
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -177,8 +177,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script 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>
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
||||||
|
|
||||||
function showCreateModal() {
|
function showCreateModal() {
|
||||||
|
|||||||
Reference in New Issue
Block a user