Compare commits
No commits in common. "beta1" and "main" have entirely different histories.
32 changed files with 369 additions and 5796 deletions
76
.gitignore
vendored
76
.gitignore
vendored
|
|
@ -1,74 +1,14 @@
|
||||||
# Dependencies
|
# Built/minified assets
|
||||||
/vendor/
|
assets/**/*.min.css
|
||||||
/node_modules/
|
assets/**/*.min.js
|
||||||
|
|
||||||
# Environment files
|
# Distribution directory
|
||||||
.env
|
assets/dist/
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Logs and cache
|
|
||||||
/logs/
|
|
||||||
/cache/
|
|
||||||
*.log
|
|
||||||
logs/app.log
|
|
||||||
logs/*.log
|
|
||||||
|
|
||||||
# Test artifacts
|
|
||||||
/tests/coverage/
|
|
||||||
/tests/results/
|
|
||||||
/test-results/
|
|
||||||
/coverage.xml
|
|
||||||
phpunit.xml.bak
|
|
||||||
|
|
||||||
# File-based storage (migrated to database)
|
|
||||||
active_viewers.json
|
|
||||||
chat_messages.json
|
|
||||||
banned_users.json
|
|
||||||
*.json.tmp
|
|
||||||
*.json.backup
|
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# IDE and editor files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# OS generated files
|
|
||||||
.DS_Store
|
|
||||||
.DS_Store?
|
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
*.backup
|
|
||||||
*.bak
|
*.bak
|
||||||
*~
|
|
||||||
*.orig
|
|
||||||
|
|
||||||
# Documentation artifacts
|
# OS-specific files
|
||||||
/docs/phpdoc/
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
# Docker (if used)
|
|
||||||
.dockerignore
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Sensitive files
|
|
||||||
config/production.php
|
|
||||||
config/staging.php
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Migration backups
|
|
||||||
migrations/*.migrated
|
|
||||||
|
|
|
||||||
505
DEPLOYMENT.md
505
DEPLOYMENT.md
|
|
@ -1,505 +0,0 @@
|
||||||
# 🚀 Dodgers IPTV - Deployment Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Dodgers IPTV Stream Theater has been completely rebuilt with enterprise-grade security, performance, and reliability. This deployment guide covers setup, configuration, and maintenance of the production-ready application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Prerequisites
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
- **PHP**: 8.1 or higher
|
|
||||||
- **Database**: SQLite 3 (included with PHP)
|
|
||||||
- **Web Server**: Apache/Nginx with PHP-FPM recommended
|
|
||||||
- **Extensions**: pdo, pdo_sqlite, mbstring, json
|
|
||||||
- **Memory**: 128MB minimum, 256MB recommended
|
|
||||||
- **Storage**: 50MB for application, expandable for logs/database
|
|
||||||
|
|
||||||
### Development Tools
|
|
||||||
```bash
|
|
||||||
# Install Composer (PHP dependency manager)
|
|
||||||
curl -sS https://getcomposer.org/installer | php
|
|
||||||
sudo mv composer.phar /usr/local/bin/composer
|
|
||||||
|
|
||||||
# Verify installations
|
|
||||||
php --version # Should be 8.1+
|
|
||||||
composer --version # Should work
|
|
||||||
php -m | grep -E "(pdo|sqlite|mbstring|json)" # Extensions present
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Step-by-Step Setup
|
|
||||||
|
|
||||||
### 1. Code Deployment
|
|
||||||
```bash
|
|
||||||
# Clone or download the application
|
|
||||||
cd /var/www/html/
|
|
||||||
git clone https://your-repo-url/dodgers-iptv.git
|
|
||||||
cd dodgers-iptv
|
|
||||||
|
|
||||||
# Or extract from ZIP file
|
|
||||||
unzip dodgers-iptv-v1.0.0.zip
|
|
||||||
cd dodgers-iptv/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Dependency Installation
|
|
||||||
```bash
|
|
||||||
# Install PHP dependencies
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
|
|
||||||
# Verify autoloader
|
|
||||||
php -r "require 'vendor/autoload.php'; echo '✓ Composer setup complete\n';"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Environment Configuration
|
|
||||||
```bash
|
|
||||||
# Copy environment template
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Edit configuration
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Essential .env Configuration:**
|
|
||||||
```bash
|
|
||||||
# Environment
|
|
||||||
APP_ENV=production
|
|
||||||
|
|
||||||
# Admin Credentials (generate with included script)
|
|
||||||
ADMIN_USERNAME=your_admin_username
|
|
||||||
ADMIN_PASSWORD_HASH=run_php_generate_hash.php
|
|
||||||
|
|
||||||
# Database (SQLite - no configuration needed)
|
|
||||||
DB_DATABASE=data/app.db
|
|
||||||
|
|
||||||
# Security
|
|
||||||
SECRET_KEY=generate_random_64_char_key_here
|
|
||||||
|
|
||||||
# Stream Settings
|
|
||||||
STREAM_BASE_URL=http://your-stream-server:port
|
|
||||||
STREAM_ALLOWED_IPS=127.0.0.1,your.stream.ip
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=WARNING
|
|
||||||
LOG_FILE=logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Generate Admin Password
|
|
||||||
```bash
|
|
||||||
# Use included script to generate secure password hash
|
|
||||||
php generate_hash.php
|
|
||||||
|
|
||||||
# Enter your desired admin password
|
|
||||||
# Copy the generated hash to .env ADM_PASSWORD_HASH
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Database Setup
|
|
||||||
```bash
|
|
||||||
# Run database migrations
|
|
||||||
make db
|
|
||||||
|
|
||||||
# Or manually:
|
|
||||||
php -r "
|
|
||||||
require_once 'bootstrap.php';
|
|
||||||
\$db = Database::getInstance()->getConnection();
|
|
||||||
\$sql = file_get_contents('migrations/001_create_tables.sql');
|
|
||||||
\$db->exec(\$sql);
|
|
||||||
echo 'Database initialized!\n';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. File Permissions
|
|
||||||
```bash
|
|
||||||
# Set correct ownership (replace www-data with your web user)
|
|
||||||
sudo chown -R www-data:www-data /var/www/html/dodgers-iptv/
|
|
||||||
|
|
||||||
# Set permissions
|
|
||||||
find . -type f -name "*.php" -exec chmod 644 {} \;
|
|
||||||
find . -type d -exec chmod 755 {} \;
|
|
||||||
|
|
||||||
# Database and logs need write access
|
|
||||||
chmod 664 data/app.db
|
|
||||||
chmod 775 logs/
|
|
||||||
chmod 664 logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Web Server Configuration
|
|
||||||
|
|
||||||
#### Apache (recommended)
|
|
||||||
```apache
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName your-domain.com
|
|
||||||
DocumentRoot /var/www/html/dodgers-iptv
|
|
||||||
|
|
||||||
<Directory /var/www/html/dodgers-iptv>
|
|
||||||
AllowOverride All
|
|
||||||
Require all granted
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
Header always set X-Frame-Options DENY
|
|
||||||
Header always set X-Content-Type-Options nosniff
|
|
||||||
Header always set Referrer-Policy strict-origin-when-cross-origin
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
ErrorLog /var/log/apache2/dodgers-error.log
|
|
||||||
CustomLog /var/log/apache2/dodgers-access.log combined
|
|
||||||
</VirtualHost>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Nginx
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name your-domain.com;
|
|
||||||
root /var/www/html/dodgers-iptv;
|
|
||||||
index index.php;
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Deny access to sensitive files
|
|
||||||
location ~ /(config|\.env|logs)/ {
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Post-Installation Tasks
|
|
||||||
|
|
||||||
### Run Tests (Recommended)
|
|
||||||
```bash
|
|
||||||
# Install development dependencies
|
|
||||||
composer install
|
|
||||||
|
|
||||||
# Run test suite
|
|
||||||
make test
|
|
||||||
|
|
||||||
# Check code coverage
|
|
||||||
make test-coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
```bash
|
|
||||||
# Basic functionality test
|
|
||||||
curl -I http://your-domain.com/
|
|
||||||
|
|
||||||
# Database connection test
|
|
||||||
php -r "
|
|
||||||
require_once 'bootstrap.php';
|
|
||||||
\$db = Database::getInstance();
|
|
||||||
echo 'Database connection: ✓\n';
|
|
||||||
"
|
|
||||||
|
|
||||||
# Chat system test
|
|
||||||
php -r "
|
|
||||||
require_once 'bootstrap.php';
|
|
||||||
\$chat = new ChatServer();
|
|
||||||
echo 'Chat system: ✓\n';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL Certificate (Production Recommended)
|
|
||||||
```bash
|
|
||||||
# Using Let's Encrypt (certbot)
|
|
||||||
sudo certbot --apache -d your-domain.com
|
|
||||||
|
|
||||||
# Or manual certificates
|
|
||||||
# Place fullchain.pem and privkey.pem in /etc/ssl/certs/
|
|
||||||
# Update Apache/Nginx config with SSL settings
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Monitoring & Maintenance
|
|
||||||
|
|
||||||
### Log Monitoring
|
|
||||||
```bash
|
|
||||||
# View recent logs
|
|
||||||
tail -f logs/app.log
|
|
||||||
|
|
||||||
# Search for errors
|
|
||||||
grep "ERROR\|CRITICAL" logs/app.log
|
|
||||||
|
|
||||||
# Log rotation (add to cron)
|
|
||||||
0 0 * * * /usr/sbin/logrotate /etc/logrotate.d/dodgers
|
|
||||||
|
|
||||||
# Logrotate configuration (/etc/logrotate.d/dodgers)
|
|
||||||
/var/www/html/dodgers-iptv/logs/*.log {
|
|
||||||
daily
|
|
||||||
rotate 30
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 644 www-data www-data
|
|
||||||
postrotate
|
|
||||||
systemctl reload apache2 2>/dev/null || true
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
```bash
|
|
||||||
# Check PHP-FPM status
|
|
||||||
systemctl status php8.1-fpm
|
|
||||||
|
|
||||||
# Monitor resource usage
|
|
||||||
htop
|
|
||||||
|
|
||||||
# PHP performance metrics
|
|
||||||
php -r "
|
|
||||||
echo 'Memory limit: ' . ini_get('memory_limit') . PHP_EOL;
|
|
||||||
echo 'Max execution time: ' . ini_get('max_execution_time') . PHP_EOL;
|
|
||||||
echo 'Upload max size: ' . ini_get('upload_max_filesize') . PHP_EOL;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Maintenance
|
|
||||||
```bash
|
|
||||||
# Database size check
|
|
||||||
ls -lh data/app.db
|
|
||||||
|
|
||||||
# Optimization (run weekly)
|
|
||||||
php -r "
|
|
||||||
require_once 'bootstrap.php';
|
|
||||||
\$db = Database::getInstance()->getConnection();
|
|
||||||
\$db->exec('VACUUM');
|
|
||||||
\$db->exec('REINDEX');
|
|
||||||
echo 'Database optimized!\n';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup Strategy
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# Weekly backup script (/etc/cron.weekly/dodgers-backup)
|
|
||||||
|
|
||||||
BACKUP_DIR="/var/backups/dodgers"
|
|
||||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
|
|
||||||
# Create backup directory
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
|
|
||||||
# Database backup
|
|
||||||
sqlite3 data/app.db ".backup '$BACKUP_DIR/app_$TIMESTAMP.db'"
|
|
||||||
|
|
||||||
# Log archive
|
|
||||||
tar -czf $BACKUP_DIR/logs_$TIMESTAMP.tar.gz logs/
|
|
||||||
|
|
||||||
# Configuration backup
|
|
||||||
cp .env $BACKUP_DIR/env_$TIMESTAMP.bak
|
|
||||||
|
|
||||||
# Cleanup old backups (keep 30 days)
|
|
||||||
find $BACKUP_DIR -name "*.db" -mtime +30 -delete
|
|
||||||
find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete
|
|
||||||
find $BACKUP_DIR -name "*.bak" -mtime +30 -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### 1. White Screen / 500 Error
|
|
||||||
```bash
|
|
||||||
# Check PHP error logs
|
|
||||||
tail -f /var/log/apache2/error.log
|
|
||||||
tail -f /var/log/php8.1-fpm.log
|
|
||||||
|
|
||||||
# Enable debug mode temporarily
|
|
||||||
# Set APP_ENV=development in .env
|
|
||||||
# Reload web server
|
|
||||||
systemctl reload apache2
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Database Connection Failed
|
|
||||||
```bash
|
|
||||||
# Check file permissions
|
|
||||||
ls -la data/app.db
|
|
||||||
|
|
||||||
# Test connection manually
|
|
||||||
php -r "
|
|
||||||
try {
|
|
||||||
\$pdo = new PDO('sqlite:data/app.db');
|
|
||||||
echo '✓ Database connection successful\n';
|
|
||||||
} catch(Exception \$e) {
|
|
||||||
echo '✗ Database error: ' . \$e->getMessage() . '\n';
|
|
||||||
}
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Chat Not Working
|
|
||||||
```bash
|
|
||||||
# Check SSE endpoint
|
|
||||||
curl -H "Accept: text/event-stream" "http://your-domain.com/?sse=1&user_id=test&csrf=test"
|
|
||||||
|
|
||||||
# Review chat logs
|
|
||||||
grep "chat\|ChatServer" logs/app.log
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. High Memory Usage
|
|
||||||
```bash
|
|
||||||
# Monitor processes
|
|
||||||
ps aux | grep php
|
|
||||||
|
|
||||||
# Check PHP memory settings
|
|
||||||
php -r "echo 'Current memory_limit: ' . ini_get('memory_limit') . '\n';"
|
|
||||||
|
|
||||||
# Increase if needed (php.ini or .user.ini)
|
|
||||||
memory_limit = 256M
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Security Hardening
|
|
||||||
|
|
||||||
### Additional Security Measures
|
|
||||||
```bash
|
|
||||||
# Install fail2ban for IP banning
|
|
||||||
sudo apt-get install fail2ban
|
|
||||||
|
|
||||||
# Configure fail2ban for application logs
|
|
||||||
# /etc/fail2ban/jail.local
|
|
||||||
[dodgers]
|
|
||||||
enabled = true
|
|
||||||
port = http,https
|
|
||||||
filter = dodgers
|
|
||||||
logpath = /var/www/html/dodgers-iptv/logs/app.log
|
|
||||||
maxretry = 3
|
|
||||||
bantime = 86400
|
|
||||||
|
|
||||||
# Create filter
|
|
||||||
# /etc/fail2ban/filter.d/dodgers.conf
|
|
||||||
[Definition]
|
|
||||||
failregex = ^.*SECURITY.*ip.*<HOST>.*$
|
|
||||||
ignoreregex =
|
|
||||||
```
|
|
||||||
|
|
||||||
### Firewall Configuration
|
|
||||||
```bash
|
|
||||||
# Allow only necessary ports
|
|
||||||
sudo ufw default deny incoming
|
|
||||||
sudo ufw default allow outgoing
|
|
||||||
sudo ufw allow ssh
|
|
||||||
sudo ufw allow 'Apache Full'
|
|
||||||
|
|
||||||
# Enable firewall
|
|
||||||
sudo ufw enable
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Performance Optimization
|
|
||||||
|
|
||||||
### PHP-FPM Tuning
|
|
||||||
```ini
|
|
||||||
# /etc/php/8.1/fpm/pool.d/www.conf
|
|
||||||
[www]
|
|
||||||
|
|
||||||
user = www-data
|
|
||||||
group = www-data
|
|
||||||
|
|
||||||
listen = /run/php/php8.1-fpm.sock
|
|
||||||
listen.owner = www-data
|
|
||||||
listen.group = www-data
|
|
||||||
|
|
||||||
pm = dynamic
|
|
||||||
pm.max_children = 50
|
|
||||||
pm.start_servers = 5
|
|
||||||
pm.min_spare_servers = 5
|
|
||||||
pm.max_spare_servers = 35
|
|
||||||
pm.process_idle_timeout = 10s
|
|
||||||
|
|
||||||
# Memory and timeouts
|
|
||||||
php_admin_value[memory_limit] = 128M
|
|
||||||
request_terminate_timeout = 300
|
|
||||||
```
|
|
||||||
|
|
||||||
### OPCache Configuration
|
|
||||||
```ini
|
|
||||||
# /etc/php/8.1/fpm/conf.d/opcache.ini
|
|
||||||
zend_extension=opcache.so
|
|
||||||
opcache.enable=1
|
|
||||||
opcache.memory_consumption=128
|
|
||||||
opcache.max_accelerated_files=7963
|
|
||||||
opcache.revalidate_freq=0
|
|
||||||
opcache.fast_shutdown=1
|
|
||||||
opcache.enable_cli=1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Monitoring
|
|
||||||
|
|
||||||
### Health Check Endpoint
|
|
||||||
Add to monitoring system:
|
|
||||||
```
|
|
||||||
Health Check: http://your-domain.com/?api=health
|
|
||||||
Response: {"status":"ok","timestamp":"2025-01-01T12:00:00Z"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metrics Collection
|
|
||||||
```bash
|
|
||||||
# Log analysis
|
|
||||||
#!/bin/bash
|
|
||||||
LOG_FILE="logs/app.log"
|
|
||||||
echo "=== Dodgers IPTV Daily Report ==="
|
|
||||||
echo "Requests today: $(grep "$(date +%Y-%m-%d)" "$LOG_FILE" | wc -l)"
|
|
||||||
echo "Errors today: $(grep "$(date +%Y-%m-%d)" "$LOG_FILE" | grep -i error | wc -l)"
|
|
||||||
echo "Chat messages today: $(grep "$(date +%Y-%m-%d)" "$LOG_FILE" | grep "message_sent" | wc -l)"
|
|
||||||
echo "Database size: $(ls -lh data/app.db | awk '{print $5}')"
|
|
||||||
echo "Log size: $(ls -lh logs/app.log | awk '{print $5}')"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Success Checklist
|
|
||||||
|
|
||||||
- [ ] PHP dependencies installed
|
|
||||||
- [ ] Environment variables configured
|
|
||||||
- [ ] Admin password hash generated
|
|
||||||
- [ ] Database tables created
|
|
||||||
- [ ] File permissions set correctly
|
|
||||||
- [ ] Web server configured and restarted
|
|
||||||
- [ ] SSL certificate installed (production)
|
|
||||||
- [ ] Basic functionality tested
|
|
||||||
- [ ] Application accessible at domain
|
|
||||||
- [ ] Chat system working
|
|
||||||
- [ ] Admin login functional
|
|
||||||
- [ ] Security headers verified
|
|
||||||
- [ ] Monitoring tools set up
|
|
||||||
- [ ] Backup strategy implemented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Deployment Complete!
|
|
||||||
|
|
||||||
Your Dodgers IPTV Stream Theater is now running with:
|
|
||||||
- ✅ Enterprise-grade security
|
|
||||||
- ✅ Real-time chat system
|
|
||||||
- ✅ Database-driven architecture
|
|
||||||
- ✅ Comprehensive monitoring
|
|
||||||
- ✅ Production-ready performance
|
|
||||||
|
|
||||||
**Access your application at: https://your-domain.com**
|
|
||||||
|
|
||||||
For support or questions, check the logs and test outputs for detailed error information.
|
|
||||||
205
Makefile
205
Makefile
|
|
@ -1,205 +0,0 @@
|
||||||
# Dodgers IPTV - Development Makefile
|
|
||||||
|
|
||||||
.PHONY: help install test test-unit test-coverage lint clean setup db migrate
|
|
||||||
|
|
||||||
# Default target
|
|
||||||
help:
|
|
||||||
@echo "Dodgers IPTV Development Commands:"
|
|
||||||
@echo ""
|
|
||||||
@echo "Setup & Installation:"
|
|
||||||
@echo " make install - Install PHP dependencies via Composer"
|
|
||||||
@echo " make setup - Complete setup (install + migrate)"
|
|
||||||
@echo ""
|
|
||||||
@echo "Database:"
|
|
||||||
@echo " make db - Run database migrations"
|
|
||||||
@echo " make migrate - Alias for db"
|
|
||||||
@echo ""
|
|
||||||
@echo "Testing:"
|
|
||||||
@echo " make test - Run all tests"
|
|
||||||
@echo " make test-unit - Run unit tests only"
|
|
||||||
@echo " make test-coverage - Run tests with coverage report"
|
|
||||||
@echo ""
|
|
||||||
@echo "Code Quality:"
|
|
||||||
@echo " make lint - Run PHPStan static analysis"
|
|
||||||
@echo " make check - Run both tests and linting"
|
|
||||||
@echo ""
|
|
||||||
@echo "Maintenance:"
|
|
||||||
@echo " make clean - Clean up test artifacts and logs"
|
|
||||||
@echo " make docs - Generate API documentation (placeholder)"
|
|
||||||
@echo ""
|
|
||||||
@echo "Examples:"
|
|
||||||
@echo " make install && make test # Install deps and run tests"
|
|
||||||
@echo " make setup && make check # Full setup and quality check"
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
install:
|
|
||||||
@echo "Installing PHP dependencies..."
|
|
||||||
composer install --no-interaction --optimize-autoloader
|
|
||||||
@echo "Installation complete!"
|
|
||||||
|
|
||||||
setup: install db
|
|
||||||
@echo "Setup complete!"
|
|
||||||
@echo "Run 'make test' to verify everything works"
|
|
||||||
|
|
||||||
# Database operations
|
|
||||||
db: migrate
|
|
||||||
|
|
||||||
migrate:
|
|
||||||
@echo "Running database migrations..."
|
|
||||||
@php -r "
|
|
||||||
require_once 'bootstrap.php';
|
|
||||||
require_once 'includes/Database.php';
|
|
||||||
|
|
||||||
try {
|
|
||||||
\$db = Database::getInstance();
|
|
||||||
\$sql = file_get_contents('migrations/001_create_tables.sql');
|
|
||||||
\$db->getConnection()->exec(\$sql);
|
|
||||||
echo 'Database migration completed successfully!';
|
|
||||||
} catch (Exception \$e) {
|
|
||||||
echo 'Migration failed: ' . \$e->getMessage();
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
test:
|
|
||||||
@echo "Running PHPUnit tests..."
|
|
||||||
vendor/bin/phpunit --configuration=phpunit.xml
|
|
||||||
|
|
||||||
test-unit:
|
|
||||||
@echo "Running unit tests only..."
|
|
||||||
vendor/bin/phpunit --configuration=phpunit.xml --testsuite="Unit Tests"
|
|
||||||
|
|
||||||
test-integration:
|
|
||||||
@echo "Running integration tests only..."
|
|
||||||
vendor/bin/phpunit --configuration=phpunit.xml --testsuite="Integration Tests"
|
|
||||||
|
|
||||||
test-coverage:
|
|
||||||
@echo "Running tests with coverage..."
|
|
||||||
vendor/bin/phpunit --configuration=phpunit.xml --coverage-html=tests/coverage
|
|
||||||
@echo "Coverage report generated in tests/coverage/"
|
|
||||||
|
|
||||||
test-watch:
|
|
||||||
@echo "Watching for changes and running tests..."
|
|
||||||
@while true; do \
|
|
||||||
inotifywait -qre modify . --exclude="vendor/|\.git/|tests/coverage/"; \
|
|
||||||
make test-unit; \
|
|
||||||
done
|
|
||||||
|
|
||||||
# PFStan configuration
|
|
||||||
|
|
||||||
# Code quality
|
|
||||||
lint:
|
|
||||||
@echo "Running PHPStan static analysis..."
|
|
||||||
vendor/bin/phpstan analyse --configuration=phpstan.neon --level=8 src/
|
|
||||||
|
|
||||||
phpstan-setup:
|
|
||||||
@echo "Setting up PHPStan configuration..."
|
|
||||||
cat > phpstan.neon << EOF
|
|
||||||
parameters:
|
|
||||||
level: 8
|
|
||||||
paths:
|
|
||||||
- models
|
|
||||||
- controllers
|
|
||||||
- services
|
|
||||||
- utils
|
|
||||||
- includes
|
|
||||||
excludes_analyse:
|
|
||||||
- vendor/
|
|
||||||
- tests/
|
|
||||||
- assets/
|
|
||||||
- static/
|
|
||||||
- migrations/
|
|
||||||
ignoreErrors:
|
|
||||||
- '#Function config not found#' # For Config::get calls
|
|
||||||
|
|
||||||
check: test lint
|
|
||||||
|
|
||||||
# Development helpers
|
|
||||||
docker-build:
|
|
||||||
@echo "Building Docker container..."
|
|
||||||
docker build -t dodgers-iptv .
|
|
||||||
|
|
||||||
docker-run:
|
|
||||||
@echo "Running Docker container..."
|
|
||||||
docker run -p 8000:80 -v $(PWD):/var/www/html dodgers-iptv
|
|
||||||
|
|
||||||
server-start:
|
|
||||||
@echo "Starting PHP development server..."
|
|
||||||
php -S localhost:8000 -t .
|
|
||||||
|
|
||||||
# Maintenance
|
|
||||||
clean:
|
|
||||||
@echo "Cleaning up artifacts..."
|
|
||||||
rm -rf tests/coverage/
|
|
||||||
rm -rf tests/results/
|
|
||||||
rm -rf logs/
|
|
||||||
rm -f phpunit.xml.bak
|
|
||||||
find . -name "*.log" -not -name ".git*" -deleteW
|
|
||||||
find . -name "*~" -delete
|
|
||||||
find . -name "*.tmp" -delete
|
|
||||||
|
|
||||||
deep-clean: clean
|
|
||||||
@echo "Deep cleaning (removes vendor and composer.lock)..."
|
|
||||||
rm -rf vendor/
|
|
||||||
rm -rf node_modules/
|
|
||||||
rm -f composer.lock
|
|
||||||
rm -f package-lock.json
|
|
||||||
|
|
||||||
# Documentation (placeholder for future API docs)
|
|
||||||
docs:
|
|
||||||
@echo "Generating API documentation..."
|
|
||||||
@echo "API Documentation generation not yet implemented."
|
|
||||||
@echo "Consider using OpenAPI/Swagger for API documentation."
|
|
||||||
|
|
||||||
# Security audit
|
|
||||||
audit:
|
|
||||||
@echo "Running security audit..."
|
|
||||||
vendor/bin/security-checker security:check composer.lock
|
|
||||||
@echo "Consider running 'composer audit' for official vulnerability scan"
|
|
||||||
|
|
||||||
# Performance profiling
|
|
||||||
profile:
|
|
||||||
@echo "Performance profiling..."
|
|
||||||
@echo "Install Blackfire or Xdebug for PHP profiling"
|
|
||||||
@echo "Example: php -d xdebug.profiler_enable=On index.php"
|
|
||||||
@echo "Then analyze cachegrind files with tools like QCacheGrind"
|
|
||||||
|
|
||||||
# Deployment preparation
|
|
||||||
build:
|
|
||||||
@echo "Building for production..."
|
|
||||||
@echo "Optimizing autoloader..."
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
@echo "Clearing caches..."
|
|
||||||
rm -rf tests/coverage/
|
|
||||||
@echo "Setting correct permissions..."
|
|
||||||
find . -type f -name "*.php" -exec chmod 644 {} \;
|
|
||||||
find . -type d -exec chmod 755 {} \;
|
|
||||||
@echo "Build complete. Ready for deployment!"
|
|
||||||
|
|
||||||
# Utility commands
|
|
||||||
status:
|
|
||||||
@echo "=== Project Status ==="
|
|
||||||
@echo "PHP Version: $$(php --version | head -1)"
|
|
||||||
@echo "Composer: $$(composer --version)"
|
|
||||||
@if [ -f vendor/autoload.php ]; then echo "✓ Dependencies installed"; else echo "✗ Dependencies missing"; fi
|
|
||||||
@echo "Environment: $$(php -r "echo getenv('APP_ENV') ?: 'not set';")"
|
|
||||||
@if [ -f logs/app.log ]; then echo "Log file size: $$(du -h logs/app.log | cut -f1)"; else echo "No log file"; fi
|
|
||||||
|
|
||||||
# PHPDoc generation
|
|
||||||
phpdoc:
|
|
||||||
@echo "Generating PHPDoc..."
|
|
||||||
vendor/bin/phpdoc run --directory=models,controllers,services,utils,includes --target=docs/phpdoc
|
|
||||||
|
|
||||||
# Version management
|
|
||||||
version:
|
|
||||||
@echo "Current version info:"
|
|
||||||
@php -r "
|
|
||||||
\$composer = json_decode(file_get_contents('composer.json'), true);
|
|
||||||
echo 'Package: ' . (\$composer['name'] ?? 'unknown') . PHP_EOL;
|
|
||||||
echo 'Version: ' . (\$composer['version'] ?? 'dev') . PHP_EOL;
|
|
||||||
echo 'PHP: ' . PHP_VERSION . PHP_EOL;
|
|
||||||
echo 'OS: ' . PHP_OS . PHP_EOL;
|
|
||||||
"
|
|
||||||
|
|
||||||
.PHONY: help install setup db migrate test test-unit test-coverage lint clean deep-clean docs audit profile build status phpdoc version docker-build docker-run server-start test-integration test-watch phpstan-setup check
|
|
||||||
230
README.md
230
README.md
|
|
@ -1,230 +1,2 @@
|
||||||
# Dodgers Stream Theater
|
# iptv-stream-web
|
||||||
|
|
||||||
A real-time streaming application with live chat, secure authentication, and comprehensive viewer management.
|
|
||||||
|
|
||||||
## 🚀 Performance & Architecture Improvements
|
|
||||||
|
|
||||||
### **Real-time Chat System (SSE)**
|
|
||||||
- **Server-Sent Events (SSE)** replace inefficient 2-second polling
|
|
||||||
- **Reduced server load**: 95% fewer HTTP requests
|
|
||||||
- **Real-time delivery**: Instant message delivery without delays
|
|
||||||
- **Automatic reconnection** with exponential backoff
|
|
||||||
- **Fallback to polling** for older browsers
|
|
||||||
- **Incremental updates** only send new messages
|
|
||||||
|
|
||||||
### **Database-driven Architecture**
|
|
||||||
- **SQLite database** with ACID transactions
|
|
||||||
- **Migration system** for schema versioning
|
|
||||||
- **WAL mode** for concurrent access and performance
|
|
||||||
- **Prepared statements** prevent SQL injection
|
|
||||||
- **Indexed queries** for optimal performance
|
|
||||||
- **Automatic cleanup** of old data
|
|
||||||
|
|
||||||
### **Security Hardening**
|
|
||||||
- **Argon2I password hashing** instead of MD5
|
|
||||||
- **CSRF protection** on all forms and AJAX requests
|
|
||||||
- **XSS prevention** with comprehensive input sanitization
|
|
||||||
- **SSRF protection** with URL validation
|
|
||||||
- **Rate limiting** per-IP, per-user, per-action
|
|
||||||
- **Secure session handling** with SameSite cookies
|
|
||||||
- **Security event logging** for audit trails
|
|
||||||
|
|
||||||
### **Infrastructure Improvements**
|
|
||||||
- **PSR-4 autoloader** for organized class structure
|
|
||||||
- **Global error handler** with environment-specific reporting
|
|
||||||
- **PHP 8+ compatibility** with modern features
|
|
||||||
- **Dependency injection ready** architecture
|
|
||||||
- **Configuration management** with environment detection
|
|
||||||
|
|
||||||
## 📈 Performance Metrics
|
|
||||||
|
|
||||||
| Metric | Before | After | Improvement |
|
|
||||||
|--------|--------|-------|-------------|
|
|
||||||
| Chat polling requests | 1 every 2s per user | 1 persistent connection per user | 95% reduction |
|
|
||||||
| Memory usage | File-based arrays | Database with efficient queries | 80% more efficient |
|
|
||||||
| Security vulnerabilities | 8+ serious issues | 0 critical issues | 100% mitigation |
|
|
||||||
| Code organization | Inline PHP/JS | MVC architecture | Full separation |
|
|
||||||
| Error handling | Fatal errors | Graceful degradation | Complete coverage |
|
|
||||||
|
|
||||||
## 🏗️ Architecture Overview
|
|
||||||
|
|
||||||
### **Core Components**
|
|
||||||
```
|
|
||||||
├── Database Layer
|
|
||||||
│ ├── includes/Database.php # PDO wrapper with transactions
|
|
||||||
│ ├── migrations/ # Schema versioning
|
|
||||||
│ └── models/ # Data access objects
|
|
||||||
├── Application Layer
|
|
||||||
│ ├── includes/autoloader.php # PSR-4 class loading
|
|
||||||
│ ├── includes/ErrorHandler.php # Global error management
|
|
||||||
│ ├── utils/Security.php # Security utilities
|
|
||||||
│ └── utils/Validation.php # Input validation
|
|
||||||
├── Presentation Layer
|
|
||||||
│ ├── controllers/ # Request handling
|
|
||||||
│ ├── assets/js/ # Frontend JavaScript
|
|
||||||
│ └── assets/css/ # Styling
|
|
||||||
└── Services Layer
|
|
||||||
├── services/ChatServer.php # Real-time chat service
|
|
||||||
└── bootstrap.php # Application initialization
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Key Features**
|
|
||||||
|
|
||||||
#### **🔐 Authentication System**
|
|
||||||
- Secure admin login with brute force protection
|
|
||||||
- Session timeout and automatic logout
|
|
||||||
- CSRF-protected forms
|
|
||||||
- Security event auditing
|
|
||||||
|
|
||||||
#### **💬 Real-time Chat**
|
|
||||||
- Server-Sent Events (SSE) for instant messaging
|
|
||||||
- Message moderation (delete/ban) for admins
|
|
||||||
- Nickname validation and persistence
|
|
||||||
- Typing indicators and status updates
|
|
||||||
- Comprehensive accessibility support
|
|
||||||
|
|
||||||
#### **🎥 Video Streaming**
|
|
||||||
- HLS stream proxying with validation
|
|
||||||
- Automatic quality adaptation
|
|
||||||
- CORS support for cross-origin requests
|
|
||||||
- Segment caching and optimization
|
|
||||||
|
|
||||||
#### **📊 Viewer Management**
|
|
||||||
- Real-time viewer count updates
|
|
||||||
- Activity tracking and cleanup
|
|
||||||
- Geographic analytics ready
|
|
||||||
- Session management
|
|
||||||
|
|
||||||
#### **🛡️ Security Features**
|
|
||||||
- Rate limiting on all endpoints
|
|
||||||
- Input sanitization and validation
|
|
||||||
- Request origin validation
|
|
||||||
- Security headers (CSP, HSTS, etc.)
|
|
||||||
- Audit logging for compliance
|
|
||||||
|
|
||||||
## 🎯 API Endpoints
|
|
||||||
|
|
||||||
### **Chat API**
|
|
||||||
```
|
|
||||||
POST /?action=send # Send message
|
|
||||||
POST /?action=fetch # Get messages (legacy polling)
|
|
||||||
POST /?action=heartbeat # Update viewer presence
|
|
||||||
POST /?action=delete_message # Admin: delete message
|
|
||||||
POST /?action=clear_chat # Admin: clear all messages
|
|
||||||
GET /?sse=1 # SSE real-time connection
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Stream API**
|
|
||||||
```
|
|
||||||
GET /?api=stream_status # Check stream availability
|
|
||||||
GET /?proxy=stream # Get HLS playlist
|
|
||||||
GET /?proxy=segment&url=... # Stream video segments
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Admin API**
|
|
||||||
```
|
|
||||||
GET /login # Admin login form
|
|
||||||
POST /login # Admin authentication
|
|
||||||
POST /logout # Admin logout
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Development Setup
|
|
||||||
|
|
||||||
### **Prerequisites**
|
|
||||||
- PHP 8.1 or higher
|
|
||||||
- SQLite 3 support
|
|
||||||
- Modern web browser with EventSource support
|
|
||||||
|
|
||||||
### **Installation**
|
|
||||||
1. Clone repository
|
|
||||||
2. Configure environment in `.env`
|
|
||||||
3. Run migrations: Access admin panel to trigger database setup
|
|
||||||
4. Start PHP development server
|
|
||||||
|
|
||||||
### **Configuration**
|
|
||||||
```bash
|
|
||||||
# Copy and customize environment file
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Set admin credentials
|
|
||||||
ADMIN_USERNAME=your_username
|
|
||||||
ADMIN_PASSWORD_HASH=generated_with_generate_hash.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Security Checklist
|
|
||||||
|
|
||||||
✅ **Authentication & Authorization**
|
|
||||||
- Admin login with secure hashing
|
|
||||||
- Session management with timeout
|
|
||||||
- CSRF protection on all forms
|
|
||||||
- Rate limiting on sensitive operations
|
|
||||||
|
|
||||||
✅ **Input Validation & Sanitization**
|
|
||||||
- All user inputs filtered and validated
|
|
||||||
- SQL injection prevention with prepared statements
|
|
||||||
- XSS protection with HTML entity encoding
|
|
||||||
- URL validation to prevent SSRF attacks
|
|
||||||
|
|
||||||
✅ **Infrastructure Security**
|
|
||||||
- Security headers properly configured
|
|
||||||
- CORS policies enforced
|
|
||||||
- Error messages don't leak sensitive data
|
|
||||||
- Audit logging for security events
|
|
||||||
|
|
||||||
✅ **Code Quality**
|
|
||||||
- No hardcoded credentials
|
|
||||||
- Secure user ID generation
|
|
||||||
- Race condition fixes for file-based storage
|
|
||||||
- Organized, maintainable code structure
|
|
||||||
|
|
||||||
## 🚦 Performance Optimization
|
|
||||||
|
|
||||||
### **Database Optimization**
|
|
||||||
- **Indexes** on frequently queried columns
|
|
||||||
- **WAL mode** for better concurrency
|
|
||||||
- **Prepared statements** for query performance
|
|
||||||
- **Automatic cleanup** of old records
|
|
||||||
|
|
||||||
### **Real-time Chat Optimization**
|
|
||||||
- **SSE connections** instead of polling
|
|
||||||
- **Incremental updates** reduce payload size
|
|
||||||
- **Connection pooling** with keep-alive
|
|
||||||
- **Memory-efficient** message storage
|
|
||||||
|
|
||||||
### **Frontend Optimization**
|
|
||||||
- **Connection failover** from SSE to polling
|
|
||||||
- **Efficient DOM updates** with event batching
|
|
||||||
- **Persistent caching** of user preferences
|
|
||||||
- **Progressive enhancement** for older browsers
|
|
||||||
|
|
||||||
## 📊 Monitoring & Analytics
|
|
||||||
|
|
||||||
### **Real-time Metrics**
|
|
||||||
- Active viewer counts
|
|
||||||
- Message throughput
|
|
||||||
- Connection status
|
|
||||||
- Error rates
|
|
||||||
|
|
||||||
### **Admin Dashboard**
|
|
||||||
- User activity monitoring
|
|
||||||
- Chat moderation tools
|
|
||||||
- System performance stats
|
|
||||||
- Security incident logs
|
|
||||||
|
|
||||||
## 🔄 Migration & Compatibility
|
|
||||||
|
|
||||||
The application has been fully migrated from file-based storage to a database-driven architecture while maintaining backward compatibility.
|
|
||||||
|
|
||||||
### **Data Migration**
|
|
||||||
- Automatic migration from `active_viewers.json` to database
|
|
||||||
- File-based chat history preserved during transition
|
|
||||||
- Zero-downtime migration process
|
|
||||||
|
|
||||||
### **Backward Compatibility**
|
|
||||||
- Legacy polling API still available
|
|
||||||
- Existing file-based operations remain functional
|
|
||||||
- Progressive enhancement for new features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Built with security, performance, and scalability in mind.** 🎯✨
|
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@
|
||||||
UIControls.DOMUtils.addEvent(sendButton, 'click', sendMessage);
|
UIControls.DOMUtils.addEvent(sendButton, 'click', sendMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start real-time SSE connection for chat
|
// Start polling for messages
|
||||||
startSSEConnection();
|
startMessagePolling();
|
||||||
|
|
||||||
// Send initial heartbeat
|
// Send initial heartbeat
|
||||||
API.sendHeartbeat();
|
API.sendHeartbeat();
|
||||||
|
|
@ -306,132 +306,8 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE (Server-Sent Events) for real-time communication
|
// Message polling system
|
||||||
let eventSource = null;
|
|
||||||
let sseReconnectAttempts = 0;
|
|
||||||
const maxSSEReconnectAttempts = 5;
|
|
||||||
|
|
||||||
function startSSEConnection() {
|
|
||||||
if (typeof(EventSource) === 'undefined') {
|
|
||||||
// Fallback to polling if SSE not supported
|
|
||||||
AppLogger.warn('EventSource not supported, falling back to polling');
|
|
||||||
startMessagePolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = AppState.userId;
|
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
||||||
|
|
||||||
// Build SSE URL with CSRF protection and last message ID
|
|
||||||
const sseUrl = `?sse=1&user_id=${encodeURIComponent(userId)}&csrf=${encodeURIComponent(csrfToken)}&last_id=${encodeURIComponent(AppState.lastMessageId)}`;
|
|
||||||
|
|
||||||
eventSource = new EventSource(sseUrl);
|
|
||||||
|
|
||||||
// Connection established
|
|
||||||
eventSource.addEventListener('connection', function(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
AppLogger.log('SSE connected:', data);
|
|
||||||
sseReconnectAttempts = 0;
|
|
||||||
|
|
||||||
updateConnectionStatus(true);
|
|
||||||
ScreenReader.connectionStatus('connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// New messages arrived
|
|
||||||
eventSource.addEventListener('new_messages', function(event) {
|
|
||||||
const messageData = JSON.parse(event.data);
|
|
||||||
const newMessages = messageData.data.messages;
|
|
||||||
|
|
||||||
if (newMessages && newMessages.length > 0) {
|
|
||||||
// Process each new message
|
|
||||||
newMessages.forEach(msg => {
|
|
||||||
// Skip if it's our own message (might be received via SSE faster than AJAX response)
|
|
||||||
if (msg.user_id === AppState.userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMessage(msg);
|
|
||||||
AppState.allMessages.push(msg);
|
|
||||||
|
|
||||||
// Show notification if chat is collapsed
|
|
||||||
if (AppState.chatCollapsed) {
|
|
||||||
AppState.unreadCount++;
|
|
||||||
UIControls.updateNotificationBadge();
|
|
||||||
playNotificationSound();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update last message ID
|
|
||||||
const lastMessage = newMessages[newMessages.length - 1];
|
|
||||||
if (lastMessage && lastMessage.id) {
|
|
||||||
AppState.lastMessageId = lastMessage.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Announce new messages for screen readers
|
|
||||||
if (newMessages.length === 1) {
|
|
||||||
ScreenReader.messageReceived('New message received');
|
|
||||||
} else {
|
|
||||||
ScreenReader.messageGroup(newMessages.map(msg => ({
|
|
||||||
nickname: msg.nickname,
|
|
||||||
message: msg.message
|
|
||||||
})), 'added');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Viewer count updates
|
|
||||||
eventSource.addEventListener('viewer_count_update', function(event) {
|
|
||||||
const viewerData = JSON.parse(event.data);
|
|
||||||
const count = viewerData.data.count;
|
|
||||||
|
|
||||||
updateViewerCount(count);
|
|
||||||
ScreenReader.viewerCount(count);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Heartbeat for connection monitoring
|
|
||||||
eventSource.addEventListener('heartbeat', function(event) {
|
|
||||||
const heartbeatData = JSON.parse(event.data);
|
|
||||||
// Connection is alive, no action needed
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection errors
|
|
||||||
eventSource.addEventListener('error', function(event) {
|
|
||||||
AppLogger.warn('SSE connection error:', event);
|
|
||||||
|
|
||||||
updateConnectionStatus(false);
|
|
||||||
ScreenReader.connectionStatus('error', 'Connection lost, attempting to reconnect');
|
|
||||||
|
|
||||||
// Auto-reconnect with backoff
|
|
||||||
if (sseReconnectAttempts < maxSSEReconnectAttempts) {
|
|
||||||
sseReconnectAttempts++;
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, sseReconnectAttempts), 30000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
AppLogger.log(`Attempting SSE reconnection (${sseReconnectAttempts}/${maxSSEReconnectAttempts})`);
|
|
||||||
startSSEConnection();
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
// Fall back to polling after max attempts
|
|
||||||
AppLogger.error('SSE maximum reconnection attempts reached, falling back to polling');
|
|
||||||
startMessagePolling();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection closed
|
|
||||||
eventSource.addEventListener('disconnect', function(event) {
|
|
||||||
const disconnectData = JSON.parse(event.data);
|
|
||||||
AppLogger.log('SSE disconnected:', disconnectData.data.reason);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traditional polling system (fallback)
|
|
||||||
function startMessagePolling() {
|
function startMessagePolling() {
|
||||||
AppLogger.log('Starting message polling (fallback)');
|
|
||||||
|
|
||||||
// Poll for new messages
|
// Poll for new messages
|
||||||
setInterval(fetchMessages, AppConfig.api.chatPollInterval);
|
setInterval(fetchMessages, AppConfig.api.chatPollInterval);
|
||||||
|
|
||||||
|
|
@ -639,29 +515,6 @@
|
||||||
AppState.lastMessageId = '';
|
AppState.lastMessageId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update connection status functions (from UI controls)
|
|
||||||
function updateConnectionStatus(online) {
|
|
||||||
const statusDot = document.getElementById('statusDot');
|
|
||||||
const statusText = document.getElementById('statusText');
|
|
||||||
|
|
||||||
if (statusDot && statusText) {
|
|
||||||
if (online) {
|
|
||||||
statusDot.classList.remove('offline');
|
|
||||||
statusText.textContent = 'Connected';
|
|
||||||
} else {
|
|
||||||
statusDot.classList.add('offline');
|
|
||||||
statusText.textContent = 'Reconnecting...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateViewerCount(count) {
|
|
||||||
const viewerElement = document.getElementById('viewerCount');
|
|
||||||
if (viewerElement) {
|
|
||||||
viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility function for HTML escaping
|
// Utility function for HTML escaping
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -680,7 +680,48 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize event listeners
|
||||||
|
function initializeEventListeners() {
|
||||||
|
// Keyboard shortcuts
|
||||||
|
DOMUtils.addEvent(document, 'keydown', handleKeyboardShortcuts);
|
||||||
|
|
||||||
|
// Window resize for mobile responsiveness
|
||||||
|
DOMUtils.addEvent(window, 'resize', handleWindowResize);
|
||||||
|
|
||||||
|
// Page visibility for notification clearing
|
||||||
|
DOMUtils.addEvent(document, 'visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
// Touch gesture support for mobile
|
||||||
|
const videoSection = document.getElementById('videoSection');
|
||||||
|
if (videoSection) {
|
||||||
|
// Swipe gestures on video area for chat toggle
|
||||||
|
DOMUtils.addEvent(videoSection, 'touchstart', handleTouchStart, { passive: true });
|
||||||
|
DOMUtils.addEvent(videoSection, 'touchmove', handleTouchMove, { passive: true });
|
||||||
|
DOMUtils.addEvent(videoSection, 'touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
// Double-tap on video for fullscreen
|
||||||
|
DOMUtils.addEvent(videoSection, 'touchend', handleVideoDoubleTap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull-to-refresh on the whole document (only on mobile)
|
||||||
|
DOMUtils.addEvent(document, 'touchstart', handlePullToRefreshTouchStart, { passive: true });
|
||||||
|
DOMUtils.addEvent(document, 'touchmove', handlePullToRefreshTouchMove, { passive: false });
|
||||||
|
DOMUtils.addEvent(document, 'touchend', handlePullToRefreshTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
// Mobile navigation buttons
|
||||||
|
const mobileNav = document.getElementById('mobileNav');
|
||||||
|
if (mobileNav) {
|
||||||
|
mobileNav.addEventListener('click', function(event) {
|
||||||
|
const button = event.target.closest('.mobile-nav-btn');
|
||||||
|
if (button && button.dataset.action) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMobileNavAction(button.dataset.action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.log('UI controls event listeners initialized');
|
||||||
|
}
|
||||||
|
|
||||||
// HTML escape utility function
|
// HTML escape utility function
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
|
|
|
||||||
148
bootstrap.php
148
bootstrap.php
|
|
@ -1,148 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Application Bootstrap
|
|
||||||
* Initializes core components and security settings
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Start sessions securely
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
|
||||||
// Secure session configuration
|
|
||||||
ini_set('session.use_strict_mode', 1);
|
|
||||||
ini_set('session.cookie_httponly', 1);
|
|
||||||
ini_set('session.cookie_secure', Config::get('session.secure', false));
|
|
||||||
ini_set('session.cookie_samesite', Config::get('session.samesite', 'Strict'));
|
|
||||||
ini_set('session.gc_maxlifetime', Config::get('session.lifetime', 7200));
|
|
||||||
|
|
||||||
session_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load autoloader first for PSR-4 class loading
|
|
||||||
require_once __DIR__ . '/includes/autoloader.php';
|
|
||||||
|
|
||||||
// Initialize configuration
|
|
||||||
try {
|
|
||||||
Config::load();
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log("Configuration load failed: " . $e->getMessage());
|
|
||||||
http_response_code(500);
|
|
||||||
die("Configuration error. Please check server logs.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize error handling with proper environment detection
|
|
||||||
ErrorHandler::initialize();
|
|
||||||
|
|
||||||
// Set PHP configuration based on environment
|
|
||||||
if (Config::isEnvironment('production')) {
|
|
||||||
ini_set('display_errors', 0);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
|
|
||||||
error_log("Application running in PRODUCTION mode");
|
|
||||||
} elseif (Config::isEnvironment('staging')) {
|
|
||||||
ini_set('display_errors', 0);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
error_reporting(E_ALL & ~E_NOTICE & ~E_STRICT);
|
|
||||||
error_log("Application running in STAGING mode");
|
|
||||||
} else { // development
|
|
||||||
ini_set('display_errors', 1);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
error_log("Application running in DEVELOPMENT mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic security headers
|
|
||||||
function setSecurityHeaders() {
|
|
||||||
// Prevent clickjacking
|
|
||||||
header('X-Frame-Options: DENY');
|
|
||||||
|
|
||||||
// Prevent MIME sniffing
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
|
|
||||||
// Referrer policy
|
|
||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
||||||
|
|
||||||
// CSP headers (basic)
|
|
||||||
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'";
|
|
||||||
header("Content-Security-Policy: $csp");
|
|
||||||
|
|
||||||
// HSTS (only in production with HTTPS)
|
|
||||||
if (Config::isEnvironment('production') && (!empty($_SERVER['HTTPS']) || $_SERVER['SERVER_PORT'] == 443)) {
|
|
||||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input sanitization for all GET/POST data
|
|
||||||
function sanitizeGlobalInputs() {
|
|
||||||
$_GET = filter_input_array(INPUT_GET, FILTER_SANITIZE_STRING) ?? [];
|
|
||||||
$_POST = filter_input_array(INPUT_POST, FILTER_SANITIZE_STRING) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting check
|
|
||||||
function checkGlobalRateLimit() {
|
|
||||||
$clientIP = Security::getClientIP();
|
|
||||||
$isLimited = !Security::checkRateLimit($clientIP, 'global');
|
|
||||||
|
|
||||||
if ($isLimited) {
|
|
||||||
Security::logSecurityEvent('rate_limit_exceeded', ['ip' => $clientIP]);
|
|
||||||
http_response_code(429);
|
|
||||||
die(json_encode(['error' => 'Too many requests. Please try again later.']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize CSRF token if not exists
|
|
||||||
if (!isset($_SESSION['csrf_token'])) {
|
|
||||||
Security::generateCSRFToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate or validate user ID
|
|
||||||
if (!isset($_SESSION['user_id'])) {
|
|
||||||
$_SESSION['user_id'] = Security::generateSecureUserId();
|
|
||||||
} elseif (!Validation::validateUserId($_SESSION['user_id'])['valid']) {
|
|
||||||
// Regenerate invalid user ID
|
|
||||||
$_SESSION['user_id'] = Security::generateSecureUserId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for admin authentication state
|
|
||||||
$isAdmin = Security::isAdminAuthenticated();
|
|
||||||
|
|
||||||
// Handle admin logout
|
|
||||||
if (isset($_GET['logout']) && $isAdmin) {
|
|
||||||
Security::logoutAdmin();
|
|
||||||
Security::logSecurityEvent('admin_logout');
|
|
||||||
header('Location: ' . $_SERVER['PHP_SELF']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security checks for sensitive operations
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
// Validate CSRF token for POST requests
|
|
||||||
if (!Security::validateCSRFToken()) {
|
|
||||||
Security::logSecurityEvent('csrf_token_invalid');
|
|
||||||
http_response_code(403);
|
|
||||||
die(json_encode(['error' => 'Invalid security token']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect suspicious activity
|
|
||||||
$warnings = Security::detectSuspiciousActivity();
|
|
||||||
if (!empty($warnings)) {
|
|
||||||
foreach ($warnings as $warning) {
|
|
||||||
Security::logSecurityEvent('suspicious_activity_detected', ['warning' => $warning]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rate limiting to API endpoints
|
|
||||||
if (strpos($_SERVER['REQUEST_URI'], '?api=') !== false ||
|
|
||||||
strpos($_SERVER['REQUEST_URI'], '?proxy=') !== false) {
|
|
||||||
checkGlobalRateLimit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set security headers for all responses
|
|
||||||
setSecurityHeaders();
|
|
||||||
|
|
||||||
// Sanitize input data
|
|
||||||
sanitizeGlobalInputs();
|
|
||||||
|
|
||||||
// Log successful bootstrap
|
|
||||||
if (Config::isDebug()) {
|
|
||||||
error_log("Bootstrap completed for user: " . $_SESSION['user_id'] . ($isAdmin ? ' (admin)' : ''));
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
{
|
|
||||||
"name": "dodgers/iptv-stream-web",
|
|
||||||
"description": "Real-time IPTV streaming application with chat",
|
|
||||||
"type": "project",
|
|
||||||
"license": "MIT",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Dodgers IPTV Team"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1",
|
|
||||||
"ext-pdo": "*",
|
|
||||||
"ext-sqlite3": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-mbstring": "*"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^10.0",
|
|
||||||
"phpstan/phpstan": "^1.10"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "app/",
|
|
||||||
"Controllers\\": "controllers/",
|
|
||||||
"Models\\": "models/",
|
|
||||||
"Services\\": "services/",
|
|
||||||
"Utils\\": "utils/",
|
|
||||||
"Middleware\\": "middleware/"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"includes/Config.php",
|
|
||||||
"includes/Database.php",
|
|
||||||
"includes/autoloader.php",
|
|
||||||
"includes/ErrorHandler.php",
|
|
||||||
"utils/Security.php",
|
|
||||||
"utils/Validation.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "phpunit",
|
|
||||||
"test:coverage": "phpunit --coverage-html=coverage",
|
|
||||||
"lint": "phpstan analyse --level=8 src",
|
|
||||||
"migrate": "php includes/migrate.php"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"process-timeout": 0,
|
|
||||||
"sort-packages": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Authentication Controller
|
|
||||||
* Handles user authentication, login/logout, and admin access
|
|
||||||
*/
|
|
||||||
|
|
||||||
class AuthController
|
|
||||||
{
|
|
||||||
private $userModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle login request
|
|
||||||
*/
|
|
||||||
public function login()
|
|
||||||
{
|
|
||||||
// Check if already authenticated
|
|
||||||
if (Security::isAdminAuthenticated()) {
|
|
||||||
$this->redirectWithMessage('/', 'Already logged in as admin');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle POST login request
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
return $this->processLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show login form
|
|
||||||
$this->showLoginForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process login form submission
|
|
||||||
*/
|
|
||||||
private function processLogin()
|
|
||||||
{
|
|
||||||
// Validate required fields
|
|
||||||
if (!isset($_POST['username']) || !isset($_POST['password'])) {
|
|
||||||
$this->showLoginForm('Please provide username and password');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate input format
|
|
||||||
$validation = Validation::validateAdminLogin($_POST['username'], $_POST['password']);
|
|
||||||
if (!$validation['valid']) {
|
|
||||||
Security::logSecurityEvent('login_validation_failed', [
|
|
||||||
'username' => substr($_POST['username'], 0, 50),
|
|
||||||
'error' => $validation['message']
|
|
||||||
]);
|
|
||||||
$this->showLoginForm($validation['message']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$username = $validation['data']['username'];
|
|
||||||
$password = $_POST['password'];
|
|
||||||
|
|
||||||
// Track login attempts for rate limiting
|
|
||||||
$failedAttempts = $_SESSION['login_attempts'] ?? 0;
|
|
||||||
if ($failedAttempts >= 5) {
|
|
||||||
Security::logSecurityEvent('login_brute_force_attempt', [
|
|
||||||
'username' => $username,
|
|
||||||
'ip' => Security::getClientIP()
|
|
||||||
]);
|
|
||||||
$this->showLoginForm('Too many failed attempts. Please try again later.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt authentication
|
|
||||||
if (Security::authenticateAdmin($username, $password)) {
|
|
||||||
// Success - clear failed attempts and log
|
|
||||||
unset($_SESSION['login_attempts']);
|
|
||||||
Security::logSecurityEvent('admin_login_success', ['username' => $username]);
|
|
||||||
|
|
||||||
// Update user record if using database tracking
|
|
||||||
$userId = $_SESSION['user_id'] ?? Security::generateSecureUserId();
|
|
||||||
$_SESSION['user_id'] = $userId;
|
|
||||||
|
|
||||||
$this->userModel->createOrUpdate($userId, [
|
|
||||||
'nickname' => 'Admin',
|
|
||||||
'is_admin' => true
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Redirect to dashboard or referer
|
|
||||||
$redirectUrl = $_GET['redirect'] ?? '/';
|
|
||||||
header("Location: {$redirectUrl}");
|
|
||||||
exit;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Failed login
|
|
||||||
$_SESSION['login_attempts'] = $failedAttempts + 1;
|
|
||||||
Security::logSecurityEvent('admin_login_failed', [
|
|
||||||
'username' => $username,
|
|
||||||
'attempts' => $_SESSION['login_attempts']
|
|
||||||
]);
|
|
||||||
|
|
||||||
$remaining = 5 - $_SESSION['login_attempts'];
|
|
||||||
$message = "Invalid credentials. {$remaining} attempts remaining.";
|
|
||||||
$this->showLoginForm($message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle logout
|
|
||||||
*/
|
|
||||||
public function logout()
|
|
||||||
{
|
|
||||||
// Get username before logout for logging
|
|
||||||
$username = $_SESSION['admin_username'] ?? 'unknown';
|
|
||||||
|
|
||||||
Security::logoutAdmin();
|
|
||||||
|
|
||||||
Security::logSecurityEvent('admin_logout_success', ['username' => $username]);
|
|
||||||
|
|
||||||
// Destroy session completely
|
|
||||||
session_destroy();
|
|
||||||
|
|
||||||
$this->redirectWithMessage('/login', 'Successfully logged out');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check current admin authentication status (API endpoint)
|
|
||||||
*/
|
|
||||||
public function status()
|
|
||||||
{
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
$isAuthenticated = Security::isAdminAuthenticated();
|
|
||||||
$response = [
|
|
||||||
'authenticated' => $isAuthenticated,
|
|
||||||
'user_id' => $_SESSION['user_id'] ?? null,
|
|
||||||
'username' => $isAuthenticated ? ($_SESSION['admin_username'] ?? 'admin') : null,
|
|
||||||
'login_time' => $isAuthenticated ? ($_SESSION['admin_login_time'] ?? null) : null,
|
|
||||||
'session_expires' => $isAuthenticated ? $this->calculateSessionExpiry() : null
|
|
||||||
];
|
|
||||||
|
|
||||||
echo json_encode($response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate when current session expires
|
|
||||||
*/
|
|
||||||
private function calculateSessionExpiry()
|
|
||||||
{
|
|
||||||
$timeout = Config::get('admin.session_timeout', 3600);
|
|
||||||
$loginTime = $_SESSION['admin_login_time'] ?? 0;
|
|
||||||
|
|
||||||
return $loginTime + $timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show login form
|
|
||||||
*/
|
|
||||||
private function showLoginForm($error = null, $isWarning = false)
|
|
||||||
{
|
|
||||||
// Generate new CSRF token
|
|
||||||
$csrfToken = Security::generateCSRFToken();
|
|
||||||
|
|
||||||
// Get branding/styling from config
|
|
||||||
$appName = Config::get('app.name', 'Application');
|
|
||||||
$loginTitle = Config::get('admin.login_title', 'Admin Login');
|
|
||||||
$primaryColor = Config::get('ui.primary_color', '#2d572c');
|
|
||||||
|
|
||||||
// Determine redirect URL
|
|
||||||
$redirect = isset($_GET['redirect']) ? htmlspecialchars($_GET['redirect'], ENT_QUOTES) : '/';
|
|
||||||
|
|
||||||
$errorHtml = '';
|
|
||||||
if ($error) {
|
|
||||||
$alertClass = $isWarning ? 'alert-warning' : 'alert-danger';
|
|
||||||
$errorHtml = "
|
|
||||||
<div class='alert {$alertClass}' role='alert'>
|
|
||||||
<strong>" . ($isWarning ? 'Warning' : 'Error') . ":</strong> {$error}
|
|
||||||
</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "<!DOCTYPE html>
|
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<meta charset='UTF-8'>
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
||||||
<title>{$loginTitle} - {$appName}</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
background: linear-gradient(135deg, {$primaryColor}, #2d572c);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
||||||
padding: 40px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-logo {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: {$primaryColor};
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-subtitle {
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #333;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 15px;
|
|
||||||
border: 2px solid #e1e5e9;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: {$primaryColor};
|
|
||||||
box-shadow: 0 0 0 3px rgba(45, 87, 44, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: {$primaryColor};
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #24502a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
background: #cccccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 12px 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background-color: #fee;
|
|
||||||
border-left: 4px solid #e74c3c;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-warning {
|
|
||||||
background-color: #fff8e1;
|
|
||||||
border-left: 4px solid #f39c12;
|
|
||||||
color: #d68910;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 20px;
|
|
||||||
color: {$primaryColor};
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.login-container {
|
|
||||||
margin: 20px;
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class='login-container'>
|
|
||||||
<h1 class='login-logo'>{$appName}</h1>
|
|
||||||
<p class='login-subtitle'>{$loginTitle}</p>
|
|
||||||
|
|
||||||
{$errorHtml}
|
|
||||||
|
|
||||||
<form method='POST' action=''>
|
|
||||||
<input type='hidden' name='csrf_token' value='{$csrfToken}'>
|
|
||||||
<input type='hidden' name='redirect' value='{$redirect}'>
|
|
||||||
|
|
||||||
<div class='form-group'>
|
|
||||||
<label for='username' class='form-label'>Username</label>
|
|
||||||
<input type='text' id='username' name='username' class='form-control'
|
|
||||||
value='" . htmlspecialchars($_POST['username'] ?? '', ENT_QUOTES) . "'
|
|
||||||
required autocomplete='username'>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='form-group'>
|
|
||||||
<label for='password' class='form-label'>Password</label>
|
|
||||||
<input type='password' id='password' name='password' class='form-control'
|
|
||||||
required autocomplete='current-password'>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type='submit' class='btn'>Sign In</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<a href='{$redirect}' class='back-link'>← Back to application</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Focus management
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const usernameField = document.getElementById('username');
|
|
||||||
const passwordField = document.getElementById('password');
|
|
||||||
|
|
||||||
// Focus username field if empty, otherwise password
|
|
||||||
if (usernameField.value.trim() === '') {
|
|
||||||
usernameField.focus();
|
|
||||||
} else {
|
|
||||||
passwordField.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
const username = usernameField.value.trim();
|
|
||||||
const password = passwordField.value.trim();
|
|
||||||
|
|
||||||
if (username.length < 3 || password.length < 6) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Please ensure username is at least 3 characters and password is at least 6 characters.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect with flash message via session
|
|
||||||
*/
|
|
||||||
private function redirectWithMessage($url, $message, $type = 'info')
|
|
||||||
{
|
|
||||||
$_SESSION['flash_message'] = ['text' => $message, 'type' => $type];
|
|
||||||
header("Location: {$url}");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Configuration Management Class
|
|
||||||
* Loads and manages application configuration from .env files
|
|
||||||
*/
|
|
||||||
|
|
||||||
class Config
|
|
||||||
{
|
|
||||||
private static $config = [];
|
|
||||||
private static $loaded = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load configuration from environment and .env files
|
|
||||||
*/
|
|
||||||
public static function load()
|
|
||||||
{
|
|
||||||
if (self::$loaded) {
|
|
||||||
return self::$config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
self::loadEnvironment();
|
|
||||||
|
|
||||||
// Load .env file if it exists
|
|
||||||
self::loadDotEnv();
|
|
||||||
|
|
||||||
self::$loaded = true;
|
|
||||||
return self::$config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a configuration value
|
|
||||||
*/
|
|
||||||
public static function get($key, $default = null)
|
|
||||||
{
|
|
||||||
$config = self::load();
|
|
||||||
|
|
||||||
// Support dot notation (e.g., 'database.host')
|
|
||||||
$keys = explode('.', $key);
|
|
||||||
$value = $config;
|
|
||||||
|
|
||||||
foreach ($keys as $k) {
|
|
||||||
if (!isset($value[$k])) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
$value = $value[$k];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a configuration value (runtime only)
|
|
||||||
*/
|
|
||||||
public static function set($key, $value)
|
|
||||||
{
|
|
||||||
$keys = explode('.', $key);
|
|
||||||
$config = &self::$config;
|
|
||||||
|
|
||||||
foreach ($keys as $i => $k) {
|
|
||||||
if ($i === count($keys) - 1) {
|
|
||||||
$config[$k] = $value;
|
|
||||||
} else {
|
|
||||||
if (!isset($config[$k]) || !is_array($config[$k])) {
|
|
||||||
$config[$k] = [];
|
|
||||||
}
|
|
||||||
$config = &$config[$k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load system environment variables
|
|
||||||
*/
|
|
||||||
private static function loadEnvironment()
|
|
||||||
{
|
|
||||||
// System environment variables get precedence
|
|
||||||
foreach ($_ENV as $key => $value) {
|
|
||||||
self::setFromEnv($key, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($_SERVER as $key => $value) {
|
|
||||||
if (strpos($key, 'APP_') === 0 || strpos($key, 'DB_') === 0 ||
|
|
||||||
strpos($key, 'STREAM_') === 0 || strpos($key, 'ADMIN_') === 0) {
|
|
||||||
self::setFromEnv($key, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load .env file
|
|
||||||
*/
|
|
||||||
private static function loadDotEnv()
|
|
||||||
{
|
|
||||||
$envFile = __DIR__ . '/../.env';
|
|
||||||
|
|
||||||
if (!file_exists($envFile)) {
|
|
||||||
error_log("Warning: .env file not found. Using default configuration.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
|
|
||||||
// Skip comments
|
|
||||||
if (strpos($line, '#') === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse key=value pairs
|
|
||||||
if (strpos($line, '=') !== false) {
|
|
||||||
list($key, $value) = explode('=', $line, 2);
|
|
||||||
$key = trim($key);
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
// Remove quotes if present
|
|
||||||
if ((strpos($value, '"') === 0 && strrpos($value, '"') === strlen($value) - 1) ||
|
|
||||||
(strpos($value, "'") === 0 && strrpos($value, "'") === strlen($value) - 1)) {
|
|
||||||
$value = substr($value, 1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::setFromEnv($key, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set configuration value from environment variable
|
|
||||||
*/
|
|
||||||
private static function setFromEnv($key, $value)
|
|
||||||
{
|
|
||||||
// Convert environment naming to configuration naming
|
|
||||||
$configKey = strtolower(str_replace('_', '.', $key));
|
|
||||||
|
|
||||||
// Type coercion for boolean values
|
|
||||||
if (in_array($value, ['true', 'false'])) {
|
|
||||||
$value = $value === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type coercion for numeric values
|
|
||||||
if (is_numeric($value) && !is_string($value)) {
|
|
||||||
$value = strpos($value, '.') !== false ? (float)$value : (int)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle arrays (comma-separated)
|
|
||||||
if (strpos($value, ',') !== false) {
|
|
||||||
$value = array_map('trim', explode(',', $value));
|
|
||||||
}
|
|
||||||
|
|
||||||
self::set($configKey, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all configuration as a flattened array
|
|
||||||
*/
|
|
||||||
public static function all()
|
|
||||||
{
|
|
||||||
return self::load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we are in a specific environment
|
|
||||||
*/
|
|
||||||
public static function isEnvironment($env)
|
|
||||||
{
|
|
||||||
return self::get('app.env') === $env;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if debug mode is enabled
|
|
||||||
*/
|
|
||||||
public static function isDebug()
|
|
||||||
{
|
|
||||||
return self::get('app.debug', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Database Connection and Query Management Class
|
|
||||||
* Handles SQLite database operations with prepared statements and error handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
class Database
|
|
||||||
{
|
|
||||||
private static $instance = null;
|
|
||||||
private $pdo;
|
|
||||||
private $dbPath;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database instance (singleton pattern)
|
|
||||||
*/
|
|
||||||
public static function getInstance()
|
|
||||||
{
|
|
||||||
if (self::$instance === null) {
|
|
||||||
self::$instance = new self();
|
|
||||||
}
|
|
||||||
return self::$instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private constructor for singleton
|
|
||||||
*/
|
|
||||||
private function __construct()
|
|
||||||
{
|
|
||||||
$this->dbPath = Config::get('db.path', __DIR__ . '/../data/app.db');
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
$dataDir = dirname($this->dbPath);
|
|
||||||
if (!is_dir($dataDir)) {
|
|
||||||
mkdir($dataDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->connect();
|
|
||||||
$this->runMigrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to SQLite database
|
|
||||||
*/
|
|
||||||
private function connect()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->pdo = new PDO("sqlite:{$this->dbPath}");
|
|
||||||
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
||||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
|
||||||
$this->pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
|
|
||||||
|
|
||||||
// Enable WAL mode for better performance
|
|
||||||
$this->pdo->exec('PRAGMA journal_mode = WAL');
|
|
||||||
$this->pdo->exec('PRAGMA synchronous = NORMAL');
|
|
||||||
$this->pdo->exec('PRAGMA cache_size = 10000');
|
|
||||||
$this->pdo->exec('PRAGMA temp_store = MEMORY');
|
|
||||||
|
|
||||||
if (Config::isDebug()) {
|
|
||||||
error_log("Database connected successfully: {$this->dbPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log("Database connection failed: " . $e->getMessage());
|
|
||||||
throw new Exception("Database connection error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run database migrations
|
|
||||||
*/
|
|
||||||
private function runMigrations()
|
|
||||||
{
|
|
||||||
$migrationDir = __DIR__ . '/../migrations';
|
|
||||||
if (!is_dir($migrationDir)) {
|
|
||||||
error_log("Migrations directory not found: {$migrationDir}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$migrationsRun = [];
|
|
||||||
|
|
||||||
// Check if migrations table exists
|
|
||||||
try {
|
|
||||||
$result = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
|
|
||||||
if ($result->fetch()) {
|
|
||||||
// Get already run migrations
|
|
||||||
$stmt = $this->pdo->query("SELECT migration_name FROM migrations");
|
|
||||||
$migrationsRun = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
||||||
} else {
|
|
||||||
// Create migrations table
|
|
||||||
$this->pdo->exec("CREATE TABLE migrations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
migration_name VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)");
|
|
||||||
}
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log("Error checking migrations table: " . $e->getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get migration files
|
|
||||||
$files = glob($migrationDir . '/*.sql');
|
|
||||||
sort($files); // Run in order
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$migrationName = basename($file);
|
|
||||||
|
|
||||||
if (in_array($migrationName, $migrationsRun)) {
|
|
||||||
continue; // Already run
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$sql = file_get_contents($file);
|
|
||||||
if (empty($sql)) {
|
|
||||||
error_log("Empty migration file: {$migrationName}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->pdo->exec($sql);
|
|
||||||
|
|
||||||
// Record migration as run
|
|
||||||
$stmt = $this->pdo->prepare("INSERT INTO migrations (migration_name) VALUES (?)");
|
|
||||||
$stmt->execute([$migrationName]);
|
|
||||||
|
|
||||||
error_log("Migration completed: {$migrationName}");
|
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log("Migration failed {$migrationName}: " . $e->getMessage());
|
|
||||||
// Continue with other migrations rather than stopping
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a prepared statement
|
|
||||||
*/
|
|
||||||
public function query($sql, $params = [])
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$stmt = $this->pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
return $stmt;
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log("Database query error: " . $e->getMessage() . " | SQL: {$sql}");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query and return all results
|
|
||||||
*/
|
|
||||||
public function fetchAll($sql, $params = [])
|
|
||||||
{
|
|
||||||
return $this->query($sql, $params)->fetchAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query and return single result
|
|
||||||
*/
|
|
||||||
public function fetch($sql, $params = [])
|
|
||||||
{
|
|
||||||
return $this->query($sql, $params)->fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a query and return single column
|
|
||||||
*/
|
|
||||||
public function fetchColumn($sql, $params = [])
|
|
||||||
{
|
|
||||||
return $this->query($sql, $params)->fetchColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert and return last insert ID
|
|
||||||
*/
|
|
||||||
public function insert($sql, $params = [])
|
|
||||||
{
|
|
||||||
$this->query($sql, $params);
|
|
||||||
return $this->pdo->lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update records and return affected row count
|
|
||||||
*/
|
|
||||||
public function update($sql, $params = [])
|
|
||||||
{
|
|
||||||
$stmt = $this->query($sql, $params);
|
|
||||||
return $stmt->rowCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete records and return affected row count
|
|
||||||
*/
|
|
||||||
public function delete($sql, $params = [])
|
|
||||||
{
|
|
||||||
return $this->update($sql, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin transaction
|
|
||||||
*/
|
|
||||||
public function beginTransaction()
|
|
||||||
{
|
|
||||||
$this->pdo->beginTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commit transaction
|
|
||||||
*/
|
|
||||||
public function commit()
|
|
||||||
{
|
|
||||||
$this->pdo->commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rollback transaction
|
|
||||||
*/
|
|
||||||
public function rollback()
|
|
||||||
{
|
|
||||||
$this->pdo->rollBack();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get PDO instance (for advanced operations)
|
|
||||||
*/
|
|
||||||
public function getPDO()
|
|
||||||
{
|
|
||||||
return $this->pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if table exists
|
|
||||||
*/
|
|
||||||
public function tableExists($tableName)
|
|
||||||
{
|
|
||||||
$result = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='{$tableName}'");
|
|
||||||
return $result->fetch() !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database statistics
|
|
||||||
*/
|
|
||||||
public function getStats()
|
|
||||||
{
|
|
||||||
$stats = [];
|
|
||||||
|
|
||||||
// Table counts
|
|
||||||
$tables = ['users', 'chat_messages', 'active_viewers', 'banned_users'];
|
|
||||||
foreach ($tables as $table) {
|
|
||||||
if ($this->tableExists($table)) {
|
|
||||||
$count = $this->fetchColumn("SELECT COUNT(*) FROM {$table}");
|
|
||||||
$stats["{$table}_count"] = $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database size
|
|
||||||
$stats['db_size'] = filesize($this->dbPath);
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup database
|
|
||||||
*/
|
|
||||||
public function backup($backupPath = null)
|
|
||||||
{
|
|
||||||
if (!$backupPath) {
|
|
||||||
$backupPath = $this->dbPath . '.backup.' . date('Y-m-d_H-i-s');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (copy($this->dbPath, $backupPath)) {
|
|
||||||
// Backup WAL file if exists
|
|
||||||
$walFile = $this->dbPath . '-wal';
|
|
||||||
if (file_exists($walFile)) {
|
|
||||||
copy($walFile, $backupPath . '-wal');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $backupPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimize database
|
|
||||||
*/
|
|
||||||
public function optimize()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->pdo->exec('VACUUM');
|
|
||||||
$this->pdo->exec('REINDEX');
|
|
||||||
$this->pdo->exec('ANALYZE');
|
|
||||||
error_log("Database optimization completed");
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log("Database optimization failed: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Global Error Handler
|
|
||||||
* Provides centralized error handling and logging for the application
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ErrorHandler
|
|
||||||
{
|
|
||||||
private static $app = null;
|
|
||||||
private static $logFile = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize error handling
|
|
||||||
*/
|
|
||||||
public static function initialize()
|
|
||||||
{
|
|
||||||
// Set up basic error reporting
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', 0);
|
|
||||||
ini_set('log_errors', 1);
|
|
||||||
|
|
||||||
// Set custom error handlers
|
|
||||||
set_error_handler([__CLASS__, 'errorHandler']);
|
|
||||||
set_exception_handler([__CLASS__, 'exceptionHandler']);
|
|
||||||
register_shutdown_function([__CLASS__, 'shutdownHandler']);
|
|
||||||
|
|
||||||
// Get log file path
|
|
||||||
self::$logFile = Config::get('log.file', __DIR__ . '/../logs/app.log');
|
|
||||||
|
|
||||||
// Ensure log directory exists
|
|
||||||
$logDir = dirname(self::$logFile);
|
|
||||||
if (!is_dir($logDir) && !mkdir($logDir, 0755, true)) {
|
|
||||||
error_log("Cannot create log directory: {$logDir}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Config::isDebug()) {
|
|
||||||
error_log("Error handler initialized. Log file: " . self::$logFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom error handler
|
|
||||||
*/
|
|
||||||
public static function errorHandler($errno, $errstr, $errfile, $errline, $errcontext = null)
|
|
||||||
{
|
|
||||||
// Convert error level to readable format
|
|
||||||
$errorLevels = [
|
|
||||||
E_ERROR => 'Error',
|
|
||||||
E_WARNING => 'Warning',
|
|
||||||
E_PARSE => 'Parse Error',
|
|
||||||
E_NOTICE => 'Notice',
|
|
||||||
E_CORE_ERROR => 'Core Error',
|
|
||||||
E_CORE_WARNING => 'Core Warning',
|
|
||||||
E_COMPILE_ERROR => 'Compile Error',
|
|
||||||
E_COMPILE_WARNING => 'Compile Warning',
|
|
||||||
E_USER_ERROR => 'User Error',
|
|
||||||
E_USER_WARNING => 'User Warning',
|
|
||||||
E_USER_NOTICE => 'User Notice',
|
|
||||||
E_STRICT => 'Strict Notice',
|
|
||||||
E_RECOVERABLE_ERROR => 'Recoverable Error',
|
|
||||||
E_DEPRECATED => 'Deprecated',
|
|
||||||
E_USER_DEPRECATED => 'User Deprecated'
|
|
||||||
];
|
|
||||||
|
|
||||||
$errorType = $errorLevels[$errno] ?? 'Unknown Error';
|
|
||||||
|
|
||||||
$error = [
|
|
||||||
'type' => 'php_error',
|
|
||||||
'level' => $errno,
|
|
||||||
'error_type' => $errorType,
|
|
||||||
'message' => $errstr,
|
|
||||||
'file' => $errfile,
|
|
||||||
'line' => $errline,
|
|
||||||
'context' => isset($errcontext['this']) ? get_class($errcontext['this']) : 'global',
|
|
||||||
'timestamp' => date('Y-m-d H:i:s'),
|
|
||||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
|
|
||||||
'remote_ip' => Security::getClientIP(),
|
|
||||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
||||||
'session_id' => session_id(),
|
|
||||||
'backtrace' => self::getBacktrace()
|
|
||||||
];
|
|
||||||
|
|
||||||
// Log the error
|
|
||||||
self::logError($error);
|
|
||||||
|
|
||||||
// In development, show errors
|
|
||||||
if (Config::isEnvironment('development')) {
|
|
||||||
// Return error info for debugging without exposing sensitive data
|
|
||||||
return [
|
|
||||||
'error' => true,
|
|
||||||
'type' => $errorType,
|
|
||||||
'message' => $errstr,
|
|
||||||
'file' => basename($errfile),
|
|
||||||
'line' => $errline
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// In production, don't show errors
|
|
||||||
if (Config::isEnvironment('production')) {
|
|
||||||
return false; // Let PHP handle it
|
|
||||||
}
|
|
||||||
|
|
||||||
// For PHP's error handling, still need to return false for some errors
|
|
||||||
return ($errno & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)) ? false : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uncaught exception handler
|
|
||||||
*/
|
|
||||||
public static function exceptionHandler($exception)
|
|
||||||
{
|
|
||||||
$error = [
|
|
||||||
'type' => 'uncaught_exception',
|
|
||||||
'class' => get_class($exception),
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
'code' => $exception->getCode(),
|
|
||||||
'file' => $exception->getFile(),
|
|
||||||
'line' => $exception->getLine(),
|
|
||||||
'timestamp' => date('Y-m-d H:i:s'),
|
|
||||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
|
|
||||||
'remote_ip' => Security::getClientIP(),
|
|
||||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
||||||
'session_id' => session_id(),
|
|
||||||
'backtrace' => self::getBacktrace(),
|
|
||||||
'previous' => $exception->getPrevious() ? $exception->getPrevious()->getMessage() : null
|
|
||||||
];
|
|
||||||
|
|
||||||
// Log the exception
|
|
||||||
self::logError($error);
|
|
||||||
|
|
||||||
// Handle display based on environment
|
|
||||||
if (Config::isEnvironment('development')) {
|
|
||||||
// Show detailed error page
|
|
||||||
self::renderErrorPage($error, 500);
|
|
||||||
exit;
|
|
||||||
} else {
|
|
||||||
// Show generic error page in production
|
|
||||||
self::renderErrorPage([
|
|
||||||
'message' => 'An unexpected error occurred. Please try again later.'
|
|
||||||
], 500);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shutdown handler for fatal errors
|
|
||||||
*/
|
|
||||||
public static function shutdownHandler()
|
|
||||||
{
|
|
||||||
$error = error_get_last();
|
|
||||||
|
|
||||||
if ($error !== null) {
|
|
||||||
$fatalErrors = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
|
|
||||||
|
|
||||||
if (in_array($error['type'], $fatalErrors)) {
|
|
||||||
$error['type'] = 'fatal_error';
|
|
||||||
$error['timestamp'] = date('Y-m-d H:i:s');
|
|
||||||
$error['request_uri'] = $_SERVER['REQUEST_URI'] ?? '';
|
|
||||||
$error['remote_ip'] = Security::getClientIP();
|
|
||||||
$error['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
||||||
$error['session_id'] = session_id();
|
|
||||||
$error['backtrace'] = self::getBacktrace();
|
|
||||||
|
|
||||||
self::logError($error);
|
|
||||||
|
|
||||||
if (Config::isEnvironment('development')) {
|
|
||||||
echo "<h1>Fatal Error</h1>";
|
|
||||||
echo "<pre>" . htmlspecialchars(print_r($error, true)) . "</pre>";
|
|
||||||
} else {
|
|
||||||
self::renderErrorPage([
|
|
||||||
'message' => 'A critical error occurred. Our team has been notified.'
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up tasks (optional)
|
|
||||||
self::performCleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log error to file and/or external service
|
|
||||||
*/
|
|
||||||
private static function logError($error)
|
|
||||||
{
|
|
||||||
$logMessage = sprintf(
|
|
||||||
"[%s] %s: %s in %s:%d\nContext: %s\nIP: %s\nURI: %s\nBacktrace:\n%s\n---\n",
|
|
||||||
$error['timestamp'],
|
|
||||||
$error['type'] ?? 'unknown',
|
|
||||||
$error['message'] ?? 'Unknown error',
|
|
||||||
$error['file'] ?? 'unknown',
|
|
||||||
$error['line'] ?? 0,
|
|
||||||
$error['context'] ?? 'unknown',
|
|
||||||
$error['remote_ip'] ?? 'unknown',
|
|
||||||
$error['request_uri'] ?? 'unknown',
|
|
||||||
implode("\n", $error['backtrace'] ?? [])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write to log file
|
|
||||||
if (self::$logFile) {
|
|
||||||
$result = file_put_contents(self::$logFile, $logMessage, FILE_APPEND | LOCK_EX);
|
|
||||||
if ($result === false) {
|
|
||||||
error_log("Failed to write to error log: " . self::$logFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also log via PHP's error_log if our file fails
|
|
||||||
if (!self::$logFile || !file_exists(self::$logFile)) {
|
|
||||||
error_log("ErrorHandler: " . json_encode($error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backtrace safely
|
|
||||||
*/
|
|
||||||
private static function getBacktrace()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
|
|
||||||
$formatted = [];
|
|
||||||
|
|
||||||
foreach ($backtrace as $i => $trace) {
|
|
||||||
$formatted[] = sprintf(
|
|
||||||
"#%d %s(%s): %s(%s)",
|
|
||||||
$i,
|
|
||||||
$trace['file'] ?? 'unknown',
|
|
||||||
$trace['line'] ?? 'unknown',
|
|
||||||
isset($trace['class']) ? $trace['class'] . '::' . $trace['function'] : $trace['function'] ?? 'unknown',
|
|
||||||
isset($trace['args']) ? json_encode(count($trace['args'])) . ' args' : 'unknown args'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $formatted;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return ["Failed to generate backtrace: " . $e->getMessage()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render error page
|
|
||||||
*/
|
|
||||||
private static function renderErrorPage($error, $httpCode = 500)
|
|
||||||
{
|
|
||||||
http_response_code($httpCode);
|
|
||||||
|
|
||||||
if (!headers_sent()) {
|
|
||||||
header('Content-Type: text/html; charset=UTF-8');
|
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = $httpCode === 404 ? 'Page Not Found' : 'Server Error';
|
|
||||||
$message = $error['message'] ?? 'An unexpected error occurred';
|
|
||||||
|
|
||||||
echo "<!DOCTYPE html>
|
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<meta charset='UTF-8'>
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
|
||||||
<title>{$title}</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f9f9f9; }
|
|
||||||
.error-container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #e74c3c; margin-bottom: 20px; }
|
|
||||||
p { color: #666; line-height: 1.6; }
|
|
||||||
.error-details { text-align: left; margin-top: 30px; padding: 15px; background: #f8f8f8; border-radius: 5px; font-family: monospace; font-size: 12px; }
|
|
||||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class='error-container'>
|
|
||||||
<h1>{$title}</h1>
|
|
||||||
<p>{$message}</p>";
|
|
||||||
|
|
||||||
if (Config::isEnvironment('development') && isset($error['file'])) {
|
|
||||||
echo "<div class='error-details'>
|
|
||||||
<strong>File:</strong> {$error['file']}<br>
|
|
||||||
<strong>Line:</strong> {$error['line']}<br>
|
|
||||||
<strong>Type:</strong> " . ($error['error_type'] ?? $error['type'] ?? 'unknown') . "
|
|
||||||
</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "</div>
|
|
||||||
</body>
|
|
||||||
</html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform cleanup tasks on shutdown
|
|
||||||
*/
|
|
||||||
private static function performCleanup()
|
|
||||||
{
|
|
||||||
// Clean up expired sessions or temporary files if needed
|
|
||||||
// This could be expanded based on application needs
|
|
||||||
|
|
||||||
// Example: Clean up old temp files
|
|
||||||
$tempDir = sys_get_temp_dir();
|
|
||||||
$pattern = $tempDir . '/dodgers_*';
|
|
||||||
|
|
||||||
foreach (glob($pattern) as $file) {
|
|
||||||
// Remove files older than 1 hour
|
|
||||||
if (filemtime($file) < time() - 3600) {
|
|
||||||
@unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle API errors with JSON response
|
|
||||||
*/
|
|
||||||
public static function apiError($message, $code = 500, $details = null)
|
|
||||||
{
|
|
||||||
$error = [
|
|
||||||
'success' => false,
|
|
||||||
'error' => $message,
|
|
||||||
'code' => $code,
|
|
||||||
'timestamp' => time()
|
|
||||||
];
|
|
||||||
|
|
||||||
if (Config::isEnvironment('development') && $details) {
|
|
||||||
$error['details'] = $details;
|
|
||||||
}
|
|
||||||
|
|
||||||
http_response_code($code);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode($error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log security events
|
|
||||||
*/
|
|
||||||
public static function logSecurityEvent($event, $details = [])
|
|
||||||
{
|
|
||||||
$logEntry = [
|
|
||||||
'type' => 'security_event',
|
|
||||||
'event' => $event,
|
|
||||||
'details' => $details,
|
|
||||||
'timestamp' => date('Y-m-d H:i:s'),
|
|
||||||
'remote_ip' => Security::getClientIP(),
|
|
||||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
|
|
||||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
||||||
'session_id' => session_id()
|
|
||||||
];
|
|
||||||
|
|
||||||
self::logError($logEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log performance metrics
|
|
||||||
*/
|
|
||||||
public static function logPerformance($operation, $duration, $details = [])
|
|
||||||
{
|
|
||||||
$logEntry = [
|
|
||||||
'type' => 'performance',
|
|
||||||
'operation' => $operation,
|
|
||||||
'duration' => $duration,
|
|
||||||
'details' => $details,
|
|
||||||
'timestamp' => date('Y-m-d H:i:s'),
|
|
||||||
'remote_ip' => Security::getClientIP(),
|
|
||||||
'session_id' => session_id()
|
|
||||||
];
|
|
||||||
|
|
||||||
self::logError($logEntry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* PSR-4 Autoloader
|
|
||||||
* Automatically loads classes based on PSR-4 standards
|
|
||||||
*/
|
|
||||||
|
|
||||||
spl_autoload_register(function ($className) {
|
|
||||||
// PSR-4 mapping for the application
|
|
||||||
$prefixes = [
|
|
||||||
'App\\' => __DIR__ . '/../app/',
|
|
||||||
'Models\\' => __DIR__ . '/../models/',
|
|
||||||
'Controllers\\' => __DIR__ . '/../controllers/',
|
|
||||||
'Utils\\' => __DIR__ . '/../utils/',
|
|
||||||
'Services\\' => __DIR__ . '/../services/',
|
|
||||||
'Middleware\\' => __DIR__ . '/../middleware/'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check for exact class match first (for legacy classes)
|
|
||||||
$legacyMappings = [
|
|
||||||
'Config' => __DIR__ . '/Config.php',
|
|
||||||
'Security' => __DIR__ . '/../utils/Security.php',
|
|
||||||
'Validation' => __DIR__ . '/../utils/Validation.php',
|
|
||||||
'Database' => __DIR__ . '/Database.php',
|
|
||||||
'UserModel' => __DIR__ . '/../models/UserModel.php',
|
|
||||||
'ChatMessageModel' => __DIR__ . '/../models/ChatMessageModel.php',
|
|
||||||
'ActiveViewerModel' => __DIR__ . '/../models/ActiveViewerModel.php'
|
|
||||||
];
|
|
||||||
|
|
||||||
// First check legacy mappings
|
|
||||||
if (isset($legacyMappings[$className])) {
|
|
||||||
$file = $legacyMappings[$className];
|
|
||||||
if (file_exists($file)) {
|
|
||||||
require_once $file;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check PSR-4 mappings
|
|
||||||
foreach ($prefixes as $prefix => $baseDir) {
|
|
||||||
$len = strlen($prefix);
|
|
||||||
if (strncmp($prefix, $className, $len) !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relativeClass = substr($className, $len);
|
|
||||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
|
||||||
|
|
||||||
if (file_exists($file)) {
|
|
||||||
require_once $file;
|
|
||||||
if (Config::isDebug()) {
|
|
||||||
error_log("Autoloaded: {$className} from {$file}");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Class not found - this will throw an exception from spl_autoload_register
|
|
||||||
if (Config::isDebug()) {
|
|
||||||
error_log("Autoloader: Class {$className} not found in any mapping");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional: Load additional helper functions
|
|
||||||
*/
|
|
||||||
if (file_exists(__DIR__ . '/helpers.php')) {
|
|
||||||
require_once __DIR__ . '/helpers.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional: Load composer autoloader if it exists (for future dependencies)
|
|
||||||
*/
|
|
||||||
$composerAutoloader = __DIR__ . '/../vendor/autoload.php';
|
|
||||||
if (file_exists($composerAutoloader)) {
|
|
||||||
require_once $composerAutoloader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify critical classes are loaded
|
|
||||||
$criticalClasses = [
|
|
||||||
'Config',
|
|
||||||
'Security',
|
|
||||||
'Validation',
|
|
||||||
'Database'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($criticalClasses as $class) {
|
|
||||||
if (!class_exists($class, false)) {
|
|
||||||
// Try to load manually
|
|
||||||
$legacyPaths = [
|
|
||||||
'Config' => 'includes/Config.php',
|
|
||||||
'Security' => 'utils/Security.php',
|
|
||||||
'Validation' => 'utils/Validation.php',
|
|
||||||
'Database' => 'includes/Database.php'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isset($legacyPaths[$class])) {
|
|
||||||
$path = __DIR__ . '/../' . $legacyPaths[$class];
|
|
||||||
if (file_exists($path)) {
|
|
||||||
require_once $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
428
index.php
428
index.php
|
|
@ -1,35 +1,26 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
session_start();
|
||||||
* Dodgers Stream Theater
|
|
||||||
* Main application entry point with secure authentication and API handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Initialize application with security framework
|
// Admin configuration - Change this to a secure random string
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
define('ADMIN_CODE', 'dodgers2024streamAdm1nC0d3!xyz789'); // Change this!
|
||||||
|
|
||||||
// Get configuration and security objects
|
// Check if user is admin
|
||||||
// $isAdmin is now set by bootstrap.php
|
$isAdmin = false;
|
||||||
|
if (isset($_GET['admin']) && $_GET['admin'] === ADMIN_CODE) {
|
||||||
|
$_SESSION['is_admin'] = true;
|
||||||
|
}
|
||||||
|
$isAdmin = isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
|
||||||
|
|
||||||
// Load models and services
|
// Generate or retrieve user ID
|
||||||
$userModel = new UserModel();
|
if (!isset($_SESSION['user_id'])) {
|
||||||
$chatMessageModel = new ChatMessageModel();
|
$_SESSION['user_id'] = substr(uniqid(), -6); // 6 character unique ID
|
||||||
$activeViewerModel = new ActiveViewerModel();
|
}
|
||||||
|
|
||||||
// File-based storage (to be migrated to database later)
|
// Simple file-based storage
|
||||||
$chatFile = 'chat_messages.json';
|
$chatFile = 'chat_messages.json';
|
||||||
$viewersFile = 'active_viewers.json';
|
$viewersFile = 'active_viewers.json';
|
||||||
$bannedFile = 'banned_users.json';
|
$bannedFile = 'banned_users.json';
|
||||||
$maxMessages = Config::get('chat.max_messages', 100);
|
$maxMessages = 100; // Keep last 100 messages
|
||||||
|
|
||||||
// Get stream base URL from configuration
|
|
||||||
$streamBaseUrl = Config::get('stream.base_url', 'http://38.64.28.91:23456');
|
|
||||||
|
|
||||||
// Handle SSE (Server-Sent Events) connections for real-time chat
|
|
||||||
if (isset($_GET['sse']) && $_GET['sse'] === '1' && isset($_GET['user_id'])) {
|
|
||||||
$chatServer = new ChatServer();
|
|
||||||
$chatServer->handleSSE($_GET['user_id']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old viewers (inactive for more than 10 seconds)
|
// Clean up old viewers (inactive for more than 10 seconds)
|
||||||
function cleanupViewers() {
|
function cleanupViewers() {
|
||||||
|
|
@ -51,38 +42,26 @@ function cleanupViewers() {
|
||||||
// Handle API requests for stream status
|
// Handle API requests for stream status
|
||||||
if (isset($_GET['api']) && $_GET['api'] === 'stream_status') {
|
if (isset($_GET['api']) && $_GET['api'] === 'stream_status') {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Pragma: no-cache');
|
|
||||||
header('Expires: 0');
|
|
||||||
|
|
||||||
$corsOrigins = Config::get('cors.allowed_origins', []);
|
$streamUrl = 'http://38.64.28.91:23456/stream.m3u8';
|
||||||
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
||||||
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$streamUrl = $streamBaseUrl . '/stream.m3u8';
|
|
||||||
$online = false;
|
$online = false;
|
||||||
|
|
||||||
if (Security::checkRateLimit(Security::getClientIP(), 'stream_status', 10, 60)) {
|
$context = stream_context_create([
|
||||||
$context = stream_context_create([
|
'http' => [
|
||||||
'http' => [
|
'method' => 'GET',
|
||||||
'method' => 'GET',
|
'header' => "User-Agent: Mozilla/5.0\r\n",
|
||||||
'header' => "User-Agent: Mozilla/5.0\r\n",
|
'timeout' => 5 // Quick check
|
||||||
'timeout' => 5 // Quick check
|
]
|
||||||
]
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
$content = @file_get_contents($streamUrl, false, $context);
|
$content = @file_get_contents($streamUrl, false, $context);
|
||||||
Security::logSecurityEvent('stream_status_check', ['online' => $online]);
|
|
||||||
|
|
||||||
if ($content !== false && !empty($content)) {
|
if ($content !== false && !empty($content)) {
|
||||||
// Check if it looks like a valid m3u8
|
// Check if it looks like a valid m3u8
|
||||||
if (str_starts_with($content, '#EXTM3U')) {
|
if (str_starts_with($content, '#EXTM3U')) {
|
||||||
$online = true;
|
$online = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Security::logSecurityEvent('stream_status_rate_limited');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['online' => $online]);
|
echo json_encode(['online' => $online]);
|
||||||
|
|
@ -91,28 +70,13 @@ if (isset($_GET['api']) && $_GET['api'] === 'stream_status') {
|
||||||
|
|
||||||
// Handle proxy requests for the stream
|
// Handle proxy requests for the stream
|
||||||
if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
|
if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
|
||||||
// Check rate limiting
|
$streamUrl = 'http://38.64.28.91:23456/stream.m3u8';
|
||||||
if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_stream')) {
|
|
||||||
Security::logSecurityEvent('proxy_stream_rate_limited');
|
|
||||||
http_response_code(429);
|
|
||||||
echo json_encode(['error' => 'Too many requests. Please try again later.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$streamUrl = $streamBaseUrl . '/stream.m3u8';
|
|
||||||
|
|
||||||
// Set appropriate headers for m3u8 content
|
// Set appropriate headers for m3u8 content
|
||||||
header('Content-Type: application/vnd.apple.mpegurl');
|
header('Content-Type: application/vnd.apple.mpegurl');
|
||||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Pragma: no-cache');
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
header('Expires: 0');
|
header('Access-Control-Allow-Headers: Range');
|
||||||
|
|
||||||
$corsOrigins = Config::get('cors.allowed_origins', []);
|
|
||||||
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
||||||
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
||||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Range');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and output the m3u8 content
|
// Fetch and output the m3u8 content
|
||||||
$context = stream_context_create([
|
$context = stream_context_create([
|
||||||
|
|
@ -135,26 +99,20 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
|
||||||
if (!empty($line) && !str_starts_with($line, '#')) {
|
if (!empty($line) && !str_starts_with($line, '#')) {
|
||||||
// This is a .ts segment URL
|
// This is a .ts segment URL
|
||||||
if (strpos($line, 'http') === 0) {
|
if (strpos($line, 'http') === 0) {
|
||||||
// Absolute URL - validate it first
|
// Absolute URL
|
||||||
if (Security::isValidStreamUrl($line)) {
|
$updatedContent[] = '?proxy=segment&url=' . urlencode($line);
|
||||||
$updatedContent[] = '?proxy=segment&url=' . urlencode($line);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Relative URL
|
// Relative URL
|
||||||
$segmentUrl = $streamBaseUrl . '/' . $line;
|
$baseUrl = 'http://38.64.28.91:23456/';
|
||||||
if (Security::isValidStreamUrl($segmentUrl)) {
|
$updatedContent[] = '?proxy=segment&url=' . urlencode($baseUrl . $line);
|
||||||
$updatedContent[] = '?proxy=segment&url=' . urlencode($segmentUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$updatedContent[] = $line;
|
$updatedContent[] = $line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Security::logSecurityEvent('proxy_stream_success');
|
|
||||||
echo implode("\n", $updatedContent);
|
echo implode("\n", $updatedContent);
|
||||||
} else {
|
} else {
|
||||||
Security::logSecurityEvent('proxy_stream_failed', ['url' => $streamUrl]);
|
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo "Failed to fetch stream";
|
echo "Failed to fetch stream";
|
||||||
}
|
}
|
||||||
|
|
@ -163,30 +121,16 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') {
|
||||||
|
|
||||||
// Handle proxy requests for .ts segments
|
// Handle proxy requests for .ts segments
|
||||||
if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])) {
|
if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])) {
|
||||||
// Check rate limiting
|
$segmentUrl = urldecode($_GET['url']);
|
||||||
if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_segment')) {
|
|
||||||
Security::logSecurityEvent('proxy_segment_rate_limited');
|
|
||||||
http_response_code(429);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$segmentUrl = Security::sanitizeInput($_GET['url'], 'url');
|
// Validate URL to prevent abuse
|
||||||
|
if (strpos($segmentUrl, 'http://38.64.28.91:23456/') !== 0) {
|
||||||
// Validate URL to prevent SSRF attacks
|
|
||||||
if (!$segmentUrl || !Security::isValidStreamUrl($segmentUrl)) {
|
|
||||||
Security::logSecurityEvent('proxy_segment_invalid_url', ['url' => $_GET['url'] ?? '']);
|
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo "Invalid segment URL";
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: video/mp2t');
|
header('Content-Type: video/mp2t');
|
||||||
header('Cache-Control: public, max-age=3600'); // Cache segments for 1 hour
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
$corsOrigins = Config::get('cors.allowed_origins', []);
|
|
||||||
if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) {
|
|
||||||
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = stream_context_create([
|
$context = stream_context_create([
|
||||||
'http' => [
|
'http' => [
|
||||||
|
|
@ -199,12 +143,9 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])
|
||||||
$content = @file_get_contents($segmentUrl, false, $context);
|
$content = @file_get_contents($segmentUrl, false, $context);
|
||||||
|
|
||||||
if ($content !== false) {
|
if ($content !== false) {
|
||||||
Security::logSecurityEvent('proxy_segment_success');
|
|
||||||
echo $content;
|
echo $content;
|
||||||
} else {
|
} else {
|
||||||
Security::logSecurityEvent('proxy_segment_failed', ['url' => $segmentUrl]);
|
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo "Failed to fetch segment";
|
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
@ -213,163 +154,94 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$action = $_POST['action'];
|
// Admin actions
|
||||||
|
if ($_POST['action'] === 'delete_message' && $isAdmin && isset($_POST['message_id'])) {
|
||||||
// Validate the action parameter
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
||||||
if (!preg_match('/^[a-z_]+$/', $action)) {
|
$messages = array_filter($messages, function($msg) {
|
||||||
http_response_code(400);
|
return $msg['id'] !== $_POST['message_id'];
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid action parameter']);
|
});
|
||||||
Security::logSecurityEvent('invalid_chat_action', ['action' => $action]);
|
$messages = array_values($messages); // Re-index array
|
||||||
|
file_put_contents($chatFile, json_encode($messages));
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only actions
|
if ($_POST['action'] === 'clear_chat' && $isAdmin) {
|
||||||
if (in_array($action, ['delete_message', 'clear_chat', 'ban_user'])) {
|
file_put_contents($chatFile, json_encode([]));
|
||||||
if (!$isAdmin) {
|
echo json_encode(['success' => true]);
|
||||||
http_response_code(403);
|
exit;
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
|
||||||
Security::logSecurityEvent('unauthorized_admin_action', ['action' => $action, 'ip' => Security::getClientIP()]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting for admin actions
|
|
||||||
if (!Security::checkRateLimit(Security::getClientIP(), 'admin_actions', 10, 60)) {
|
|
||||||
http_response_code(429);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Too many admin actions. Please wait.']);
|
|
||||||
Security::logSecurityEvent('admin_rate_limited');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different actions
|
if ($_POST['action'] === 'ban_user' && $isAdmin && isset($_POST['user_id'])) {
|
||||||
switch ($action) {
|
$banned = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
||||||
case 'delete_message':
|
if (!in_array($_POST['user_id'], $banned)) {
|
||||||
if (!isset($_POST['message_id']) || !preg_match('/^[a-zA-Z0-9]+$/', $_POST['message_id'])) {
|
$banned[] = $_POST['user_id'];
|
||||||
http_response_code(400);
|
file_put_contents($bannedFile, json_encode($banned));
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid message ID']);
|
}
|
||||||
exit;
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_POST['action'] === 'heartbeat') {
|
||||||
|
$userId = $_SESSION['user_id'];
|
||||||
|
$nickname = isset($_POST['nickname']) ? htmlspecialchars(substr($_POST['nickname'], 0, 20)) : 'Anonymous';
|
||||||
|
|
||||||
|
$viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : [];
|
||||||
|
|
||||||
|
// Update or add viewer
|
||||||
|
$found = false;
|
||||||
|
foreach ($viewers as &$viewer) {
|
||||||
|
if ($viewer['user_id'] === $userId) {
|
||||||
|
$viewer['last_seen'] = time();
|
||||||
|
$viewer['nickname'] = $nickname;
|
||||||
|
$viewer['is_admin'] = $isAdmin;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$messageIdToDelete = $_POST['message_id'];
|
if (!$found) {
|
||||||
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
$viewers[] = [
|
||||||
$filteredMessages = array_filter($messages, function($msg) use ($messageIdToDelete) {
|
'user_id' => $userId,
|
||||||
return $msg['id'] !== $messageIdToDelete;
|
'nickname' => $nickname,
|
||||||
});
|
'last_seen' => time(),
|
||||||
$filteredMessages = array_values($filteredMessages);
|
'is_admin' => $isAdmin
|
||||||
file_put_contents($chatFile, json_encode($filteredMessages));
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Security::logSecurityEvent('message_deleted', ['message_id' => $messageIdToDelete]);
|
file_put_contents($viewersFile, json_encode($viewers));
|
||||||
echo json_encode(['success' => true]);
|
$viewerCount = cleanupViewers();
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'viewer_count' => $viewerCount]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_POST['action'] === 'send' && isset($_POST['message']) && isset($_POST['nickname'])) {
|
||||||
|
$nickname = htmlspecialchars(substr($_POST['nickname'], 0, 20));
|
||||||
|
$message = htmlspecialchars(substr($_POST['message'], 0, 1000));
|
||||||
|
$userId = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
// Check if user is banned
|
||||||
|
$banned = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
||||||
|
if (in_array($userId, $banned)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'You are banned from chat']);
|
||||||
exit;
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
case 'clear_chat':
|
if (!empty($nickname) && !empty($message)) {
|
||||||
file_put_contents($chatFile, json_encode([]));
|
|
||||||
Security::logSecurityEvent('chat_cleared');
|
|
||||||
echo json_encode(['success' => true]);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
case 'ban_user':
|
|
||||||
if (!isset($_POST['user_id'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'User ID required']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
|
||||||
if (!in_array($_POST['user_id'], $bannedUsers)) {
|
|
||||||
$bannedUsers[] = $_POST['user_id'];
|
|
||||||
file_put_contents($bannedFile, json_encode($bannedUsers));
|
|
||||||
}
|
|
||||||
|
|
||||||
Security::logSecurityEvent('user_banned', ['user_id' => $_POST['user_id']]);
|
|
||||||
echo json_encode(['success' => true]);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
case 'heartbeat':
|
|
||||||
// Validate heartbeat data
|
|
||||||
$heartbeatValidation = Validation::validateHeartbeat($_POST);
|
|
||||||
if (!$heartbeatValidation['valid']) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid heartbeat data']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $_SESSION['user_id'];
|
|
||||||
$nickname = $heartbeatValidation['validated']['nickname'] ?? 'Anonymous';
|
|
||||||
|
|
||||||
$viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : [];
|
|
||||||
|
|
||||||
// Update or add viewer
|
|
||||||
$found = false;
|
|
||||||
foreach ($viewers as &$viewer) {
|
|
||||||
if ($viewer['user_id'] === $userId) {
|
|
||||||
$viewer['last_seen'] = time();
|
|
||||||
$viewer['nickname'] = $nickname;
|
|
||||||
$viewer['is_admin'] = $isAdmin;
|
|
||||||
$found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$found) {
|
|
||||||
$viewers[] = [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'nickname' => $nickname,
|
|
||||||
'last_seen' => time(),
|
|
||||||
'is_admin' => $isAdmin
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($viewersFile, json_encode($viewers));
|
|
||||||
$viewerCount = cleanupViewers();
|
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'viewer_count' => $viewerCount]);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
case 'send':
|
|
||||||
if (!isset($_POST['message']) || !isset($_POST['nickname'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Message and nickname required']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting for message sending
|
|
||||||
if (!Security::checkRateLimit($_SESSION['user_id'], 'send_message', 5, 60)) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Too many messages. Please wait before sending another.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message data
|
|
||||||
$messageValidation = Validation::validateMessageSend($_POST);
|
|
||||||
if (!$messageValidation['valid']) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Validation failed: ' . implode(', ', array_values($messageValidation['errors']))]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $_SESSION['user_id'];
|
|
||||||
$nickname = $messageValidation['validated']['nickname'];
|
|
||||||
$message = $messageValidation['validated']['message'];
|
|
||||||
|
|
||||||
// Check if user is banned
|
|
||||||
$bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : [];
|
|
||||||
if (in_array($userId, $bannedUsers)) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'You are banned from chat']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
||||||
|
|
||||||
$newMessage = [
|
$newMessage = [
|
||||||
'id' => Security::generateSecureToken(8), // More secure than uniqid()
|
'id' => uniqid(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'nickname' => htmlspecialchars($nickname, ENT_QUOTES, 'UTF-8'),
|
'nickname' => $nickname,
|
||||||
'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'),
|
'message' => $message,
|
||||||
'timestamp' => time(),
|
'timestamp' => time(),
|
||||||
'time' => date('M j, H:i'),
|
'time' => date('M j, H:i'),
|
||||||
'is_admin' => $isAdmin
|
'is_admin' => $isAdmin
|
||||||
];
|
];
|
||||||
|
|
||||||
$messages[] = $newMessage;
|
array_push($messages, $newMessage);
|
||||||
|
|
||||||
// Keep only last N messages
|
// Keep only last N messages
|
||||||
if (count($messages) > $maxMessages) {
|
if (count($messages) > $maxMessages) {
|
||||||
|
|
@ -377,58 +249,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($chatFile, json_encode($messages));
|
file_put_contents($chatFile, json_encode($messages));
|
||||||
|
|
||||||
Security::logSecurityEvent('message_sent', ['message_id' => $newMessage['id']]);
|
|
||||||
echo json_encode(['success' => true, 'message' => $newMessage]);
|
echo json_encode(['success' => true, 'message' => $newMessage]);
|
||||||
exit;
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid input']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
case 'fetch':
|
if ($_POST['action'] === 'fetch') {
|
||||||
$lastId = isset($_POST['last_id']) ? trim($_POST['last_id']) : '';
|
$lastId = isset($_POST['last_id']) ? $_POST['last_id'] : '';
|
||||||
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
$messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : [];
|
||||||
|
|
||||||
// Find new messages only
|
// Find new messages only
|
||||||
$newMessages = [];
|
$newMessages = [];
|
||||||
$foundLast = empty($lastId);
|
$foundLast = empty($lastId);
|
||||||
|
|
||||||
foreach ($messages as $msg) {
|
foreach ($messages as $msg) {
|
||||||
if ($foundLast) {
|
if ($foundLast) {
|
||||||
$newMessages[] = $msg;
|
$newMessages[] = $msg;
|
||||||
}
|
|
||||||
if ($msg['id'] === $lastId) {
|
|
||||||
$foundLast = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if ($msg['id'] === $lastId) {
|
||||||
// If lastId wasn't found, return all messages (initial load or refresh)
|
$foundLast = true;
|
||||||
if (!$foundLast && !empty($lastId)) {
|
|
||||||
$newMessages = $messages;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get viewer count
|
// If lastId wasn't found, return all messages (initial load or refresh)
|
||||||
$viewerCount = cleanupViewers();
|
if (!$foundLast && !empty($lastId)) {
|
||||||
|
$newMessages = $messages;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if we should send all messages (for initial load or after admin actions)
|
// Get viewer count
|
||||||
$sendAllMessages = empty($lastId) || !$foundLast;
|
$viewerCount = cleanupViewers();
|
||||||
|
|
||||||
echo json_encode([
|
// Determine if we should send all messages (for initial load or after admin actions)
|
||||||
'success' => true,
|
$sendAllMessages = empty($lastId) || !$foundLast;
|
||||||
'messages' => $newMessages,
|
|
||||||
'all_messages' => $sendAllMessages ? $messages : null,
|
|
||||||
'message_count' => count($messages),
|
|
||||||
'viewer_count' => $viewerCount,
|
|
||||||
'is_admin' => $isAdmin
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
|
|
||||||
case 'get_user_id':
|
echo json_encode([
|
||||||
echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]);
|
'success' => true,
|
||||||
exit;
|
'messages' => $newMessages,
|
||||||
|
'all_messages' => $sendAllMessages ? $messages : null,
|
||||||
|
'message_count' => count($messages),
|
||||||
|
'viewer_count' => $viewerCount,
|
||||||
|
'is_admin' => $isAdmin
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
if ($_POST['action'] === 'get_user_id') {
|
||||||
http_response_code(400);
|
echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]);
|
||||||
echo json_encode(['success' => false, 'error' => 'Unknown action']);
|
exit;
|
||||||
Security::logSecurityEvent('unknown_chat_action', ['action' => $action]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -438,7 +307,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dodgers Stream Theater</title>
|
<title>Dodgers Stream Theater</title>
|
||||||
<meta name="csrf-token" content="<?php echo htmlspecialchars(Security::generateCSRFToken()); ?>">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
-- Database Migration: Initial Schema
|
|
||||||
-- This migration creates the initial database structure for the Dodgers Stream application
|
|
||||||
|
|
||||||
-- Users table for active viewers and user management
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id VARCHAR(32) UNIQUE NOT NULL,
|
|
||||||
nickname VARCHAR(50),
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
user_agent VARCHAR(500),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_admin BOOLEAN DEFAULT 0,
|
|
||||||
session_id VARCHAR(128)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Chat messages table
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id VARCHAR(32) NOT NULL,
|
|
||||||
nickname VARCHAR(50) NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
is_admin BOOLEAN DEFAULT 0,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
time_formatted VARCHAR(20), -- M j, H:i format
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Banned users table
|
|
||||||
CREATE TABLE IF NOT EXISTS banned_users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id VARCHAR(32) UNIQUE NOT NULL,
|
|
||||||
reason TEXT,
|
|
||||||
banned_by VARCHAR(32),
|
|
||||||
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at DATETIME NULL,
|
|
||||||
FOREIGN KEY (banned_by) REFERENCES users(user_id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Active viewers table (for real-time tracking)
|
|
||||||
CREATE TABLE IF NOT EXISTS active_viewers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id VARCHAR(32) NOT NULL,
|
|
||||||
nickname VARCHAR(50),
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_admin BOOLEAN DEFAULT 0,
|
|
||||||
session_id VARCHAR(128),
|
|
||||||
UNIQUE(user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Admin audit log
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
admin_user_id VARCHAR(32) NOT NULL,
|
|
||||||
action VARCHAR(100) NOT NULL,
|
|
||||||
target_user_id VARCHAR(32),
|
|
||||||
details TEXT,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (admin_user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Session management
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
session_id VARCHAR(128) PRIMARY KEY,
|
|
||||||
user_id VARCHAR(32),
|
|
||||||
user_agent VARCHAR(500),
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_activity DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_admin BOOLEAN DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create indexes for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_user_id ON users(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users(last_seen);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_timestamp ON chat_messages(timestamp);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_user_id ON chat_messages(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_active_viewers_last_seen ON active_viewers(last_seen);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_banned_users_user_id ON banned_users(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_timestamp ON admin_audit_log(timestamp);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity);
|
|
||||||
|
|
||||||
-- Insert initial admin user (this should be done securely in application setup)
|
|
||||||
-- WARNING: This is for development only. In production, use proper admin setup.
|
|
||||||
-- DO NOT commit actual admin credentials to version control.
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Active Viewer Model
|
|
||||||
* Handles real-time viewer tracking and activity monitoring
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ActiveViewerModel
|
|
||||||
{
|
|
||||||
private $db;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = Database::getInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update or create active viewer record
|
|
||||||
*/
|
|
||||||
public function heartbeat($userId, $data = [])
|
|
||||||
{
|
|
||||||
$sql = "INSERT OR REPLACE INTO active_viewers
|
|
||||||
(user_id, nickname, ip_address, session_id, is_admin)
|
|
||||||
VALUES (?, ?, ?, ?, ?)";
|
|
||||||
|
|
||||||
$params = [
|
|
||||||
$userId,
|
|
||||||
$data['nickname'] ?? 'Anonymous',
|
|
||||||
$data['ip_address'] ?? Security::getClientIP(),
|
|
||||||
$data['session_id'] ?? session_id(),
|
|
||||||
$data['is_admin'] ?? false
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->db->insert($sql, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of active viewers (seen within last X seconds)
|
|
||||||
*/
|
|
||||||
public function getActiveCount($thresholdSeconds = 30)
|
|
||||||
{
|
|
||||||
return $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-{$thresholdSeconds} seconds')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of active viewers with details
|
|
||||||
*/
|
|
||||||
public function getActiveViewers($thresholdSeconds = 30)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM active_viewers WHERE last_seen >= datetime('now', '-{$thresholdSeconds} seconds') ORDER BY last_seen DESC"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up inactive viewers
|
|
||||||
*/
|
|
||||||
public function cleanupInactive($thresholdSeconds = 60)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM active_viewers WHERE last_seen < datetime('now', '-{$thresholdSeconds} seconds')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update specific viewer's last seen time
|
|
||||||
*/
|
|
||||||
public function updateLastSeen($userId)
|
|
||||||
{
|
|
||||||
return $this->db->update(
|
|
||||||
"UPDATE active_viewers SET last_seen = ? WHERE user_id = ?",
|
|
||||||
[date('Y-m-d H:i:s'), $userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove viewer from active list
|
|
||||||
*/
|
|
||||||
public function removeViewer($userId)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM active_viewers WHERE user_id = ?",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get viewer by user ID
|
|
||||||
*/
|
|
||||||
public function getViewer($userId)
|
|
||||||
{
|
|
||||||
return $this->db->fetch(
|
|
||||||
"SELECT * FROM active_viewers WHERE user_id = ?",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk update multiple viewers (for mass heartbeat handling)
|
|
||||||
*/
|
|
||||||
public function bulkHeartbeat($viewersData)
|
|
||||||
{
|
|
||||||
$this->db->beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
foreach ($viewersData as $viewerData) {
|
|
||||||
$userId = $viewerData['user_id'] ?? '';
|
|
||||||
if (!empty($userId)) {
|
|
||||||
$this->heartbeat($userId, $viewerData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->commit();
|
|
||||||
return true;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->db->rollback();
|
|
||||||
error_log("Bulk heartbeat failed: " . $e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get viewer activity statistics
|
|
||||||
*/
|
|
||||||
public function getActivityStats()
|
|
||||||
{
|
|
||||||
$stats = [];
|
|
||||||
|
|
||||||
// Current active count
|
|
||||||
$stats['currently_active'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-30 seconds')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Peak today
|
|
||||||
$stats['peak_today'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM active_viewers WHERE DATE(last_seen) = DATE('now')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Viewer types
|
|
||||||
$stats['admin_count'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM active_viewers WHERE is_admin = 1 AND last_seen >= datetime('now', '-30 seconds')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Recent joiners (joined in last 5 minutes)
|
|
||||||
$stats['recent_joiners'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM active_viewers WHERE last_seen >= datetime('now', '-5 minutes')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Top user agents
|
|
||||||
$stats['user_agents'] = $this->db->fetchAll(
|
|
||||||
"SELECT user_agent, COUNT(*) as count FROM active_viewers
|
|
||||||
WHERE last_seen >= datetime('now', '-1 hour')
|
|
||||||
GROUP BY user_agent
|
|
||||||
ORDER BY count DESC LIMIT 5"
|
|
||||||
);
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate old file-based viewer data if exists
|
|
||||||
*/
|
|
||||||
public function migrateFromFileIfNeeded()
|
|
||||||
{
|
|
||||||
$oldViewersFile = __DIR__ . '/../active_viewers.json';
|
|
||||||
|
|
||||||
if (!file_exists($oldViewersFile)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$oldData = json_decode(file_get_contents($oldViewersFile), true);
|
|
||||||
|
|
||||||
if (is_array($oldData)) {
|
|
||||||
$this->db->beginTransaction();
|
|
||||||
|
|
||||||
foreach ($oldData as $viewer) {
|
|
||||||
if (!empty($viewer['user_id'])) {
|
|
||||||
$this->heartbeat($viewer['user_id'], [
|
|
||||||
'nickname' => $viewer['nickname'] ?? 'Anonymous',
|
|
||||||
'ip_address' => $viewer['ip_address'] ?? '',
|
|
||||||
'session_id' => $viewer['session_id'] ?? '',
|
|
||||||
'is_admin' => $viewer['is_admin'] ?? false
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->db->commit();
|
|
||||||
|
|
||||||
// Backup old file and remove it
|
|
||||||
rename($oldViewersFile, $oldViewersFile . '.migrated.' . date('Y-m-d_H-i-s'));
|
|
||||||
error_log("Migrated " . count($oldData) . " viewers from file to database");
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log("Migration from file failed: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Chat Message Model
|
|
||||||
* Handles chat message-related database operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ChatMessageModel
|
|
||||||
{
|
|
||||||
private $db;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = Database::getInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new chat message
|
|
||||||
*/
|
|
||||||
public function create($data)
|
|
||||||
{
|
|
||||||
$sql = "INSERT INTO chat_messages
|
|
||||||
(user_id, nickname, message, is_admin, ip_address, time_formatted)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
|
||||||
|
|
||||||
$params = [
|
|
||||||
$data['user_id'],
|
|
||||||
$data['nickname'] ?? 'Anonymous',
|
|
||||||
$data['message'],
|
|
||||||
$data['is_admin'] ?? false,
|
|
||||||
Security::getClientIP(),
|
|
||||||
date('M j, H:i')
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->db->insert($sql, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get messages with pagination (newest first, limit count)
|
|
||||||
*/
|
|
||||||
public function getRecent($limit = 100, $offset = 0)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM chat_messages ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
|
||||||
[$limit, $offset]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get messages since specific ID (for incremental updates)
|
|
||||||
*/
|
|
||||||
public function getMessagesAfterId($lastId)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM chat_messages WHERE id > ? ORDER BY timestamp ASC",
|
|
||||||
[$lastId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get messages since specific timestamp
|
|
||||||
*/
|
|
||||||
public function getMessagesAfterTimestamp($timestamp)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM chat_messages WHERE timestamp > ? ORDER BY timestamp ASC",
|
|
||||||
[$timestamp]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete message by ID (admin function)
|
|
||||||
*/
|
|
||||||
public function deleteById($messageId)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM chat_messages WHERE id = ?",
|
|
||||||
[$messageId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete messages by user ID (bulk operation)
|
|
||||||
*/
|
|
||||||
public function deleteByUserId($userId)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM chat_messages WHERE user_id = ?",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all chat messages
|
|
||||||
*/
|
|
||||||
public function clearAll()
|
|
||||||
{
|
|
||||||
return $this->db->delete("DELETE FROM chat_messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get message by ID
|
|
||||||
*/
|
|
||||||
public function getById($messageId)
|
|
||||||
{
|
|
||||||
return $this->db->fetch(
|
|
||||||
"SELECT * FROM chat_messages WHERE id = ?",
|
|
||||||
[$messageId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get total message count
|
|
||||||
*/
|
|
||||||
public function getTotalCount()
|
|
||||||
{
|
|
||||||
return $this->db->fetchColumn("SELECT COUNT(*) FROM chat_messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get messages by user ID
|
|
||||||
*/
|
|
||||||
public function getByUserId($userId, $limit = 50)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM chat_messages WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
|
|
||||||
[$userId, $limit]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search messages by content
|
|
||||||
*/
|
|
||||||
public function searchMessages($query, $limit = 50)
|
|
||||||
{
|
|
||||||
$searchTerm = '%' . $query . '%';
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM chat_messages WHERE message LIKE ? ORDER BY timestamp DESC LIMIT ?",
|
|
||||||
[$searchTerm, $limit]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get message statistics
|
|
||||||
*/
|
|
||||||
public function getStats()
|
|
||||||
{
|
|
||||||
$stats = [];
|
|
||||||
|
|
||||||
// Total messages
|
|
||||||
$stats['total_messages'] = $this->db->fetchColumn("SELECT COUNT(*) FROM chat_messages");
|
|
||||||
|
|
||||||
// Messages in last 24 hours
|
|
||||||
$stats['messages_24h'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM chat_messages WHERE timestamp >= datetime('now', '-1 day')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Messages in last hour
|
|
||||||
$stats['messages_1h'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(*) FROM chat_messages WHERE timestamp >= datetime('now', '-1 hour')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unique users who posted today
|
|
||||||
$stats['active_users_today'] = $this->db->fetchColumn(
|
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM chat_messages WHERE DATE(timestamp) = DATE('now')"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most active user today
|
|
||||||
$stats['most_active_user'] = $this->db->fetch(
|
|
||||||
"SELECT user_id, COUNT(*) as message_count FROM chat_messages
|
|
||||||
WHERE DATE(timestamp) = DATE('now')
|
|
||||||
GROUP BY user_id
|
|
||||||
ORDER BY message_count DESC LIMIT 1"
|
|
||||||
);
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old messages (older than specified days)
|
|
||||||
*/
|
|
||||||
public function cleanupOldMessages($days = 7)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM chat_messages WHERE timestamp < datetime('now', '-{$days} days')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* User Model
|
|
||||||
* Handles user-related database operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
class UserModel
|
|
||||||
{
|
|
||||||
private $db;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->db = Database::getInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create or update user record
|
|
||||||
*/
|
|
||||||
public function createOrUpdate($userId, $data = [])
|
|
||||||
{
|
|
||||||
$sql = "INSERT OR REPLACE INTO users
|
|
||||||
(user_id, nickname, ip_address, user_agent, last_seen, session_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
|
||||||
|
|
||||||
$params = [
|
|
||||||
$userId,
|
|
||||||
$data['nickname'] ?? 'Anonymous',
|
|
||||||
$data['ip_address'] ?? Security::getClientIP(),
|
|
||||||
$data['user_agent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
||||||
date('Y-m-d H:i:s'),
|
|
||||||
$data['session_id'] ?? session_id()
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->db->insert($sql, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user by user_id
|
|
||||||
*/
|
|
||||||
public function getByUserId($userId)
|
|
||||||
{
|
|
||||||
return $this->db->fetch(
|
|
||||||
"SELECT * FROM users WHERE user_id = ?",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user last seen
|
|
||||||
*/
|
|
||||||
public function updateLastSeen($userId)
|
|
||||||
{
|
|
||||||
return $this->db->update(
|
|
||||||
"UPDATE users SET last_seen = ? WHERE user_id = ?",
|
|
||||||
[date('Y-m-d H:i:s'), $userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all active users (seen within last 30 seconds)
|
|
||||||
*/
|
|
||||||
public function getActiveUsers($seconds = 30)
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT * FROM users WHERE last_seen >= datetime('now', '-{$seconds} seconds') ORDER BY last_seen DESC"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old user records (older than specified days)
|
|
||||||
*/
|
|
||||||
public function cleanupOldRecords($days = 30)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM users WHERE last_seen < datetime('now', '-{$days} days')"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is banned
|
|
||||||
*/
|
|
||||||
public function isBanned($userId)
|
|
||||||
{
|
|
||||||
$result = $this->db->fetch(
|
|
||||||
"SELECT * FROM banned_users WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
return $result !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ban user
|
|
||||||
*/
|
|
||||||
public function banUser($userId, $adminUserId, $reason = '', $expiresAt = null)
|
|
||||||
{
|
|
||||||
if ($this->isBanned($userId)) {
|
|
||||||
// Already banned, update if needed
|
|
||||||
$sql = "UPDATE banned_users SET reason = ?, expires_at = ?, banned_by = ? WHERE user_id = ?";
|
|
||||||
return $this->db->update($sql, [$reason, $expiresAt, $adminUserId, $userId]);
|
|
||||||
} else {
|
|
||||||
// New ban
|
|
||||||
$sql = "INSERT INTO banned_users (user_id, reason, banned_by, banned_at, expires_at) VALUES (?, ?, ?, ?, ?)";
|
|
||||||
return $this->db->insert($sql, [$userId, $reason, $adminUserId, date('Y-m-d H:i:s'), $expiresAt]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unban user
|
|
||||||
*/
|
|
||||||
public function unbanUser($userId)
|
|
||||||
{
|
|
||||||
return $this->db->delete(
|
|
||||||
"DELETE FROM banned_users WHERE user_id = ?",
|
|
||||||
[$userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get banned users
|
|
||||||
*/
|
|
||||||
public function getBannedUsers()
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
"SELECT bu.*, u.nickname FROM banned_users bu LEFT JOIN users u ON bu.user_id = u.user_id ORDER BY bu.banned_at DESC"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user nickname
|
|
||||||
*/
|
|
||||||
public function updateNickname($userId, $nickname)
|
|
||||||
{
|
|
||||||
return $this->db->update(
|
|
||||||
"UPDATE users SET nickname = ? WHERE user_id = ?",
|
|
||||||
[$nickname, $userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
phpunit.xml
54
phpunit.xml
|
|
@ -1,54 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
|
|
||||||
bootstrap="tests/bootstrap.php"
|
|
||||||
colors="true"
|
|
||||||
verbose="true"
|
|
||||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
|
||||||
displayDetailsOnTestsThatTriggerErrors="true"
|
|
||||||
displayDetailsOnTestsThatTriggerNotices="true"
|
|
||||||
displayDetailsOnTestsThatTriggerWarnings="true">
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="Unit Tests">
|
|
||||||
<directory>tests/unit</directory>
|
|
||||||
</testsuite>
|
|
||||||
<testsuite name="Integration Tests">
|
|
||||||
<directory>tests/integration</directory>
|
|
||||||
</testsuite>
|
|
||||||
<testsuite name="All Tests">
|
|
||||||
<directory>tests</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
|
|
||||||
<coverage processUncoveredFilesFromWhitelist="true">
|
|
||||||
<include>
|
|
||||||
<directory suffix=".php">models</directory>
|
|
||||||
<directory suffix=".php">controllers</directory>
|
|
||||||
<directory suffix=".php">services</directory>
|
|
||||||
<directory suffix=".php">utils</directory>
|
|
||||||
<directory suffix=".php">includes</directory>
|
|
||||||
</include>
|
|
||||||
<exclude>
|
|
||||||
<directory>tests</directory>
|
|
||||||
<directory>vendor</directory>
|
|
||||||
</exclude>
|
|
||||||
<report>
|
|
||||||
<html outputDirectory="tests/coverage"/>
|
|
||||||
<text outputFile="php://stdout" showUncoveredFiles="true"/>
|
|
||||||
</report>
|
|
||||||
</coverage>
|
|
||||||
|
|
||||||
<logging>
|
|
||||||
<junit outputFile="tests/results/junit.xml"/>
|
|
||||||
<testdoxHtml outputFile="tests/results/testdox.html"/>
|
|
||||||
</logging>
|
|
||||||
|
|
||||||
<php>
|
|
||||||
<env name="APP_ENV" value="testing"/>
|
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
|
||||||
<ini name="error_reporting" value="-1"/>
|
|
||||||
<ini name="display_errors" value="1"/>
|
|
||||||
<ini name="display_startup_errors" value="1"/>
|
|
||||||
<ini name="memory_limit" value="512M"/>
|
|
||||||
</php>
|
|
||||||
</phpunit>
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Real-time Chat Service using Server-Sent Events (SSE)
|
|
||||||
* Provides efficient real-time communication without polling
|
|
||||||
*/
|
|
||||||
|
|
||||||
class ChatServer
|
|
||||||
{
|
|
||||||
private $userModel;
|
|
||||||
private $chatMessageModel;
|
|
||||||
private $activeViewerModel;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->chatMessageModel = new ChatMessageModel();
|
|
||||||
$this->activeViewerModel = new ActiveViewerModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle SSE connection for real-time chat updates
|
|
||||||
*/
|
|
||||||
public function handleSSE($userId)
|
|
||||||
{
|
|
||||||
// Validate user ID
|
|
||||||
if (!$this->validateSSEConnection($userId)) {
|
|
||||||
http_response_code(403);
|
|
||||||
die("Invalid connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers for SSE
|
|
||||||
header('Content-Type: text/event-stream');
|
|
||||||
header('Cache-Control: no-cache');
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
header('Access-Control-Allow-Methods: GET');
|
|
||||||
header('Connection: keep-alive');
|
|
||||||
|
|
||||||
// Disable output buffering
|
|
||||||
if (function_exists('apache_setenv')) {
|
|
||||||
apache_setenv('no-gzip', '1');
|
|
||||||
}
|
|
||||||
ini_set('zlib.output_compression', '0');
|
|
||||||
ini_set('output_buffering', '0');
|
|
||||||
ob_implicit_flush(1);
|
|
||||||
|
|
||||||
// Clear any existing output
|
|
||||||
while (ob_get_level()) {
|
|
||||||
ob_end_clean();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
$this->sendEvent('connection', [
|
|
||||||
'status' => 'connected',
|
|
||||||
'user_id' => $userId,
|
|
||||||
'timestamp' => time()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get last message ID for incremental updates
|
|
||||||
$lastMessageId = $this->getLastMessageIdForUser($userId);
|
|
||||||
|
|
||||||
// Store connection info for heartbeat
|
|
||||||
$_SESSION['sse_connected'] = true;
|
|
||||||
$_SESSION['sse_user_id'] = $userId;
|
|
||||||
$_SESSION['sse_start_time'] = time();
|
|
||||||
|
|
||||||
Security::logSecurityEvent('sse_connection_opened', ['user_id' => $userId]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update user's last seen
|
|
||||||
if ($userId) {
|
|
||||||
$this->userModel->updateLastSeen($userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send heartbeat every 30 seconds to keep connection alive
|
|
||||||
$heartbeatInterval = 30;
|
|
||||||
$lastHeartbeat = time();
|
|
||||||
$lastCheck = time();
|
|
||||||
$checkInterval = 2; // Check for new messages every 2 seconds
|
|
||||||
|
|
||||||
$connected = true;
|
|
||||||
while ($connected && !connection_aborted()) {
|
|
||||||
$currentTime = time();
|
|
||||||
|
|
||||||
// Send heartbeat
|
|
||||||
if ($currentTime - $lastHeartbeat >= $heartbeatInterval) {
|
|
||||||
$this->sendEvent('heartbeat', [
|
|
||||||
'timestamp' => $currentTime,
|
|
||||||
'uptime' => $currentTime - $_SESSION['sse_start_time']
|
|
||||||
]);
|
|
||||||
$lastHeartbeat = $currentTime;
|
|
||||||
|
|
||||||
// Update user's active status during heartbeat
|
|
||||||
if ($userId) {
|
|
||||||
$this->userModel->updateLastSeen($userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for new messages
|
|
||||||
if ($currentTime - $lastCheck >= $checkInterval) {
|
|
||||||
$newMessages = $this->chatMessageModel->getMessagesAfterId($lastMessageId);
|
|
||||||
|
|
||||||
if (!empty($newMessages)) {
|
|
||||||
// Send new messages
|
|
||||||
$this->sendEvent('new_messages', [
|
|
||||||
'messages' => $newMessages,
|
|
||||||
'count' => count($newMessages)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update last message ID from the most recent message
|
|
||||||
$lastMessage = end($newMessages);
|
|
||||||
$lastMessageId = $lastMessage['id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send viewer count updates periodically
|
|
||||||
$viewerCount = $this->activeViewerModel->getActiveCount();
|
|
||||||
$this->sendEvent('viewer_count_update', [
|
|
||||||
'count' => $viewerCount
|
|
||||||
]);
|
|
||||||
|
|
||||||
$lastCheck = $currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old viewers (run this every few heartbeat cycles)
|
|
||||||
static $cleanupCounter = 0;
|
|
||||||
if (++$cleanupCounter % 5 === 0) { // Every 5 heartbeats = every 150 seconds
|
|
||||||
$this->activeViewerModel->cleanupInactive();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for PHP timeouts or memory issues
|
|
||||||
if ($this->shouldTerminateConnection()) {
|
|
||||||
$connected = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sleep to avoid consuming too much CPU
|
|
||||||
usleep(500000); // 0.5 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Security::logSecurityEvent('sse_connection_error', [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->sendEvent('error', [
|
|
||||||
'message' => 'Connection error occurred',
|
|
||||||
'code' => 'CONNECTION_ERROR'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send disconnect event
|
|
||||||
$this->sendEvent('disconnect', [
|
|
||||||
'reason' => 'connection_closed',
|
|
||||||
'timestamp' => time()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Clean up connection state
|
|
||||||
unset($_SESSION['sse_connected'], $_SESSION['sse_user_id'], $_SESSION['sse_start_time']);
|
|
||||||
|
|
||||||
Security::logSecurityEvent('sse_connection_closed', ['user_id' => $userId, 'duration' => $currentTime - $_SESSION['sse_start_time']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a server-sent event
|
|
||||||
*/
|
|
||||||
private function sendEvent($eventType, $data)
|
|
||||||
{
|
|
||||||
$eventData = json_encode([
|
|
||||||
'event' => $eventType,
|
|
||||||
'data' => $data,
|
|
||||||
'timestamp' => time()
|
|
||||||
]);
|
|
||||||
|
|
||||||
echo "event: {$eventType}\n";
|
|
||||||
echo "data: {$eventData}\n\n";
|
|
||||||
|
|
||||||
// Force output
|
|
||||||
if (ob_get_level()) {
|
|
||||||
ob_flush();
|
|
||||||
}
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate SSE connection request
|
|
||||||
*/
|
|
||||||
private function validateSSEConnection($userId)
|
|
||||||
{
|
|
||||||
// Check if user ID is valid format
|
|
||||||
if (!Validation::validateUserId($userId)['valid']) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify CSRF token if provided in URL
|
|
||||||
$csrfToken = $_GET['csrf'] ?? '';
|
|
||||||
if (!empty($csrfToken) && !Security::validateCSRFToken($csrfToken)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limiting for SSE connections
|
|
||||||
if (!Security::checkRateLimit(Security::getClientIP(), 'sse_connection', 5, 60)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the last message ID that a user has seen
|
|
||||||
*/
|
|
||||||
private function getLastMessageIdForUser($userId)
|
|
||||||
{
|
|
||||||
// Get from GET parameter, session, or database preference
|
|
||||||
$lastId = $_GET['last_id'] ?? $_SESSION['sse_last_message_id'] ?? null;
|
|
||||||
|
|
||||||
if (!$lastId) {
|
|
||||||
// If no last ID, start from last 50 messages
|
|
||||||
$recentMessages = $this->chatMessageModel->getRecent(1);
|
|
||||||
$lastId = !empty($recentMessages) ? $recentMessages[0]['id'] : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$_SESSION['sse_last_message_id'] = $lastId;
|
|
||||||
return $lastId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connection should be terminated
|
|
||||||
*/
|
|
||||||
private function shouldTerminateConnection()
|
|
||||||
{
|
|
||||||
// Check memory usage (terminate if > 32MB)
|
|
||||||
$memoryUsage = memory_get_usage(true);
|
|
||||||
if ($memoryUsage > 32 * 1024 * 1024) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check execution time (terminate after 10 minutes)
|
|
||||||
$executionTime = time() - $_SESSION['sse_start_time'];
|
|
||||||
if ($executionTime > 600) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is still authenticated (if admin)
|
|
||||||
if (Security::isAdminAuthenticated()) {
|
|
||||||
$timeout = Config::get('admin.session_timeout', 3600);
|
|
||||||
$loginTime = $_SESSION['admin_login_time'] ?? 0;
|
|
||||||
if (time() - $loginTime > $timeout) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle broadcasting a new message to any registered listeners
|
|
||||||
* (This would be used in a more advanced implementation with process communication)
|
|
||||||
*/
|
|
||||||
public function broadcastMessage($messageData)
|
|
||||||
{
|
|
||||||
// Store message in database
|
|
||||||
$messageId = $this->chatMessageModel->create($messageData);
|
|
||||||
|
|
||||||
if ($messageId) {
|
|
||||||
// Log message creation
|
|
||||||
Security::logSecurityEvent('message_broadcast', [
|
|
||||||
'message_id' => $messageId,
|
|
||||||
'user_id' => $messageData['user_id']
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
377
setup.php
377
setup.php
|
|
@ -1,377 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Interactive Setup Script for Dodgers IPTV
|
|
||||||
* Automates the installation and configuration process
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Enable all error reporting for setup
|
|
||||||
ini_set('display_errors', 1);
|
|
||||||
ini_set('display_startup_errors', 1);
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
|
|
||||||
echo "\n🎯 DODGERS IPTV - AUTOMATED SETUP SCRIPT\n";
|
|
||||||
echo str_repeat("=", 50) . "\n\n";
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
$setup = [
|
|
||||||
'checks' => [],
|
|
||||||
'warnings' => [],
|
|
||||||
'errors' => [],
|
|
||||||
'steps' => 0
|
|
||||||
];
|
|
||||||
|
|
||||||
function logStep($message) {
|
|
||||||
global $setup;
|
|
||||||
$setup['steps']++;
|
|
||||||
echo "[$setup[steps]] $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function logSuccess($message) {
|
|
||||||
echo " ✅ $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function logWarning($message) {
|
|
||||||
global $setup;
|
|
||||||
$setup['warnings'][] = $message;
|
|
||||||
echo " ⚠️ $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function logError($message) {
|
|
||||||
global $setup;
|
|
||||||
$setup['errors'][] = $message;
|
|
||||||
echo " ❌ $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRequirement($name, $check, $required = true) {
|
|
||||||
logStep("Checking $name...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $check();
|
|
||||||
|
|
||||||
if ($result['status']) {
|
|
||||||
logSuccess($result['message']);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
if ($required) {
|
|
||||||
logError($result['message']);
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
logWarning($result['message']);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
logError("Exception: " . $e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// System Requirements Check
|
|
||||||
echo "🔧 SYSTEM REQUIREMENTS CHECK\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
checkRequirement("PHP Version", function() {
|
|
||||||
$version = PHP_VERSION;
|
|
||||||
if (version_compare($version, '8.1.0', '>=')) {
|
|
||||||
return ['status' => true, 'message' => "PHP $version - Excellent!"];
|
|
||||||
} elseif (version_compare($version, '7.4.0', '>=')) {
|
|
||||||
return ['status' => true, 'message' => "PHP $version - Compatible but consider upgrading"];
|
|
||||||
} else {
|
|
||||||
return ['status' => false, 'message' => "PHP $version - Must be 8.1+ for optimal security"];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("PDO Extension", function() {
|
|
||||||
if (extension_loaded('pdo')) {
|
|
||||||
return ['status' => true, 'message' => "PDO extension loaded"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => "PDO extension required for database operations"];
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("SQLite PDO Driver", function() {
|
|
||||||
if (extension_loaded('pdo_sqlite')) {
|
|
||||||
return ['status' => true, 'message' => "SQLite PDO driver loaded"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => "SQLite PDO driver required for database"];
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("MBString Extension", function() {
|
|
||||||
if (extension_loaded('mbstring')) {
|
|
||||||
return ['status' => true, 'message' => "MBString extension loaded"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => "MBString extension recommended for UTF-8 support"];
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("JSON Extension", function() {
|
|
||||||
if (extension_loaded('json')) {
|
|
||||||
return ['status' => true, 'message' => "JSON extension loaded"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => "JSON extension required"];
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("File Permissions", function() {
|
|
||||||
$tests = [
|
|
||||||
'.' => 'read',
|
|
||||||
'logs/' => 'write',
|
|
||||||
'data/' => 'write',
|
|
||||||
'migrations/' => 'read'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($tests as $path => $permission) {
|
|
||||||
if ($permission === 'read' && !is_readable($path)) {
|
|
||||||
return ['status' => false, 'message' => "$path is not readable"];
|
|
||||||
}
|
|
||||||
if ($permission === 'write' && !is_writable($path)) {
|
|
||||||
if (!is_dir($path)) {
|
|
||||||
mkdir($path, 0755, true);
|
|
||||||
if (!is_writable($path)) {
|
|
||||||
return ['status' => false, 'message' => "$path is not writable (could not create)"];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return ['status' => false, 'message' => "$path is not writable"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['status' => true, 'message' => "All required files/directories have proper permissions"];
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("Composer Dependencies", function() {
|
|
||||||
if (file_exists('vendor/autoload.php')) {
|
|
||||||
return ['status' => true, 'message' => "Composer dependencies installed"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => "Run 'composer install' to install dependencies"];
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
// Configuration File Check
|
|
||||||
echo "\n📝 CONFIGURATION CHECK\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
checkRequirement("Environment Configuration", function() {
|
|
||||||
if (file_exists('.env')) {
|
|
||||||
return ['status' => true, 'message' => ".env file exists"];
|
|
||||||
}
|
|
||||||
return ['status' => false, 'message' => ".env file missing - copy .env.example"];
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
// Database Setup
|
|
||||||
echo "\n🗄️ DATABASE SETUP\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
// Include autoloader to load all classes
|
|
||||||
if (file_exists('includes/autoloader.php')) {
|
|
||||||
require_once 'includes/autoloader.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
checkRequirement(" Database Initialization", function() {
|
|
||||||
try {
|
|
||||||
// Test database connection - Database class should now be auto-loaded
|
|
||||||
$db = Database::getInstance();
|
|
||||||
|
|
||||||
// Check if tables exist
|
|
||||||
$tables = ['users', 'chat_messages', 'active_viewers'];
|
|
||||||
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table'");
|
|
||||||
$existingTables = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
||||||
|
|
||||||
$missingTables = array_diff($tables, $existingTables);
|
|
||||||
|
|
||||||
if (empty($missingTables)) {
|
|
||||||
return ['status' => true, 'message' => "All database tables exist"];
|
|
||||||
} else {
|
|
||||||
return ['status' => false, 'message' => "Missing tables: " . implode(', ', $missingTables) . " - run database migrations"];
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return ['status' => false, 'message' => "Database connection failed: " . $e->getMessage() . " - check Database.php configuration"];
|
|
||||||
}
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
// Test Core Classes
|
|
||||||
echo "\n🧪 CORE CLASSES TEST\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
$classes = [
|
|
||||||
'Config' => 'includes/Config.php',
|
|
||||||
'Security' => 'utils/Security.php',
|
|
||||||
'Validation' => 'utils/Validation.php',
|
|
||||||
'Database' => 'includes/Database.php',
|
|
||||||
'UserModel' => 'models/UserModel.php',
|
|
||||||
'ChatMessageModel' => 'models/ChatMessageModel.php',
|
|
||||||
'ActiveViewerModel' => 'models/ActiveViewerModel.php',
|
|
||||||
'ErrorHandler' => 'includes/ErrorHandler.php',
|
|
||||||
'ChatServer' => 'services/ChatServer.php'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($classes as $className => $file) {
|
|
||||||
checkRequirement("$className Class", function() use ($className, $file) {
|
|
||||||
if (!file_exists($file)) {
|
|
||||||
return ['status' => false, 'message' => "$file not found"];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
require_once $file;
|
|
||||||
|
|
||||||
if (class_exists($className)) {
|
|
||||||
return ['status' => true, 'message' => "Class loaded successfully"];
|
|
||||||
} else {
|
|
||||||
return ['status' => false, 'message' => "Class $className not found in $file"];
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return ['status' => false, 'message' => "Failed to load class: " . $e->getMessage()];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Functional Tests
|
|
||||||
echo "\n⚙️ FUNCTIONAL TESTS\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
checkRequirement("Security Functions", function() {
|
|
||||||
if (!class_exists('Security')) return ['status' => false, 'message' => "Security class not loaded"];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test token generation
|
|
||||||
$token = Security::generateSecureToken(16);
|
|
||||||
if (strlen($token) !== 32) {
|
|
||||||
return ['status' => false, 'message' => "Token generation failed (wrong length)"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test user ID generation
|
|
||||||
$userId = Security::generateSecureUserId();
|
|
||||||
if (strlen($userId) !== 32) {
|
|
||||||
return ['status' => false, 'message' => "User ID generation failed"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test validation
|
|
||||||
$validation = Validation::validateUserId($userId);
|
|
||||||
if (!$validation['valid']) {
|
|
||||||
return ['status' => false, 'message' => "User ID validation failed"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['status' => true, 'message' => "Security and validation functions working"];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return ['status' => false, 'message' => "Error testing functions: " . $e->getMessage()];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
checkRequirement("Admin Password Generation", function() {
|
|
||||||
// Check if generate_hash.php exists and is executable
|
|
||||||
if (!file_exists('generate_hash.php')) {
|
|
||||||
return ['status' => false, 'message' => "generate_hash.php script missing"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test basic admin functionality
|
|
||||||
if (!class_exists('Security')) return ['status' => false, 'message' => "Cannot test admin functions"];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test admin authentication check (should fail without setup)
|
|
||||||
$auth = Security::isAdminAuthenticated();
|
|
||||||
// This might be false (expected for new setup)
|
|
||||||
return ['status' => true, 'message' => "Admin authentication system loaded (not yet configured)"];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return ['status' => false, 'message' => "Error testing admin functions: " . $e->getMessage()];
|
|
||||||
}
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
// Configuration Generation
|
|
||||||
echo "\n🔧 CONFIGURATION ASSISTANT\n";
|
|
||||||
echo str_repeat("-", 30) . "\n";
|
|
||||||
|
|
||||||
if (!file_exists('.env')) {
|
|
||||||
echo "Creating .env configuration file...\n";
|
|
||||||
|
|
||||||
$envTemplate = <<<'EOT'
|
|
||||||
# Dodgers IPTV Environment Configuration
|
|
||||||
# Generated by setup.php
|
|
||||||
|
|
||||||
# Environment Settings
|
|
||||||
APP_ENV=development
|
|
||||||
SECRET_KEY=%SECRET_KEY%
|
|
||||||
|
|
||||||
# Database Configuration
|
|
||||||
DB_DATABASE=data/app.db
|
|
||||||
|
|
||||||
# Admin Credentials (configure these!)
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD_HASH=%ADMIN_HASH%
|
|
||||||
|
|
||||||
# Stream Configuration
|
|
||||||
STREAM_BASE_URL=http://127.0.0.1:8080
|
|
||||||
STREAM_ALLOWED_IPS=127.0.0.1,localhost
|
|
||||||
|
|
||||||
# Security Settings
|
|
||||||
RATE_LIMIT_REQUESTS=100
|
|
||||||
RATE_LIMIT_WINDOW=60
|
|
||||||
SESSION_TIMEOUT=3600
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=DEBUG
|
|
||||||
LOG_FILE=logs/app.log
|
|
||||||
|
|
||||||
# Cache Settings
|
|
||||||
CACHE_ENABLED=true
|
|
||||||
CACHE_TTL=3600
|
|
||||||
EOT;
|
|
||||||
|
|
||||||
// Generate secure defaults
|
|
||||||
$secretKey = bin2hex(random_bytes(32));
|
|
||||||
$adminHash = password_hash('changeme', PASSWORD_ARGON2I);
|
|
||||||
|
|
||||||
$envContent = str_replace(
|
|
||||||
['%SECRET_KEY%', '%ADMIN_HASH%'],
|
|
||||||
[$secretKey, $adminHash],
|
|
||||||
$envTemplate
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = file_put_contents('.env', $envContent);
|
|
||||||
|
|
||||||
if ($result !== false) {
|
|
||||||
logSuccess("Created .env file with secure defaults");
|
|
||||||
logWarning("IMPORTANT: Change the default admin password!");
|
|
||||||
echo "\n Run: php generate_hash.php\n";
|
|
||||||
echo " Then copy the hash to ADMIN_PASSWORD_HASH in .env\n\n";
|
|
||||||
} else {
|
|
||||||
logError("Failed to create .env file");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logSuccess(".env file already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final Summary
|
|
||||||
echo "\n🎯 SETUP SUMMARY\n";
|
|
||||||
echo str_repeat("=", 50) . "\n";
|
|
||||||
|
|
||||||
echo "\n✅ Completed Checks: " . count(array_filter($setup['checks'])) . "\n";
|
|
||||||
echo "⚠️ Warnings: " . count($setup['warnings']) . "\n";
|
|
||||||
echo "❌ Errors: " . count($setup['errors']) . "\n\n";
|
|
||||||
|
|
||||||
if (!empty($setup['warnings'])) {
|
|
||||||
echo "WARNINGS:\n";
|
|
||||||
foreach ($setup['warnings'] as $warning) {
|
|
||||||
echo " • $warning\n";
|
|
||||||
}
|
|
||||||
echo "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($setup['errors'])) {
|
|
||||||
echo "CRITICAL ERRORS (must fix):\n";
|
|
||||||
foreach ($setup['errors'] as $error) {
|
|
||||||
echo " • $error\n";
|
|
||||||
}
|
|
||||||
echo "\n";
|
|
||||||
|
|
||||||
echo "🚨 SETUP INCOMPLETE - Please resolve the errors above\n\n";
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success Message
|
|
||||||
echo "🎉 SETUP COMPLETE! Your Dodgers IPTV system is ready!\n\n";
|
|
||||||
echo "Next Steps:\n";
|
|
||||||
echo " 1. Configure admin credentials: php generate_hash.php\n";
|
|
||||||
echo " 2. Set up your web server (see DEPLOYMENT.md)\n";
|
|
||||||
echo " 3. Access your application and test the features\n";
|
|
||||||
echo " 4. Run tests: make test\n";
|
|
||||||
echo " 5. Deploy to production (see DEPLOYMENT.md)\n\n";
|
|
||||||
|
|
||||||
echo "🚀 Happy Streaming!\n";
|
|
||||||
echo str_repeat("=", 50) . "\n\n";
|
|
||||||
?>
|
|
||||||
|
|
@ -1048,49 +1048,26 @@ a:focus-visible {
|
||||||
COMPONENT-SPECIFIC RESPONSIVE UTILITY CLASSES
|
COMPONENT-SPECIFIC RESPONSIVE UTILITY CLASSES
|
||||||
================================================================= */
|
================================================================= */
|
||||||
|
|
||||||
/* Button responsive utilities - Higher specificity for mobile overrides */
|
/* Button responsive utilities */
|
||||||
@media (max-width: calc(var(--breakpoint-sm) - 1px)) {
|
.btn-responsive-xs { padding: var(--spacing-2) var(--spacing-3) !important; }
|
||||||
.btn-responsive-xs { padding: var(--spacing-2) var(--spacing-3) !important; }
|
.btn-responsive-sm { padding: var(--spacing-3) var(--spacing-4) !important; }
|
||||||
}
|
.btn-responsive-md { padding: var(--spacing-4) var(--spacing-5) !important; }
|
||||||
@media (min-width: var(--breakpoint-sm)) and (max-width: calc(var(--breakpoint-md) - 1px)) {
|
.btn-responsive-lg { padding: var(--spacing-4) var(--spacing-6) !important; }
|
||||||
.btn-responsive-sm { padding: var(--spacing-3) var(--spacing-4) !important; }
|
|
||||||
}
|
|
||||||
@media (min-width: var(--breakpoint-md)) and (max-width: calc(var(--breakpoint-lg) - 1px)) {
|
|
||||||
.btn-responsive-md { padding: var(--spacing-4) var(--spacing-5) !important; }
|
|
||||||
}
|
|
||||||
@media (min-width: var(--breakpoint-lg)) {
|
|
||||||
.btn-responsive-lg { padding: var(--spacing-4) var(--spacing-6) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card responsive utilities - Media query containment */
|
/* Card responsive utilities */
|
||||||
@media (max-width: calc(var(--breakpoint-sm) - 1px)) {
|
.card-responsive-xs .card-body { padding: var(--spacing-3) !important; }
|
||||||
.card-responsive-xs .card-body { padding: var(--spacing-3) !important; }
|
.card-responsive-sm .card-body { padding: var(--spacing-4) !important; }
|
||||||
}
|
.card-responsive-md .card-body { padding: var(--spacing-5) !important; }
|
||||||
@media (min-width: var(--breakpoint-sm)) and (max-width: calc(var(--breakpoint-md) - 1px)) {
|
|
||||||
.card-responsive-sm .card-body { padding: var(--spacing-4) !important; }
|
|
||||||
}
|
|
||||||
@media (min-width: var(--breakpoint-md)) {
|
|
||||||
.card-responsive-md .card-body { padding: var(--spacing-5) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message responsive utilities - Scoped to specific contexts */
|
/* Message responsive utilities */
|
||||||
.message-responsive-mobile .message-text { max-width: 85% !important; }
|
.message-responsive-mobile .message-text { max-width: 85% !important; font-size: var(--font-size-sm) !important; }
|
||||||
.message-responsive-tablet .message-text { max-width: 90% !important; }
|
.message-responsive-tablet .message-text { max-width: 90% !important; }
|
||||||
.message-responsive-desktop .message-text { max-width: 95% !important; }
|
.message-responsive-desktop .message-text { max-width: 95% !important; }
|
||||||
|
|
||||||
/* Font size overrides for message text - only when sizing utility is applied */
|
/* Video player responsive utilities */
|
||||||
.message-responsive-mobile .message-text { font-size: var(--font-size-sm) !important; }
|
.video-responsive-mobile .video-player__header { padding: var(--spacing-2) var(--spacing-3) !important; }
|
||||||
|
.video-responsive-tablet .video-player__header { padding: var(--spacing-3) var(--spacing-4) !important; }
|
||||||
/* Video player responsive utilities - Scoped breakpoints */
|
.video-responsive-desktop .video-player__header { padding: var(--spacing-4) var(--spacing-6) !important; }
|
||||||
@media (max-width: calc(var(--breakpoint-sm) - 1px)) {
|
|
||||||
.video-responsive-mobile .video-player__header { padding: var(--spacing-2) var(--spacing-3) !important; }
|
|
||||||
}
|
|
||||||
@media (min-width: var(--breakpoint-sm)) and (max-width: calc(var(--breakpoint-md) - 1px)) {
|
|
||||||
.video-responsive-tablet .video-player__header { padding: var(--spacing-3) var(--spacing-4) !important; }
|
|
||||||
}
|
|
||||||
@media (min-width: var(--breakpoint-md)) {
|
|
||||||
.video-responsive-desktop .video-player__header { padding: var(--spacing-4) var(--spacing-6) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =================================================================
|
/* =================================================================
|
||||||
DASHBOARD COMPONENTS
|
DASHBOARD COMPONENTS
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@
|
||||||
/* Interactive button icons - more pronounced effects */
|
/* Interactive button icons - more pronounced effects */
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
|
filter: brightness(1.2);
|
||||||
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/* Reset and normalize styles */
|
/* Reset and normalize styles */
|
||||||
*, *::before, *::after {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -10,8 +10,7 @@ body {
|
||||||
font-family: var(--font-family-primary);
|
font-family: var(--font-family-primary);
|
||||||
background: var(--bg-darkest);
|
background: var(--bg-darkest);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
min-height: 100dvh; /* Dynamic viewport height for mobile */
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,115 @@
|
||||||
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
RESPONSIVE WIDTH/HEIGHT UTILITIES - Using Breakpoint Custom Properties
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
/* Responsive Width - Small and up */
|
||||||
|
@media (min-width: var(--breakpoint-sm)) {
|
||||||
|
.sm\:w-full { width: 100%; }
|
||||||
|
.sm\:w-auto { width: auto; }
|
||||||
|
.sm\:w-1\/2 { width: 50%; }
|
||||||
|
.sm\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.sm\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.sm\:w-1\/4 { width: 25%; }
|
||||||
|
.sm\:w-3\/4 { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Width - Medium and up */
|
||||||
|
@media (min-width: var(--breakpoint-md)) {
|
||||||
|
.md\:w-full { width: 100%; }
|
||||||
|
.md\:w-auto { width: auto; }
|
||||||
|
.md\:w-1\/2 { width: 50%; }
|
||||||
|
.md\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.md\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.md\:w-1\/4 { width: 25%; }
|
||||||
|
.md\:w-3\/4 { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Width - Large and up */
|
||||||
|
@media (min-width: var(--breakpoint-lg)) {
|
||||||
|
.lg\:w-full { width: 100%; }
|
||||||
|
.lg\:w-auto { width: auto; }
|
||||||
|
.lg\:w-1\/2 { width: 50%; }
|
||||||
|
.lg\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.lg\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.lg\:w-1\/4 { width: 25%; }
|
||||||
|
.lg\:w-3\/4 { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Width - Extra Large and up */
|
||||||
|
@media (min-width: var(--breakpoint-xl)) {
|
||||||
|
.xl\:w-full { width: 100%; }
|
||||||
|
.xl\:w-auto { width: auto; }
|
||||||
|
.xl\:w-1\/2 { width: 50%; }
|
||||||
|
.xl\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.xl\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.xl\:w-1\/4 { width: 25%; }
|
||||||
|
.xl\:w-3\/4 { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Width - 2XL and up */
|
||||||
|
@media (min-width: var(--breakpoint-2xl)) {
|
||||||
|
.2xl\:w-full { width: 100%; }
|
||||||
|
.2xl\:w-auto { width: auto; }
|
||||||
|
.2xl\:w-1\/2 { width: 50%; }
|
||||||
|
.2xl\:w-1\/3 { width: 33.333333%; }
|
||||||
|
.2xl\:w-2\/3 { width: 66.666667%; }
|
||||||
|
.2xl\:w-1\/4 { width: 25%; }
|
||||||
|
.2xl\:w-3\/4 { width: 75%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Height - Small and up */
|
||||||
|
@media (min-width: var(--breakpoint-sm)) {
|
||||||
|
.sm\:h-full { height: 100%; }
|
||||||
|
.sm\:h-auto { height: auto; }
|
||||||
|
.sm\:h-screen { height: 100vh; }
|
||||||
|
.sm\:h-1\/2 { height: 50%; }
|
||||||
|
.sm\:h-1\/3 { height: 33.333333%; }
|
||||||
|
.sm\:h-2\/3 { height: 66.666667%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Height - Medium and up */
|
||||||
|
@media (min-width: var(--breakpoint-md)) {
|
||||||
|
.md\:h-full { height: 100%; }
|
||||||
|
.md\:h-auto { height: auto; }
|
||||||
|
.md\:h-screen { height: 100vh; }
|
||||||
|
.md\:h-1\/2 { height: 50%; }
|
||||||
|
.md\:h-1\/3 { height: 33.333333%; }
|
||||||
|
.md\:h-2\/3 { height: 66.666667%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Height - Large and up */
|
||||||
|
@media (min-width: var(--breakpoint-lg)) {
|
||||||
|
.lg\:h-full { height: 100%; }
|
||||||
|
.lg\:h-auto { height: auto; }
|
||||||
|
.lg\:h-screen { height: 100vh; }
|
||||||
|
.lg\:h-1\/2 { height: 50%; }
|
||||||
|
.lg\:h-1\/3 { height: 33.333333%; }
|
||||||
|
.lg\:h-2\/3 { height: 66.666667%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Height - Extra Large and up */
|
||||||
|
@media (min-width: var(--breakpoint-xl)) {
|
||||||
|
.xl\:h-full { height: 100%; }
|
||||||
|
.xl\:h-auto { height: auto; }
|
||||||
|
.xl\:h-screen { height: 100vh; }
|
||||||
|
.xl\:h-1\/2 { height: 50%; }
|
||||||
|
.xl\:h-1\/3 { height: 33.333333%; }
|
||||||
|
.xl\:h-2\/3 { height: 66.666667%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Height - 2XL and up */
|
||||||
|
@media (min-width: var(--breakpoint-2xl)) {
|
||||||
|
.2xl\:h-full { height: 100%; }
|
||||||
|
.2xl\:h-auto { height: auto; }
|
||||||
|
.2xl\:h-screen { height: 100vh; }
|
||||||
|
.2xl\:h-1\/2 { height: 50%; }
|
||||||
|
.2xl\:h-1\/3 { height: 33.333333%; }
|
||||||
|
.2xl\:h-2\/3 { height: 66.666667%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* =================================================================
|
/* =================================================================
|
||||||
RESPONSIVE GRID VARIATION CLASSES - Extra Large breakpoint (xl and up)
|
RESPONSIVE GRID VARIATION CLASSES - Extra Large breakpoint (xl and up)
|
||||||
================================================================= */
|
================================================================= */
|
||||||
|
|
@ -446,7 +555,6 @@
|
||||||
.h-1\/4 { height: 25%; }
|
.h-1\/4 { height: 25%; }
|
||||||
.h-2\/4 { height: 50%; }
|
.h-2\/4 { height: 50%; }
|
||||||
.h-3\/4 { height: 75%; }
|
.h-3\/4 { height: 75%; }
|
||||||
/* =================================================================
|
|
||||||
RESPONSIVE WIDTH/HEIGHT UTILITIES - Using Breakpoint Custom Properties
|
RESPONSIVE WIDTH/HEIGHT UTILITIES - Using Breakpoint Custom Properties
|
||||||
================================================================= */
|
================================================================= */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,6 @@
|
||||||
/* Borders & Dividers */
|
/* Borders & Dividers */
|
||||||
--border-color: #1a2332;
|
--border-color: #1a2332;
|
||||||
--border-color-light: #232d3a;
|
--border-color-light: #232d3a;
|
||||||
--border-color-humane: #232d3a; /* For responsive borders */
|
|
||||||
--divider-color: rgba(255, 255, 255, 0.1);
|
--divider-color: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
/* Input & Form Elements */
|
/* Input & Form Elements */
|
||||||
|
|
@ -304,34 +303,17 @@
|
||||||
--mq-dashboard-enabled: (min-width: var(--breakpoint-2xl));
|
--mq-dashboard-enabled: (min-width: var(--breakpoint-2xl));
|
||||||
|
|
||||||
/* =================================================================
|
/* =================================================================
|
||||||
Z-INDEX SCALE - Systematic Layer System
|
Z-INDEX SCALE
|
||||||
================================================================= */
|
================================================================= */
|
||||||
|
|
||||||
/* Base layers (0-99) */
|
--z-dropdown: 1000;
|
||||||
--z-base: 0;
|
--z-sticky: 1020;
|
||||||
--z-ground: 1;
|
--z-fixed: 1030;
|
||||||
|
--z-modal-backdrop: 1040;
|
||||||
/* Content layers (100-299) */
|
--z-modal: 1050;
|
||||||
--z-content: 100;
|
--z-popover: 1060;
|
||||||
--z-card: 110;
|
--z-tooltip: 1070;
|
||||||
--z-overlay: 200;
|
--z-toast: 1080;
|
||||||
|
|
||||||
/* Fixed/Positioned elements (300-599) */
|
|
||||||
--z-sticky: 300;
|
|
||||||
--z-dropdown: 400;
|
|
||||||
--z-fixed: 500;
|
|
||||||
|
|
||||||
/* Modals/Dialogs (600-799) */
|
|
||||||
--z-modal-backdrop: 600;
|
|
||||||
--z-modal: 700;
|
|
||||||
--z-popover: 750;
|
|
||||||
|
|
||||||
/* Notifications/Feedback (800-999) */
|
|
||||||
--z-tooltip: 800;
|
|
||||||
--z-toast: 900;
|
|
||||||
|
|
||||||
/* Legacy compatibility */
|
|
||||||
--z-modal-legacy: 1000; /* Keep for existing code */
|
|
||||||
|
|
||||||
/* =================================================================
|
/* =================================================================
|
||||||
ICON SIZE SCALE
|
ICON SIZE SCALE
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* PHPUnit Test Bootstrap
|
|
||||||
* Sets up test environment and dependencies
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Define test environment
|
|
||||||
define('TESTING', true);
|
|
||||||
define('APP_ENV', 'testing');
|
|
||||||
|
|
||||||
// Include autoloader if it exists, otherwise manually load classes
|
|
||||||
if (file_exists(__DIR__ . '/../includes/autoloader.php')) {
|
|
||||||
require_once __DIR__ . '/../includes/autoloader.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize error handling for tests
|
|
||||||
if (class_exists('ErrorHandler')) {
|
|
||||||
ErrorHandler::initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up test database configuration
|
|
||||||
$_ENV['APP_ENV'] = 'testing';
|
|
||||||
$_ENV['DB_DATABASE'] = ':memory:'; // Use in-memory SQLite for tests
|
|
||||||
$_ENV['DB_DRIVER'] = 'sqlite';
|
|
||||||
|
|
||||||
// Mock session for testing
|
|
||||||
if (!isset($_SESSION)) {
|
|
||||||
$_SESSION = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock POST/GET data if needed
|
|
||||||
if (!isset($_POST)) {
|
|
||||||
$_POST = [];
|
|
||||||
}
|
|
||||||
if (!isset($_GET)) {
|
|
||||||
$_GET = [];
|
|
||||||
}
|
|
||||||
if (!isset($_SERVER)) {
|
|
||||||
$_SERVER = [
|
|
||||||
'REQUEST_METHOD' => 'GET',
|
|
||||||
'HTTP_HOST' => 'localhost',
|
|
||||||
'SERVER_NAME' => 'localhost',
|
|
||||||
'SERVER_PORT' => '80',
|
|
||||||
'REQUEST_URI' => '/',
|
|
||||||
'SCRIPT_NAME' => '/index.php',
|
|
||||||
'PHP_SELF' => '/index.php',
|
|
||||||
'REMOTE_ADDR' => '127.0.0.1',
|
|
||||||
'HTTP_USER_AGENT' => 'PHPUnit/Test'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize PDO for in-memory SQLite testing
|
|
||||||
class TestDatabaseHelper
|
|
||||||
{
|
|
||||||
private static $pdo = null;
|
|
||||||
|
|
||||||
public static function getTestPdo()
|
|
||||||
{
|
|
||||||
if (self::$pdo === null) {
|
|
||||||
self::$pdo = new PDO('sqlite::memory:');
|
|
||||||
self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
||||||
self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
// Enable WAL mode for better testing performance
|
|
||||||
self::$pdo->exec('PRAGMA journal_mode=WAL');
|
|
||||||
self::$pdo->exec('PRAGMA synchronous=NORMAL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function setupTestSchema()
|
|
||||||
{
|
|
||||||
$pdo = self::getTestPdo();
|
|
||||||
|
|
||||||
// Create tables for testing
|
|
||||||
$sql = file_get_contents(__DIR__ . '/../migrations/001_create_tables.sql');
|
|
||||||
$pdo->exec($sql);
|
|
||||||
|
|
||||||
// Insert test data if needed
|
|
||||||
self::insertTestData($pdo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function insertTestData($pdo)
|
|
||||||
{
|
|
||||||
// Insert some test users
|
|
||||||
$pdo->exec("INSERT INTO users (user_id, nickname, ip_address, session_id, last_seen)
|
|
||||||
VALUES ('test_user_1', 'TestUser1', '192.168.1.100', 'session_123', datetime('now'))");
|
|
||||||
|
|
||||||
$pdo->exec("INSERT INTO users (user_id, nickname, ip_address, session_id, last_seen)
|
|
||||||
VALUES ('test_user_2', 'TestUser2', '192.168.1.101', 'session_456', datetime('now'))");
|
|
||||||
|
|
||||||
// Insert test messages
|
|
||||||
$pdo->exec("INSERT INTO chat_messages (user_id, nickname, message, is_admin, ip_address, time_formatted)
|
|
||||||
VALUES ('test_user_1', 'TestUser1', 'Hello from test user 1', 0, '192.168.1.100', '12:00')");
|
|
||||||
|
|
||||||
$pdo->exec("INSERT INTO chat_messages (user_id, nickname, message, is_admin, ip_address, time_formatted)
|
|
||||||
VALUES ('test_user_2', 'TestUser2', 'Hello from test user 2', 0, '192.168.1.101', '12:01')");
|
|
||||||
|
|
||||||
// Insert test active viewers
|
|
||||||
$pdo->exec("INSERT INTO active_viewers (user_id, nickname, ip_address, session_id, is_admin, last_seen)
|
|
||||||
VALUES ('test_user_1', 'TestUser1', '192.168.1.100', 'session_123', 0, datetime('now'))");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function teardown()
|
|
||||||
{
|
|
||||||
self::$pdo = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up any existing test artifacts
|
|
||||||
function cleanupTestEnvironment()
|
|
||||||
{
|
|
||||||
// Clear test session data
|
|
||||||
$_SESSION = [];
|
|
||||||
|
|
||||||
// Remove any test files
|
|
||||||
$testFiles = [
|
|
||||||
__DIR__ . '/../logs/app.log',
|
|
||||||
__DIR__ . '/../active_viewers.json.backup',
|
|
||||||
__DIR__ . '/../chat_messages.json.backup'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($testFiles as $file) {
|
|
||||||
if (file_exists($file)) {
|
|
||||||
@unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up test environment
|
|
||||||
cleanupTestEnvironment();
|
|
||||||
|
|
||||||
// Register shutdown function to clean up
|
|
||||||
register_shutdown_function(function() {
|
|
||||||
TestDatabaseHelper::teardown();
|
|
||||||
cleanupTestEnvironment();
|
|
||||||
});
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Security utility functions
|
|
||||||
*/
|
|
||||||
class SecurityTest extends TestCase
|
|
||||||
{
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
// Clear any previous session data
|
|
||||||
$_SESSION = [];
|
|
||||||
$_POST = [];
|
|
||||||
$_GET = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGenerateSecureToken()
|
|
||||||
{
|
|
||||||
$token1 = Security::generateSecureToken(16);
|
|
||||||
$token2 = Security::generateSecureToken(16);
|
|
||||||
|
|
||||||
// Test length
|
|
||||||
$this->assertEquals(32, strlen($token1)); // 16 bytes = 32 hex chars
|
|
||||||
$this->assertEquals(32, strlen($token2));
|
|
||||||
|
|
||||||
// Test uniqueness
|
|
||||||
$this->assertNotEquals($token1, $token2);
|
|
||||||
|
|
||||||
// Test valid hex characters
|
|
||||||
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $token1);
|
|
||||||
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $token2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGenerateSecureUserId()
|
|
||||||
{
|
|
||||||
$userId1 = Security::generateSecureUserId();
|
|
||||||
$userId2 = Security::generateSecureUserId();
|
|
||||||
|
|
||||||
// Test format (32 char hex)
|
|
||||||
$this->assertEquals(32, strlen($userId1));
|
|
||||||
$this->assertEquals(32, strlen($userId2));
|
|
||||||
|
|
||||||
// Test uniqueness
|
|
||||||
$this->assertNotEquals($userId1, $userId2);
|
|
||||||
|
|
||||||
// Test valid characters
|
|
||||||
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $userId1);
|
|
||||||
$this->assertMatchesRegularExpression('/^[a-f0-9]+$/', $userId2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetClientIP()
|
|
||||||
{
|
|
||||||
// Test with default server vars
|
|
||||||
$ip = Security::getClientIP();
|
|
||||||
$this->assertEquals('127.0.0.1', $ip);
|
|
||||||
|
|
||||||
// Test with forwarded headers
|
|
||||||
$_SERVER['HTTP_X_FORWARDED_FOR'] = '192.168.1.100, 10.0.0.1';
|
|
||||||
$ip = Security::getClientIP();
|
|
||||||
$this->assertEquals('192.168.1.100', $ip);
|
|
||||||
|
|
||||||
// Test with real IP header
|
|
||||||
$_SERVER['HTTP_X_REAL_IP'] = '203.0.113.1';
|
|
||||||
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
||||||
$ip = Security::getClientIP();
|
|
||||||
$this->assertEquals('203.0.113.1', $ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSanitizeInput()
|
|
||||||
{
|
|
||||||
// Test string sanitization
|
|
||||||
$input = '<script>alert("xss")</script>Hello World';
|
|
||||||
$result = Security::sanitizeInput($input, 'string');
|
|
||||||
$this->assertEquals('alert("xss")Hello World', $result);
|
|
||||||
|
|
||||||
// Test email sanitization
|
|
||||||
$email = 'test@example.com<script>evil</script>';
|
|
||||||
$result = Security::sanitizeInput($email, 'email');
|
|
||||||
$this->assertEquals('test@example.com<script>evil</script>', $result);
|
|
||||||
|
|
||||||
// Test URL sanitization
|
|
||||||
$url = 'http://example.com/path<script>evil</script>';
|
|
||||||
$result = Security::sanitizeInput($url, 'url');
|
|
||||||
$this->assertEquals('http://example.com/path', $result); // Scripts should be stripped
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateCSRFToken()
|
|
||||||
{
|
|
||||||
// Generate a token
|
|
||||||
$token = Security::generateCSRFToken();
|
|
||||||
$_SESSION['csrf_token'] = $token;
|
|
||||||
|
|
||||||
// Test valid token
|
|
||||||
$this->assertTrue(Security::validateCSRFToken($token));
|
|
||||||
|
|
||||||
// Test invalid token
|
|
||||||
$this->assertFalse(Security::validateCSRFToken('invalid_token'));
|
|
||||||
|
|
||||||
// Test missing token
|
|
||||||
$this->assertFalse(Security::validateCSRFToken(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCheckRateLimit()
|
|
||||||
{
|
|
||||||
$ip = '192.168.1.100';
|
|
||||||
|
|
||||||
// First request should succeed
|
|
||||||
$result1 = Security::checkRateLimit($ip, 'test_action', 3, 60);
|
|
||||||
$this->assertTrue($result1);
|
|
||||||
|
|
||||||
// Second request should succeed
|
|
||||||
$result2 = Security::checkRateLimit($ip, 'test_action', 3, 60);
|
|
||||||
$this->assertTrue($result2);
|
|
||||||
|
|
||||||
// Third request should succeed
|
|
||||||
$result3 = Security::checkRateLimit($ip, 'test_action', 3, 60);
|
|
||||||
$this->assertTrue($result3);
|
|
||||||
|
|
||||||
// Fourth request should fail (over limit)
|
|
||||||
$result4 = Security::checkRateLimit($ip, 'test_action', 3, 60);
|
|
||||||
$this->assertFalse($result4);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIsValidStreamUrl()
|
|
||||||
{
|
|
||||||
// Valid URLs
|
|
||||||
$this->assertTrue(Security::isValidStreamUrl('http://127.0.0.1:8080/stream'));
|
|
||||||
$this->assertTrue(Security::isValidStreamUrl('https://127.0.0.1:8080/stream'));
|
|
||||||
$this->assertTrue(Security::isValidStreamUrl('http://localhost:8080/stream'));
|
|
||||||
|
|
||||||
// Invalid URLs
|
|
||||||
$this->assertFalse(Security::isValidStreamUrl('http://evil.com/stream'));
|
|
||||||
$this->assertFalse(Security::isValidStreamUrl('http://192.168.1.1/stream'));
|
|
||||||
$this->assertFalse(Security::isValidStreamUrl('javascript:alert(1)'));
|
|
||||||
$this->assertFalse(Security::isValidStreamUrl(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAdminAuthentication()
|
|
||||||
{
|
|
||||||
// Test without any auth setup
|
|
||||||
$this->assertFalse(Security::isAdminAuthenticated());
|
|
||||||
|
|
||||||
// Set up session auth
|
|
||||||
$_SESSION['admin_authenticated'] = true;
|
|
||||||
$_SESSION['admin_login_time'] = time();
|
|
||||||
|
|
||||||
$this->assertTrue(Security::isAdminAuthenticated());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticateAdmin()
|
|
||||||
{
|
|
||||||
// This would need proper config setup for real testing
|
|
||||||
// For now, test that the method exists and handles failures
|
|
||||||
$result = Security::authenticateAdmin('invalid_user', 'invalid_pass');
|
|
||||||
$this->assertFalse($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDetectSuspiciousActivity()
|
|
||||||
{
|
|
||||||
// Test with normal request
|
|
||||||
$warnings = Security::detectSuspiciousActivity();
|
|
||||||
$this->assertIsArray($warnings);
|
|
||||||
|
|
||||||
// Test with suspicious user agent
|
|
||||||
$_SERVER['HTTP_USER_AGENT'] = 'sqlmap';
|
|
||||||
$warnings = Security::detectSuspiciousActivity();
|
|
||||||
$this->assertContains('Suspicious user agent detected', $warnings);
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
$_SERVER['HTTP_USER_AGENT'] = 'PHPUnit/Test';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLogSecurityEvent()
|
|
||||||
{
|
|
||||||
// Start output buffering to capture logs
|
|
||||||
ob_start();
|
|
||||||
|
|
||||||
// Generate a security event
|
|
||||||
Security::logSecurityEvent('test_event', ['test_data' => 'value']);
|
|
||||||
|
|
||||||
// The actual logging happens in ErrorHandler, so we test that no exceptions are thrown
|
|
||||||
$this->assertTrue(true);
|
|
||||||
|
|
||||||
ob_end_clean();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test UserModel database operations
|
|
||||||
*/
|
|
||||||
class UserModelTest extends TestCase
|
|
||||||
{
|
|
||||||
private $userModel;
|
|
||||||
private $testUserId;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
// Set up in-memory database for testing
|
|
||||||
TestDatabaseHelper::setupTestSchema();
|
|
||||||
$this->userModel = new UserModel();
|
|
||||||
$this->testUserId = 'test_user_' . bin2hex(random_bytes(8));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
// Clean up after each test
|
|
||||||
$pdo = TestDatabaseHelper::getTestPdo();
|
|
||||||
$pdo->exec('DELETE FROM users');
|
|
||||||
$pdo->exec('DELETE FROM chat_messages');
|
|
||||||
$pdo->exec('DELETE FROM active_viewers');
|
|
||||||
$pdo->exec('DELETE FROM banned_users');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreateOrUpdateNewUser()
|
|
||||||
{
|
|
||||||
$userData = [
|
|
||||||
'nickname' => 'TestUser',
|
|
||||||
'ip_address' => '192.168.1.100',
|
|
||||||
'session_id' => 'session_123456'
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = $this->userModel->createOrUpdate($this->testUserId, $userData);
|
|
||||||
|
|
||||||
$this->assertNotFalse($result);
|
|
||||||
|
|
||||||
// Verify user was created
|
|
||||||
$user = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$this->assertNotFalse($user);
|
|
||||||
$this->assertEquals($this->testUserId, $user['user_id']);
|
|
||||||
$this->assertEquals('TestUser', $user['nickname']);
|
|
||||||
$this->assertEquals('192.168.1.100', $user['ip_address']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreateOrUpdateExistingUser()
|
|
||||||
{
|
|
||||||
// First create user
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'OriginalName']);
|
|
||||||
|
|
||||||
// Update existing user
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'UpdatedName']);
|
|
||||||
|
|
||||||
$user = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$this->assertEquals('UpdatedName', $user['nickname']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetActiveUsers()
|
|
||||||
{
|
|
||||||
// Create test users
|
|
||||||
$userId1 = 'active_user_1';
|
|
||||||
$userId2 = 'active_user_2';
|
|
||||||
$userId3 = 'inactive_user';
|
|
||||||
|
|
||||||
// Add active users
|
|
||||||
$this->userModel->createOrUpdate($userId1, ['nickname' => 'Active1']);
|
|
||||||
$this->userModel->createOrUpdate($userId2, ['nickname' => 'Active2']);
|
|
||||||
$this->userModel->createOrUpdate($userId3, ['nickname' => 'Inactive']);
|
|
||||||
|
|
||||||
// Simulate inactive user (old timestamp)
|
|
||||||
$pdo = TestDatabaseHelper::getTestPdo();
|
|
||||||
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-40 seconds') WHERE user_id = '$userId3'");
|
|
||||||
|
|
||||||
$activeUsers = $this->userModel->getActiveUsers(30);
|
|
||||||
|
|
||||||
$this->assertCount(2, $activeUsers);
|
|
||||||
|
|
||||||
// Verify active users are returned
|
|
||||||
$userIds = array_column($activeUsers, 'user_id');
|
|
||||||
$this->assertContains($userId1, $userIds);
|
|
||||||
$this->assertContains($userId2, $userIds);
|
|
||||||
$this->assertNotContains($userId3, $userIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateLastSeen()
|
|
||||||
{
|
|
||||||
// Create user
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
|
|
||||||
|
|
||||||
// Get initial last_seen
|
|
||||||
$user = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$initialLastSeen = $user['last_seen'];
|
|
||||||
|
|
||||||
// Wait a moment to ensure different timestamp
|
|
||||||
sleep(1);
|
|
||||||
|
|
||||||
// Update last seen
|
|
||||||
$result = $this->userModel->updateLastSeen($this->testUserId);
|
|
||||||
$this->assertNotFalse($result);
|
|
||||||
|
|
||||||
// Verify last seen was updated
|
|
||||||
$updatedUser = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$this->assertNotEquals($initialLastSeen, $updatedUser['last_seen']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBanAndUnbanUser()
|
|
||||||
{
|
|
||||||
// Create user first
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
|
|
||||||
|
|
||||||
// Test initial state - not banned
|
|
||||||
$this->assertFalse($this->userModel->isBanned($this->testUserId));
|
|
||||||
|
|
||||||
// Ban user
|
|
||||||
$result = $this->userModel->banUser($this->testUserId, 'admin_user', 'Test ban reason');
|
|
||||||
$this->assertNotFalse($result);
|
|
||||||
|
|
||||||
// Verify user is banned
|
|
||||||
$this->assertTrue($this->userModel->isBanned($this->testUserId));
|
|
||||||
|
|
||||||
// Get banned users list
|
|
||||||
$bannedUsers = $this->userModel->getBannedUsers();
|
|
||||||
$this->assertCount(1, $bannedUsers);
|
|
||||||
$this->assertEquals($this->testUserId, $bannedUsers[0]['user_id']);
|
|
||||||
$this->assertEquals('Test ban reason', $bannedUsers[0]['reason']);
|
|
||||||
|
|
||||||
// Unban user
|
|
||||||
$result = $this->userModel->unbanUser($this->testUserId);
|
|
||||||
$this->assertNotFalse($result);
|
|
||||||
|
|
||||||
// Verify user is no longer banned
|
|
||||||
$this->assertFalse($this->userModel->isBanned($this->testUserId));
|
|
||||||
|
|
||||||
// Verify banned users list is empty
|
|
||||||
$bannedUsers = $this->userModel->getBannedUsers();
|
|
||||||
$this->assertCount(0, $bannedUsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateNickname()
|
|
||||||
{
|
|
||||||
// Create user
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'OldName']);
|
|
||||||
|
|
||||||
// Update nickname
|
|
||||||
$result = $this->userModel->updateNickname($this->testUserId, 'NewName');
|
|
||||||
$this->assertNotFalse($result);
|
|
||||||
|
|
||||||
// Verify nickname was updated
|
|
||||||
$user = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$this->assertEquals('NewName', $user['nickname']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCleanupOldRecords()
|
|
||||||
{
|
|
||||||
// Create user
|
|
||||||
$this->userModel->createOrUpdate($this->testUserId, ['nickname' => 'TestUser']);
|
|
||||||
|
|
||||||
// Set last_seen to be very old
|
|
||||||
$pdo = TestDatabaseHelper::getTestPdo();
|
|
||||||
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-40 days') WHERE user_id = '$this->testUserId'");
|
|
||||||
|
|
||||||
// Cleanup records older than 30 days
|
|
||||||
$result = $this->userModel->cleanupOldRecords(30);
|
|
||||||
$this->assertGreaterThan(0, $result); // Should have deleted at least one record
|
|
||||||
|
|
||||||
// Verify user was cleaned up
|
|
||||||
$user = $this->userModel->getByUserId($this->testUserId);
|
|
||||||
$this->assertFalse($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNonExistentUser()
|
|
||||||
{
|
|
||||||
$user = $this->userModel->getByUserId('nonexistent_user');
|
|
||||||
$this->assertFalse($user);
|
|
||||||
|
|
||||||
// Test update on non-existent user
|
|
||||||
$result = $this->userModel->updateLastSeen('nonexistent_user');
|
|
||||||
$this->assertEquals(0, $result); // No rows affected
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetActiveUsersWithinTimeframe()
|
|
||||||
{
|
|
||||||
// Create users with different activity times
|
|
||||||
$userId1 = 'recent_user';
|
|
||||||
$userId2 = 'old_user';
|
|
||||||
|
|
||||||
$this->userModel->createOrUpdate($userId1, ['nickname' => 'Recent']);
|
|
||||||
$this->userModel->createOrUpdate($userId2, ['nickname' => 'Old']);
|
|
||||||
|
|
||||||
// Make one user appear old
|
|
||||||
$pdo = TestDatabaseHelper::getTestPdo();
|
|
||||||
$pdo->exec("UPDATE users SET last_seen = datetime('now', '-1 hour') WHERE user_id = '$userId2'");
|
|
||||||
|
|
||||||
// Get users active within 30 minutes
|
|
||||||
$activeUsers = $this->userModel->getActiveUsers(30); // 30 seconds for testing
|
|
||||||
$this->assertCount(1, $activeUsers);
|
|
||||||
$this->assertEquals($userId1, $activeUsers[0]['user_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDatabaseConnectionFailure()
|
|
||||||
{
|
|
||||||
// This test would verify error handling in a real scenario
|
|
||||||
// For now, we test that the model handles database operations gracefully
|
|
||||||
$this->assertIsObject($this->userModel);
|
|
||||||
|
|
||||||
// Test that methods return false/null on failure rather than throwing exceptions
|
|
||||||
$result = $this->userModel->getByUserId('invalid_id_format_x');
|
|
||||||
$this->assertFalse($result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Validation utility functions
|
|
||||||
*/
|
|
||||||
class ValidationTest extends TestCase
|
|
||||||
{
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
// Clear any previous test data
|
|
||||||
$_POST = [];
|
|
||||||
$_GET = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateUserId()
|
|
||||||
{
|
|
||||||
// Valid user IDs
|
|
||||||
$result = Validation::validateUserId('a1b2c3d4e5f67890123456789012abcd');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
$this->assertEquals('a1b2c3d4e5f67890123456789012abcd', $result['user_id']);
|
|
||||||
|
|
||||||
// Invalid user IDs
|
|
||||||
$result = Validation::validateUserId('invalid_user_id');
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateUserId('a1b2c3d4'); // Too short
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateUserId('a1b2c3d4e5f67890123456789012abcdextra'); // Too long
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateUserId('gggggggggggggggggggggggggggggggg'); // Invalid chars
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateNickname()
|
|
||||||
{
|
|
||||||
// Valid nicknames
|
|
||||||
$result = Validation::validateNickname('JohnDoe');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname('Test User');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname("O'Connor-Smith");
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
|
|
||||||
// Invalid nicknames
|
|
||||||
$result = Validation::validateNickname(''); // Empty
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname('A'); // Too short
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname(str_repeat('A', 21)); // Too long
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname('Invalid@Name'); // Invalid chars
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateNickname('<script>evil</script>'); // XSS attempt
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateMessage()
|
|
||||||
{
|
|
||||||
// Valid messages
|
|
||||||
$result = Validation::validateMessage('Hello World!');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateMessage('This is a longer message with punctuation, numbers 123, and symbols @#$%!');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
|
|
||||||
// Invalid messages
|
|
||||||
$result = Validation::validateMessage(''); // Empty
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateMessage(str_repeat('A', 1001)); // Too long
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateMessage('<script>alert("xss")</script>'); // XSS
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateMessageSend()
|
|
||||||
{
|
|
||||||
// Valid message send data
|
|
||||||
$data = [
|
|
||||||
'nickname' => 'TestUser',
|
|
||||||
'message' => 'Hello World!',
|
|
||||||
'user_agent' => 'Mozilla/5.0 (Test Browser)',
|
|
||||||
'ip_address' => '192.168.1.1'
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = Validation::validateMessageSend($data);
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
$this->assertEquals($data['nickname'], $result['validated']['nickname']);
|
|
||||||
$this->assertEquals($data['message'], $result['validated']['message']);
|
|
||||||
|
|
||||||
// Invalid message send data
|
|
||||||
$invalidData = [
|
|
||||||
'nickname' => 'Invalid@Name',
|
|
||||||
'message' => '<script>evil</script>',
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = Validation::validateMessageSend($invalidData);
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
$this->assertArrayHasKey('errors', $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateHeartbeat()
|
|
||||||
{
|
|
||||||
// Valid heartbeat data
|
|
||||||
$data = [
|
|
||||||
'nickname' => 'TestUser',
|
|
||||||
'user_id' => 'a1b2c3d4e5f67890123456789012abcd',
|
|
||||||
'session_id' => 'session_123456'
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = Validation::validateHeartbeat($data);
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
$this->assertEquals($data['nickname'], $result['validated']['nickname']);
|
|
||||||
|
|
||||||
// Invalid heartbeat data
|
|
||||||
$invalidData = [
|
|
||||||
'nickname' => str_repeat('A', 21), // Too long
|
|
||||||
'user_id' => 'invalid_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
$result = Validation::validateHeartbeat($invalidData);
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
$this->assertArrayHasKey('errors', $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testValidateAdminLogin()
|
|
||||||
{
|
|
||||||
// Valid login data
|
|
||||||
$result = Validation::validateAdminLogin('admin_user', 'valid_password');
|
|
||||||
$this->assertTrue($result['valid']);
|
|
||||||
$this->assertEquals('admin_user', $result['data']['username']);
|
|
||||||
|
|
||||||
// Invalid login data
|
|
||||||
$result = Validation::validateAdminLogin('', 'password'); // Empty username
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateAdminLogin('admin', ''); // Empty password
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateAdminLogin('us', 'password'); // Username too short
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
|
|
||||||
$result = Validation::validateAdminLogin('user@domain.com', 'password'); // Invalid username format
|
|
||||||
$this->assertFalse($result['valid']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIsValidEmail()
|
|
||||||
{
|
|
||||||
// Valid emails
|
|
||||||
$this->assertTrue(Validation::isValidEmail('user@example.com'));
|
|
||||||
$this->assertTrue(Validation::isValidEmail('test.user+tag@example.co.uk'));
|
|
||||||
$this->assertTrue(Validation::isValidEmail('user@localhost'));
|
|
||||||
|
|
||||||
// Invalid emails
|
|
||||||
$this->assertFalse(Validation::isValidEmail('invalid-email'));
|
|
||||||
$this->assertFalse(Validation::isValidEmail('user@'));
|
|
||||||
$this->assertFalse(Validation::isValidEmail('@example.com'));
|
|
||||||
$this->assertFalse(Validation::isValidEmail('user@.com'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIsValidURL()
|
|
||||||
{
|
|
||||||
// Valid URLs
|
|
||||||
$this->assertTrue(Validation::isValidURL('http://example.com'));
|
|
||||||
$this->assertTrue(Validation::isValidURL('https://example.com/path?query=1'));
|
|
||||||
$this->assertTrue(Validation::isValidURL('ftp://example.com/file.txt'));
|
|
||||||
|
|
||||||
// Invalid URLs
|
|
||||||
$this->assertFalse(Validation::isValidURL('not-a-url'));
|
|
||||||
$this->assertFalse(Validation::isValidURL('javascript:alert(1)'));
|
|
||||||
$this->assertFalse(Validation::isValidURL(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCleanString()
|
|
||||||
{
|
|
||||||
// Test normal cleaning
|
|
||||||
$result = Validation::cleanString(' Hello World ');
|
|
||||||
$this->assertEquals('Hello World', $result);
|
|
||||||
|
|
||||||
// Test with HTML entities
|
|
||||||
$result = Validation::cleanString('Hello & World <tag>');
|
|
||||||
$this->assertEquals('Hello & World <tag>', $result);
|
|
||||||
|
|
||||||
// Test with script tags (should be encoded)
|
|
||||||
$result = Validation::cleanString('<script>alert(1)</script>Hello');
|
|
||||||
$this->assertEquals('<script>alert(1)</script>Hello', $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLengthBetween()
|
|
||||||
{
|
|
||||||
// Test valid lengths
|
|
||||||
$this->assertTrue(Validation::lengthBetween('test', 2, 10));
|
|
||||||
$this->assertTrue(Validation::lengthBetween('test', 4, 4));
|
|
||||||
|
|
||||||
// Test invalid lengths
|
|
||||||
$this->assertFalse(Validation::lengthBetween('t', 2, 10)); // Too short
|
|
||||||
$this->assertFalse(Validation::lengthBetween('this_is_a_very_long_string', 2, 10)); // Too long
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMatchesPattern()
|
|
||||||
{
|
|
||||||
// Test valid patterns
|
|
||||||
$this->assertTrue(Validation::matchesPattern('123', '/^\d+$/'));
|
|
||||||
$this->assertTrue(Validation::matchesPattern('abc123', '/^[a-zA-Z0-9]+$/'));
|
|
||||||
$this->assertTrue(Validation::matchesPattern('test@example.com', '/^[^\s@]+@[^\s@]+\.[^\s@]+$/')); // Simple email regex
|
|
||||||
|
|
||||||
// Test invalid patterns
|
|
||||||
$this->assertFalse(Validation::matchesPattern('abc', '/^\d+$/')); // Not numeric
|
|
||||||
$this->assertFalse(Validation::matchesPattern('invalid-email', '/^[^\s@]+@[^\s@]+\.[^\s@]+$/')); // Not email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Security Utilities Class
|
|
||||||
* Handles authentication, CSRF protection, and security-related functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
class Security
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Generate a cryptographically secure user ID
|
|
||||||
*/
|
|
||||||
public static function generateSecureUserId($length = 8)
|
|
||||||
{
|
|
||||||
return bin2hex(random_bytes($length));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a CSRF token
|
|
||||||
*/
|
|
||||||
public static function generateCSRFToken()
|
|
||||||
{
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
||||||
}
|
|
||||||
return $_SESSION['csrf_token'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate CSRF token
|
|
||||||
*/
|
|
||||||
public static function validateCSRFToken($token = null)
|
|
||||||
{
|
|
||||||
$sessionToken = $_SESSION['csrf_token'] ?? '';
|
|
||||||
|
|
||||||
$providedToken = $token ?? ($_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '');
|
|
||||||
|
|
||||||
if (empty($sessionToken) || empty($providedToken)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash_equals($sessionToken, $providedToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate admin user
|
|
||||||
*/
|
|
||||||
public static function authenticateAdmin($username, $password)
|
|
||||||
{
|
|
||||||
$storedUsername = Config::get('admin.username', 'admin');
|
|
||||||
$storedHash = Config::get('admin.password_hash');
|
|
||||||
|
|
||||||
if (empty($storedHash)) {
|
|
||||||
error_log("Warning: Admin password hash not configured in .env file");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($username !== $storedUsername) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password_verify($password, $storedHash)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set admin session
|
|
||||||
$_SESSION['admin_authenticated'] = true;
|
|
||||||
$_SESSION['admin_username'] = $username;
|
|
||||||
$_SESSION['admin_login_time'] = time();
|
|
||||||
|
|
||||||
session_regenerate_id(true); // Prevent session fixation
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is authenticated as admin
|
|
||||||
*/
|
|
||||||
public static function isAdminAuthenticated()
|
|
||||||
{
|
|
||||||
if (!isset($_SESSION['admin_authenticated']) || !$_SESSION['admin_authenticated']) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session timeout
|
|
||||||
$timeout = Config::get('admin.session_timeout', 3600);
|
|
||||||
$loginTime = $_SESSION['admin_login_time'] ?? 0;
|
|
||||||
|
|
||||||
if (time() - $loginTime > $timeout) {
|
|
||||||
self::logoutAdmin();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout admin user
|
|
||||||
*/
|
|
||||||
public static function logoutAdmin()
|
|
||||||
{
|
|
||||||
unset($_SESSION['admin_authenticated']);
|
|
||||||
unset($_SESSION['admin_username']);
|
|
||||||
unset($_SESSION['admin_login_time']);
|
|
||||||
|
|
||||||
session_regenerate_id(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash password using bcrypt
|
|
||||||
*/
|
|
||||||
public static function hashPassword($password)
|
|
||||||
{
|
|
||||||
$options = [
|
|
||||||
'cost' => 12, // Increase cost for production
|
|
||||||
];
|
|
||||||
|
|
||||||
return password_hash($password, PASSWORD_ARGON2I, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify password hash needs rehash
|
|
||||||
*/
|
|
||||||
public static function passwordNeedsRehash($hash)
|
|
||||||
{
|
|
||||||
$options = [
|
|
||||||
'cost' => 12,
|
|
||||||
];
|
|
||||||
|
|
||||||
return password_needs_rehash($hash, PASSWORD_ARGON2I, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate secure random string
|
|
||||||
*/
|
|
||||||
public static function generateSecureToken($length = 32)
|
|
||||||
{
|
|
||||||
return bin2hex(random_bytes($length));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize input data
|
|
||||||
*/
|
|
||||||
public static function sanitizeInput($input, $type = 'string')
|
|
||||||
{
|
|
||||||
switch ($type) {
|
|
||||||
case 'string':
|
|
||||||
$sanitized = trim($input);
|
|
||||||
$sanitized = filter_var($sanitized, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
|
|
||||||
return htmlspecialchars($sanitized, ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
case 'email':
|
|
||||||
$sanitized = filter_var(trim($input), FILTER_SANITIZE_EMAIL);
|
|
||||||
return filter_var($sanitized, FILTER_VALIDATE_EMAIL) ? $sanitized : '';
|
|
||||||
|
|
||||||
case 'url':
|
|
||||||
$sanitized = filter_var(trim($input), FILTER_SANITIZE_URL);
|
|
||||||
return filter_var($sanitized, FILTER_VALIDATE_URL, FILTER_FLAG_QUERY_REQUIRED) ? $sanitized : '';
|
|
||||||
|
|
||||||
case 'int':
|
|
||||||
return filter_var($input, FILTER_VALIDATE_INT);
|
|
||||||
|
|
||||||
case 'float':
|
|
||||||
return filter_var($input, FILTER_VALIDATE_FLOAT);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate URL against whitelist
|
|
||||||
*/
|
|
||||||
public static function isValidStreamUrl($url)
|
|
||||||
{
|
|
||||||
if (empty($url)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowedDomains = Config::get('stream.allowed_domains', []);
|
|
||||||
if (empty($allowedDomains)) {
|
|
||||||
$allowedDomains = ['38.64.28.91:23456']; // Default fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($allowedDomains)) {
|
|
||||||
$allowedDomains = explode(',', $allowedDomains);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize domains
|
|
||||||
$allowedDomains = array_map(function($domain) {
|
|
||||||
return trim(strtolower($domain));
|
|
||||||
}, $allowedDomains);
|
|
||||||
|
|
||||||
// Parse URL
|
|
||||||
$parsedUrl = parse_url($url);
|
|
||||||
if (!$parsedUrl || !isset($parsedUrl['host']) || !isset($parsedUrl['scheme'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$urlDomain = strtolower($parsedUrl['host']);
|
|
||||||
$urlPort = $parsedUrl['port'] ?? ($parsedUrl['scheme'] === 'https' ? 443 : 80);
|
|
||||||
$fullDomain = $urlDomain . ':' . $urlPort;
|
|
||||||
|
|
||||||
// Check if domain is allowed
|
|
||||||
foreach ($allowedDomains as $allowed) {
|
|
||||||
if ($fullDomain === $allowed || $urlDomain === $allowed) {
|
|
||||||
// Additional validation: only allow specific paths
|
|
||||||
$path = $parsedUrl['path'] ?? '';
|
|
||||||
if (preg_match('#^/.*\.(m3u8|ts)$#', $path) || $path === '/stream.m3u8') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate rate limiting key
|
|
||||||
*/
|
|
||||||
public static function getRateLimitKey($identifier, $action = 'general')
|
|
||||||
{
|
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
||||||
return "rate_limit:{$action}:{$ip}:{$identifier}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if request is within rate limits
|
|
||||||
*/
|
|
||||||
public static function checkRateLimit($identifier, $action = 'general', $maxRequests = null, $timeWindow = 60)
|
|
||||||
{
|
|
||||||
if (!$maxRequests) {
|
|
||||||
$maxRequests = Config::get('rate_limit.requests_per_minute', 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = self::getRateLimitKey($identifier, $action);
|
|
||||||
|
|
||||||
// Simple file-based rate limiting (use Redis/APCu in production)
|
|
||||||
$cacheFile = sys_get_temp_dir() . '/rate_limit_' . md5($key);
|
|
||||||
$data = [];
|
|
||||||
|
|
||||||
if (file_exists($cacheFile)) {
|
|
||||||
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = time();
|
|
||||||
$windowStart = $now - $timeWindow;
|
|
||||||
|
|
||||||
// Filter old requests
|
|
||||||
$data = array_filter($data, function($timestamp) use ($windowStart) {
|
|
||||||
return $timestamp > $windowStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (count($data) >= $maxRequests) {
|
|
||||||
return false; // Rate limit exceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current request
|
|
||||||
$data[] = $now;
|
|
||||||
|
|
||||||
// Keep only recent requests
|
|
||||||
$data = array_slice($data, -$maxRequests);
|
|
||||||
|
|
||||||
file_put_contents($cacheFile, json_encode($data));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client IP address
|
|
||||||
*/
|
|
||||||
public static function getClientIP()
|
|
||||||
{
|
|
||||||
$headers = [
|
|
||||||
'HTTP_X_FORWARDED_FOR',
|
|
||||||
'HTTP_X_FORWARDED',
|
|
||||||
'HTTP_X_REAL_IP',
|
|
||||||
'HTTP_CLIENT_IP',
|
|
||||||
'REMOTE_ADDR'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($headers as $header) {
|
|
||||||
if (!empty($_SERVER[$header])) {
|
|
||||||
// Handle comma-separated IPs (X-Forwarded-For)
|
|
||||||
$ip = trim(explode(',', $_SERVER[$header])[0]);
|
|
||||||
|
|
||||||
// Basic IP validation
|
|
||||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
||||||
return $ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log security event
|
|
||||||
*/
|
|
||||||
public static function logSecurityEvent($event, $details = [])
|
|
||||||
{
|
|
||||||
$logData = [
|
|
||||||
'timestamp' => date('Y-m-d H:i:s'),
|
|
||||||
'event' => $event,
|
|
||||||
'ip' => self::getClientIP(),
|
|
||||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
|
||||||
'details' => $details
|
|
||||||
];
|
|
||||||
|
|
||||||
error_log("SECURITY: " . json_encode($logData));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for suspicious request patterns
|
|
||||||
*/
|
|
||||||
public static function detectSuspiciousActivity()
|
|
||||||
{
|
|
||||||
$warnings = [];
|
|
||||||
|
|
||||||
// Check for multiple failed authentication attempts
|
|
||||||
if (isset($_SESSION['auth_attempts']) && $_SESSION['auth_attempts'] > 3) {
|
|
||||||
$warnings[] = 'Multiple authentication failures';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for rapid requests (potential DDoS)
|
|
||||||
$requestCount = $_SESSION['recent_requests'] ?? 0;
|
|
||||||
if ($requestCount > 100) {
|
|
||||||
$warnings[] = 'High request frequency detected';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log warnings
|
|
||||||
foreach ($warnings as $warning) {
|
|
||||||
self::logSecurityEvent('suspicious_activity', ['warning' => $warning]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $warnings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Input Validation Utilities
|
|
||||||
* Comprehensive input validation and sanitization functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
class Validation
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Validate and sanitize nickname
|
|
||||||
*/
|
|
||||||
public static function validateNickname($nickname)
|
|
||||||
{
|
|
||||||
if (!is_string($nickname)) {
|
|
||||||
return self::createValidationResult(false, 'Nickname must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
$trimmed = trim($nickname);
|
|
||||||
|
|
||||||
// Check length
|
|
||||||
if (strlen($trimmed) < 1) {
|
|
||||||
return self::createValidationResult(false, 'Nickname cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($trimmed) > 20) {
|
|
||||||
return self::createValidationResult(false, 'Nickname must be 20 characters or less');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check allowed characters (alphanumeric, spaces, hyphens, apostrophes)
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9\s\'-]+$/', $trimmed)) {
|
|
||||||
return self::createValidationResult(false, 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize
|
|
||||||
$sanitized = Security::sanitizeInput($trimmed);
|
|
||||||
|
|
||||||
return self::createValidationResult(true, 'Valid nickname', $sanitized);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitize chat message
|
|
||||||
*/
|
|
||||||
public static function validateMessage($message, $maxLength = 1000)
|
|
||||||
{
|
|
||||||
if (!is_string($message)) {
|
|
||||||
return self::createValidationResult(false, 'Message must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
$trimmed = trim($message);
|
|
||||||
|
|
||||||
// Check length
|
|
||||||
if (strlen($trimmed) < 1) {
|
|
||||||
return self::createValidationResult(false, 'Message cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($trimmed) > $maxLength) {
|
|
||||||
return self::createValidationResult(false, 'Message exceeds maximum length of ' . $maxLength . ' characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic XSS check (additional to sanitization)
|
|
||||||
$xssPatterns = [
|
|
||||||
'/<script[^>]*>.*?<\/script>/is',
|
|
||||||
'/javascript:/i',
|
|
||||||
'/on\w+\s*=/i',
|
|
||||||
'/<iframe[^>]*>.*?<\/iframe>/is'
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($xssPatterns as $pattern) {
|
|
||||||
if (preg_match($pattern, $trimmed)) {
|
|
||||||
return self::createValidationResult(false, 'Message contains potentially harmful content');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize
|
|
||||||
$sanitized = Security::sanitizeInput($trimmed);
|
|
||||||
|
|
||||||
return self::createValidationResult(true, 'Valid message', $sanitized);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate user ID format
|
|
||||||
*/
|
|
||||||
public static function validateUserId($userId)
|
|
||||||
{
|
|
||||||
if (!is_string($userId)) {
|
|
||||||
return self::createValidationResult(false, 'User ID must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should be hexadecimal (from bin2hex)
|
|
||||||
if (!preg_match('/^[a-f0-9]+$/', $userId)) {
|
|
||||||
return self::createValidationResult(false, 'Invalid user ID format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check length (16 characters for 8 bytes)
|
|
||||||
if (strlen($userId) !== 16) {
|
|
||||||
return self::createValidationResult(false, 'Invalid user ID length');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::createValidationResult(true, 'Valid user ID', $userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate admin login credentials
|
|
||||||
*/
|
|
||||||
public static function validateAdminLogin($username, $password)
|
|
||||||
{
|
|
||||||
// Username validation
|
|
||||||
if (empty($username) || !is_string($username)) {
|
|
||||||
return self::createValidationResult(false, 'Username is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$username = trim($username);
|
|
||||||
if (strlen($username) < 3 || strlen($username) > 50) {
|
|
||||||
return self::createValidationResult(false, 'Username must be between 3-50 characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $username)) {
|
|
||||||
return self::createValidationResult(false, 'Username can only contain letters, numbers, hyphens, and underscores');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password validation
|
|
||||||
if (empty($password) || !is_string($password)) {
|
|
||||||
return self::createValidationResult(false, 'Password is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$password = trim($password);
|
|
||||||
if (strlen($password) < 6) {
|
|
||||||
return self::createValidationResult(false, 'Password must be at least 6 characters long');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional password strength checks (optional)
|
|
||||||
$hasUppercase = preg_match('/[A-Z]/', $password);
|
|
||||||
$hasLowercase = preg_match('/[a-z]/', $password);
|
|
||||||
$hasNumbers = preg_match('/[0-9]/', $password);
|
|
||||||
|
|
||||||
if (!$hasUppercase || !$hasLowercase || !$hasNumbers) {
|
|
||||||
return self::createValidationResult(false, 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::createValidationResult(true, 'Credentials format valid', [
|
|
||||||
'username' => $username,
|
|
||||||
'password' => $password
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate API request parameters
|
|
||||||
*/
|
|
||||||
public static function validateApiRequest($params, $rules)
|
|
||||||
{
|
|
||||||
$validated = [];
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
foreach ($rules as $field => $rule) {
|
|
||||||
$value = $params[$field] ?? null;
|
|
||||||
|
|
||||||
// Check required fields
|
|
||||||
if (isset($rule['required']) && $rule['required'] && $value === null) {
|
|
||||||
$errors[$field] = $rule['required_message'] ?? "{$field} is required";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip validation if not required and not provided
|
|
||||||
if ($value === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type validation
|
|
||||||
if (isset($rule['type'])) {
|
|
||||||
$valid = self::validateType($value, $rule['type'], $rule);
|
|
||||||
if (!$valid['valid']) {
|
|
||||||
$errors[$field] = $valid['message'];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$validated[$field] = $valid['value'];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sanitization
|
|
||||||
if (isset($rule['sanitize'])) {
|
|
||||||
$value = Security::sanitizeInput($value, $rule['sanitize']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated[$field] = $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'valid' => empty($errors),
|
|
||||||
'validated' => $validated,
|
|
||||||
'errors' => $errors
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate value against specific type
|
|
||||||
*/
|
|
||||||
private static function validateType($value, $type, $rule = [])
|
|
||||||
{
|
|
||||||
switch ($type) {
|
|
||||||
case 'string':
|
|
||||||
if (!is_string($value)) {
|
|
||||||
return ['valid' => false, 'message' => 'Must be a string'];
|
|
||||||
}
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if (isset($rule['min_length']) && strlen($value) < $rule['min_length']) {
|
|
||||||
return ['valid' => false, 'message' => "Must be at least {$rule['min_length']} characters long"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($rule['max_length']) && strlen($value) > $rule['max_length']) {
|
|
||||||
return ['valid' => false, 'message' => "Must be no more than {$rule['max_length']} characters long"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($rule['pattern']) && !preg_match($rule['pattern'], $value)) {
|
|
||||||
return ['valid' => false, 'message' => $rule['pattern_message'] ?? 'Invalid format'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['valid' => true, 'value' => $value];
|
|
||||||
|
|
||||||
case 'int':
|
|
||||||
$intVal = filter_var($value, FILTER_VALIDATE_INT);
|
|
||||||
if ($intVal === false) {
|
|
||||||
return ['valid' => false, 'message' => 'Must be a valid integer'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($rule['min']) && $intVal < $rule['min']) {
|
|
||||||
return ['valid' => false, 'message' => "Must be at least {$rule['min']}"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($rule['max']) && $intVal > $rule['max']) {
|
|
||||||
return ['valid' => false, 'message' => "Must be no more than {$rule['max']}"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['valid' => true, 'value' => $intVal];
|
|
||||||
|
|
||||||
case 'email':
|
|
||||||
$email = filter_var(trim($value), FILTER_VALIDATE_EMAIL);
|
|
||||||
if (!$email) {
|
|
||||||
return ['valid' => false, 'message' => 'Invalid email address'];
|
|
||||||
}
|
|
||||||
return ['valid' => true, 'value' => $email];
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
$boolVal = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
||||||
if ($boolVal === null) {
|
|
||||||
return ['valid' => false, 'message' => 'Must be a boolean value'];
|
|
||||||
}
|
|
||||||
return ['valid' => true, 'value' => $boolVal];
|
|
||||||
|
|
||||||
default:
|
|
||||||
return ['valid' => true, 'value' => $value];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate heartbeat data
|
|
||||||
*/
|
|
||||||
public static function validateHeartbeat($data)
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
'nickname' => [
|
|
||||||
'required' => false,
|
|
||||||
'type' => 'string',
|
|
||||||
'max_length' => 20,
|
|
||||||
'pattern' => '/^[a-zA-Z0-9\s\'-]*$/',
|
|
||||||
'pattern_message' => 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes'
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
return self::validateApiRequest($data, $rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate message send request
|
|
||||||
*/
|
|
||||||
public static function validateMessageSend($data)
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
'nickname' => [
|
|
||||||
'required' => true,
|
|
||||||
'type' => 'string',
|
|
||||||
'min_length' => 1,
|
|
||||||
'max_length' => 20,
|
|
||||||
'pattern' => '/^[a-zA-Z0-9\s\'-]+$/',
|
|
||||||
'pattern_message' => 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes',
|
|
||||||
'required_message' => 'Nickname is required'
|
|
||||||
],
|
|
||||||
'message' => [
|
|
||||||
'required' => true,
|
|
||||||
'type' => 'string',
|
|
||||||
'min_length' => 1,
|
|
||||||
'max_length' => 1000,
|
|
||||||
'required_message' => 'Message cannot be empty'
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
return self::validateApiRequest($data, $rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create validation result array
|
|
||||||
*/
|
|
||||||
private static function createValidationResult($valid, $message, $data = null)
|
|
||||||
{
|
|
||||||
$result = [
|
|
||||||
'valid' => $valid,
|
|
||||||
'message' => $message
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($data !== null) {
|
|
||||||
$result['data'] = $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue