SSO Update :)
This commit is contained in:
688
Claude.md
Normal file
688
Claude.md
Normal 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
445
DEPLOYMENT_GUIDE.md
Normal 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`
|
||||||
@@ -10,18 +10,29 @@ try {
|
|||||||
// Include required files with proper error handling
|
// Include required files with proper error handling
|
||||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
if (!file_exists($configPath)) {
|
if (!file_exists($configPath)) {
|
||||||
throw new Exception("Config file not found: $configPath");
|
throw new Exception("Config file not found: $configPath");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file_exists($commentModelPath)) {
|
if (!file_exists($commentModelPath)) {
|
||||||
throw new Exception("CommentModel file not found: $commentModelPath");
|
throw new Exception("CommentModel file not found: $commentModelPath");
|
||||||
}
|
}
|
||||||
|
|
||||||
require_once $configPath;
|
require_once $configPath;
|
||||||
require_once $commentModelPath;
|
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
|
// Create database connection
|
||||||
$conn = new mysqli(
|
$conn = new mysqli(
|
||||||
$GLOBALS['config']['DB_HOST'],
|
$GLOBALS['config']['DB_HOST'],
|
||||||
@@ -29,29 +40,35 @@ try {
|
|||||||
$GLOBALS['config']['DB_PASS'],
|
$GLOBALS['config']['DB_PASS'],
|
||||||
$GLOBALS['config']['DB_NAME']
|
$GLOBALS['config']['DB_NAME']
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
if ($conn->connect_error) {
|
||||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get POST data
|
// Get POST data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
throw new Exception("Invalid JSON data received");
|
throw new Exception("Invalid JSON data received");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = $data['ticket_id'];
|
||||||
|
|
||||||
// Initialize CommentModel directly
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
// Add comment
|
|
||||||
$result = $commentModel->addComment($ticketId, $data);
|
// 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
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|||||||
@@ -37,24 +37,39 @@ try {
|
|||||||
// Load models directly with absolute paths
|
// Load models directly with absolute paths
|
||||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||||
require_once $ticketModelPath;
|
require_once $ticketModelPath;
|
||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
|
require_once $auditLogModelPath;
|
||||||
debug_log("Models loaded successfully");
|
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
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController {
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
private $auditLog;
|
||||||
private $envVars;
|
private $envVars;
|
||||||
|
private $userId;
|
||||||
public function __construct($conn, $envVars = []) {
|
|
||||||
|
public function __construct($conn, $envVars = [], $userId = null) {
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
$this->envVars = $envVars;
|
$this->envVars = $envVars;
|
||||||
|
$this->userId = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($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");
|
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket with user tracking
|
||||||
$result = $this->ticketModel->updateTicket($updateData);
|
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||||
|
|
||||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
|
// Log ticket update to audit log
|
||||||
|
if ($this->userId) {
|
||||||
|
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||||
|
}
|
||||||
|
|
||||||
// Send Discord webhook notification
|
// Send Discord webhook notification
|
||||||
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $updateData['status'],
|
'status' => $updateData['status'],
|
||||||
@@ -259,7 +279,7 @@ try {
|
|||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
debug_log("Initializing controller");
|
debug_log("Initializing controller");
|
||||||
$controller = new ApiTicketController($conn, $envVars);
|
$controller = new ApiTicketController($conn, $envVars, $userId);
|
||||||
debug_log("Controller initialized");
|
debug_log("Controller initialized");
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
|
|||||||
@@ -27,23 +27,36 @@ class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function view($id) {
|
public function view($id) {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Get ticket data
|
// Get ticket data
|
||||||
$ticket = $this->ticketModel->getTicketById($id);
|
$ticket = $this->ticketModel->getTicketById($id);
|
||||||
|
|
||||||
if (!$ticket) {
|
if (!$ticket) {
|
||||||
header("HTTP/1.0 404 Not Found");
|
header("HTTP/1.0 404 Not Found");
|
||||||
echo "Ticket not found";
|
echo "Ticket not found";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log ticket view to audit log
|
||||||
|
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||||
|
$GLOBALS['auditLog']->logTicketView($userId, $id);
|
||||||
|
}
|
||||||
|
|
||||||
// Get comments for this ticket using CommentModel
|
// Get comments for this ticket using CommentModel
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||||
|
|
||||||
// Load the view
|
// Load the view
|
||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create() {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Check if form was submitted
|
// Check if form was submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketData = [
|
$ticketData = [
|
||||||
@@ -53,21 +66,26 @@ class TicketController {
|
|||||||
'category' => $_POST['category'] ?? 'General',
|
'category' => $_POST['category'] ?? 'General',
|
||||||
'type' => $_POST['type'] ?? 'Issue'
|
'type' => $_POST['type'] ?? 'Issue'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (empty($ticketData['title'])) {
|
if (empty($ticketData['title'])) {
|
||||||
$error = "Title is required";
|
$error = "Title is required";
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket
|
// Create ticket with user tracking
|
||||||
$result = $this->ticketModel->createTicket($ticketData);
|
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||||
|
|
||||||
if ($result['success']) {
|
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
|
// Send Discord webhook notification for new ticket
|
||||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||||
|
|
||||||
// Redirect to the new ticket
|
// Redirect to the new ticket
|
||||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||||
exit;
|
exit;
|
||||||
@@ -83,15 +101,19 @@ class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function update($id) {
|
public function update($id) {
|
||||||
|
// Get current user
|
||||||
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Check if this is an AJAX request
|
// Check if this is an AJAX request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// For AJAX requests, get JSON data
|
// For AJAX requests, get JSON data
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
// Add ticket_id to the data
|
// Add ticket_id to the data
|
||||||
$data['ticket_id'] = $id;
|
$data['ticket_id'] = $id;
|
||||||
|
|
||||||
// Validate input data
|
// Validate input data
|
||||||
if (empty($data['title'])) {
|
if (empty($data['title'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -101,10 +123,15 @@ class TicketController {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket with user tracking
|
||||||
$result = $this->ticketModel->updateTicket($data);
|
$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
|
// Return JSON response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
if ($result) {
|
if ($result) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ error_reporting(E_ALL);
|
|||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 1);
|
||||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||||
|
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
@@ -41,6 +40,22 @@ if ($conn->connect_error) {
|
|||||||
exit;
|
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
|
// Create tickets table with hash column if not exists
|
||||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -122,9 +137,9 @@ if (!$data) {
|
|||||||
// Generate ticket ID (9-digit format with leading zeros)
|
// Generate ticket ID (9-digit format with leading zeros)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||||
|
|
||||||
// Prepare insert query
|
// Prepare insert query with created_by field
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
// First, store all values in variables
|
// First, store all values in variables
|
||||||
@@ -137,7 +152,7 @@ $type = $data['type'] ?? 'Issue';
|
|||||||
|
|
||||||
// Then use the variables in bind_param
|
// Then use the variables in bind_param
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"ssssssss",
|
"ssssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$title,
|
$title,
|
||||||
$description,
|
$description,
|
||||||
@@ -145,10 +160,20 @@ $stmt->bind_param(
|
|||||||
$priority,
|
$priority,
|
||||||
$category,
|
$category,
|
||||||
$type,
|
$type,
|
||||||
$ticketHash
|
$ticketHash,
|
||||||
|
$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
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([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $ticket_id,
|
'ticket_id' => $ticket_id,
|
||||||
|
|||||||
101
generate_api_key.php
Normal file
101
generate_api_key.php
Normal 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";
|
||||||
|
?>
|
||||||
12
index.php
12
index.php
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
// Main entry point for the application
|
// Main entry point for the application
|
||||||
require_once 'config/config.php';
|
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
|
// Parse the URL - no need to remove base path since we're at document root
|
||||||
$request = $_SERVER['REQUEST_URI'];
|
$request = $_SERVER['REQUEST_URI'];
|
||||||
@@ -20,6 +22,16 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
if ($conn->connect_error) {
|
if ($conn->connect_error) {
|
||||||
die("Connection failed: " . $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
|
// Simple router
|
||||||
|
|||||||
141
middleware/ApiKeyAuth.php
Normal file
141
middleware/ApiKeyAuth.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
middleware/AuthMiddleware.php
Normal file
256
middleware/AuthMiddleware.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
migrations/001_create_users_table.sql
Normal file
17
migrations/001_create_users_table.sql
Normal 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;
|
||||||
15
migrations/002_create_api_keys_table.sql
Normal file
15
migrations/002_create_api_keys_table.sql
Normal 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;
|
||||||
16
migrations/003_create_audit_log_table.sql
Normal file
16
migrations/003_create_audit_log_table.sql
Normal 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;
|
||||||
30
migrations/004_alter_tickets_table.sql
Normal file
30
migrations/004_alter_tickets_table.sql
Normal 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;
|
||||||
19
migrations/005_alter_comments_table.sql
Normal file
19
migrations/005_alter_comments_table.sql
Normal 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
|
||||||
39
migrations/006_add_indexes.sql
Normal file
39
migrations/006_add_indexes.sql
Normal 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;
|
||||||
25
migrations/rollback_all.sql
Normal file
25
migrations/rollback_all.sql
Normal 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;
|
||||||
107
migrations/run_migrations.php
Normal file
107
migrations/run_migrations.php
Normal 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
229
models/ApiKeyModel.php
Normal 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
292
models/AuditLogModel.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,44 +7,58 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
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 = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
$comments = [];
|
$comments = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
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;
|
$comments[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $comments;
|
return $comments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId, $commentData) {
|
public function addComment($ticketId, $commentData, $userId = null) {
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Set default username
|
// Set default username (kept for backward compatibility)
|
||||||
$username = $commentData['user_name'] ?? 'User';
|
$username = $commentData['user_name'] ?? 'User';
|
||||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
// Preserve line breaks in the comment text
|
// Preserve line breaks in the comment text
|
||||||
$commentText = $commentData['comment_text'];
|
$commentText = $commentData['comment_text'];
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssi",
|
"sissi",
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$userId,
|
||||||
$username,
|
$username,
|
||||||
$commentText,
|
$commentText,
|
||||||
$markdownEnabled
|
$markdownEnabled
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
|
$commentId = $this->conn->insert_id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
'comment_id' => $commentId,
|
||||||
'user_name' => $username,
|
'user_name' => $username,
|
||||||
'created_at' => date('M d, Y H:i'),
|
'created_at' => date('M d, Y H:i'),
|
||||||
'markdown_enabled' => $markdownEnabled,
|
'markdown_enabled' => $markdownEnabled,
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class TicketModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateTicket($ticketData) {
|
public function updateTicket($ticketData, $updatedBy = null) {
|
||||||
// Debug function
|
// Debug function
|
||||||
$debug = function($message, $data = null) {
|
$debug = function($message, $data = null) {
|
||||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||||
@@ -146,46 +146,48 @@ class TicketModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$debug("updateTicket called with data", $ticketData);
|
$debug("updateTicket called with data", $ticketData);
|
||||||
|
|
||||||
$sql = "UPDATE tickets SET
|
$sql = "UPDATE tickets SET
|
||||||
title = ?,
|
title = ?,
|
||||||
priority = ?,
|
priority = ?,
|
||||||
status = ?,
|
status = ?,
|
||||||
description = ?,
|
description = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
type = ?,
|
type = ?,
|
||||||
updated_at = NOW()
|
updated_by = ?,
|
||||||
|
updated_at = NOW()
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
$debug("SQL query", $sql);
|
$debug("SQL query", $sql);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
$debug("Prepare statement failed", $this->conn->error);
|
$debug("Prepare statement failed", $this->conn->error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$debug("Binding parameters");
|
$debug("Binding parameters");
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssi",
|
"siisssii",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
$ticketData['description'],
|
$ticketData['description'],
|
||||||
$ticketData['category'],
|
$ticketData['category'],
|
||||||
$ticketData['type'],
|
$ticketData['type'],
|
||||||
|
$updatedBy,
|
||||||
$ticketData['ticket_id']
|
$ticketData['ticket_id']
|
||||||
);
|
);
|
||||||
|
|
||||||
$debug("Executing statement");
|
$debug("Executing statement");
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
$debug("Execute failed", $stmt->error);
|
$debug("Execute failed", $stmt->error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$debug("Update successful");
|
$debug("Update successful");
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} 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)
|
// Generate ticket ID (9-digit format with leading zeros)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||||
|
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Set default values if not provided
|
// Set default values if not provided
|
||||||
$status = $ticketData['status'] ?? 'Open';
|
$status = $ticketData['status'] ?? 'Open';
|
||||||
$priority = $ticketData['priority'] ?? '4';
|
$priority = $ticketData['priority'] ?? '4';
|
||||||
$category = $ticketData['category'] ?? 'General';
|
$category = $ticketData['category'] ?? 'General';
|
||||||
$type = $ticketData['type'] ?? 'Issue';
|
$type = $ticketData['type'] ?? 'Issue';
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssssss",
|
"sssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['description'],
|
$ticketData['description'],
|
||||||
$status,
|
$status,
|
||||||
$priority,
|
$priority,
|
||||||
$category,
|
$category,
|
||||||
$type
|
$type,
|
||||||
|
$createdBy
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
227
models/UserModel.php
Normal file
227
models/UserModel.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,21 @@
|
|||||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
<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">
|
<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>
|
<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">
|
<div class="search-container">
|
||||||
<form method="GET" action="" class="search-form">
|
<form method="GET" action="" class="search-form">
|
||||||
|
|||||||
@@ -27,6 +27,19 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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-container" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||||
<div class="ticket-header">
|
<div class="ticket-header">
|
||||||
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
|
<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">
|
<div class="comments-list">
|
||||||
<?php
|
<?php
|
||||||
foreach ($comments as $comment) {
|
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'>";
|
||||||
echo "<div class='comment-header'>";
|
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 "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
echo "<div class='comment-text'>";
|
echo "<div class='comment-text'>";
|
||||||
|
|||||||
Reference in New Issue
Block a user