diff --git a/Claude.md b/Claude.md new file mode 100644 index 0000000..5e31014 --- /dev/null +++ b/Claude.md @@ -0,0 +1,688 @@ +# Tinker Tickets - Project Documentation for AI Assistants + +## Project Overview + +Tinker Tickets is a lightweight, self-hosted ticket management system built for managing data center infrastructure issues. It features automatic ticket creation via hardware monitoring integration, Discord notifications, and a clean web interface. + +**Tech Stack:** +- Backend: PHP 8+ with MySQLi +- Frontend: Vanilla JavaScript, CSS3 +- Database: MariaDB (on separate LXC: 10.10.10.???) +- Web Server: Nginx on production (10.10.10.45) +- External Libraries: marked.js (Markdown rendering) + +**Production Environment:** +- **Primary URL**: https://t.lotusguild.org +- **Beta URL**: https://beta.t.lotusguild.org (React port - in development) +- **Web Server**: Nginx at 10.10.10.45 (`/var/www/html/tinkertickets`) +- **Database**: MariaDB on separate LXC (`ticketing_system` database) + +## Architecture + +### Project Structure +``` +/tinker_tickets/ +├── api/ # API endpoints (standalone PHP files) +│ ├── add_comment.php # POST: Add comment to ticket +│ └── update_ticket.php # POST: Update ticket fields (partial updates) +├── assets/ # Static assets +│ ├── css/ +│ │ ├── dashboard.css # Shared + dashboard styles +│ │ └── ticket.css # Ticket page styles +│ ├── js/ +│ │ ├── dashboard.js # Dashboard + hamburger menu +│ │ └── ticket.js # Ticket interactions +│ └── images/ +│ └── favicon.png +├── config/ +│ └── config.php # Config + .env loading +├── controllers/ # MVC Controllers +│ ├── CommentController.php # Comment operations +│ ├── DashboardController.php # Dashboard/listing +│ └── TicketController.php # Ticket CRUD + webhooks +├── models/ # Data models +│ ├── CommentModel.php # Comment data access +│ └── TicketModel.php # Ticket data access +├── views/ # PHP templates +│ ├── CreateTicketView.php # Ticket creation form +│ ├── DashboardView.php # Main listing page +│ └── TicketView.php # Single ticket view +├── .env # Environment variables (GITIGNORED) +├── .gitignore +├── create_ticket_api.php # External API for hwmonDaemon +├── deploy.sh # Legacy manual deploy (not used) +├── index.php # Main entry point + router +└── README.md +``` + +### Routing System (`index.php`) + +Simple switch-based router: +- `/` → Dashboard +- `/ticket/{id}` → View ticket +- `/ticket/create` → Create ticket form +- `/api/update_ticket.php` → Update ticket (AJAX) +- `/api/add_comment.php` → Add comment (AJAX) + +**Important Notes:** +- API routes handle their own database connections +- Page routes receive connection from index.php +- Legacy routes redirect to new URLs + +## Database Schema + +**Database Name:** `ticketing_system` (NOT `tinkertickets`) + +### `tickets` Table +```sql +CREATE TABLE tickets ( + ticket_id VARCHAR(9) PRIMARY KEY, -- 9-digit format with leading zeros + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(50) DEFAULT 'Open', -- 'Open', 'Closed', 'In Progress', 'Pending' + priority INT DEFAULT 4, -- 1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest + category VARCHAR(50) DEFAULT 'General', -- Hardware, Software, Network, Security, General + type VARCHAR(50) DEFAULT 'Issue', -- Maintenance, Install, Task, Upgrade, Issue + hash VARCHAR(64), -- For duplicate detection (hwmonDaemon) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; +``` + +### `ticket_comments` Table +```sql +CREATE TABLE ticket_comments ( + comment_id INT AUTO_INCREMENT PRIMARY KEY, + ticket_id VARCHAR(10), + user_name VARCHAR(50), + comment_text TEXT, + markdown_enabled TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) +) ENGINE=InnoDB; +``` + +**Key Points:** +- Ticket IDs are VARCHAR (9-digit format with leading zeros) +- Status ENUM-like validation in application layer +- Hash field used for duplicate detection by hwmonDaemon +- Comments support optional Markdown rendering + +## API Endpoints + +### POST `/api/update_ticket.php` +Updates ticket fields (supports partial updates). + +**Request:** +```json +{ + "ticket_id": 123456789, + "status": "In Progress", // Optional + "priority": 2, // Optional (1-5) + "title": "Updated title", // Optional + "description": "...", // Optional + "category": "Software", // Optional + "type": "Task" // Optional +} +``` + +**Response:** +```json +{ + "success": true, + "status": "In Progress", + "priority": 2, + "message": "Ticket updated successfully" +} +``` + +**Features:** +- Merges updates with existing ticket data (partial updates) +- Validates status against allowed values +- Validates priority range (1-5) +- Sends Discord webhook on changes +- Debug logging to `/tmp/api_debug.log` + +**Discord Webhook:** +- Triggered on ticket updates +- Shows field changes (old → new) +- Color-coded by priority +- Links to ticket URL +- Only sends if changes detected + +### POST `/api/add_comment.php` +Adds a comment to a ticket. + +**Request:** +```json +{ + "ticket_id": "123456789", + "comment_text": "Comment content", + "markdown_enabled": true, // Optional, default false + "user_name": "User" // Optional, default "User" +} +``` + +**Response:** +```json +{ + "success": true, + "user_name": "User", + "created_at": "Jan 01, 2026 12:00", + "markdown_enabled": 1, + "comment_text": "Comment content" +} +``` + +### POST `/create_ticket_api.php` +**EXTERNAL API** used by hwmonDaemon for automated ticket creation. + +**Request:** +```json +{ + "title": "[hostname][auto][hardware]Issue[single-node][production][maintenance]", + "description": "Detailed hardware issue...", + "priority": "2", + "category": "Hardware", + "type": "Problem" +} +``` + +**Response:** +```json +{ + "success": true, + "ticket_id": "123456789", + "message": "Ticket created successfully" +} +``` +**OR** (if duplicate): +```json +{ + "success": false, + "error": "Duplicate ticket", + "existing_ticket_id": "987654321" +} +``` + +**Special Features:** +- Duplicate detection via SHA-256 hashing +- Hash based on: hostname, SMART attributes, environment tags, device +- 24-hour duplicate window +- Sends Discord webhook notification +- Auto-creates tickets table if not exists + +## Frontend Components + +### Dashboard (`views/DashboardView.php` + `assets/js/dashboard.js`) + +**Features:** +- Pagination (default 15, configurable via settings) +- Search (title, description, ticket_id, category, type) +- Status filtering (Open, In Progress, Closed) +- Category/Type filtering via hamburger menu +- Column sorting (click headers) +- Theme toggle (light/dark, persisted to localStorage) +- Settings modal (rows per page) + +**Default Behavior:** +- Shows Open + In Progress tickets (Closed hidden) +- Use `?show_all=1` to see all tickets +- Use `?status=Open,Closed` for specific statuses + +**Hamburger Menu:** +- Left sidebar with filters +- Multi-select checkboxes +- Apply/Clear filter buttons + +### Ticket View (`views/TicketView.php` + `assets/js/ticket.js`) + +**Features:** +- Tabbed interface (Description, Comments) +- Inline editing via Edit button +- Real-time status/priority indicators +- Markdown support for comments +- Live markdown preview toggle + +**Hamburger Menu (Ticket Page):** +- Quick edit: Status, Priority, Category, Type +- Click value → dropdown → save/cancel +- Updates main page elements dynamically +- Changes ticket border color based on priority + +**Visual Indicators:** + +Priority Colors: +- P1 (Critical): Red `#ff4d4d` +- P2 (High): Orange `#ffa726` +- P3 (Medium): Blue `#42a5f5` +- P4 (Low): Green `#66bb6a` +- P5 (Lowest): Gray `#9e9e9e` + +Status Colors: +- Open: Green `#28a745` +- In Progress: Yellow `#ffc107` +- Closed: Red `#dc3545` + +### Create Ticket (`views/CreateTicketView.php`) + +**Form Fields:** +- Title (required) +- Description (required, textarea) +- Status (dropdown, default: Open) +- Priority (dropdown, default: P4) +- Category (dropdown, default: General) +- Type (dropdown, default: Issue) + +**On Submit:** +- Server-side validation +- Discord webhook notification +- Redirect to new ticket + +## Configuration + +### Environment Variables (`.env`) +```ini +DB_HOST= +DB_USER= +DB_PASS= +DB_NAME=ticketing_system +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +``` + +**CRITICAL:** `.env` is gitignored! Never commit this file. + +### Config (`config/config.php`) +```php +$GLOBALS['config'] = [ + 'DB_HOST' => $envVars['DB_HOST'], + 'DB_USER' => $envVars['DB_USER'], + 'DB_PASS' => $envVars['DB_PASS'], + 'DB_NAME' => $envVars['DB_NAME'], + 'BASE_URL' => '', // Empty (serving from root) + 'ASSETS_URL' => '/assets', + 'API_URL' => '/api' +]; +``` + +## Deployment System + +### Auto-Deploy Pipeline + +**Gitea → Webhook → Production Server** + +1. **Push to `main` branch** on Gitea (code.lotusguild.org) +2. **Gitea sends webhook** to `http://10.10.10.45:9000/hooks/tinker-deploy` +3. **Webhook service** validates signature and triggers deploy script +4. **Deploy script** pulls code, preserves `.env`, sets permissions + +### Webhook Configuration + +**Service:** `/etc/systemd/system/webhook.service` +```ini +[Unit] +Description=Webhook Listener for Auto Deploy +After=network.target + +[Service] +ExecStart=/usr/bin/webhook -hooks /etc/webhook/hooks.json -port 9000 +Restart=always +User=root +``` + +**Hooks:** `/etc/webhook/hooks.json` +```json +{ + "id": "tinker-deploy", + "execute-command": "/usr/local/bin/tinker_deploy.sh", + "command-working-directory": "/var/www/html/tinkertickets", + "response-message": "Deploying tinker_tickets...", + "trigger-rule": { + "match": { + "type": "payload-hash-sha256", + "secret": "...", + "parameter": { + "source": "header", + "name": "X-Gitea-Signature" + } + } + } +} +``` + +**Deploy Script:** `/usr/local/bin/tinker_deploy.sh` +```bash +#!/bin/bash +set -e +WEBROOT="/var/www/html/tinkertickets" + +# Backup .env +if [ -f "$WEBROOT/.env" ]; then + cp "$WEBROOT/.env" /tmp/.env.backup +fi + +# Pull latest code +if [ ! -d "$WEBROOT/.git" ]; then + rm -rf "$WEBROOT" + git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT" +else + cd "$WEBROOT" + git fetch --all + git reset --hard origin/main +fi + +# Restore .env +if [ -f /tmp/.env.backup ]; then + mv /tmp/.env.backup "$WEBROOT/.env" +fi + +# Set permissions +chown -R www-data:www-data "$WEBROOT" +``` + +**IMPORTANT NOTES:** +- **Test thoroughly before pushing to main!** +- Low traffic (single user), so testing in production is acceptable +- But avoid breaking changes +- `.env` is preserved across deployments +- Database changes require manual migration + +### Nginx Configuration + +**Site Config:** `/etc/nginx/sites-enabled/tinker_prod` +```nginx +server { + listen 80; + server_name t.lotusguild.org; + root /var/www/html/tinkertickets; + index index.php index.html; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php-fpm.sock; + } + + location ~ /\.env { + deny all; + } +} +``` + +**Key Points:** +- Clean URLs via `try_files` +- PHP-FPM via Unix socket +- `.env` explicitly denied + +## Hardware Monitoring Integration + +### hwmonDaemon Overview + +The `hwmonDaemon` runs on all Proxmox VE servers as a systemd timer (hourly). It monitors: +- SMART drive health +- Disk usage +- Memory (including ECC errors) +- CPU usage +- Network connectivity (Management + Ceph networks) +- System logs for drive errors + +**When issues are detected**, it automatically creates tickets via `/create_ticket_api.php`. + +### Daemon Configuration + +**Service:** `/etc/systemd/system/hwmon.service` +- Executes Python script from Gitea URL (self-updating) +- Runs as root for hardware access +- Auto-restarts on failure + +**Timer:** `/etc/systemd/system/hwmon.timer` +- Runs hourly +- 5-minute randomized delay + +**API Endpoint:** +```python +TICKET_API_URL = 'http://10.10.10.45/create_ticket_api.php' +``` + +### Ticket Title Format + +hwmonDaemon creates tickets with structured titles: +``` +[hostname][auto][hardware]Issue Description[single-node][production][maintenance] +``` + +**Components:** +- `[hostname]`: Server name +- `[auto]`: Automated creation +- `[hardware]`: Issue category +- Issue Description: e.g., "Drive /dev/sda has SMART issues: Reallocated_Sector_Ct" +- `[single-node]`: Scope +- `[production]`: Environment +- `[maintenance]`: Ticket type + +### Duplicate Detection + +Tickets are hashed based on: +- Hostname +- SMART attribute types (not values) +- Environment tags +- Device path (for drive issues) + +**Hash Window:** 24 hours + +This prevents duplicate tickets for the same issue on the same host. + +## Development Guidelines + +### Code Style +- Tabs for indentation in PHP +- Parameterized queries (prepared statements) +- Output escaping with `htmlspecialchars()` +- Error logging to `/tmp/api_debug.log` + +### Security Practices +- **SQL Injection**: All queries use prepared statements +- **XSS**: HTML output escaped +- **CSRF**: Not implemented (single-user system) +- **Environment Variables**: `.env` gitignored +- **File Permissions**: `www-data:www-data` ownership + +### Error Handling +- API endpoints use output buffering +- Errors returned as JSON with `success: false` +- Debug logging in `/tmp/api_debug.log` +- Display errors disabled in production + +### JavaScript Patterns +- Vanilla JavaScript (no framework) +- `DOMContentLoaded` for initialization +- `fetch()` for AJAX +- `window.ticketData` for ticket pages +- CSS class toggling for state changes + +## Common Tasks + +### Adding a New Ticket Field + +1. **Database:** Add column to `tickets` table on MariaDB server +2. **Model:** Update `TicketModel.php`: + - `getTicketById()` + - `updateTicket()` + - `createTicket()` +3. **API:** Update `update_ticket.php`: + - Add to validation + - Add to merge logic (line 73-81) +4. **Views:** + - Add field to `TicketView.php` + - Add field to `CreateTicketView.php` +5. **JavaScript:** Add to hamburger menu in `dashboard.js` (if editable) +6. **CSS:** Add styling if needed + +### Modifying Status/Priority Values + +1. **API:** Update validation in `update_ticket.php`:172 + - Status: Line 102-108 + - Priority: Line 93-98 +2. **Views:** Update dropdowns: + - `TicketView.php:410-414` (status) + - `TicketView.php:426-432` (priority) + - `CreateTicketView.php` +3. **JavaScript:** Update hamburger options in `dashboard.js` +4. **CSS:** Add color classes to `dashboard.css` and `ticket.css` + +### Changing Discord Notifications + +Edit `update_ticket.php` → `sendDiscordWebhook()` (lines 135-219): +- Change embed structure +- Modify color mapping +- Add/remove fields +- Update ticket URL + +Also check `TicketController.php` → `sendDiscordWebhook()` (lines 128-207) for ticket creation webhooks. + +### Updating Pagination Defaults + +1. **Controller:** `DashboardController.php:16` (default: 15) +2. **JavaScript:** `dashboard.js:128-133` (settings modal options) +3. **Cookie:** Stored as `ticketsPerPage` + +## Known Behaviors & Quirks + +### Ticket ID Generation +- Format: 9-digit random number with leading zeros +- Generation: `sprintf('%09d', mt_rand(1, 999999999))` +- Stored as VARCHAR +- **Collision possible** (no uniqueness check beyond DB constraint) + +### Status Filtering +- **Default:** Shows Open + In Progress (hides Closed) +- `?show_all=1` → All statuses +- `?status=Open,Closed` → Specific statuses +- No status param → Default behavior + +### Markdown Rendering +- Client-side only (marked.js from CDN) +- Toggle must be enabled before preview works +- **XSS Risk:** Consider adding DOMPurify + +### CSS Class Naming +- Status: `status-Open`, `status-In-Progress`, `status-Closed` +- Spaces replaced with hyphens +- Priority: `priority-1` through `priority-5` + +### Theme Persistence +- Stored: `localStorage['theme']` → `light` or `dark` +- Applied via `data-theme` attribute on `` +- CSS variables change based on theme + +## Debugging + +### API Issues +```bash +tail -f /tmp/api_debug.log +``` + +### JavaScript Issues +- Browser console (F12) +- Check Network tab for API responses +- Look for `console.log()` statements + +### Database Issues +```bash +# Connect to MariaDB server +ssh root@ +mysql ticketing_system +``` + +### Deployment Issues +```bash +# On production server (10.10.10.45) +journalctl -u webhook.service -f +systemctl status webhook.service + +# Manual deploy +cd /var/www/html/tinkertickets +git pull +chown -R www-data:www-data . +``` + +### hwmonDaemon Issues +```bash +# On Proxmox server +journalctl -u hwmon.service -f +systemctl status hwmon.timer + +# Manual test +python3 /path/to/hwmonDaemon.py --dry-run +``` + +## Important Notes for AI Assistants + +1. **Always read existing code** before suggesting changes +2. **Test carefully** - auto-deploy to production is enabled +3. **Database changes** require manual migration (no auto-rollback) +4. **Preserve security** (prepared statements, escaping, `.env` protection) +5. **Consider auto-deploy** when making changes +6. **Single-user system** - authentication/authorization not implemented +7. **hwmonDaemon integration** - test with `create_ticket_api.php` +8. **Duplicate detection** - understand hashing for automated tickets +9. **Discord webhooks** - changes trigger notifications +10. **MariaDB on separate server** - can't access directly from this machine + +## Future Considerations + +### Potential Improvements +- User authentication/authorization +- CSRF protection +- File attachments +- Email notifications +- Advanced search/filters +- Ticket assignment +- Activity/audit log +- API rate limiting +- Database migrations system +- Unit tests +- DOMPurify for Markdown XSS protection + +### Performance Optimizations +- Database indexes +- Query caching +- Lazy load comments +- Minify/bundle assets + +## Related Systems + +### React Beta Site +- **URL:** https://beta.t.lotusguild.org +- **Branch:** `react_test` +- **Status:** Early development (brother's project) +- **Deploy:** Separate webhook + script (`tinker_react_deploy.sh`) +- **Location:** `/var/www/html/tinkertickets-react` + +## File Reference Quick Guide + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `index.php` | Router | URL routing, DB connection | +| `create_ticket_api.php` | hwmonDaemon API | Duplicate detection, auto-tickets | +| `api/update_ticket.php` | Update API | Partial updates, Discord webhooks | +| `api/add_comment.php` | Comment API | Markdown-enabled comments | +| `models/TicketModel.php` | Ticket data layer | CRUD, filtering, sorting | +| `models/CommentModel.php` | Comment data layer | Get/add comments | +| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters | +| `controllers/TicketController.php` | Ticket logic | CRUD, webhooks | +| `assets/js/dashboard.js` | Dashboard UI | Filters, sorting, hamburger | +| `assets/js/ticket.js` | Ticket UI | Edit mode, comments, markdown | +| `assets/css/dashboard.css` | Shared styles | Layout, table, theme | +| `assets/css/ticket.css` | Ticket styles | Ticket-specific components | + +## Contact & Repository + +- **Gitea:** https://code.lotusguild.org/LotusGuild/tinker_tickets +- **Production:** https://t.lotusguild.org +- **Beta:** https://beta.t.lotusguild.org + +This is a personal project for infrastructure management. For issues, use the Gitea repository. diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..e1565a3 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,445 @@ +# Tinker Tickets SSO Integration - Deployment Guide + +## ✅ Implementation Complete + +All code for Authelia SSO with LLDAP integration has been implemented. This guide will walk you through deployment and testing. + +## 📋 What Was Implemented + +### 1. Database Schema (New Tables) +- **users** - Stores user accounts synced from LLDAP +- **api_keys** - Manages API keys for external services (hwmonDaemon) +- **audit_log** - Tracks all user actions for audit trail + +### 2. Authentication System +- **AuthMiddleware.php** - Reads Authelia forward auth headers and syncs users +- **ApiKeyAuth.php** - Validates API keys for external services +- Session management with 5-hour timeout +- Group-based access control (admin/employee groups) + +### 3. Models +- **UserModel.php** - User CRUD operations and authentication +- **ApiKeyModel.php** - API key generation and validation +- **AuditLogModel.php** - Audit trail logging +- Updated **TicketModel.php** and **CommentModel.php** with user tracking + +### 4. Protected Endpoints +- All web pages now require Authelia authentication +- API endpoints require session authentication +- `/create_ticket_api.php` requires API key authentication + +### 5. User Interface Updates +- User info header showing logged-in user +- Admin badge for admin users +- Comment display shows user's display name from LLDAP + +## 🚀 Deployment Steps + +### Step 1: Push Code to Git + +```bash +cd /root/code/tinker_tickets +git add . +git commit -m "Add Authelia SSO integration with LLDAP + +- Implement user authentication via forward auth headers +- Add API key authentication for hwmonDaemon +- Create audit log for all user actions +- Add user tracking to tickets and comments +- Update views to display user information" +git push origin main +``` + +The Gitea webhook will automatically deploy to production at 10.10.10.45. + +### Step 2: Run Database Migrations + +SSH into the production server: + +```bash +ssh jared@10.10.10.45 +``` + +Navigate to the application directory: + +```bash +cd /var/www/html/tinkertickets +``` + +Run the migrations: + +```bash +php migrations/run_migrations.php +``` + +Expected output: +``` +Connected to database: ticketing_system + +Found 6 migration(s): + - 001_create_users_table.sql + - 002_create_api_keys_table.sql + - 003_create_audit_log_table.sql + - 004_alter_tickets_table.sql + - 005_alter_comments_table.sql + - 006_add_indexes.sql + +Executing: 001_create_users_table.sql... OK +Executing: 002_create_api_keys_table.sql... OK +Executing: 003_create_audit_log_table.sql... OK +Executing: 004_alter_tickets_table.sql... OK +Executing: 005_alter_comments_table.sql... OK +Executing: 006_add_indexes.sql... OK + +Migration Summary: + Success: 6 + Errors: 0 + +All migrations completed successfully! +``` + +### Step 3: Generate API Key for hwmonDaemon + +Since we need an admin user to generate API keys, we'll create a temporary PHP script: + +Create `/var/www/html/tinkertickets/generate_api_key.php`: + +```php +connect_error) { + die("Connection failed: " . $conn->connect_error); +} + +$userModel = new UserModel($conn); +$apiKeyModel = new ApiKeyModel($conn); + +// Get system user (should exist from migration) +$systemUser = $userModel->getSystemUser(); + +if (!$systemUser) { + die("Error: System user not found. Check migrations.\n"); +} + +echo "System user found: ID " . $systemUser['user_id'] . "\n"; + +// Generate API key +$result = $apiKeyModel->createKey( + 'hwmonDaemon', + $systemUser['user_id'], + null // No expiration +); + +if ($result['success']) { + echo "\n✅ API Key generated successfully!\n\n"; + echo "API Key: " . $result['api_key'] . "\n"; + echo "Key Prefix: " . $result['key_prefix'] . "\n"; + echo "\n⚠️ IMPORTANT: Save this API key now! It cannot be retrieved later.\n"; + echo "\nAdd this to hwmonDaemon .env file:\n"; + echo "TICKET_API_KEY=" . $result['api_key'] . "\n"; +} else { + echo "Error generating API key: " . $result['error'] . "\n"; +} + +$conn->close(); +?> +``` + +Run it: + +```bash +php generate_api_key.php +``` + +Save the API key output - you'll need it for hwmonDaemon. + +Delete the script after use: + +```bash +rm generate_api_key.php +``` + +### Step 4: Update hwmonDaemon Configuration + +On each Proxmox server running hwmonDaemon, update the `.env` file: + +```bash +# On each Proxmox server +cd /path/to/hwmonDaemon +nano .env +``` + +Add the API key: + +```env +TICKET_API_KEY= +``` + +Update `hwmonDaemon.py` to send the Authorization header (around line 1198): + +```python +def create_ticket(self, title, description, priority=4, category="Hardware", ticket_type="Issue"): + """Create a ticket via the API""" + url = self.CONFIG["TICKET_API_URL"] + + payload = { + "title": title, + "description": description, + "priority": priority, + "category": category, + "type": ticket_type + } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.CONFIG["TICKET_API_KEY"]}' + } + + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Failed to create ticket: {e}") + return None +``` + +Restart hwmonDaemon: + +```bash +sudo systemctl restart hwmonDaemon +``` + +### Step 5: Migrate Legacy Data (Optional) + +If you want to assign existing comments to a specific user: + +1. First, log into the web UI to create your user account in the database +2. Then run this SQL to update existing comments: + +```sql +-- Get jared's user_id (replace with actual value after login) +SELECT user_id FROM users WHERE username = 'jared'; + +-- Update existing comments (replace 1 with actual user_id) +UPDATE ticket_comments SET user_id = 1 WHERE user_id IS NULL; +``` + +## 🧪 Testing + +### Test 1: Web UI Authentication + +1. Open https://t.lotusguild.org in your browser +2. You should be redirected to Authelia login (if not already logged in) +3. Log in with your LLDAP credentials +4. Verify you see your name in the top-right corner +5. If you're in the admin group, verify you see the "Admin" badge + +### Test 2: Create Ticket via Web UI + +1. Click "New Ticket" +2. Fill out the form +3. Submit the ticket +4. Verify the ticket appears in the dashboard +5. Check the database to confirm `created_by` is set: + +```sql +SELECT ticket_id, title, created_by FROM tickets ORDER BY created_at DESC LIMIT 5; +``` + +### Test 3: Add Comment + +1. Open a ticket +2. Add a comment +3. Verify your display name appears on the comment +4. Check the database to confirm `user_id` is set: + +```sql +SELECT comment_id, ticket_id, user_id, comment_text FROM ticket_comments ORDER BY created_at DESC LIMIT 5; +``` + +### Test 4: hwmonDaemon API + +1. Trigger a hardware issue or manually test the API: + +```bash +curl -X POST https://t.lotusguild.org/create_ticket_api.php \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "title": "[test-server][auto][hardware]Test Ticket[single-device][production][warning]", + "description": "This is a test ticket from hwmonDaemon", + "priority": "2", + "category": "Hardware", + "type": "Issue" + }' +``` + +Expected response: + +```json +{ + "success": true, + "ticket_id": "123456789", + "message": "Ticket created successfully" +} +``` + +2. Verify the ticket appears in the dashboard +3. Check the database to confirm `created_by` is the system user: + +```sql +SELECT t.ticket_id, t.title, u.username +FROM tickets t +LEFT JOIN users u ON t.created_by = u.user_id +ORDER BY t.created_at DESC LIMIT 5; +``` + +### Test 5: Audit Log + +Check that actions are being logged: + +```sql +-- View recent audit log entries +SELECT al.*, u.username, u.display_name +FROM audit_log al +LEFT JOIN users u ON al.user_id = u.user_id +ORDER BY al.created_at DESC +LIMIT 20; +``` + +You should see entries for: +- Ticket views +- Ticket creations +- Ticket updates +- Comment creations + +## 🔒 Security Notes + +1. **API Keys**: The API key generated for hwmonDaemon is shown only once. Store it securely. +2. **Session Timeout**: Web sessions expire after 5 hours of inactivity. +3. **Group Access**: Only users in `admin` or `employee` groups can access the system. +4. **Audit Trail**: All actions are logged with user ID, IP address, and timestamp. + +## 🔄 Rollback Procedure + +If you need to rollback the changes: + +```bash +cd /var/www/html/tinkertickets +mysql -u -p ticketing_system < migrations/rollback_all.sql +``` + +Then revert the git commit: + +```bash +git revert HEAD +git push origin main +``` + +## 📊 Database Indexes Added + +For improved performance: +- `tickets.status` - Speeds up status filtering +- `tickets.priority` - Speeds up priority filtering +- `tickets.created_at` - Speeds up date sorting +- `users.username` - Speeds up user lookups +- `audit_log.user_id` - Speeds up user audit queries +- `audit_log.created_at` - Speeds up date-based audit queries + +## 🎯 Next Steps (Future Enhancements) + +1. **Admin Panel** - Create UI for managing API keys +2. **Bulk Actions** - Admin-only bulk ticket operations +3. **User Audit View** - UI to view audit logs per ticket +4. **Advanced Permissions** - Fine-grained permission system +5. **Email Notifications** - Email users on ticket updates + +## 📝 Files Created/Modified + +### New Files Created: +- `migrations/001_create_users_table.sql` +- `migrations/002_create_api_keys_table.sql` +- `migrations/003_create_audit_log_table.sql` +- `migrations/004_alter_tickets_table.sql` +- `migrations/005_alter_comments_table.sql` +- `migrations/006_add_indexes.sql` +- `migrations/rollback_all.sql` +- `migrations/run_migrations.php` +- `middleware/AuthMiddleware.php` +- `middleware/ApiKeyAuth.php` +- `models/UserModel.php` +- `models/ApiKeyModel.php` +- `models/AuditLogModel.php` +- `DEPLOYMENT_GUIDE.md` (this file) + +### Modified Files: +- `index.php` - Added authentication +- `create_ticket_api.php` - Added API key auth +- `api/add_comment.php` - Added session auth +- `api/update_ticket.php` - Added session auth +- `models/TicketModel.php` - Added user_id parameters +- `models/CommentModel.php` - Added user_id parameters +- `controllers/TicketController.php` - Pass current user, log actions +- `views/TicketView.php` - Display user info +- `views/DashboardView.php` - Display user info + +## ❓ Troubleshooting + +### Issue: "Authentication Required" error on web UI + +**Solution**: Check that Nginx Proxy Manager is sending the forward auth headers: +- Remote-User +- Remote-Groups +- Remote-Name +- Remote-Email + +Verify headers are being sent: + +```php + +``` + +Access https://t.lotusguild.org/test.php and look for `HTTP_REMOTE_USER` in the output. + +### Issue: hwmonDaemon tickets failing with 401 Unauthorized + +**Solution**: +1. Verify API key is correct in hwmonDaemon `.env` +2. Check that Authorization header is being sent +3. Verify API key exists in database: `SELECT * FROM api_keys WHERE is_active = 1;` + +### Issue: Existing comments show "Unknown User" + +**Solution**: This is expected for legacy data. To fix: +1. Log into the web UI to create your user account +2. Run the SQL migration to assign your user_id to legacy comments + +### Issue: Database migration fails + +**Solution**: +1. Check database connection in `.env` +2. Ensure database user has CREATE, ALTER, and INSERT privileges +3. Review migration output for specific error messages +4. Check `/tmp/api_debug.log` for detailed errors + +## 📧 Support + +For issues or questions: +1. Check the audit log: `SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 50;` +2. Check PHP error logs: `tail -f /var/log/php-fpm/error.log` +3. Check debug logs: `tail -f /tmp/api_debug.log` +4. Review Authelia logs: `docker logs authelia` diff --git a/api/add_comment.php b/api/add_comment.php index 5f28ab2..5e878f2 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -10,18 +10,29 @@ try { // Include required files with proper error handling $configPath = dirname(__DIR__) . '/config/config.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; - + $auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php'; + if (!file_exists($configPath)) { throw new Exception("Config file not found: $configPath"); } - + if (!file_exists($commentModelPath)) { throw new Exception("CommentModel file not found: $commentModelPath"); } - + require_once $configPath; require_once $commentModelPath; - + require_once $auditLogModelPath; + + // Check authentication via session + session_start(); + if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { + throw new Exception("Authentication required"); + } + + $currentUser = $_SESSION['user']; + $userId = $currentUser['user_id']; + // Create database connection $conn = new mysqli( $GLOBALS['config']['DB_HOST'], @@ -29,29 +40,35 @@ try { $GLOBALS['config']['DB_PASS'], $GLOBALS['config']['DB_NAME'] ); - + if ($conn->connect_error) { throw new Exception("Database connection failed: " . $conn->connect_error); } - + // Get POST data $data = json_decode(file_get_contents('php://input'), true); - + if (!$data) { throw new Exception("Invalid JSON data received"); } - + $ticketId = $data['ticket_id']; - - // Initialize CommentModel directly + + // Initialize models $commentModel = new CommentModel($conn); - - // Add comment - $result = $commentModel->addComment($ticketId, $data); - + $auditLog = new AuditLogModel($conn); + + // Add comment with user tracking + $result = $commentModel->addComment($ticketId, $data, $userId); + + // Log comment creation to audit log + if ($result['success'] && isset($result['comment_id'])) { + $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); + } + // Discard any unexpected output ob_end_clean(); - + // Return JSON response header('Content-Type: application/json'); echo json_encode($result); diff --git a/api/update_ticket.php b/api/update_ticket.php index 77c691d..3aab4be 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -37,24 +37,39 @@ try { // Load models directly with absolute paths $ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; - + $auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php'; + debug_log("Loading models from: $ticketModelPath and $commentModelPath"); require_once $ticketModelPath; require_once $commentModelPath; + require_once $auditLogModelPath; debug_log("Models loaded successfully"); - + + // Check authentication via session + session_start(); + if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { + throw new Exception("Authentication required"); + } + $currentUser = $_SESSION['user']; + $userId = $currentUser['user_id']; + debug_log("User authenticated: " . $currentUser['username']); + // Updated controller class that handles partial updates class ApiTicketController { private $ticketModel; private $commentModel; + private $auditLog; private $envVars; - - public function __construct($conn, $envVars = []) { + private $userId; + + public function __construct($conn, $envVars = [], $userId = null) { $this->ticketModel = new TicketModel($conn); $this->commentModel = new CommentModel($conn); + $this->auditLog = new AuditLogModel($conn); $this->envVars = $envVars; + $this->userId = $userId; } - + public function update($id, $data) { debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data)); @@ -108,16 +123,21 @@ try { } debug_log("Validation passed, calling ticketModel->updateTicket"); - - // Update ticket - $result = $this->ticketModel->updateTicket($updateData); - + + // Update ticket with user tracking + $result = $this->ticketModel->updateTicket($updateData, $this->userId); + debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false')); - + if ($result) { + // Log ticket update to audit log + if ($this->userId) { + $this->auditLog->logTicketUpdate($this->userId, $id, $data); + } + // Send Discord webhook notification $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data); - + return [ 'success' => true, 'status' => $updateData['status'], @@ -259,7 +279,7 @@ try { // Initialize controller debug_log("Initializing controller"); - $controller = new ApiTicketController($conn, $envVars); + $controller = new ApiTicketController($conn, $envVars, $userId); debug_log("Controller initialized"); // Update ticket diff --git a/controllers/TicketController.php b/controllers/TicketController.php index e7357a7..209901b 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -27,23 +27,36 @@ class TicketController { } public function view($id) { + // Get current user + $currentUser = $GLOBALS['currentUser'] ?? null; + $userId = $currentUser['user_id'] ?? null; + // Get ticket data $ticket = $this->ticketModel->getTicketById($id); - + if (!$ticket) { header("HTTP/1.0 404 Not Found"); echo "Ticket not found"; return; } - + + // Log ticket view to audit log + if (isset($GLOBALS['auditLog']) && $userId) { + $GLOBALS['auditLog']->logTicketView($userId, $id); + } + // Get comments for this ticket using CommentModel $comments = $this->commentModel->getCommentsByTicketId($id); - + // Load the view include dirname(__DIR__) . '/views/TicketView.php'; } public function create() { + // Get current user + $currentUser = $GLOBALS['currentUser'] ?? null; + $userId = $currentUser['user_id'] ?? null; + // Check if form was submitted if ($_SERVER['REQUEST_METHOD'] === 'POST') { $ticketData = [ @@ -53,21 +66,26 @@ class TicketController { 'category' => $_POST['category'] ?? 'General', 'type' => $_POST['type'] ?? 'Issue' ]; - + // Validate input if (empty($ticketData['title'])) { $error = "Title is required"; include dirname(__DIR__) . '/views/CreateTicketView.php'; return; } - - // Create ticket - $result = $this->ticketModel->createTicket($ticketData); - + + // Create ticket with user tracking + $result = $this->ticketModel->createTicket($ticketData, $userId); + if ($result['success']) { + // Log ticket creation to audit log + if (isset($GLOBALS['auditLog']) && $userId) { + $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); + } + // Send Discord webhook notification for new ticket $this->sendDiscordWebhook($result['ticket_id'], $ticketData); - + // Redirect to the new ticket header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']); exit; @@ -83,15 +101,19 @@ class TicketController { } public function update($id) { + // Get current user + $currentUser = $GLOBALS['currentUser'] ?? null; + $userId = $currentUser['user_id'] ?? null; + // Check if this is an AJAX request if ($_SERVER['REQUEST_METHOD'] === 'POST') { // For AJAX requests, get JSON data $input = file_get_contents('php://input'); $data = json_decode($input, true); - + // Add ticket_id to the data $data['ticket_id'] = $id; - + // Validate input data if (empty($data['title'])) { header('Content-Type: application/json'); @@ -101,10 +123,15 @@ class TicketController { ]); return; } - - // Update ticket - $result = $this->ticketModel->updateTicket($data); - + + // Update ticket with user tracking + $result = $this->ticketModel->updateTicket($data, $userId); + + // Log ticket update to audit log + if ($result && isset($GLOBALS['auditLog']) && $userId) { + $GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data); + } + // Return JSON response header('Content-Type: application/json'); if ($result) { diff --git a/create_ticket_api.php b/create_ticket_api.php index 16c026d..4684974 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -5,7 +5,6 @@ error_reporting(E_ALL); ini_set('display_errors', 1); file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND); - // Load environment variables with error check $envFile = __DIR__ . '/.env'; if (!file_exists($envFile)) { @@ -41,6 +40,22 @@ if ($conn->connect_error) { exit; } +// Authenticate via API key +require_once __DIR__ . '/middleware/ApiKeyAuth.php'; +require_once __DIR__ . '/models/AuditLogModel.php'; + +$apiKeyAuth = new ApiKeyAuth($conn); + +try { + $systemUser = $apiKeyAuth->authenticate(); +} catch (Exception $e) { + // Authentication failed - ApiKeyAuth already sent the response + exit; +} + +$userId = $systemUser['user_id']; +file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND); + // Create tickets table with hash column if not exists $createTableSQL = "CREATE TABLE IF NOT EXISTS tickets ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -122,9 +137,9 @@ if (!$data) { // Generate ticket ID (9-digit format with leading zeros) $ticket_id = sprintf('%09d', mt_rand(1, 999999999)); -// Prepare insert query -$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; +// Prepare insert query with created_by field +$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; $stmt = $conn->prepare($sql); // First, store all values in variables @@ -137,7 +152,7 @@ $type = $data['type'] ?? 'Issue'; // Then use the variables in bind_param $stmt->bind_param( - "ssssssss", + "ssssssssi", $ticket_id, $title, $description, @@ -145,10 +160,20 @@ $stmt->bind_param( $priority, $category, $type, - $ticketHash + $ticketHash, + $userId ); if ($stmt->execute()) { + // Log ticket creation to audit log + $auditLog = new AuditLogModel($conn); + $auditLog->logTicketCreate($userId, $ticket_id, [ + 'title' => $title, + 'priority' => $priority, + 'category' => $category, + 'type' => $type + ]); + echo json_encode([ 'success' => true, 'ticket_id' => $ticket_id, diff --git a/generate_api_key.php b/generate_api_key.php new file mode 100644 index 0000000..23cdd95 --- /dev/null +++ b/generate_api_key.php @@ -0,0 +1,101 @@ +connect_error) { + die("❌ Database connection failed: " . $conn->connect_error . "\n"); +} + +echo "✅ Connected to database\n\n"; + +// Initialize models +$userModel = new UserModel($conn); +$apiKeyModel = new ApiKeyModel($conn); + +// Get system user (should exist from migration) +echo "Checking for system user...\n"; +$systemUser = $userModel->getSystemUser(); + +if (!$systemUser) { + die("❌ Error: System user not found. Please run migrations first.\n"); +} + +echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n"; + +// Check if API key already exists +$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']); +if (!empty($existingKeys)) { + echo "⚠️ Warning: API keys already exist for system user:\n\n"; + foreach ($existingKeys as $key) { + echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n"; + echo " Created: " . $key['created_at'] . "\n"; + echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n"; + } + + echo "Do you want to generate a new API key? (yes/no): "; + $handle = fopen("php://stdin", "r"); + $response = trim(fgets($handle)); + fclose($handle); + + if (strtolower($response) !== 'yes') { + echo "\nAborted.\n"; + exit(0); + } + echo "\n"; +} + +// Generate API key +echo "Generating API key for hwmonDaemon...\n"; +$result = $apiKeyModel->createKey( + 'hwmonDaemon', + $systemUser['user_id'], + null // No expiration +); + +if ($result['success']) { + echo "\n"; + echo "==============================================\n"; + echo " ✅ API Key Generated Successfully!\n"; + echo "==============================================\n\n"; + echo "API Key: " . $result['api_key'] . "\n"; + echo "Key Prefix: " . $result['key_prefix'] . "\n"; + echo "Key ID: " . $result['key_id'] . "\n"; + echo "Expires: Never\n\n"; + echo "⚠️ IMPORTANT: Save this API key now!\n"; + echo " It cannot be retrieved later.\n\n"; + echo "==============================================\n"; + echo " Add to hwmonDaemon .env file:\n"; + echo "==============================================\n\n"; + echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n"; + echo "Then restart hwmonDaemon:\n"; + echo " sudo systemctl restart hwmonDaemon\n\n"; +} else { + echo "❌ Error generating API key: " . $result['error'] . "\n"; + exit(1); +} + +$conn->close(); + +echo "Done! Delete this script after use:\n"; +echo " rm " . __FILE__ . "\n\n"; +?> diff --git a/index.php b/index.php index 80454c8..dfa01ff 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,8 @@ connect_error) { die("Connection failed: " . $conn->connect_error); } + + // Authenticate user via Authelia forward auth + $authMiddleware = new AuthMiddleware($conn); + $currentUser = $authMiddleware->authenticate(); + + // Store current user in globals for controllers + $GLOBALS['currentUser'] = $currentUser; + + // Initialize audit log model + $GLOBALS['auditLog'] = new AuditLogModel($conn); } // Simple router diff --git a/middleware/ApiKeyAuth.php b/middleware/ApiKeyAuth.php new file mode 100644 index 0000000..eb47e5d --- /dev/null +++ b/middleware/ApiKeyAuth.php @@ -0,0 +1,141 @@ +conn = $conn; + $this->apiKeyModel = new ApiKeyModel($conn); + $this->userModel = new UserModel($conn); + } + + /** + * Authenticate using API key from Authorization header + * + * @return array User data for system user + * @throws Exception if authentication fails + */ + public function authenticate() { + // Get Authorization header + $authHeader = $this->getAuthorizationHeader(); + + if (empty($authHeader)) { + $this->sendUnauthorized('Missing Authorization header'); + exit; + } + + // Check if it's a Bearer token + if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + $this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer '); + exit; + } + + $apiKey = $matches[1]; + + // Validate API key + $keyData = $this->apiKeyModel->validateKey($apiKey); + + if (!$keyData) { + $this->sendUnauthorized('Invalid or expired API key'); + exit; + } + + // Get system user (or the user who created the key) + $user = $this->userModel->getSystemUser(); + + if (!$user) { + $this->sendUnauthorized('System user not found'); + exit; + } + + // Add API key info to user data for logging + $user['api_key_id'] = $keyData['api_key_id']; + $user['api_key_name'] = $keyData['key_name']; + + return $user; + } + + /** + * Get Authorization header from various sources + * + * @return string|null Authorization header value + */ + private function getAuthorizationHeader() { + // Try different header formats + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + return $_SERVER['HTTP_AUTHORIZATION']; + } + + if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + return $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } + + // Check for Authorization in getallheaders if available + if (function_exists('getallheaders')) { + $headers = getallheaders(); + if (isset($headers['Authorization'])) { + return $headers['Authorization']; + } + if (isset($headers['authorization'])) { + return $headers['authorization']; + } + } + + return null; + } + + /** + * Send 401 Unauthorized response + * + * @param string $message Error message + */ + private function sendUnauthorized($message) { + header('HTTP/1.1 401 Unauthorized'); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Unauthorized', + 'message' => $message + ]); + } + + /** + * Verify API key without throwing errors (for optional auth) + * + * @return array|null User data or null if not authenticated + */ + public function verifyOptional() { + $authHeader = $this->getAuthorizationHeader(); + + if (empty($authHeader)) { + return null; + } + + if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + return null; + } + + $apiKey = $matches[1]; + $keyData = $this->apiKeyModel->validateKey($apiKey); + + if (!$keyData) { + return null; + } + + $user = $this->userModel->getSystemUser(); + + if ($user) { + $user['api_key_id'] = $keyData['api_key_id']; + $user['api_key_name'] = $keyData['key_name']; + } + + return $user; + } +} diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php new file mode 100644 index 0000000..ce345d1 --- /dev/null +++ b/middleware/AuthMiddleware.php @@ -0,0 +1,256 @@ +conn = $conn; + $this->userModel = new UserModel($conn); + } + + /** + * Authenticate user from Authelia forward auth headers + * + * @return array User data array + * @throws Exception if authentication fails + */ + public function authenticate() { + // Start session if not already started + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + // Check if user is already authenticated in session + if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) { + // Verify session hasn't expired (5 hour timeout) + if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) { + // Session expired, clear it + session_unset(); + session_destroy(); + session_start(); + } else { + // Update last activity time + $_SESSION['last_activity'] = time(); + return $_SESSION['user']; + } + } + + // Read Authelia forward auth headers + $username = $this->getHeader('HTTP_REMOTE_USER'); + $displayName = $this->getHeader('HTTP_REMOTE_NAME'); + $email = $this->getHeader('HTTP_REMOTE_EMAIL'); + $groups = $this->getHeader('HTTP_REMOTE_GROUPS'); + + // Check if authentication headers are present + if (empty($username)) { + // No auth headers - user not authenticated + $this->redirectToAuth(); + exit; + } + + // Check if user has required group membership + if (!$this->checkGroupAccess($groups)) { + $this->showAccessDenied($username, $groups); + exit; + } + + // Sync user to database (create or update) + $user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups); + + if (!$user) { + throw new Exception("Failed to sync user from Authelia"); + } + + // Store user in session + $_SESSION['user'] = $user; + $_SESSION['last_activity'] = time(); + + return $user; + } + + /** + * Get header value from server variables + * + * @param string $header Header name + * @return string|null Header value or null if not set + */ + private function getHeader($header) { + if (isset($_SERVER[$header])) { + return $_SERVER[$header]; + } + return null; + } + + /** + * Check if user has required group membership + * + * @param string $groups Comma-separated group names + * @return bool True if user has access + */ + private function checkGroupAccess($groups) { + if (empty($groups)) { + return false; + } + + // Check for admin or employee group membership + $userGroups = array_map('trim', explode(',', strtolower($groups))); + $requiredGroups = ['admin', 'employee']; + + return !empty(array_intersect($userGroups, $requiredGroups)); + } + + /** + * Redirect to Authelia login + */ + private function redirectToAuth() { + // Redirect to the auth endpoint (Authelia will handle the redirect back) + header('HTTP/1.1 401 Unauthorized'); + echo ' + + + Authentication Required + + + +
+

Authentication Required

+

You need to be logged in to access Tinker Tickets.

+ Continue to Login +
+ +'; + exit; + } + + /** + * Show access denied page + * + * @param string $username Username + * @param string $groups User groups + */ + private function showAccessDenied($username, $groups) { + header('HTTP/1.1 403 Forbidden'); + echo ' + + + Access Denied + + + +
+

Access Denied

+

You do not have permission to access Tinker Tickets.

+

Required groups: admin or employee

+ +

Please contact your administrator if you believe this is an error.

+
+ +'; + exit; + } + + /** + * Get current authenticated user from session + * + * @return array|null User data or null if not authenticated + */ + public static function getCurrentUser() { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + return $_SESSION['user'] ?? null; + } + + /** + * Logout current user + */ + public static function logout() { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + session_unset(); + session_destroy(); + } +} diff --git a/migrations/001_create_users_table.sql b/migrations/001_create_users_table.sql new file mode 100644 index 0000000..dbbda7d --- /dev/null +++ b/migrations/001_create_users_table.sql @@ -0,0 +1,17 @@ +-- Create users table for SSO integration +CREATE TABLE IF NOT EXISTS users ( + user_id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + display_name VARCHAR(255), + email VARCHAR(255), + groups TEXT, + is_admin BOOLEAN DEFAULT FALSE, + last_login TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Insert system user for hwmonDaemon +INSERT INTO users (username, display_name, email, groups, is_admin, created_at) +VALUES ('system', 'System', 'system@lotusguild.org', '', FALSE, NOW()) +ON DUPLICATE KEY UPDATE username = username; diff --git a/migrations/002_create_api_keys_table.sql b/migrations/002_create_api_keys_table.sql new file mode 100644 index 0000000..935a4ea --- /dev/null +++ b/migrations/002_create_api_keys_table.sql @@ -0,0 +1,15 @@ +-- Create API keys table for external service authentication +CREATE TABLE IF NOT EXISTS api_keys ( + api_key_id INT AUTO_INCREMENT PRIMARY KEY, + key_name VARCHAR(100) NOT NULL, + key_hash VARCHAR(255) UNIQUE NOT NULL, + key_prefix VARCHAR(20) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_by INT, + last_used TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL, + INDEX idx_key_hash (key_hash), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/003_create_audit_log_table.sql b/migrations/003_create_audit_log_table.sql new file mode 100644 index 0000000..a2e00dc --- /dev/null +++ b/migrations/003_create_audit_log_table.sql @@ -0,0 +1,16 @@ +-- Create audit log table for tracking all user actions +CREATE TABLE IF NOT EXISTS audit_log ( + audit_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + action_type VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id VARCHAR(50), + details JSON, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL, + INDEX idx_user_id (user_id), + INDEX idx_created_at (created_at), + INDEX idx_entity (entity_type, entity_id), + INDEX idx_action_type (action_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/004_alter_tickets_table.sql b/migrations/004_alter_tickets_table.sql new file mode 100644 index 0000000..6ac77fc --- /dev/null +++ b/migrations/004_alter_tickets_table.sql @@ -0,0 +1,30 @@ +-- Add user tracking columns to tickets table +ALTER TABLE tickets + ADD COLUMN IF NOT EXISTS created_by INT, + ADD COLUMN IF NOT EXISTS updated_by INT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL; + +-- Add foreign key constraints if they don't exist +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE CONSTRAINT_NAME = 'fk_tickets_created_by' + AND TABLE_NAME = 'tickets' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_created_by FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL', + 'SELECT "Foreign key fk_tickets_created_by already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE CONSTRAINT_NAME = 'fk_tickets_updated_by' + AND TABLE_NAME = 'tickets' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_updated_by FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL', + 'SELECT "Foreign key fk_tickets_updated_by already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/005_alter_comments_table.sql b/migrations/005_alter_comments_table.sql new file mode 100644 index 0000000..12db599 --- /dev/null +++ b/migrations/005_alter_comments_table.sql @@ -0,0 +1,19 @@ +-- Add user_id column to ticket_comments table +ALTER TABLE ticket_comments + ADD COLUMN IF NOT EXISTS user_id INT; + +-- Add foreign key constraint if it doesn't exist +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE CONSTRAINT_NAME = 'fk_comments_user_id' + AND TABLE_NAME = 'ticket_comments' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE ticket_comments ADD CONSTRAINT fk_comments_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL', + 'SELECT "Foreign key fk_comments_user_id already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Update existing comments to reference jared user (first admin) +-- This will be done after jared user is created via web login diff --git a/migrations/006_add_indexes.sql b/migrations/006_add_indexes.sql new file mode 100644 index 0000000..3040361 --- /dev/null +++ b/migrations/006_add_indexes.sql @@ -0,0 +1,39 @@ +-- Add database indexes for performance optimization +-- Check and create index on tickets.status +SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_NAME = 'tickets' + AND INDEX_NAME = 'idx_status' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@index_exists = 0, + 'CREATE INDEX idx_status ON tickets(status)', + 'SELECT "Index idx_status already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Check and create index on tickets.priority +SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_NAME = 'tickets' + AND INDEX_NAME = 'idx_priority' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@index_exists = 0, + 'CREATE INDEX idx_priority ON tickets(priority)', + 'SELECT "Index idx_priority already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Check and create index on tickets.created_at +SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_NAME = 'tickets' + AND INDEX_NAME = 'idx_tickets_created_at' + AND TABLE_SCHEMA = DATABASE()); + +SET @sql = IF(@index_exists = 0, + 'CREATE INDEX idx_tickets_created_at ON tickets(created_at)', + 'SELECT "Index idx_tickets_created_at already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/rollback_all.sql b/migrations/rollback_all.sql new file mode 100644 index 0000000..4d1c021 --- /dev/null +++ b/migrations/rollback_all.sql @@ -0,0 +1,25 @@ +-- Rollback script to undo all SSO integration changes +-- WARNING: This will delete all user data, API keys, and audit logs + +-- Drop foreign keys first +ALTER TABLE ticket_comments DROP FOREIGN KEY IF EXISTS fk_comments_user_id; +ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_created_by; +ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_updated_by; +ALTER TABLE api_keys DROP FOREIGN KEY IF EXISTS api_keys_ibfk_1; +ALTER TABLE audit_log DROP FOREIGN KEY IF EXISTS audit_log_ibfk_1; + +-- Drop columns from existing tables +ALTER TABLE ticket_comments DROP COLUMN IF EXISTS user_id; +ALTER TABLE tickets DROP COLUMN IF EXISTS created_by; +ALTER TABLE tickets DROP COLUMN IF EXISTS updated_by; +ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at; + +-- Drop new tables +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS users; + +-- Drop indexes +DROP INDEX IF EXISTS idx_status ON tickets; +DROP INDEX IF EXISTS idx_priority ON tickets; +DROP INDEX IF EXISTS idx_tickets_created_at ON tickets; diff --git a/migrations/run_migrations.php b/migrations/run_migrations.php new file mode 100644 index 0000000..786683f --- /dev/null +++ b/migrations/run_migrations.php @@ -0,0 +1,107 @@ +connect_error) { + die("Database connection failed: " . $conn->connect_error . "\n"); +} + +echo "Connected to database: {$envVars['DB_NAME']}\n\n"; + +// Get all migration files +$migrationFiles = glob(__DIR__ . '/*.sql'); +sort($migrationFiles); + +// Filter out rollback script +$migrationFiles = array_filter($migrationFiles, function($file) { + return !strpos($file, 'rollback'); +}); + +if (empty($migrationFiles)) { + echo "No migration files found.\n"; + exit(0); +} + +echo "Found " . count($migrationFiles) . " migration(s):\n"; +foreach ($migrationFiles as $file) { + echo " - " . basename($file) . "\n"; +} +echo "\n"; + +// Execute each migration +$successCount = 0; +$errorCount = 0; + +foreach ($migrationFiles as $file) { + $filename = basename($file); + echo "Executing: $filename... "; + + $sql = file_get_contents($file); + + // Split SQL into individual statements + // This handles multi-statement migrations + if ($conn->multi_query($sql)) { + do { + // Store first result set + if ($result = $conn->store_result()) { + $result->free(); + } + // Check for errors + if ($conn->errno) { + echo "FAILED\n"; + echo " Error: " . $conn->error . "\n"; + $errorCount++; + break; + } + } while ($conn->more_results() && $conn->next_result()); + + // If we got through all results without error + if (!$conn->errno) { + echo "OK\n"; + $successCount++; + } + } else { + echo "FAILED\n"; + echo " Error: " . $conn->error . "\n"; + $errorCount++; + } +} + +echo "\n"; +echo "Migration Summary:\n"; +echo " Success: $successCount\n"; +echo " Errors: $errorCount\n"; + +if ($errorCount > 0) { + echo "\nSome migrations failed. Please review errors above.\n"; + exit(1); +} else { + echo "\nAll migrations completed successfully!\n"; + exit(0); +} + +$conn->close(); diff --git a/models/ApiKeyModel.php b/models/ApiKeyModel.php new file mode 100644 index 0000000..09f38b8 --- /dev/null +++ b/models/ApiKeyModel.php @@ -0,0 +1,229 @@ +conn = $conn; + } + + /** + * Generate a new API key + * + * @param string $keyName Descriptive name for the key + * @param int $createdBy User ID who created the key + * @param int|null $expiresInDays Number of days until expiration (null for no expiration) + * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error' + */ + public function createKey($keyName, $createdBy, $expiresInDays = null) { + // Generate random API key (32 bytes = 64 hex characters) + $apiKey = bin2hex(random_bytes(32)); + + // Create key prefix (first 8 characters) for identification + $keyPrefix = substr($apiKey, 0, 8); + + // Hash the API key for storage + $keyHash = hash('sha256', $apiKey); + + // Calculate expiration date if specified + $expiresAt = null; + if ($expiresInDays !== null) { + $expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days")); + } + + // Insert API key into database + $stmt = $this->conn->prepare( + "INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)" + ); + $stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt); + + if ($stmt->execute()) { + $keyId = $this->conn->insert_id; + $stmt->close(); + + return [ + 'success' => true, + 'api_key' => $apiKey, // Return plaintext key ONCE + 'key_prefix' => $keyPrefix, + 'key_id' => $keyId, + 'expires_at' => $expiresAt + ]; + } else { + $error = $this->conn->error; + $stmt->close(); + + return [ + 'success' => false, + 'error' => $error + ]; + } + } + + /** + * Validate an API key + * + * @param string $apiKey Plaintext API key to validate + * @return array|null API key record if valid, null if invalid + */ + public function validateKey($apiKey) { + if (empty($apiKey)) { + return null; + } + + // Hash the provided key + $keyHash = hash('sha256', $apiKey); + + // Query for matching key + $stmt = $this->conn->prepare( + "SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1" + ); + $stmt->bind_param("s", $keyHash); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows === 0) { + $stmt->close(); + return null; + } + + $keyData = $result->fetch_assoc(); + $stmt->close(); + + // Check expiration + if ($keyData['expires_at'] !== null) { + $expiresAt = strtotime($keyData['expires_at']); + if ($expiresAt < time()) { + return null; // Key has expired + } + } + + // Update last_used timestamp + $this->updateLastUsed($keyData['api_key_id']); + + return $keyData; + } + + /** + * Update last_used timestamp for an API key + * + * @param int $keyId API key ID + * @return bool Success status + */ + private function updateLastUsed($keyId) { + $stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?"); + $stmt->bind_param("i", $keyId); + $success = $stmt->execute(); + $stmt->close(); + return $success; + } + + /** + * Revoke an API key (set is_active to false) + * + * @param int $keyId API key ID + * @return bool Success status + */ + public function revokeKey($keyId) { + $stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?"); + $stmt->bind_param("i", $keyId); + $success = $stmt->execute(); + $stmt->close(); + return $success; + } + + /** + * Delete an API key permanently + * + * @param int $keyId API key ID + * @return bool Success status + */ + public function deleteKey($keyId) { + $stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?"); + $stmt->bind_param("i", $keyId); + $success = $stmt->execute(); + $stmt->close(); + return $success; + } + + /** + * Get all API keys (for admin panel) + * + * @return array Array of API key records (without hashes) + */ + public function getAllKeys() { + $stmt = $this->conn->prepare( + "SELECT ak.*, u.username, u.display_name + FROM api_keys ak + LEFT JOIN users u ON ak.created_by = u.user_id + ORDER BY ak.created_at DESC" + ); + $stmt->execute(); + $result = $stmt->get_result(); + + $keys = []; + while ($row = $result->fetch_assoc()) { + // Remove key_hash from response for security + unset($row['key_hash']); + $keys[] = $row; + } + + $stmt->close(); + return $keys; + } + + /** + * Get API key by ID + * + * @param int $keyId API key ID + * @return array|null API key record (without hash) or null if not found + */ + public function getKeyById($keyId) { + $stmt = $this->conn->prepare( + "SELECT ak.*, u.username, u.display_name + FROM api_keys ak + LEFT JOIN users u ON ak.created_by = u.user_id + WHERE ak.api_key_id = ?" + ); + $stmt->bind_param("i", $keyId); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows > 0) { + $key = $result->fetch_assoc(); + // Remove key_hash from response for security + unset($key['key_hash']); + $stmt->close(); + return $key; + } + + $stmt->close(); + return null; + } + + /** + * Get keys created by a specific user + * + * @param int $userId User ID + * @return array Array of API key records + */ + public function getKeysByUser($userId) { + $stmt = $this->conn->prepare( + "SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC" + ); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); + + $keys = []; + while ($row = $result->fetch_assoc()) { + // Remove key_hash from response for security + unset($row['key_hash']); + $keys[] = $row; + } + + $stmt->close(); + return $keys; + } +} diff --git a/models/AuditLogModel.php b/models/AuditLogModel.php new file mode 100644 index 0000000..957b8bf --- /dev/null +++ b/models/AuditLogModel.php @@ -0,0 +1,292 @@ +conn = $conn; + } + + /** + * Log an action to the audit trail + * + * @param int $userId User ID performing the action + * @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view') + * @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key') + * @param string|null $entityId ID of the entity affected + * @param array|null $details Additional details as associative array + * @param string|null $ipAddress IP address of the user + * @return bool Success status + */ + public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) { + // Convert details array to JSON + $detailsJson = null; + if ($details !== null) { + $detailsJson = json_encode($details); + } + + // Get IP address if not provided + if ($ipAddress === null) { + $ipAddress = $this->getClientIP(); + } + + $stmt = $this->conn->prepare( + "INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address) + VALUES (?, ?, ?, ?, ?, ?)" + ); + $stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress); + + $success = $stmt->execute(); + $stmt->close(); + + return $success; + } + + /** + * Get audit logs for a specific entity + * + * @param string $entityType Type of entity + * @param string $entityId ID of the entity + * @param int $limit Maximum number of logs to return + * @return array Array of audit log records + */ + public function getLogsByEntity($entityType, $entityId, $limit = 100) { + $stmt = $this->conn->prepare( + "SELECT al.*, u.username, u.display_name + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + WHERE al.entity_type = ? AND al.entity_id = ? + ORDER BY al.created_at DESC + LIMIT ?" + ); + $stmt->bind_param("ssi", $entityType, $entityId, $limit); + $stmt->execute(); + $result = $stmt->get_result(); + + $logs = []; + while ($row = $result->fetch_assoc()) { + // Decode JSON details + if ($row['details']) { + $row['details'] = json_decode($row['details'], true); + } + $logs[] = $row; + } + + $stmt->close(); + return $logs; + } + + /** + * Get audit logs for a specific user + * + * @param int $userId User ID + * @param int $limit Maximum number of logs to return + * @return array Array of audit log records + */ + public function getLogsByUser($userId, $limit = 100) { + $stmt = $this->conn->prepare( + "SELECT al.*, u.username, u.display_name + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + WHERE al.user_id = ? + ORDER BY al.created_at DESC + LIMIT ?" + ); + $stmt->bind_param("ii", $userId, $limit); + $stmt->execute(); + $result = $stmt->get_result(); + + $logs = []; + while ($row = $result->fetch_assoc()) { + // Decode JSON details + if ($row['details']) { + $row['details'] = json_decode($row['details'], true); + } + $logs[] = $row; + } + + $stmt->close(); + return $logs; + } + + /** + * Get recent audit logs (for admin panel) + * + * @param int $limit Maximum number of logs to return + * @param int $offset Offset for pagination + * @return array Array of audit log records + */ + public function getRecentLogs($limit = 50, $offset = 0) { + $stmt = $this->conn->prepare( + "SELECT al.*, u.username, u.display_name + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + ORDER BY al.created_at DESC + LIMIT ? OFFSET ?" + ); + $stmt->bind_param("ii", $limit, $offset); + $stmt->execute(); + $result = $stmt->get_result(); + + $logs = []; + while ($row = $result->fetch_assoc()) { + // Decode JSON details + if ($row['details']) { + $row['details'] = json_decode($row['details'], true); + } + $logs[] = $row; + } + + $stmt->close(); + return $logs; + } + + /** + * Get audit logs filtered by action type + * + * @param string $actionType Action type to filter by + * @param int $limit Maximum number of logs to return + * @return array Array of audit log records + */ + public function getLogsByAction($actionType, $limit = 100) { + $stmt = $this->conn->prepare( + "SELECT al.*, u.username, u.display_name + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.user_id + WHERE al.action_type = ? + ORDER BY al.created_at DESC + LIMIT ?" + ); + $stmt->bind_param("si", $actionType, $limit); + $stmt->execute(); + $result = $stmt->get_result(); + + $logs = []; + while ($row = $result->fetch_assoc()) { + // Decode JSON details + if ($row['details']) { + $row['details'] = json_decode($row['details'], true); + } + $logs[] = $row; + } + + $stmt->close(); + return $logs; + } + + /** + * Get total count of audit logs + * + * @return int Total count + */ + public function getTotalCount() { + $result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log"); + $row = $result->fetch_assoc(); + return (int)$row['count']; + } + + /** + * Delete old audit logs (for maintenance) + * + * @param int $daysToKeep Number of days of logs to keep + * @return int Number of deleted records + */ + public function deleteOldLogs($daysToKeep = 90) { + $stmt = $this->conn->prepare( + "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)" + ); + $stmt->bind_param("i", $daysToKeep); + $stmt->execute(); + $affectedRows = $stmt->affected_rows; + $stmt->close(); + + return $affectedRows; + } + + /** + * Get client IP address (handles proxies) + * + * @return string Client IP address + */ + private function getClientIP() { + $ipAddress = ''; + + // Check for proxy headers + if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { + // Cloudflare + $ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP']; + } elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { + // Nginx proxy + $ipAddress = $_SERVER['HTTP_X_REAL_IP']; + } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + // Standard proxy header + $ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]; + } elseif (!empty($_SERVER['REMOTE_ADDR'])) { + // Direct connection + $ipAddress = $_SERVER['REMOTE_ADDR']; + } + + return trim($ipAddress); + } + + /** + * Helper: Log ticket creation + * + * @param int $userId User ID + * @param string $ticketId Ticket ID + * @param array $ticketData Ticket data + * @return bool Success status + */ + public function logTicketCreate($userId, $ticketId, $ticketData) { + return $this->log( + $userId, + 'create', + 'ticket', + $ticketId, + ['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null] + ); + } + + /** + * Helper: Log ticket update + * + * @param int $userId User ID + * @param string $ticketId Ticket ID + * @param array $changes Array of changed fields + * @return bool Success status + */ + public function logTicketUpdate($userId, $ticketId, $changes) { + return $this->log($userId, 'update', 'ticket', $ticketId, $changes); + } + + /** + * Helper: Log comment creation + * + * @param int $userId User ID + * @param int $commentId Comment ID + * @param string $ticketId Associated ticket ID + * @return bool Success status + */ + public function logCommentCreate($userId, $commentId, $ticketId) { + return $this->log( + $userId, + 'create', + 'comment', + (string)$commentId, + ['ticket_id' => $ticketId] + ); + } + + /** + * Helper: Log ticket view + * + * @param int $userId User ID + * @param string $ticketId Ticket ID + * @return bool Success status + */ + public function logTicketView($userId, $ticketId) { + return $this->log($userId, 'view', 'ticket', $ticketId); + } +} diff --git a/models/CommentModel.php b/models/CommentModel.php index 5455210..38354de 100644 --- a/models/CommentModel.php +++ b/models/CommentModel.php @@ -7,44 +7,58 @@ class CommentModel { } public function getCommentsByTicketId($ticketId) { - $sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC"; + $sql = "SELECT tc.*, u.display_name, u.username + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.ticket_id = ? + ORDER BY tc.created_at DESC"; $stmt = $this->conn->prepare($sql); $stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar $stmt->execute(); $result = $stmt->get_result(); - + $comments = []; while ($row = $result->fetch_assoc()) { + // Use display_name from users table if available, fallback to user_name field + if (!empty($row['display_name'])) { + $row['display_name_formatted'] = $row['display_name']; + } else { + $row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User'; + } $comments[] = $row; } - + return $comments; } - public function addComment($ticketId, $commentData) { - $sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled) - VALUES (?, ?, ?, ?)"; - + public function addComment($ticketId, $commentData, $userId = null) { + $sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) + VALUES (?, ?, ?, ?, ?)"; + $stmt = $this->conn->prepare($sql); - - // Set default username + + // Set default username (kept for backward compatibility) $username = $commentData['user_name'] ?? 'User'; $markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0; - + // Preserve line breaks in the comment text $commentText = $commentData['comment_text']; - + $stmt->bind_param( - "sssi", + "sissi", $ticketId, + $userId, $username, $commentText, $markdownEnabled ); - + if ($stmt->execute()) { + $commentId = $this->conn->insert_id; + return [ 'success' => true, + 'comment_id' => $commentId, 'user_name' => $username, 'created_at' => date('M d, Y H:i'), 'markdown_enabled' => $markdownEnabled, diff --git a/models/TicketModel.php b/models/TicketModel.php index cd4e9e8..9a52182 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -134,7 +134,7 @@ class TicketModel { ]; } - public function updateTicket($ticketData) { + public function updateTicket($ticketData, $updatedBy = null) { // Debug function $debug = function($message, $data = null) { $log_message = date('Y-m-d H:i:s') . " - [Model] " . $message; @@ -146,46 +146,48 @@ class TicketModel { }; $debug("updateTicket called with data", $ticketData); - - $sql = "UPDATE tickets SET - title = ?, - priority = ?, - status = ?, + + $sql = "UPDATE tickets SET + title = ?, + priority = ?, + status = ?, description = ?, category = ?, type = ?, - updated_at = NOW() + updated_by = ?, + updated_at = NOW() WHERE ticket_id = ?"; - + $debug("SQL query", $sql); - + try { $stmt = $this->conn->prepare($sql); if (!$stmt) { $debug("Prepare statement failed", $this->conn->error); return false; } - + $debug("Binding parameters"); $stmt->bind_param( - "sissssi", + "siisssii", $ticketData['title'], $ticketData['priority'], $ticketData['status'], $ticketData['description'], $ticketData['category'], $ticketData['type'], + $updatedBy, $ticketData['ticket_id'] ); - + $debug("Executing statement"); $result = $stmt->execute(); - + if (!$result) { $debug("Execute failed", $stmt->error); return false; } - + $debug("Update successful"); return true; } catch (Exception $e) { @@ -195,32 +197,33 @@ class TicketModel { } } - public function createTicket($ticketData) { + public function createTicket($ticketData, $createdBy = null) { // Generate ticket ID (9-digit format with leading zeros) $ticket_id = sprintf('%09d', mt_rand(1, 999999999)); - - $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type) - VALUES (?, ?, ?, ?, ?, ?, ?)"; - + + $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + $stmt = $this->conn->prepare($sql); - + // Set default values if not provided $status = $ticketData['status'] ?? 'Open'; $priority = $ticketData['priority'] ?? '4'; $category = $ticketData['category'] ?? 'General'; $type = $ticketData['type'] ?? 'Issue'; - + $stmt->bind_param( - "sssssss", + "sssssssi", $ticket_id, $ticketData['title'], $ticketData['description'], $status, $priority, $category, - $type + $type, + $createdBy ); - + if ($stmt->execute()) { return [ 'success' => true, diff --git a/models/UserModel.php b/models/UserModel.php new file mode 100644 index 0000000..3602cbe --- /dev/null +++ b/models/UserModel.php @@ -0,0 +1,227 @@ +conn = $conn; + } + + /** + * Sync user from Authelia headers (create or update) + * + * @param string $username Username from Remote-User header + * @param string $displayName Display name from Remote-Name header + * @param string $email Email from Remote-Email header + * @param string $groups Comma-separated groups from Remote-Groups header + * @return array User data array + */ + public function syncUserFromAuthelia($username, $displayName = '', $email = '', $groups = '') { + // Check cache first + $cacheKey = "user_$username"; + if (isset(self::$userCache[$cacheKey])) { + return self::$userCache[$cacheKey]; + } + + // Determine if user is admin based on groups + $isAdmin = $this->checkAdminStatus($groups); + + // Try to find existing user + $stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?"); + $stmt->bind_param("s", $username); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows > 0) { + // Update existing user + $user = $result->fetch_assoc(); + + $updateStmt = $this->conn->prepare( + "UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?" + ); + $updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username); + $updateStmt->execute(); + $updateStmt->close(); + + // Refresh user data + $user['display_name'] = $displayName; + $user['email'] = $email; + $user['groups'] = $groups; + $user['is_admin'] = $isAdmin; + } else { + // Create new user + $insertStmt = $this->conn->prepare( + "INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())" + ); + $insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin); + $insertStmt->execute(); + + $userId = $this->conn->insert_id; + $insertStmt->close(); + + // Get the newly created user + $stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?"); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); + $user = $result->fetch_assoc(); + } + + $stmt->close(); + + // Cache user + self::$userCache[$cacheKey] = $user; + + return $user; + } + + /** + * Get system user (for hwmonDaemon) + * + * @return array|null System user data or null if not found + */ + public function getSystemUser() { + // Check cache first + if (isset(self::$userCache['system'])) { + return self::$userCache['system']; + } + + $stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'"); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows > 0) { + $user = $result->fetch_assoc(); + self::$userCache['system'] = $user; + $stmt->close(); + return $user; + } + + $stmt->close(); + return null; + } + + /** + * Get user by ID + * + * @param int $userId User ID + * @return array|null User data or null if not found + */ + public function getUserById($userId) { + // Check cache first + $cacheKey = "user_id_$userId"; + if (isset(self::$userCache[$cacheKey])) { + return self::$userCache[$cacheKey]; + } + + $stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?"); + $stmt->bind_param("i", $userId); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows > 0) { + $user = $result->fetch_assoc(); + self::$userCache[$cacheKey] = $user; + $stmt->close(); + return $user; + } + + $stmt->close(); + return null; + } + + /** + * Get user by username + * + * @param string $username Username + * @return array|null User data or null if not found + */ + public function getUserByUsername($username) { + // Check cache first + $cacheKey = "user_$username"; + if (isset(self::$userCache[$cacheKey])) { + return self::$userCache[$cacheKey]; + } + + $stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?"); + $stmt->bind_param("s", $username); + $stmt->execute(); + $result = $stmt->get_result(); + + if ($result->num_rows > 0) { + $user = $result->fetch_assoc(); + self::$userCache[$cacheKey] = $user; + $stmt->close(); + return $user; + } + + $stmt->close(); + return null; + } + + /** + * Check if user has admin privileges based on groups + * + * @param string $groups Comma-separated group names + * @return bool True if user is in admin group + */ + private function checkAdminStatus($groups) { + if (empty($groups)) { + return false; + } + + // Split groups by comma and check for 'admin' group + $groupArray = array_map('trim', explode(',', strtolower($groups))); + return in_array('admin', $groupArray); + } + + /** + * Check if user is admin + * + * @param array $user User data array + * @return bool True if user is admin + */ + public function isAdmin($user) { + return isset($user['is_admin']) && $user['is_admin'] == 1; + } + + /** + * Check if user has required group membership + * + * @param array $user User data array + * @param array $requiredGroups Array of required group names + * @return bool True if user is in at least one required group + */ + public function hasGroupAccess($user, $requiredGroups = ['admin', 'employee']) { + if (empty($user['groups'])) { + return false; + } + + $userGroups = array_map('trim', explode(',', strtolower($user['groups']))); + $requiredGroups = array_map('strtolower', $requiredGroups); + + return !empty(array_intersect($userGroups, $requiredGroups)); + } + + /** + * Get all users (for admin panel) + * + * @return array Array of user records + */ + public function getAllUsers() { + $stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC"); + $stmt->execute(); + $result = $stmt->get_result(); + + $users = []; + while ($row = $result->fetch_assoc()) { + $users[] = $row; + } + + $stmt->close(); + return $users; + } +} diff --git a/views/DashboardView.php b/views/DashboardView.php index 406bf9b..4ff99a7 100644 --- a/views/DashboardView.php +++ b/views/DashboardView.php @@ -13,8 +13,21 @@ +
+
+ 🎫 Tinker Tickets +
+
+ + 👤 + + Admin + + +
+
-

Tinker Tickets

+

Ticket Dashboard

diff --git a/views/TicketView.php b/views/TicketView.php index b8ef1d1..0af7ef8 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -27,6 +27,19 @@ +
+ +
+ + 👤 + + Admin + + +
+
">

" data-field="title" disabled>

@@ -84,9 +97,11 @@
"; echo "
"; - echo "" . htmlspecialchars($comment['user_name']) . ""; + echo "" . htmlspecialchars($displayName) . ""; echo "" . date('M d, Y H:i', strtotime($comment['created_at'])) . ""; echo "
"; echo "
";