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
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
if (!file_exists($configPath)) {
|
||||
throw new Exception("Config file not found: $configPath");
|
||||
}
|
||||
|
||||
|
||||
if (!file_exists($commentModelPath)) {
|
||||
throw new Exception("CommentModel file not found: $commentModelPath");
|
||||
}
|
||||
|
||||
|
||||
require_once $configPath;
|
||||
require_once $commentModelPath;
|
||||
|
||||
require_once $auditLogModelPath;
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
@@ -29,29 +40,35 @@ try {
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
|
||||
// Get POST data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
|
||||
if (!$data) {
|
||||
throw new Exception("Invalid JSON data received");
|
||||
}
|
||||
|
||||
|
||||
$ticketId = $data['ticket_id'];
|
||||
|
||||
// Initialize CommentModel directly
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
|
||||
// Add comment
|
||||
$result = $commentModel->addComment($ticketId, $data);
|
||||
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
@@ -37,24 +37,39 @@ try {
|
||||
// Load models directly with absolute paths
|
||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||
require_once $ticketModelPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
debug_log("Models loaded successfully");
|
||||
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
debug_log("User authenticated: " . $currentUser['username']);
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLog;
|
||||
private $envVars;
|
||||
|
||||
public function __construct($conn, $envVars = []) {
|
||||
private $userId;
|
||||
|
||||
public function __construct($conn, $envVars = [], $userId = null) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->envVars = $envVars;
|
||||
$this->userId = $userId;
|
||||
}
|
||||
|
||||
|
||||
public function update($id, $data) {
|
||||
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
|
||||
|
||||
@@ -108,16 +123,21 @@ try {
|
||||
}
|
||||
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($updateData);
|
||||
|
||||
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||
|
||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||
|
||||
|
||||
if ($result) {
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||
}
|
||||
|
||||
// Send Discord webhook notification
|
||||
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
@@ -259,7 +279,7 @@ try {
|
||||
|
||||
// Initialize controller
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars);
|
||||
$controller = new ApiTicketController($conn, $envVars, $userId);
|
||||
debug_log("Controller initialized");
|
||||
|
||||
// Update ticket
|
||||
|
||||
@@ -27,23 +27,36 @@ class TicketController {
|
||||
}
|
||||
|
||||
public function view($id) {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Get ticket data
|
||||
$ticket = $this->ticketModel->getTicketById($id);
|
||||
|
||||
|
||||
if (!$ticket) {
|
||||
header("HTTP/1.0 404 Not Found");
|
||||
echo "Ticket not found";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Log ticket view to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketView($userId, $id);
|
||||
}
|
||||
|
||||
// Get comments for this ticket using CommentModel
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||
|
||||
|
||||
// Load the view
|
||||
include dirname(__DIR__) . '/views/TicketView.php';
|
||||
}
|
||||
|
||||
public function create() {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Check if form was submitted
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ticketData = [
|
||||
@@ -53,21 +66,26 @@ class TicketController {
|
||||
'category' => $_POST['category'] ?? 'General',
|
||||
'type' => $_POST['type'] ?? 'Issue'
|
||||
];
|
||||
|
||||
|
||||
// Validate input
|
||||
if (empty($ticketData['title'])) {
|
||||
$error = "Title is required";
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
$result = $this->ticketModel->createTicket($ticketData);
|
||||
|
||||
|
||||
// Create ticket with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log ticket creation to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Send Discord webhook notification for new ticket
|
||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||
|
||||
|
||||
// Redirect to the new ticket
|
||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||
exit;
|
||||
@@ -83,15 +101,19 @@ class TicketController {
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// For AJAX requests, get JSON data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
|
||||
// Add ticket_id to the data
|
||||
$data['ticket_id'] = $id;
|
||||
|
||||
|
||||
// Validate input data
|
||||
if (empty($data['title'])) {
|
||||
header('Content-Type: application/json');
|
||||
@@ -101,10 +123,15 @@ class TicketController {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($data);
|
||||
|
||||
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($data, $userId);
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($result && isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||
}
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
if ($result) {
|
||||
|
||||
@@ -5,7 +5,6 @@ error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
@@ -41,6 +40,22 @@ if ($conn->connect_error) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authenticate via API key
|
||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
try {
|
||||
$systemUser = $apiKeyAuth->authenticate();
|
||||
} catch (Exception $e) {
|
||||
// Authentication failed - ApiKeyAuth already sent the response
|
||||
exit;
|
||||
}
|
||||
|
||||
$userId = $systemUser['user_id'];
|
||||
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
||||
|
||||
// Create tickets table with hash column if not exists
|
||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -122,9 +137,9 @@ if (!$data) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
// Prepare insert query
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
// Prepare insert query with created_by field
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
// First, store all values in variables
|
||||
@@ -137,7 +152,7 @@ $type = $data['type'] ?? 'Issue';
|
||||
|
||||
// Then use the variables in bind_param
|
||||
$stmt->bind_param(
|
||||
"ssssssss",
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
@@ -145,10 +160,20 @@ $stmt->bind_param(
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
// Log ticket creation to audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
|
||||
101
generate_api_key.php
Normal file
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
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Parse the URL - no need to remove base path since we're at document root
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
@@ -20,6 +22,16 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Authenticate user via Authelia forward auth
|
||||
$authMiddleware = new AuthMiddleware($conn);
|
||||
$currentUser = $authMiddleware->authenticate();
|
||||
|
||||
// Store current user in globals for controllers
|
||||
$GLOBALS['currentUser'] = $currentUser;
|
||||
|
||||
// Initialize audit log model
|
||||
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||
}
|
||||
|
||||
// Simple router
|
||||
|
||||
141
middleware/ApiKeyAuth.php
Normal file
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) {
|
||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY tc.created_at DESC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
|
||||
$comments = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Use display_name from users table if available, fallback to user_name field
|
||||
if (!empty($row['display_name'])) {
|
||||
$row['display_name_formatted'] = $row['display_name'];
|
||||
} else {
|
||||
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||
}
|
||||
$comments[] = $row;
|
||||
}
|
||||
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
public function addComment($ticketId, $commentData, $userId = null) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Set default username
|
||||
|
||||
// Set default username (kept for backward compatibility)
|
||||
$username = $commentData['user_name'] ?? 'User';
|
||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
|
||||
// Preserve line breaks in the comment text
|
||||
$commentText = $commentData['comment_text'];
|
||||
|
||||
|
||||
$stmt->bind_param(
|
||||
"sssi",
|
||||
"sissi",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled
|
||||
);
|
||||
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$commentId = $this->conn->insert_id;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'user_name' => $username,
|
||||
'created_at' => date('M d, Y H:i'),
|
||||
'markdown_enabled' => $markdownEnabled,
|
||||
|
||||
@@ -134,7 +134,7 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
|
||||
public function updateTicket($ticketData) {
|
||||
public function updateTicket($ticketData, $updatedBy = null) {
|
||||
// Debug function
|
||||
$debug = function($message, $data = null) {
|
||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||
@@ -146,46 +146,48 @@ class TicketModel {
|
||||
};
|
||||
|
||||
$debug("updateTicket called with data", $ticketData);
|
||||
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_at = NOW()
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
|
||||
$debug("SQL query", $sql);
|
||||
|
||||
|
||||
try {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
$debug("Prepare statement failed", $this->conn->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
$debug("Binding parameters");
|
||||
$stmt->bind_param(
|
||||
"sissssi",
|
||||
"siisssii",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
|
||||
|
||||
$debug("Executing statement");
|
||||
$result = $stmt->execute();
|
||||
|
||||
|
||||
if (!$result) {
|
||||
$debug("Execute failed", $stmt->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
$debug("Update successful");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
@@ -195,32 +197,33 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function createTicket($ticketData) {
|
||||
public function createTicket($ticketData, $createdBy = null) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
|
||||
// Set default values if not provided
|
||||
$status = $ticketData['status'] ?? 'Open';
|
||||
$priority = $ticketData['priority'] ?? '4';
|
||||
$category = $ticketData['category'] ?? 'General';
|
||||
$type = $ticketData['type'] ?? 'Issue';
|
||||
|
||||
|
||||
$stmt->bind_param(
|
||||
"sssssss",
|
||||
"sssssssi",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type
|
||||
$type,
|
||||
$createdBy
|
||||
);
|
||||
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
|
||||
227
models/UserModel.php
Normal file
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>
|
||||
</head>
|
||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
||||
<div class="user-header" style="background: #2c3e50; padding: 0.75rem 2rem; color: white; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<span style="font-weight: bold; font-size: 1.1rem;">🎫 Tinker Tickets</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span>👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span style="background: #e74c3c; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem;">Admin</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-header">
|
||||
<h1>Tinker Tickets</h1>
|
||||
<h1>Ticket Dashboard</h1>
|
||||
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">New Ticket</button> </div>
|
||||
<div class="search-container">
|
||||
<form method="GET" action="" class="search-form">
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header" style="background: #2c3e50; padding: 0.75rem 2rem; color: white; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<a href="/" style="color: white; text-decoration: none; font-weight: bold;">← Dashboard</a>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span>👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span style="background: #e74c3c; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.85rem;">Admin</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<div class="ticket-header">
|
||||
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
|
||||
@@ -84,9 +97,11 @@
|
||||
<div class="comments-list">
|
||||
<?php
|
||||
foreach ($comments as $comment) {
|
||||
// Use display_name_formatted which falls back appropriately
|
||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||
echo "<div class='comment'>";
|
||||
echo "<div class='comment-header'>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($comment['user_name']) . "</span>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||
echo "</div>";
|
||||
echo "<div class='comment-text'>";
|
||||
|
||||
Reference in New Issue
Block a user