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

- 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:
2026-07-10 16:11:26 -04:00
co-authored by Claude Opus 4.8
parent 27a5db8c85
commit d6214a0339
9 changed files with 601 additions and 128 deletions
+18 -5
View File
@@ -1,5 +1,11 @@
# Tinker Tickets Environment Configuration # Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values # 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 # Database Configuration
DB_HOST=10.10.10.50 DB_HOST=10.10.10.50
@@ -25,11 +31,18 @@ APP_DOMAIN=
ALLOWED_HOSTS=localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy). # Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User / # Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is # headers (Remote-User / Remote-Groups) and forwarded client IPs are only
# in this list. Leaving it empty disables that protection (relies solely on # trusted when REMOTE_ADDR is in this list.
# network topology) and lets anything reaching PHP directly spoof admin login. #
# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27 # 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= TRUSTED_PROXIES=
# Timezone (default: America/New_York) # Timezone (default: America/New_York)
+38 -16
View File
@@ -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 - **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) - **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 - **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 (P3P5) have no SLA banner.
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session - **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### Ticket Templates ### 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 - **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
### Matrix Notifications (hookshot) ### 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 - **Status Changed**: Fires on every status transition
- **@Mentions**: Mentioned users receive a direct Matrix notification - **@Mentions**: Mentioned users receive a direct Matrix notification
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable - **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 | | `?` | Show keyboard shortcuts help |
### Security Features ### 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 - **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 - **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 - **SQL Injection Prevention**: All queries use prepared statements with parameter binding
@@ -179,9 +179,9 @@ Content-Type: application/json
**Key behaviours:** **Key behaviours:**
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env` - 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) - 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` - API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture ## Technical Architecture
@@ -240,6 +240,11 @@ Content-Type: application/json
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility` - `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at` - `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 ### API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |
@@ -248,6 +253,7 @@ Content-Type: application/json
| `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user | | `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket | | `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/get_comments.php` | GET | Fetch paginated comments for a ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket | | `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template | | `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments | | `/api/get_users.php` | GET | Get user list for assignments |
@@ -292,6 +298,7 @@ tinker_tickets/
│ ├── download_attachment.php # GET: Download with visibility check │ ├── download_attachment.php # GET: Download with visibility check
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON │ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_comments.php # GET: Fetch paginated ticket comments
│ ├── get_template.php # GET: Fetch ticket template │ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list │ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint │ ├── health.php # GET: Health check endpoint
@@ -329,14 +336,20 @@ tinker_tickets/
├── config/ ├── config/
│ └── config.php # Config + .env loading │ └── config.php # Config + .env loading
├── controllers/ ├── controllers/
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters │ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility │ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/ ├── 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 │ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/ ├── helpers/
│ ├── CacheHelper.php # File-based cache (stats, avatars) │ ├── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection │ ├── Database.php # Centralized mysqli connection
│ ├── ErrorHandler.php # Global error/exception handler
│ ├── NotificationHelper.php # Matrix hookshot webhook events │ ├── 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 │ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN │ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/ ├── middleware/
@@ -347,6 +360,7 @@ tinker_tickets/
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation │ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/ ├── models/
│ ├── ApiKeyModel.php # API key generation/validation │ ├── ApiKeyModel.php # API key generation/validation
│ ├── AttachmentModel.php # Ticket file attachment metadata
│ ├── AuditLogModel.php # Audit logging + timeline │ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking │ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access │ ├── CommentModel.php # Comment data access
@@ -360,11 +374,12 @@ tinker_tickets/
│ ├── UserModel.php # User management + groups │ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences │ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows │ └── 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/ ├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets │ ├── check_requirements.php # Verify PHP extensions/config prerequisites
── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments ── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage ├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache │ └── avatars/ # lldap avatar disk cache
├── views/ ├── views/
@@ -455,13 +470,20 @@ AVATAR_CACHE_TTL=3600
### 2. Cron Jobs ### 2. Cron Jobs
Add to crontab for recurring tickets and optional cleanup: Add to crontab for recurring tickets and maintenance cleanup:
```bash ```bash
# Run every hour to create scheduled recurring tickets # Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php 0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Optional: clean up orphaned uploads weekly # Purge expired rate-limit files (every 5 minutes)
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php */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 ### 3. File Uploads
@@ -502,7 +524,7 @@ Key conventions and gotchas for working with this codebase:
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false` 3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`) 4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`) 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` 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 8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values 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'`. 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()`. 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`. 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). 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 to prevent duplicate hw-alert tickets within 24 h. 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 ## 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 | | SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces | | 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 | | Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) | | Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution | | File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
+4
View File
@@ -140,6 +140,10 @@ class TicketController
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); $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 // Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+10
View File
@@ -60,6 +60,7 @@ require_once __DIR__ . '/config/config.php';
// Authenticate via API key // Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php'; require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php'; require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn); $apiKeyAuth = new ApiKeyAuth($conn);
@@ -337,6 +338,9 @@ if ($existing) {
'status' => $existingStatus, 'status' => $existingStatus,
], 'automated'); ], 'automated');
} }
// Ticket state (priority/title/description) changed — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
} }
$conn->close(); $conn->close();
@@ -373,6 +377,9 @@ if ($existing) {
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)', 'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]); ]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close(); $conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -468,6 +475,9 @@ if ($inserted) {
'type' => $type, 'type' => $type,
]); ]);
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close(); $conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
+50
View File
@@ -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);
}
+12
View File
@@ -17,9 +17,11 @@ chdir(dirname(__DIR__));
// Include required files // Include required files
require_once 'config/config.php'; require_once 'config/config.php';
require_once 'helpers/Database.php'; require_once 'helpers/Database.php';
require_once 'helpers/NotificationHelper.php';
require_once 'models/RecurringTicketModel.php'; require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php'; require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php'; require_once 'models/AuditLogModel.php';
require_once 'models/StatsModel.php';
// Log function // Log function
function logMessage($message) function logMessage($message)
@@ -92,6 +94,10 @@ try {
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']] ['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++; $created++;
} else { } else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error')); 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"); logMessage("Completed: Created $created tickets, $errors errors");
Database::close(); Database::close();
-107
View File
@@ -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";
+326
View File
@@ -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;
+143
View File
@@ -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);