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:
2026-01-28 20:27:15 -05:00
parent a08390a500
commit fa40010287
17 changed files with 457 additions and 128 deletions

105
Claude.md
View File

@@ -17,6 +17,24 @@
- Comment Edit/Delete (owner or admin can modify their 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
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
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── 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
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
@@ -93,8 +112,8 @@ Controllers → Models → Database
├── middleware/
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # API rate limiting
│ └── SecurityHeadersMiddleware.php # Security headers
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
@@ -152,25 +171,41 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
### Core Tables
- `tickets` - Core ticket data with assignment and visibility
- `ticket_comments` - Markdown-supported comments
- `ticket_attachments` - File attachment metadata
- `ticket_dependencies` - Ticket relationships
- `users` - User accounts synced from LLDAP (includes groups)
- `user_preferences` - User settings and preferences
- `audit_log` - Complete audit trail
- `status_transitions` - Workflow configuration
- `ticket_templates` - Reusable ticket templates
- `recurring_tickets` - Scheduled ticket definitions
- `custom_field_definitions` - Custom field schemas
- `custom_field_values` - Custom field data per ticket
- `saved_filters` - User-saved dashboard filters
- `bulk_operations` - Bulk operation tracking
- `api_keys` - API key storage with hashes
| Table | Description |
|-------|-------------|
| `tickets` | Core ticket data with assignment, visibility, and tracking |
| `ticket_comments` | Markdown-supported comments with user_id reference |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
| `users` | User accounts synced from LLDAP (includes groups) |
| `user_preferences` | User settings and preferences |
| `audit_log` | Complete audit trail with indexed queries |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable ticket templates |
| `recurring_tickets` | Scheduled ticket definitions |
| `custom_field_definitions` | Custom field schemas per category |
| `custom_field_values` | Custom field data per ticket |
| `saved_filters` | User-saved dashboard filters |
| `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
### Ticket Visibility Columns
- `visibility` - ENUM('public', 'internal', 'confidential')
- `visibility_groups` - VARCHAR(500) comma-separated group names
### tickets Table Key Columns
| 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
@@ -187,9 +222,11 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
## Ticket Visibility Levels
- **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
**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
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
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
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
@@ -212,14 +252,31 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
|------|---------|
| `index.php` | Main router for all routes |
| `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 |
| `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/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 |
## 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
- **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

View File

@@ -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.
**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
### Dashboard & Ticket Management
@@ -104,12 +114,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection on all forms
- **Rate Limiting**: API rate limiting to prevent abuse
- **Security Headers**: CSP, X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements
- **XSS Protection**: All output properly escaped
- **CSRF Protection**: Token-based protection with constant-time comparison
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
- **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

View File

@@ -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
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
@@ -69,6 +72,33 @@ if ($conn->connect_error) {
}
$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
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
@@ -90,11 +120,16 @@ if (isset($result['error'])) {
'error' => $result['error']
]);
} else {
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
}
echo json_encode([
'success' => true,
'operation_id' => $operationId,
'processed' => $result['processed'],
'failed' => $result['failed'],
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
'skipped' => $inaccessibleCount,
'message' => $message
]);
}

View File

@@ -41,26 +41,42 @@ try {
exit;
}
// Verify the associated ticket exists (access control)
// Verify the associated ticket exists and user has access
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if (!$conn->connect_error) {
if ($conn->connect_error) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
$conn->close();
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
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];

View File

@@ -151,6 +151,15 @@ try {
if (is_array($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);
}

View File

@@ -307,14 +307,45 @@ switch (true) {
'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
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,
(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,
(SELECT COUNT(*) FROM ticket_comments tc WHERE tc.user_id = u.user_id AND DATE(tc.created_at) BETWEEN ? AND ?) 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,
(SELECT MAX(al.created_at) FROM audit_log al WHERE al.user_id = u.user_id) as last_activity
COALESCE(tc.tickets_created, 0) as tickets_created,
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
COALESCE(cm.comments_added, 0) as comments_added,
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
al.last_activity
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";
$stmt = $conn->prepare($sql);

View File

@@ -2,21 +2,127 @@
/**
* 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 {
// Default limits
const DEFAULT_LIMIT = 100; // requests per window
const API_LIMIT = 60; // API requests per window
const DEFAULT_LIMIT = 100; // requests per window (session)
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
// 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'
* @return bool True if request is allowed, false if rate limited
*/
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) {
session_start();
}
@@ -59,6 +165,11 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
*/
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)) {
http_response_code(429);
header('Content-Type: application/json');

View File

@@ -5,12 +5,29 @@
* Applies security-related HTTP headers to all responses.
*/
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
*/
public static function apply() {
$nonce = self::getNonce();
// 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
header("X-Frame-Options: DENY");

View File

@@ -258,8 +258,35 @@ class TicketModel {
}
public function createTicket($ticketData, $createdBy = null) {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Generate unique ticket ID (9-digit format with leading zeros)
// 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
@@ -280,8 +307,16 @@ class TicketModel {
$visibility = 'public';
}
// Validate internal visibility requires groups
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
if ($visibility !== 'internal') {
$visibilityGroups = null;
}
@@ -529,8 +564,13 @@ class TicketModel {
$visibility = 'public';
}
// Validate internal visibility requires groups
if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return false; // Internal visibility requires groups
}
} else {
// Clear visibility_groups if not internal
if ($visibility !== 'internal') {
$visibilityGroups = null;
}

View File

@@ -1,5 +1,8 @@
<?php
// 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>
<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="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">
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -239,7 +239,7 @@
</div>
<!-- END OUTER FRAME -->
<script>
<script nonce="<?php echo $nonce; ?>">
// Duplicate detection with debounce
let duplicateCheckTimeout = null;

View File

@@ -1,6 +1,9 @@
<?php
// This file contains the HTML template for the dashboard
// 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>
<html lang="en">
@@ -10,16 +13,13 @@
<title>Ticket Dashboard</title>
<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">
<script 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 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>
<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/markdown.js?v=20260124e"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
@@ -32,7 +32,7 @@
<div id="boot-sequence" class="boot-overlay">
<pre id="boot-text"></pre>
</div>
<script>
<script nonce="<?php echo $nonce; ?>">
function showBootSequence() {
const bootText = document.getElementById('boot-text');
const bootOverlay = document.getElementById('boot-sequence');
@@ -108,7 +108,7 @@
</button>
<div id="ascii-banner-container" class="banner-content"></div>
</div>
<script>
<script nonce="<?php echo $nonce; ?>">
function toggleBanner() {
const wrapper = document.querySelector('.ascii-banner-wrapper');
const icon = document.querySelector('.toggle-icon');
@@ -764,10 +764,10 @@
</div>
</div>
<script 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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
<script nonce="<?php echo $nonce; ?>" 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/advanced-search.js"></script>
<script nonce="<?php echo $nonce; ?>">
// Admin dropdown toggle
function toggleAdminMenu(event) {
event.stopPropagation();

View File

@@ -38,6 +38,10 @@ function formatDetails($details, $actionType) {
}
return '';
}
<?php
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="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">
<script 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 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>
<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/markdown.js?v=20260124e"></script>
<script nonce="<?php echo $nonce; ?>" 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/ticket.js?v=20260124e"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
</script>
<script>
<script nonce="<?php echo $nonce; ?>">
// Store ticket data in a global variable (using json_encode for XSS safety)
window.ticketData = {
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
@@ -445,7 +446,7 @@ function formatDetails($details, $actionType) {
</div>
</div>
<!-- END OUTER FRAME -->
<script>
<script nonce="<?php echo $nonce; ?>">
// Initialize the ticket view
document.addEventListener('DOMContentLoaded', function() {
if (typeof showTab === 'function') {
@@ -455,7 +456,7 @@ function formatDetails($details, $actionType) {
}
});
</script>
<script>
<script nonce="<?php echo $nonce; ?>">
// Ticket data already initialized in head, add id alias for compatibility
window.ticketData.id = window.ticketData.ticket_id;
console.log('Ticket data loaded:', window.ticketData);
@@ -597,6 +598,6 @@ function formatDetails($details, $actionType) {
</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>
</html>

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for managing API keys
// Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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>
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -161,7 +161,7 @@
</div>
</div>
<script>
<script nonce="<?php echo $nonce; ?>">
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
e.preventDefault();

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for managing custom fields
// Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script>
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -154,8 +154,8 @@
</div>
</div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for managing recurring tickets
// Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script>
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -189,8 +189,8 @@
</div>
</div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for managing ticket templates
// Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script>
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -160,8 +160,8 @@
</div>
</div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>;
function showCreateModal() {

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for workflow/status transitions designer
// Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<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="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script>
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
@@ -177,8 +177,8 @@
</div>
</div>
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>;
function showCreateModal() {