diff --git a/.gitignore b/.gitignore index 4aa90df..8aa2c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,74 +1,14 @@ -# Dependencies -/vendor/ -/node_modules/ +# Built/minified assets +assets/**/*.min.css +assets/**/*.min.js -# Environment files -.env -.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 +# Distribution directory +assets/dist/ # Temporary files *.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 -*~ -*.orig -# Documentation artifacts -/docs/phpdoc/ - -# Docker (if used) -.dockerignore -docker-compose.override.yml - -# Sensitive files -config/production.php -config/staging.php -*.key -*.pem - -# Migration backups -migrations/*.migrated +# OS-specific files +.DS_Store +Thumbs.db diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 39e7ca6..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -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 - - ServerName your-domain.com - DocumentRoot /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 - - - # Logs - ErrorLog /var/log/apache2/dodgers-error.log - CustomLog /var/log/apache2/dodgers-access.log combined - -``` - -#### 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.*.*$ -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. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7246f95..0000000 --- a/Makefile +++ /dev/null @@ -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 diff --git a/README.md b/README.md index d9f7d78..50e7202 100644 --- a/README.md +++ b/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.** šŸŽÆāœØ diff --git a/assets/js/chat.js b/assets/js/chat.js index ad112ed..5d7cd71 100644 --- a/assets/js/chat.js +++ b/assets/js/chat.js @@ -65,8 +65,8 @@ UIControls.DOMUtils.addEvent(sendButton, 'click', sendMessage); } - // Start real-time SSE connection for chat - startSSEConnection(); + // Start polling for messages + startMessagePolling(); // Send initial heartbeat API.sendHeartbeat(); @@ -306,132 +306,8 @@ }); } - // SSE (Server-Sent Events) for real-time communication - 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) + // Message polling system function startMessagePolling() { - AppLogger.log('Starting message polling (fallback)'); - // Poll for new messages setInterval(fetchMessages, AppConfig.api.chatPollInterval); @@ -639,29 +515,6 @@ 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 function escapeHtml(text) { const div = document.createElement('div'); diff --git a/assets/js/ui-controls.js b/assets/js/ui-controls.js index 0b0e3a5..54e6255 100644 --- a/assets/js/ui-controls.js +++ b/assets/js/ui-controls.js @@ -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 function escapeHtml(text) { diff --git a/bootstrap.php b/bootstrap.php deleted file mode 100644 index 9bccd0a..0000000 --- a/bootstrap.php +++ /dev/null @@ -1,148 +0,0 @@ -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)' : '')); -} diff --git a/composer.json b/composer.json deleted file mode 100644 index 1cf80ac..0000000 --- a/composer.json +++ /dev/null @@ -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 - } -} diff --git a/controllers/AuthController.php b/controllers/AuthController.php deleted file mode 100644 index b2e5c29..0000000 --- a/controllers/AuthController.php +++ /dev/null @@ -1,378 +0,0 @@ -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 = " - "; - } - - echo " - - - - - {$loginTitle} - {$appName} - - - -
-

{$appName}

- - - {$errorHtml} - -
- - - -
- - -
- -
- - -
- - -
- - ← Back to application -
- - - -"; - } - - /** - * Redirect with flash message via session - */ - private function redirectWithMessage($url, $message, $type = 'info') - { - $_SESSION['flash_message'] = ['text' => $message, 'type' => $type]; - header("Location: {$url}"); - exit; - } -} diff --git a/includes/Config.php b/includes/Config.php deleted file mode 100644 index d001180..0000000 --- a/includes/Config.php +++ /dev/null @@ -1,178 +0,0 @@ - $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); - } -} diff --git a/includes/Database.php b/includes/Database.php deleted file mode 100644 index 41a79c6..0000000 --- a/includes/Database.php +++ /dev/null @@ -1,298 +0,0 @@ -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()); - } - } -} diff --git a/includes/ErrorHandler.php b/includes/ErrorHandler.php deleted file mode 100644 index 7d388d3..0000000 --- a/includes/ErrorHandler.php +++ /dev/null @@ -1,364 +0,0 @@ - '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 "

