SSO Update :)

This commit is contained in:
2026-01-01 15:40:32 -05:00
parent 661643e45b
commit 7b25ec1dd1
25 changed files with 2880 additions and 87 deletions

688
Claude.md Normal file
View File

@@ -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=<mariadb-ip>
DB_USER=<username>
DB_PASS=<password>
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 `<html>`
- 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@<mariadb-ip>
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.

445
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -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
<?php
require_once 'config/config.php';
require_once 'models/ApiKeyModel.php';
require_once 'models/UserModel.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->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=<paste_the_api_key_here>
```
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 <your_api_key>" \
-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 <user> -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
<?php
// Create test.php in web root
print_r($_SERVER);
?>
```
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`

View File

@@ -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);

View File

@@ -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

View File

@@ -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) {

View File

@@ -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,

101
generate_api_key.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
/**
* API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key
*
* Usage: php generate_api_key.php
*/
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";
?>

View File

@@ -1,6 +1,8 @@
<?php
// Main entry point for the application
require_once 'config/config.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
// Parse the URL - no need to remove base path since we're at document root
$request = $_SERVER['REQUEST_URI'];
@@ -20,6 +22,16 @@ if (!str_starts_with($requestPath, '/api/')) {
if ($conn->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

141
middleware/ApiKeyAuth.php Normal file
View File

@@ -0,0 +1,141 @@
<?php
/**
* ApiKeyAuth - Handles API key authentication for external services
*/
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
class ApiKeyAuth {
private $apiKeyModel;
private $userModel;
private $conn;
public function __construct($conn) {
$this->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 <api_key>');
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;
}
}

View File

@@ -0,0 +1,256 @@
<?php
/**
* AuthMiddleware - Handles authentication via Authelia forward auth headers
*/
require_once dirname(__DIR__) . '/models/UserModel.php';
class AuthMiddleware {
private $userModel;
private $conn;
public function __construct($conn) {
$this->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 '<!DOCTYPE html>
<html>
<head>
<title>Authentication Required</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.auth-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
.auth-container h1 {
color: #333;
margin-bottom: 1rem;
}
.auth-container p {
color: #666;
margin-bottom: 1.5rem;
}
.auth-container a {
display: inline-block;
background: #4285f4;
color: white;
padding: 0.75rem 2rem;
border-radius: 4px;
text-decoration: none;
transition: background 0.2s;
}
.auth-container a:hover {
background: #357ae8;
}
</style>
</head>
<body>
<div class="auth-container">
<h1>Authentication Required</h1>
<p>You need to be logged in to access Tinker Tickets.</p>
<a href="/">Continue to Login</a>
</div>
</body>
</html>';
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 '<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.denied-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
}
.denied-container h1 {
color: #d32f2f;
margin-bottom: 1rem;
}
.denied-container p {
color: #666;
margin-bottom: 0.5rem;
}
.denied-container .user-info {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="denied-container">
<h1>Access Denied</h1>
<p>You do not have permission to access Tinker Tickets.</p>
<p>Required groups: <strong>admin</strong> or <strong>employee</strong></p>
<div class="user-info">
<div>Username: ' . htmlspecialchars($username) . '</div>
<div>Groups: ' . htmlspecialchars($groups ?: 'none') . '</div>
</div>
<p>Please contact your administrator if you believe this is an error.</p>
</div>
</body>
</html>';
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();
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,107 @@
<?php
/**
* Database Migration Runner
* Executes all migration files in order
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
// Load environment variables
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
die("Error: .env file not found at $envFile\n");
}
$envVars = parse_ini_file($envFile);
if (!$envVars) {
die("Error: Could not parse .env file\n");
}
// Connect to database
$conn = new mysqli(
$envVars['DB_HOST'],
$envVars['DB_USER'],
$envVars['DB_PASS'],
$envVars['DB_NAME']
);
if ($conn->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();

229
models/ApiKeyModel.php Normal file
View File

@@ -0,0 +1,229 @@
<?php
/**
* ApiKeyModel - Handles API key generation and validation
*/
class ApiKeyModel {
private $conn;
public function __construct($conn) {
$this->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;
}
}

292
models/AuditLogModel.php Normal file
View File

@@ -0,0 +1,292 @@
<?php
/**
* AuditLogModel - Handles audit trail logging for all user actions
*/
class AuditLogModel {
private $conn;
public function __construct($conn) {
$this->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);
}
}

View File

@@ -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,

View File

@@ -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,

227
models/UserModel.php Normal file
View File

@@ -0,0 +1,227 @@
<?php
/**
* UserModel - Handles user authentication and management
*/
class UserModel {
private $conn;
private static $userCache = [];
public function __construct($conn) {
$this->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;
}
}

View File

@@ -13,8 +13,21 @@
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
</head>
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
<div class="user-header" style="background: #2c3e50; padding: 0.75rem 2rem; color: white; display: flex; justify-content: space-between; align-items: center;">
<div>
<span style="font-weight: bold; font-size: 1.1rem;">🎫 Tinker Tickets</span>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span>👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span style="background: #e74c3c; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem;">Admin</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="dashboard-header">
<h1>Tinker Tickets</h1>
<h1>Ticket Dashboard</h1>
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">New Ticket</button> </div>
<div class="search-container">
<form method="GET" action="" class="search-form">

View File

@@ -27,6 +27,19 @@
</script>
</head>
<body>
<div class="user-header" style="background: #2c3e50; padding: 0.75rem 2rem; color: white; display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="/" style="color: white; text-decoration: none; font-weight: bold;">← Dashboard</a>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span>👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span style="background: #e74c3c; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem;">Admin</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<div class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
<div class="ticket-header">
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
@@ -84,9 +97,11 @@
<div class="comments-list">
<?php
foreach ($comments as $comment) {
// Use display_name_formatted which falls back appropriately
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
echo "<div class='comment'>";
echo "<div class='comment-header'>";
echo "<span class='comment-user'>" . htmlspecialchars($comment['user_name']) . "</span>";
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
echo "</div>";
echo "<div class='comment-text'>";