From d6214a0339d64407b26e7b61fb8725ea158f5dc9 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 16:11:26 -0400 Subject: [PATCH] Add schema baseline, fix cron/retention, restore cleanup, correct docs - migrations/000_baseline.sql: full schema baseline captured from prod (validated on a throwaway DB: 17 tables/17 FKs), so the schema is reproducible for fresh installs / disaster recovery - create_recurring_tickets cron: send the Matrix ticket-created notification and invalidate the stats cache like the other create paths - create_ticket_api.php + TicketController::create: invalidate the stats cache on create/escalate/reopen so dashboard counts aren't stale - scripts/cleanup_orphan_uploads.php: restored, made safe (24h mtime grace, 9-digit-dir only, skips avatars/symlinks, matches the unique filename column, --dry-run) - cron/cleanup_audit_log.php: enforce the configured audit-log retention (deleteOldLogs was implemented but never called) - README: correct CSRF-rotation, hwmon dedup (no 24h window), SLA (no P3), stats-cache callers, and the project structure/endpoint listing - .env.example: document TRUSTED_PROXIES fail-open risk and .env quoting Co-Authored-By: Claude Opus 4.8 --- .env.example | 23 +- README.md | 54 +++-- controllers/TicketController.php | 4 + create_ticket_api.php | 10 + cron/cleanup_audit_log.php | 50 +++++ cron/create_recurring_tickets.php | 12 ++ generate_api_key.php | 107 ---------- migrations/000_baseline.sql | 326 +++++++++++++++++++++++++++++ scripts/cleanup_orphan_uploads.php | 143 +++++++++++++ 9 files changed, 601 insertions(+), 128 deletions(-) create mode 100644 cron/cleanup_audit_log.php delete mode 100644 generate_api_key.php create mode 100644 migrations/000_baseline.sql create mode 100644 scripts/cleanup_orphan_uploads.php diff --git a/.env.example b/.env.example index 27a1103..cbce12a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ # Tinker Tickets Environment Configuration # Copy this file to .env and fill in your values +# +# NOTE: This file is parsed with parse_ini_file(). Any value containing special +# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes, +# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear +# error) if the .env file cannot be parsed, so an unquoted special character will +# take the whole app down rather than silently using a wrong value. # Database Configuration DB_HOST=10.10.10.50 @@ -25,11 +31,18 @@ APP_DOMAIN= ALLOWED_HOSTS=localhost,127.0.0.1 # Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy). -# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User / -# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is -# in this list. Leaving it empty disables that protection (relies solely on -# network topology) and lets anything reaching PHP directly spoof admin login. -# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27 +# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth +# headers (Remote-User / Remote-Groups) and forwarded client IPs are only +# trusted when REMOTE_ADDR is in this list. +# +# Leaving this EMPTY disables reverse-proxy verification entirely: the app then +# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if +# the PHP backend is reachable directly (bypassing the proxy), because a client +# can then spoof those headers and log in as an admin. Only leave it empty when +# network topology guarantees PHP is reachable solely via the trusted proxy. +# +# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27 +# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28 TRUSTED_PROXIES= # Timezone (default: America/New_York) diff --git a/README.md b/README.md index 08ca70f..b322062 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The following features are intentionally **not planned** for this system: - **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking - **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach) - **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names -- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h) +- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h). Lower priorities (P3–P5) have no SLA banner. - **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session ### Ticket Templates @@ -121,7 +121,7 @@ The following features are intentionally **not planned** for this system: - **Powered by audit_log**: No extra table — notifications are derived from existing audit trail ### Matrix Notifications (hookshot) -- **Ticket Created**: Fires when any ticket is created (manual or via API) +- **Ticket Created**: Fires when a ticket is created via the manual form, the external API (hwmonDaemon), or the recurring-ticket cron. (Cloned tickets do not fire this event.) - **Status Changed**: Fires on every status transition - **@Mentions**: Mentioned users receive a direct Matrix notification - **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable @@ -150,7 +150,7 @@ The following features are intentionally **not planned** for this system: | `?` | Show keyboard shortcuts help | ### Security Features -- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write +- **CSRF Protection**: Token-based protection with constant-time comparison. `bootstrap.php` rotates the token on a successful write and returns the current token in every response (including on rejection); the client (`lt.api`) resyncs from that value. Rejected requests do not rotate the token. - **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 @@ -179,9 +179,9 @@ Content-Type: application/json **Key behaviours:** - Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env` -- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours +- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device (no time window). A repeat alert matching an existing **open** ticket updates its title/description and escalates the priority if the condition worsened; if the matching ticket was already **closed**, it is reopened instead of creating a new one - Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash) -- Matrix notification sent automatically after ticket creation +- Matrix notification sent automatically on ticket creation, priority escalation, and reopen - API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY` ## Technical Architecture @@ -240,6 +240,11 @@ Content-Type: application/json - `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility` - `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at` +### Database Schema / Migrations + +- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install. +- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing. + ### API Endpoints | Endpoint | Method | Description | @@ -248,6 +253,7 @@ Content-Type: application/json | `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/assign_ticket.php` | POST | Assign ticket to user | | `/api/add_comment.php` | POST | Add comment to ticket | +| `/api/get_comments.php` | GET | Fetch paginated comments for a ticket | | `/api/clone_ticket.php` | POST | Clone an existing ticket | | `/api/get_template.php` | GET | Fetch ticket template | | `/api/get_users.php` | GET | Get user list for assignments | @@ -292,6 +298,7 @@ tinker_tickets/ │ ├── download_attachment.php # GET: Download with visibility check │ ├── export_tickets.php # GET: Export tickets to CSV/JSON │ ├── generate_api_key.php # POST: Generate API key (admin) +│ ├── get_comments.php # GET: Fetch paginated ticket comments │ ├── get_template.php # GET: Fetch ticket template │ ├── get_users.php # GET: Get user list │ ├── health.php # GET: Health check endpoint @@ -329,14 +336,20 @@ tinker_tickets/ ├── config/ │ └── config.php # Config + .env loading ├── controllers/ +│ ├── CommentController.php # Comment create/edit/delete + notifications │ ├── DashboardController.php # Dashboard with stats + filters │ └── TicketController.php # Ticket CRUD + timeline + visibility ├── cron/ +│ ├── cleanup_audit_log.php # Delete audit_log rows past retention (daily) +│ ├── cleanup_ratelimit.php # Purge expired rate-limit files (every few min) │ └── create_recurring_tickets.php # Process recurring ticket schedules ├── helpers/ │ ├── CacheHelper.php # File-based cache (stats, avatars) │ ├── Database.php # Centralized mysqli connection +│ ├── ErrorHandler.php # Global error/exception handler │ ├── NotificationHelper.php # Matrix hookshot webhook events +│ ├── OutputHelper.php # Safe HTML output helpers +│ ├── ResponseHelper.php # JSON API response helpers │ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API │ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN ├── middleware/ @@ -347,6 +360,7 @@ tinker_tickets/ │ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation ├── models/ │ ├── ApiKeyModel.php # API key generation/validation +│ ├── AttachmentModel.php # Ticket file attachment metadata │ ├── AuditLogModel.php # Audit logging + timeline │ ├── BulkOperationsModel.php # Bulk operations tracking │ ├── CommentModel.php # Comment data access @@ -360,11 +374,12 @@ tinker_tickets/ │ ├── UserModel.php # User management + groups │ ├── UserPreferencesModel.php # User preferences │ └── WorkflowModel.php # Status transition workflows +├── migrations/ +│ ├── 000_baseline.sql # Full schema baseline (safe to re-run) +│ └── migrate.php # CLI migration runner (tracks applied migrations) ├── scripts/ -│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets -│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments -│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron) -│ └── create_dependencies_table.php # Create ticket_dependencies table +│ ├── check_requirements.php # Verify PHP extensions/config prerequisites +│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron) ├── uploads/ # File attachment storage │ └── avatars/ # lldap avatar disk cache ├── views/ @@ -455,13 +470,20 @@ AVATAR_CACHE_TTL=3600 ### 2. Cron Jobs -Add to crontab for recurring tickets and optional cleanup: +Add to crontab for recurring tickets and maintenance cleanup: ```bash # Run every hour to create scheduled recurring tickets 0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php -# Optional: clean up orphaned uploads weekly -0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php +# Purge expired rate-limit files (every 5 minutes) +*/5 * * * * php /path/to/tinkertickets/cron/cleanup_ratelimit.php + +# Delete audit_log rows older than AUDIT_LOG_RETENTION_DAYS (daily) +30 3 * * * php /path/to/tinkertickets/cron/cleanup_audit_log.php + +# Delete orphaned upload files with no attachment row, past a 24h grace period (daily). +# Add --dry-run to preview without deleting. +0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php ``` ### 3. File Uploads @@ -502,7 +524,7 @@ Key conventions and gotchas for working with this codebase: 3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false` 4. **Config path**: `config/config.php` (not `config/db.php`) 5. **Comments table**: `ticket_comments` (not `comments`) -6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses +6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header. `bootstrap.php` rotates the token only on a successful write and returns the current token in the `csrf_token` field of every `apiRespond()` response (including rejections), so the client can resync. A rejected request keeps the existing token. 7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env` 8. **Ticket linking**: Use `#123456789` in markdown-enabled comments 9. **User groups**: Stored in `users.groups` as comma-separated values @@ -520,8 +542,8 @@ Key conventions and gotchas for working with this codebase: 21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`. 22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`. 23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`. -24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this). -25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h. +24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`. +25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket. ## File Reference @@ -557,7 +579,7 @@ Key conventions and gotchas for working with this codebase: |---------|---------------| | SQL Injection | All queries use prepared statements with parameter binding | | XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces | -| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write | +| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on successful writes, current token returned in every response (including rejections) for the client to resync — rejected requests do not rotate | | Session Security | Fixation prevention, secure cookies, session timeout | | Rate Limiting | Session-based + IP-based (file storage) | | File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution | diff --git a/controllers/TicketController.php b/controllers/TicketController.php index 5b3adb0..a03bd76 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -140,6 +140,10 @@ class TicketController $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); } + // Ticket counts changed — invalidate the cached dashboard stats + require_once dirname(__DIR__) . '/models/StatsModel.php'; + (new StatsModel($this->conn))->invalidateCache(); + // Auto-link as duplicate if requested from create form $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { diff --git a/create_ticket_api.php b/create_ticket_api.php index 3eeffed..3d61579 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -60,6 +60,7 @@ require_once __DIR__ . '/config/config.php'; // Authenticate via API key require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/models/AuditLogModel.php'; +require_once __DIR__ . '/models/StatsModel.php'; require_once __DIR__ . '/helpers/UrlHelper.php'; $apiKeyAuth = new ApiKeyAuth($conn); @@ -337,6 +338,9 @@ if ($existing) { 'status' => $existingStatus, ], 'automated'); } + + // Ticket state (priority/title/description) changed — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); } $conn->close(); @@ -373,6 +377,9 @@ if ($existing) { 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', ]); + // Ticket reopened (Closed → Open) — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); + $conn->close(); require_once __DIR__ . '/helpers/NotificationHelper.php'; @@ -468,6 +475,9 @@ if ($inserted) { 'type' => $type, ]); + // New ticket created — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); + $conn->close(); require_once __DIR__ . '/helpers/NotificationHelper.php'; diff --git a/cron/cleanup_audit_log.php b/cron/cleanup_audit_log.php new file mode 100644 index 0000000..3d09a42 --- /dev/null +++ b/cron/cleanup_audit_log.php @@ -0,0 +1,50 @@ +#!/usr/bin/env php +> /var/log/audit_log_cleanup.log 2>&1 + */ + +// Prevent web access +if (php_sapi_name() !== 'cli') { + http_response_code(403); + exit('CLI access only'); +} + +// Change to project root directory +chdir(dirname(__DIR__)); + +// Include required files +require_once 'config/config.php'; +require_once 'helpers/Database.php'; +require_once 'models/AuditLogModel.php'; + +// Log function +function logMessage($message) +{ + echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n"; +} + +$retentionDays = (int)($GLOBALS['config']['AUDIT_LOG_RETENTION_DAYS'] ?? 90); + +logMessage("Starting audit log cleanup (retention: {$retentionDays} days)"); + +try { + $conn = Database::getConnection(); + + $auditLog = new AuditLogModel($conn); + $deleted = $auditLog->deleteOldLogs($retentionDays); + + logMessage("Removed {$deleted} audit log row(s) older than {$retentionDays} days"); + + Database::close(); +} catch (Exception $e) { + logMessage('FATAL ERROR: ' . $e->getMessage()); + exit(1); +} diff --git a/cron/create_recurring_tickets.php b/cron/create_recurring_tickets.php index b5109e7..f2b851d 100644 --- a/cron/create_recurring_tickets.php +++ b/cron/create_recurring_tickets.php @@ -17,9 +17,11 @@ chdir(dirname(__DIR__)); // Include required files require_once 'config/config.php'; require_once 'helpers/Database.php'; +require_once 'helpers/NotificationHelper.php'; require_once 'models/RecurringTicketModel.php'; require_once 'models/TicketModel.php'; require_once 'models/AuditLogModel.php'; +require_once 'models/StatsModel.php'; // Log function function logMessage($message) @@ -92,6 +94,10 @@ try { ['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']] ); + // Fire the same Matrix "ticket created" notification the manual and + // external-API create paths send, so recurring tickets aren't silent. + NotificationHelper::sendTicketNotification($ticketId, $ticketData, 'automated'); + $created++; } else { logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error')); @@ -103,6 +109,12 @@ try { } } + // Ticket counts changed — invalidate the cached dashboard stats once for the + // whole run (mirrors the manual/API create paths, which invalidate per create). + if ($created > 0) { + (new StatsModel($conn))->invalidateCache(); + } + logMessage("Completed: Created $created tickets, $errors errors"); Database::close(); diff --git a/generate_api_key.php b/generate_api_key.php deleted file mode 100644 index d72ca4b..0000000 --- a/generate_api_key.php +++ /dev/null @@ -1,107 +0,0 @@ -connect_error) { - die("❌ Database connection failed: " . $conn->connect_error . "\n"); -} - -echo "✅ Connected to database\n\n"; - -// Initialize models -$userModel = new UserModel($conn); -$apiKeyModel = new ApiKeyModel($conn); - -// Get system user (should exist from migration) -echo "Checking for system user...\n"; -$systemUser = $userModel->getSystemUser(); - -if (!$systemUser) { - die("❌ Error: System user not found. Please run migrations first.\n"); -} - -echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n"; - -// Check if API key already exists -$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']); -if (!empty($existingKeys)) { - echo "⚠️ Warning: API keys already exist for system user:\n\n"; - foreach ($existingKeys as $key) { - echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n"; - echo " Created: " . $key['created_at'] . "\n"; - echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n"; - } - - echo "Do you want to generate a new API key? (yes/no): "; - $handle = fopen("php://stdin", "r"); - $response = trim(fgets($handle)); - fclose($handle); - - if (strtolower($response) !== 'yes') { - echo "\nAborted.\n"; - exit(0); - } - echo "\n"; -} - -// Generate API key -echo "Generating API key for hwmonDaemon...\n"; -$result = $apiKeyModel->createKey( - 'hwmonDaemon', - $systemUser['user_id'], - null // No expiration -); - -if ($result['success']) { - echo "\n"; - echo "==============================================\n"; - echo " ✅ API Key Generated Successfully!\n"; - echo "==============================================\n\n"; - echo "API Key: " . $result['api_key'] . "\n"; - echo "Key Prefix: " . $result['key_prefix'] . "\n"; - echo "Key ID: " . $result['key_id'] . "\n"; - echo "Expires: Never\n\n"; - echo "⚠️ IMPORTANT: Save this API key now!\n"; - echo " It cannot be retrieved later.\n\n"; - echo "==============================================\n"; - echo " Add to hwmonDaemon .env file:\n"; - echo "==============================================\n\n"; - echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n"; - echo "Then restart hwmonDaemon:\n"; - echo " sudo systemctl restart hwmonDaemon\n\n"; -} else { - echo "❌ Error generating API key: " . $result['error'] . "\n"; - exit(1); -} - -$conn->close(); - -echo "Done! Delete this script after use:\n"; -echo " rm " . __FILE__ . "\n\n"; diff --git a/migrations/000_baseline.sql b/migrations/000_baseline.sql new file mode 100644 index 0000000..dab0008 --- /dev/null +++ b/migrations/000_baseline.sql @@ -0,0 +1,326 @@ +-- ===================================================================== +-- 000_baseline.sql — full schema baseline for tinker_tickets +-- +-- Captured from the live production database so the schema is +-- reproducible from source (a fresh install or disaster recovery). +-- Every table uses CREATE TABLE IF NOT EXISTS, so running this against +-- an existing database is a safe no-op. FK checks are disabled during +-- creation so table order does not matter. +-- ===================================================================== + +SET FOREIGN_KEY_CHECKS = 0; + +-- ============ api_keys ============ +CREATE TABLE IF NOT EXISTS `api_keys` ( + `api_key_id` int(11) NOT NULL AUTO_INCREMENT, + `key_name` varchar(100) NOT NULL, + `key_hash` varchar(255) NOT NULL, + `key_prefix` varchar(20) NOT NULL, + `is_active` tinyint(1) DEFAULT 1, + `created_by` int(11) DEFAULT NULL, + `last_used` timestamp NULL DEFAULT NULL, + `expires_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`api_key_id`), + UNIQUE KEY `key_hash` (`key_hash`), + KEY `created_by` (`created_by`), + KEY `idx_key_hash` (`key_hash`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ audit_log ============ +CREATE TABLE IF NOT EXISTS `audit_log` ( + `audit_id` bigint(20) NOT NULL AUTO_INCREMENT, + `user_id` int(11) DEFAULT NULL, + `action_type` varchar(50) NOT NULL, + `entity_type` varchar(50) NOT NULL, + `entity_id` varchar(50) DEFAULT NULL, + `details` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`details`)), + `ip_address` varchar(45) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`audit_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_created_at` (`created_at`), + KEY `idx_entity` (`entity_type`,`entity_id`), + KEY `idx_action_type` (`action_type`), + KEY `idx_audit_log_user_created` (`user_id`,`created_at` DESC), + KEY `idx_audit_log_action_type` (`action_type`,`created_at` DESC), + KEY `idx_audit_entity` (`entity_type`,`entity_id`), + KEY `idx_audit_user` (`user_id`,`created_at`), + CONSTRAINT `audit_log_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ bulk_operations ============ +CREATE TABLE IF NOT EXISTS `bulk_operations` ( + `operation_id` int(11) NOT NULL AUTO_INCREMENT, + `operation_type` varchar(50) NOT NULL, + `ticket_ids` text NOT NULL, + `performed_by` int(11) NOT NULL, + `parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)), + `status` varchar(20) DEFAULT 'pending', + `total_tickets` int(11) DEFAULT NULL, + `processed_tickets` int(11) DEFAULT 0, + `failed_tickets` int(11) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `completed_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`operation_id`), + KEY `idx_performed_by` (`performed_by`), + KEY `idx_created_at` (`created_at`), + CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ custom_field_definitions ============ +CREATE TABLE IF NOT EXISTS `custom_field_definitions` ( + `field_id` int(11) NOT NULL AUTO_INCREMENT, + `field_name` varchar(100) NOT NULL, + `field_label` varchar(255) NOT NULL, + `field_type` enum('text','textarea','select','checkbox','date','number') NOT NULL, + `field_options` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Options for select fields: {"options": ["Option 1", "Option 2"]}' CHECK (json_valid(`field_options`)), + `category` varchar(50) DEFAULT NULL COMMENT 'NULL = applies to all categories', + `is_required` tinyint(1) DEFAULT 0, + `display_order` int(11) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`field_id`), + KEY `idx_custom_fields_category` (`category`,`is_active`), + KEY `idx_custom_fields_order` (`display_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ custom_field_values ============ +CREATE TABLE IF NOT EXISTS `custom_field_values` ( + `value_id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` varchar(9) NOT NULL, + `field_id` int(11) NOT NULL, + `field_value` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`value_id`), + UNIQUE KEY `unique_ticket_field` (`ticket_id`,`field_id`), + KEY `field_id` (`field_id`), + KEY `idx_custom_values_ticket` (`ticket_id`), + CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_field_definitions` (`field_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ migrations ============ +CREATE TABLE IF NOT EXISTS `migrations` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `filename` varchar(255) NOT NULL, + `applied_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `filename` (`filename`), + KEY `idx_filename` (`filename`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ recurring_tickets ============ +CREATE TABLE IF NOT EXISTS `recurring_tickets` ( + `recurring_id` int(11) NOT NULL AUTO_INCREMENT, + `title_template` varchar(255) NOT NULL, + `description_template` text DEFAULT NULL, + `category` varchar(50) DEFAULT 'General', + `type` varchar(50) DEFAULT 'Task', + `priority` int(11) DEFAULT 4, + `assigned_to` int(11) DEFAULT NULL, + `schedule_type` enum('daily','weekly','monthly') NOT NULL, + `schedule_day` int(11) DEFAULT NULL COMMENT 'Day of week (1-7) for weekly, day of month (1-31) for monthly', + `schedule_time` time DEFAULT '09:00:00', + `next_run_at` timestamp NOT NULL, + `last_run_at` timestamp NULL DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `created_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`recurring_id`), + KEY `assigned_to` (`assigned_to`), + KEY `created_by` (`created_by`), + KEY `idx_recurring_next_run` (`next_run_at`,`is_active`), + KEY `idx_recurring_active` (`is_active`), + CONSTRAINT `recurring_tickets_ibfk_1` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL, + CONSTRAINT `recurring_tickets_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ saved_filters ============ +CREATE TABLE IF NOT EXISTS `saved_filters` ( + `filter_id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `filter_name` varchar(100) NOT NULL, + `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`filter_criteria`)), + `is_default` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`filter_id`), + UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`), + KEY `idx_user_filters` (`user_id`,`is_default`), + CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============ status_transitions ============ +CREATE TABLE IF NOT EXISTS `status_transitions` ( + `transition_id` int(11) NOT NULL AUTO_INCREMENT, + `from_status` varchar(50) NOT NULL, + `to_status` varchar(50) NOT NULL, + `requires_comment` tinyint(1) DEFAULT 0, + `requires_admin` tinyint(1) DEFAULT 0, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`transition_id`), + UNIQUE KEY `unique_transition` (`from_status`,`to_status`), + KEY `idx_from_status` (`from_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ ticket_attachments ============ +CREATE TABLE IF NOT EXISTS `ticket_attachments` ( + `attachment_id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` varchar(9) NOT NULL, + `filename` varchar(255) NOT NULL, + `original_filename` varchar(255) NOT NULL, + `file_size` int(11) NOT NULL, + `mime_type` varchar(100) NOT NULL, + `uploaded_by` int(11) DEFAULT NULL, + `uploaded_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`attachment_id`), + KEY `idx_attachments_ticket` (`ticket_id`), + KEY `idx_attachments_uploaded_by` (`uploaded_by`), + CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============ ticket_comments ============ +CREATE TABLE IF NOT EXISTS `ticket_comments` ( + `comment_id` int(11) NOT NULL AUTO_INCREMENT, + `parent_comment_id` int(11) DEFAULT NULL, + `thread_depth` tinyint(3) unsigned NOT NULL DEFAULT 0, + `ticket_id` varchar(10) DEFAULT NULL, + `user_name` varchar(50) DEFAULT NULL, + `comment_text` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `markdown_enabled` tinyint(1) DEFAULT 0, + `user_id` int(11) DEFAULT NULL, + PRIMARY KEY (`comment_id`), + KEY `fk_comments_user_id` (`user_id`), + KEY `idx_comments_ticket_created` (`ticket_id`,`created_at` DESC), + KEY `idx_parent_comment` (`parent_comment_id`), + CONSTRAINT `fk_comments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL, + CONSTRAINT `fk_parent_comment` FOREIGN KEY (`parent_comment_id`) REFERENCES `ticket_comments` (`comment_id`) ON DELETE CASCADE, + CONSTRAINT `ticket_comments_ibfk_1` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ ticket_dependencies ============ +CREATE TABLE IF NOT EXISTS `ticket_dependencies` ( + `dependency_id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` varchar(9) NOT NULL, + `depends_on_id` varchar(9) NOT NULL, + `dependency_type` enum('blocks','blocked_by','relates_to','duplicates') DEFAULT 'blocks', + `created_by` int(11) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`dependency_id`), + UNIQUE KEY `unique_dependency` (`ticket_id`,`depends_on_id`,`dependency_type`), + KEY `idx_ticket_id` (`ticket_id`), + KEY `idx_depends_on_id` (`depends_on_id`), + KEY `created_by` (`created_by`), + CONSTRAINT `ticket_dependencies_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ ticket_templates ============ +CREATE TABLE IF NOT EXISTS `ticket_templates` ( + `template_id` int(11) NOT NULL AUTO_INCREMENT, + `template_name` varchar(100) NOT NULL, + `title_template` varchar(255) NOT NULL, + `description_template` text NOT NULL, + `category` varchar(50) DEFAULT NULL, + `type` varchar(50) DEFAULT NULL, + `default_priority` int(11) DEFAULT 4, + `created_by` int(11) DEFAULT NULL, + `is_active` tinyint(1) DEFAULT 1, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`template_id`), + KEY `created_by` (`created_by`), + KEY `idx_template_name` (`template_name`), + CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ ticket_watchers ============ +CREATE TABLE IF NOT EXISTS `ticket_watchers` ( + `ticket_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`ticket_id`,`user_id`), + KEY `idx_watcher_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ tickets ============ +CREATE TABLE IF NOT EXISTS `tickets` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_id` varchar(9) NOT NULL, + `title` varchar(255) NOT NULL, + `category` varchar(100) DEFAULT NULL, + `type` varchar(100) DEFAULT NULL, + `visibility` enum('public','internal','confidential') DEFAULT 'public', + `visibility_groups` varchar(500) DEFAULT NULL, + `status` varchar(20) NOT NULL DEFAULT 'Open', + `description` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `closed_at` timestamp NULL DEFAULT NULL, + `priority` int(11) NOT NULL DEFAULT 1 CHECK (`priority` between 1 and 6), + `hash` varchar(64) DEFAULT NULL, + `created_by` int(11) DEFAULT NULL, + `updated_by` int(11) DEFAULT NULL, + `assigned_to` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `ticket_id` (`ticket_id`), + UNIQUE KEY `unique_hash` (`hash`), + KEY `fk_tickets_updated_by` (`updated_by`), + KEY `idx_status` (`status`), + KEY `idx_priority` (`priority`), + KEY `idx_tickets_created_at` (`created_at`), + KEY `idx_assigned_to` (`assigned_to`), + KEY `idx_tickets_status` (`status`), + KEY `idx_tickets_status_priority_created` (`status`,`priority`,`created_at` DESC), + KEY `idx_tickets_visibility` (`visibility`), + KEY `idx_tickets_category` (`category`), + KEY `idx_tickets_type` (`type`), + KEY `idx_tickets_priority` (`priority`), + KEY `idx_tickets_updated_at` (`updated_at`), + KEY `idx_tickets_created_by` (`created_by`), + KEY `idx_tickets_assigned_to` (`assigned_to`), + KEY `idx_tickets_status_created` (`status`,`created_at`), + KEY `idx_tickets_assigned_status` (`assigned_to`,`status`), + KEY `idx_tickets_visibility_status` (`visibility`,`status`), + KEY `idx_tickets_closed_at` (`closed_at`), + FULLTEXT KEY `ft_title_description` (`title`,`description`), + CONSTRAINT `fk_tickets_assigned_to` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL, + CONSTRAINT `fk_tickets_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL, + CONSTRAINT `fk_tickets_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ user_preferences ============ +CREATE TABLE IF NOT EXISTS `user_preferences` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `preference_key` varchar(100) NOT NULL, + `preference_value` text DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `unique_user_pref` (`user_id`,`preference_key`), + KEY `idx_user_preferences_user_key` (`user_id`,`preference_key`), + CONSTRAINT `user_preferences_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ============ users ============ +CREATE TABLE IF NOT EXISTS `users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(100) NOT NULL, + `display_name` varchar(255) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `groups` text DEFAULT NULL, + `is_admin` tinyint(1) DEFAULT 0, + `last_login` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`user_id`), + UNIQUE KEY `username` (`username`), + KEY `idx_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/scripts/cleanup_orphan_uploads.php b/scripts/cleanup_orphan_uploads.php new file mode 100644 index 0000000..267b1f0 --- /dev/null +++ b/scripts/cleanup_orphan_uploads.php @@ -0,0 +1,143 @@ +#!/usr/bin/env php +/ that have NO matching row in + * ticket_attachments (e.g. leftovers from a failed DB insert). Intended to be + * run from cron: + * 0 4 * * * /usr/bin/php /path/to/scripts/cleanup_orphan_uploads.php >> /var/log/orphan_uploads.log 2>&1 + * + * SAFETY: + * - Only files older than a grace period (GRACE_SECONDS, default 24h) are + * considered, so a freshly written file whose DB row has not been inserted + * yet (in-flight upload) is never deleted. + * - Only 9-digit ticket directories are scanned. uploads/avatars/ (and any + * other non-ticket directory) is skipped entirely. + * - A file is deleted only when no ticket_attachments row references its + * stored filename (looked up with a prepared statement). + * + * Usage: + * php cleanup_orphan_uploads.php # delete orphaned files past grace period + * php cleanup_orphan_uploads.php --dry-run # report only, delete nothing + */ + +// Prevent web access +if (php_sapi_name() !== 'cli') { + http_response_code(403); + exit('CLI access only'); +} + +require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; + +/** Files younger than this (seconds) are never touched — protects in-flight uploads. */ +const GRACE_SECONDS = 86400; + +$dryRun = in_array('--dry-run', $argv, true); + +function logMessage($message) +{ + echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n"; +} + +$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? (dirname(__DIR__) . '/uploads'); +$uploadRoot = realpath($uploadDir); + +if ($uploadRoot === false || !is_dir($uploadRoot)) { + logMessage("Upload directory not found: {$uploadDir}"); + exit(0); +} + +logMessage('Starting orphan upload cleanup' . ($dryRun ? ' (DRY RUN)' : '')); + +try { + $conn = Database::getConnection(); +} catch (Exception $e) { + logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage()); + exit(1); +} + +// Prepared lookup: does any attachment row reference this stored filename? +// Stored filenames are globally unique (uniqid), so filename alone is sufficient +// and safe — a match in any ticket means the file is a real attachment. +$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1'); +if ($lookup === false) { + logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error); + exit(1); +} + +$now = time(); +$scanned = 0; +$orphaned = 0; +$deleted = 0; +$skippedTooNew = 0; +$errors = 0; + +foreach (new DirectoryIterator($uploadRoot) as $entry) { + if ($entry->isDot() || !$entry->isDir() || $entry->isLink()) { + continue; + } + + // Ticket directories are 9-digit ticket IDs. Skip avatars/ and anything else. + $dirName = $entry->getFilename(); + if (!preg_match('/^\d{9}$/', $dirName)) { + continue; + } + + foreach (new DirectoryIterator($entry->getPathname()) as $file) { + if ($file->isDot() || !$file->isFile() || $file->isLink()) { + continue; + } + + $scanned++; + $filename = $file->getFilename(); + + // Never touch files younger than the grace period (in-flight uploads). + $age = $now - $file->getMTime(); + if ($age < GRACE_SECONDS) { + $skippedTooNew++; + continue; + } + + // Keep the file if any attachment row references it. + $lookup->bind_param('s', $filename); + $lookup->execute(); + $hasRow = $lookup->get_result()->num_rows > 0; + + if ($hasRow) { + continue; + } + + $orphaned++; + $path = $file->getPathname(); + + if ($dryRun) { + logMessage("WOULD DELETE orphan: {$dirName}/{$filename}"); + continue; + } + + if (@unlink($path)) { + $deleted++; + logMessage("Deleted orphan: {$dirName}/{$filename}"); + } else { + $errors++; + logMessage("ERROR: could not delete: {$dirName}/{$filename}"); + } + } +} + +$lookup->close(); +Database::close(); + +logMessage('Cleanup complete' . ($dryRun ? ' (DRY RUN — nothing deleted)' : '') . ':'); +logMessage(" - Scanned: {$scanned} files"); +logMessage(" - Orphaned: {$orphaned} files"); +logMessage(" - Deleted: {$deleted} files"); +logMessage(" - Skipped (too new): {$skippedTooNew} files"); +if ($errors > 0) { + logMessage(" - Errors: {$errors} files"); +} + +exit($errors > 0 ? 1 : 0);