Fatal Error

"; - echo "
" . htmlspecialchars(print_r($error, true)) . "
"; - } 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 " - - - - - {$title} - - - -
-

{$title}

-

{$message}

"; - - if (Config::isEnvironment('development') && isset($error['file'])) { - echo "
- File: {$error['file']}
- Line: {$error['line']}
- Type: " . ($error['error_type'] ?? $error['type'] ?? 'unknown') . " -
"; - } - - echo "
- -"; - } - - /** - * 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); - } -} diff --git a/includes/autoloader.php b/includes/autoloader.php deleted file mode 100644 index 566d2f8..0000000 --- a/includes/autoloader.php +++ /dev/null @@ -1,103 +0,0 @@ - __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; - } - } - } -} diff --git a/index.php b/index.php index 208a903..35104f0 100644 --- a/index.php +++ b/index.php @@ -1,35 +1,26 @@ handleSSE($_GET['user_id']); - exit; -} +$maxMessages = 100; // Keep last 100 messages // Clean up old viewers (inactive for more than 10 seconds) function cleanupViewers() { @@ -51,38 +42,26 @@ function cleanupViewers() { // Handle API requests for stream status if (isset($_GET['api']) && $_GET['api'] === 'stream_status') { header('Content-Type: application/json'); - header('Cache-Control: no-cache, no-store, must-revalidate'); - header('Pragma: no-cache'); - header('Expires: 0'); + 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']); - } - - $streamUrl = $streamBaseUrl . '/stream.m3u8'; + $streamUrl = 'http://38.64.28.91:23456/stream.m3u8'; $online = false; - if (Security::checkRateLimit(Security::getClientIP(), 'stream_status', 10, 60)) { - $context = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: Mozilla/5.0\r\n", - 'timeout' => 5 // Quick check - ] - ]); + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: Mozilla/5.0\r\n", + 'timeout' => 5 // Quick check + ] + ]); - $content = @file_get_contents($streamUrl, false, $context); - Security::logSecurityEvent('stream_status_check', ['online' => $online]); + $content = @file_get_contents($streamUrl, false, $context); - if ($content !== false && !empty($content)) { - // Check if it looks like a valid m3u8 - if (str_starts_with($content, '#EXTM3U')) { - $online = true; - } + if ($content !== false && !empty($content)) { + // Check if it looks like a valid m3u8 + if (str_starts_with($content, '#EXTM3U')) { + $online = true; } - } else { - Security::logSecurityEvent('stream_status_rate_limited'); } echo json_encode(['online' => $online]); @@ -91,29 +70,14 @@ if (isset($_GET['api']) && $_GET['api'] === 'stream_status') { // Handle proxy requests for the stream if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') { - // Check rate limiting - 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'; - + $streamUrl = 'http://38.64.28.91:23456/stream.m3u8'; + // Set appropriate headers for m3u8 content header('Content-Type: application/vnd.apple.mpegurl'); - header('Cache-Control: no-cache, no-store, must-revalidate'); - header('Pragma: no-cache'); - header('Expires: 0'); - - $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'); - } - + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, OPTIONS'); + header('Access-Control-Allow-Headers: Range'); + // Fetch and output the m3u8 content $context = stream_context_create([ 'http' => [ @@ -122,39 +86,33 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') { 'timeout' => 10 ] ]); - + $content = @file_get_contents($streamUrl, false, $context); - + if ($content !== false) { // Parse and update the m3u8 content to use our proxy for .ts segments $lines = explode("\n", $content); $updatedContent = []; - + foreach ($lines as $line) { $line = trim($line); if (!empty($line) && !str_starts_with($line, '#')) { // This is a .ts segment URL if (strpos($line, 'http') === 0) { - // Absolute URL - validate it first - if (Security::isValidStreamUrl($line)) { - $updatedContent[] = '?proxy=segment&url=' . urlencode($line); - } + // Absolute URL + $updatedContent[] = '?proxy=segment&url=' . urlencode($line); } else { // Relative URL - $segmentUrl = $streamBaseUrl . '/' . $line; - if (Security::isValidStreamUrl($segmentUrl)) { - $updatedContent[] = '?proxy=segment&url=' . urlencode($segmentUrl); - } + $baseUrl = 'http://38.64.28.91:23456/'; + $updatedContent[] = '?proxy=segment&url=' . urlencode($baseUrl . $line); } } else { $updatedContent[] = $line; } } - - Security::logSecurityEvent('proxy_stream_success'); + echo implode("\n", $updatedContent); } else { - Security::logSecurityEvent('proxy_stream_failed', ['url' => $streamUrl]); http_response_code(500); echo "Failed to fetch stream"; } @@ -163,31 +121,17 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') { // Handle proxy requests for .ts segments if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])) { - // Check rate limiting - 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 SSRF attacks - if (!$segmentUrl || !Security::isValidStreamUrl($segmentUrl)) { - Security::logSecurityEvent('proxy_segment_invalid_url', ['url' => $_GET['url'] ?? '']); + $segmentUrl = urldecode($_GET['url']); + + // Validate URL to prevent abuse + if (strpos($segmentUrl, 'http://38.64.28.91:23456/') !== 0) { http_response_code(403); - echo "Invalid segment URL"; exit; } - + header('Content-Type: video/mp2t'); - header('Cache-Control: public, max-age=3600'); // Cache segments for 1 hour - - $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-Origin: *'); + $context = stream_context_create([ 'http' => [ 'method' => 'GET', @@ -195,16 +139,13 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url']) 'timeout' => 10 ] ]); - + $content = @file_get_contents($segmentUrl, false, $context); - + if ($content !== false) { - Security::logSecurityEvent('proxy_segment_success'); echo $content; } else { - Security::logSecurityEvent('proxy_segment_failed', ['url' => $segmentUrl]); http_response_code(500); - echo "Failed to fetch segment"; } exit; } @@ -212,223 +153,151 @@ if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url']) // Handle chat actions if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { header('Content-Type: application/json'); - - $action = $_POST['action']; - - // Validate the action parameter - if (!preg_match('/^[a-z_]+$/', $action)) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid action parameter']); - Security::logSecurityEvent('invalid_chat_action', ['action' => $action]); + + // Admin actions + if ($_POST['action'] === 'delete_message' && $isAdmin && isset($_POST['message_id'])) { + $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; + $messages = array_filter($messages, function($msg) { + return $msg['id'] !== $_POST['message_id']; + }); + $messages = array_values($messages); // Re-index array + file_put_contents($chatFile, json_encode($messages)); + echo json_encode(['success' => true]); exit; } - - // Admin-only actions - if (in_array($action, ['delete_message', 'clear_chat', 'ban_user'])) { - if (!$isAdmin) { - http_response_code(403); - 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; - } + + if ($_POST['action'] === 'clear_chat' && $isAdmin) { + file_put_contents($chatFile, json_encode([])); + echo json_encode(['success' => true]); + exit; } - - // Handle different actions - switch ($action) { - case 'delete_message': - if (!isset($_POST['message_id']) || !preg_match('/^[a-zA-Z0-9]+$/', $_POST['message_id'])) { - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Invalid message ID']); - exit; + + if ($_POST['action'] === 'ban_user' && $isAdmin && isset($_POST['user_id'])) { + $banned = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : []; + if (!in_array($_POST['user_id'], $banned)) { + $banned[] = $_POST['user_id']; + file_put_contents($bannedFile, json_encode($banned)); + } + 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']; - $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; - $filteredMessages = array_filter($messages, function($msg) use ($messageIdToDelete) { - return $msg['id'] !== $messageIdToDelete; - }); - $filteredMessages = array_values($filteredMessages); - file_put_contents($chatFile, json_encode($filteredMessages)); - - Security::logSecurityEvent('message_deleted', ['message_id' => $messageIdToDelete]); - echo json_encode(['success' => true]); - exit; - - case 'clear_chat': - 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) : []; - - $newMessage = [ - 'id' => Security::generateSecureToken(8), // More secure than uniqid() + } + + if (!$found) { + $viewers[] = [ 'user_id' => $userId, - 'nickname' => htmlspecialchars($nickname, ENT_QUOTES, 'UTF-8'), - 'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), + '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; + } + + 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; + } + + if (!empty($nickname) && !empty($message)) { + $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; + + $newMessage = [ + 'id' => uniqid(), + 'user_id' => $userId, + 'nickname' => $nickname, + 'message' => $message, 'timestamp' => time(), 'time' => date('M j, H:i'), 'is_admin' => $isAdmin ]; - - $messages[] = $newMessage; - + + array_push($messages, $newMessage); + // Keep only last N messages if (count($messages) > $maxMessages) { $messages = array_slice($messages, -$maxMessages); } - + file_put_contents($chatFile, json_encode($messages)); - - Security::logSecurityEvent('message_sent', ['message_id' => $newMessage['id']]); echo json_encode(['success' => true, 'message' => $newMessage]); - exit; - - case 'fetch': - $lastId = isset($_POST['last_id']) ? trim($_POST['last_id']) : ''; - $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; - - // Find new messages only - $newMessages = []; - $foundLast = empty($lastId); - - foreach ($messages as $msg) { - if ($foundLast) { - $newMessages[] = $msg; - } - if ($msg['id'] === $lastId) { - $foundLast = true; - } + } else { + echo json_encode(['success' => false, 'error' => 'Invalid input']); + } + exit; + } + + if ($_POST['action'] === 'fetch') { + $lastId = isset($_POST['last_id']) ? $_POST['last_id'] : ''; + $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; + + // Find new messages only + $newMessages = []; + $foundLast = empty($lastId); + + foreach ($messages as $msg) { + if ($foundLast) { + $newMessages[] = $msg; } - - // If lastId wasn't found, return all messages (initial load or refresh) - if (!$foundLast && !empty($lastId)) { - $newMessages = $messages; + if ($msg['id'] === $lastId) { + $foundLast = true; } - - // Get viewer count - $viewerCount = cleanupViewers(); - - // Determine if we should send all messages (for initial load or after admin actions) - $sendAllMessages = empty($lastId) || !$foundLast; - - echo json_encode([ - 'success' => true, - '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(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]); - exit; - - default: - http_response_code(400); - echo json_encode(['success' => false, 'error' => 'Unknown action']); - Security::logSecurityEvent('unknown_chat_action', ['action' => $action]); - exit; + } + + // If lastId wasn't found, return all messages (initial load or refresh) + if (!$foundLast && !empty($lastId)) { + $newMessages = $messages; + } + + // Get viewer count + $viewerCount = cleanupViewers(); + + // Determine if we should send all messages (for initial load or after admin actions) + $sendAllMessages = empty($lastId) || !$foundLast; + + echo json_encode([ + 'success' => true, + 'messages' => $newMessages, + 'all_messages' => $sendAllMessages ? $messages : null, + 'message_count' => count($messages), + 'viewer_count' => $viewerCount, + 'is_admin' => $isAdmin + ]); + exit; + } + + if ($_POST['action'] === 'get_user_id') { + echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]); + exit; } } ?> @@ -438,7 +307,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { Dodgers Stream Theater - diff --git a/migrations/001_create_tables.sql b/migrations/001_create_tables.sql deleted file mode 100644 index 7e4ef65..0000000 --- a/migrations/001_create_tables.sql +++ /dev/null @@ -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. diff --git a/models/ActiveViewerModel.php b/models/ActiveViewerModel.php deleted file mode 100644 index ab416dd..0000000 --- a/models/ActiveViewerModel.php +++ /dev/null @@ -1,199 +0,0 @@ -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()); - } - } -} diff --git a/models/ChatMessageModel.php b/models/ChatMessageModel.php deleted file mode 100644 index a0d6bfd..0000000 --- a/models/ChatMessageModel.php +++ /dev/null @@ -1,187 +0,0 @@ -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')" - ); - } -} diff --git a/models/UserModel.php b/models/UserModel.php deleted file mode 100644 index 75069ff..0000000 --- a/models/UserModel.php +++ /dev/null @@ -1,138 +0,0 @@ -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] - ); - } -} diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 2f608fb..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - tests/unit - - - tests/integration - - - tests - - - - - - models - controllers - services - utils - includes - - - tests - vendor - - - - - - - - - - - - - - - - - - - - - diff --git a/services/ChatServer.php b/services/ChatServer.php deleted file mode 100644 index 13b1bcc..0000000 --- a/services/ChatServer.php +++ /dev/null @@ -1,276 +0,0 @@ -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; - } -} diff --git a/setup.php b/setup.php deleted file mode 100644 index a997d7d..0000000 --- a/setup.php +++ /dev/null @@ -1,377 +0,0 @@ - [], - '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"; -?> diff --git a/static/css/components.css b/static/css/components.css index 2e317b9..1f7dbee 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -1048,49 +1048,26 @@ a:focus-visible { COMPONENT-SPECIFIC RESPONSIVE UTILITY CLASSES ================================================================= */ -/* Button responsive utilities - Higher specificity for mobile overrides */ -@media (max-width: calc(var(--breakpoint-sm) - 1px)) { - .btn-responsive-xs { padding: var(--spacing-2) var(--spacing-3) !important; } -} -@media (min-width: var(--breakpoint-sm)) and (max-width: calc(var(--breakpoint-md) - 1px)) { - .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; } -} +/* Button responsive utilities */ +.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; } +.btn-responsive-lg { padding: var(--spacing-4) var(--spacing-6) !important; } -/* Card responsive utilities - Media query containment */ -@media (max-width: calc(var(--breakpoint-sm) - 1px)) { - .card-responsive-xs .card-body { padding: var(--spacing-3) !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; } -} +/* Card responsive utilities */ +.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; } -/* Message responsive utilities - Scoped to specific contexts */ -.message-responsive-mobile .message-text { max-width: 85% !important; } +/* Message responsive utilities */ +.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-desktop .message-text { max-width: 95% !important; } -/* Font size overrides for message text - only when sizing utility is applied */ -.message-responsive-mobile .message-text { font-size: var(--font-size-sm) !important; } - -/* Video player responsive utilities - Scoped breakpoints */ -@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; } -} +/* Video player responsive utilities */ +.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-responsive-desktop .video-player__header { padding: var(--spacing-4) var(--spacing-6) !important; } /* ================================================================= DASHBOARD COMPONENTS diff --git a/static/css/icons.css b/static/css/icons.css index 773af63..b615084 100644 --- a/static/css/icons.css +++ b/static/css/icons.css @@ -385,6 +385,7 @@ /* Interactive button icons - more pronounced effects */ .icon-button:hover { transform: scale(1.15); + filter: brightness(1.2); transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); } diff --git a/static/css/reset.css b/static/css/reset.css index 25598ba..88ea847 100644 --- a/static/css/reset.css +++ b/static/css/reset.css @@ -1,5 +1,5 @@ /* Reset and normalize styles */ -*, *::before, *::after { +* { margin: 0; padding: 0; box-sizing: border-box; @@ -10,8 +10,7 @@ body { font-family: var(--font-family-primary); background: var(--bg-darkest); color: var(--text-primary); - min-height: 100vh; - min-height: 100dvh; /* Dynamic viewport height for mobile */ + height: 100vh; overflow: hidden; position: relative; } diff --git a/static/css/utilities.css b/static/css/utilities.css index c9326d3..898e579 100644 --- a/static/css/utilities.css +++ b/static/css/utilities.css @@ -144,6 +144,115 @@ 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) ================================================================= */ @@ -446,7 +555,6 @@ .h-1\/4 { height: 25%; } .h-2\/4 { height: 50%; } .h-3\/4 { height: 75%; } -/* ================================================================= RESPONSIVE WIDTH/HEIGHT UTILITIES - Using Breakpoint Custom Properties ================================================================= */ diff --git a/static/css/variables.css b/static/css/variables.css index 0a382c7..7ff7468 100644 --- a/static/css/variables.css +++ b/static/css/variables.css @@ -114,7 +114,6 @@ /* Borders & Dividers */ --border-color: #1a2332; --border-color-light: #232d3a; - --border-color-humane: #232d3a; /* For responsive borders */ --divider-color: rgba(255, 255, 255, 0.1); /* Input & Form Elements */ @@ -304,34 +303,17 @@ --mq-dashboard-enabled: (min-width: var(--breakpoint-2xl)); /* ================================================================= - Z-INDEX SCALE - Systematic Layer System + Z-INDEX SCALE ================================================================= */ - /* Base layers (0-99) */ - --z-base: 0; - --z-ground: 1; - - /* Content layers (100-299) */ - --z-content: 100; - --z-card: 110; - --z-overlay: 200; - - /* 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 */ + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-popover: 1060; + --z-tooltip: 1070; + --z-toast: 1080; /* ================================================================= ICON SIZE SCALE diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index e14ec97..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,138 +0,0 @@ - '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(); -}); diff --git a/tests/unit/SecurityTest.php b/tests/unit/SecurityTest.php deleted file mode 100644 index a9f4388..0000000 --- a/tests/unit/SecurityTest.php +++ /dev/null @@ -1,187 +0,0 @@ -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 = 'Hello World'; - $result = Security::sanitizeInput($input, 'string'); - $this->assertEquals('alert("xss")Hello World', $result); - - // Test email sanitization - $email = 'test@example.com'; - $result = Security::sanitizeInput($email, 'email'); - $this->assertEquals('test@example.com', $result); - - // Test URL sanitization - $url = 'http://example.com/path'; - $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(); - } -} diff --git a/tests/unit/UserModelTest.php b/tests/unit/UserModelTest.php deleted file mode 100644 index 5c8f213..0000000 --- a/tests/unit/UserModelTest.php +++ /dev/null @@ -1,215 +0,0 @@ -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); - } -} diff --git a/tests/unit/ValidationTest.php b/tests/unit/ValidationTest.php deleted file mode 100644 index b7dfaba..0000000 --- a/tests/unit/ValidationTest.php +++ /dev/null @@ -1,222 +0,0 @@ -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(''); // 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(''); // 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' => '', - ]; - - $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 '); - $this->assertEquals('Hello & World ', $result); - - // Test with script tags (should be encoded) - $result = Validation::cleanString('Hello'); - $this->assertEquals('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 - } -} diff --git a/utils/Security.php b/utils/Security.php deleted file mode 100644 index 87e26f5..0000000 --- a/utils/Security.php +++ /dev/null @@ -1,337 +0,0 @@ - $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; - } -} diff --git a/utils/Validation.php b/utils/Validation.php deleted file mode 100644 index 827fb94..0000000 --- a/utils/Validation.php +++ /dev/null @@ -1,316 +0,0 @@ - 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>/is', - '/javascript:/i', - '/on\w+\s*=/i', - '/]*>.*?<\/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; - } -}