Add schema baseline, fix cron/retention, restore cleanup, correct docs
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Security / PHP Security (semgrep) (push) Successful in 1m11s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 47s
Lint / JS (eslint) (pull_request) Successful in 12s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 59s
Security / PHP Security (semgrep) (pull_request) Successful in 1m6s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Security / PHP Security (semgrep) (push) Successful in 1m11s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 47s
Lint / JS (eslint) (pull_request) Successful in 12s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 59s
Security / PHP Security (semgrep) (pull_request) Successful in 1m6s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
- 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 <noreply@anthropic.com>
This commit is contained in:
+18
-5
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Audit Log Retention Cron Job
|
||||
*
|
||||
* Deletes audit_log rows older than AUDIT_LOG_RETENTION_DAYS (config, default 90).
|
||||
* Recommended: run once daily.
|
||||
*
|
||||
* Example crontab entry (03:30 every day):
|
||||
* 30 3 * * * /usr/bin/php /path/to/cron/cleanup_audit_log.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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API Key Generator for hwmonDaemon
|
||||
* Run this script once after migrations to generate the API key
|
||||
*
|
||||
* Usage: php generate_api_key.php
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||
require_once __DIR__ . '/models/UserModel.php';
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " Tinker Tickets - API Key Generator\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Orphan Upload Cleanup
|
||||
*
|
||||
* Removes files under uploads/<ticketId>/ 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);
|
||||
Reference in New Issue
Block a user