diff --git a/CHANGELOG.md b/CHANGELOG.md index 258ad18..d49191a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migration tracking system - Consolidated database schema for v1.1.0 fresh installs - Smart migration system (consolidated for new, incremental for upgrades) +- **Two-Factor Authentication (2FA) System**: + - TOTP (Time-based One-Time Password) implementation + - Email backup codes for 2FA recovery + - 2FA verification attempts tracking with rate limiting + - 2FA policy settings (optional/required/disabled) + - Complete 2FA setup, verification, and management flow + - Backup codes generation and verification system +- **CAPTCHA Security System**: + - Support for reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile + - Configurable CAPTCHA settings in admin panel + - Score-based verification for reCAPTCHA v3 + - Integration with login and registration forms + - CAPTCHA provider selection and configuration +- **Domain Tags System**: + - Domain tagging for organization and categorization + - Comma-separated tags field in domains table + - Tag-based domain filtering and organization + - Indexed tag searches for performance +- **Advanced Error Logging System**: + - Database-backed error logging and tracking + - Error deduplication and occurrence counting + - Request context capture (method, URI, data) + - User context (IP, user agent, session data) + - System context (PHP version, memory usage) + - Error resolution tracking and management + - Admin error log interface for debugging +- **Enhanced Logger Service**: + - Structured logging with context arrays + - Multiple log levels (debug, info, warning, error, critical) + - Date-based log file rotation + - Context-aware logging throughout the application + - JSON-formatted log entries with timestamps +- **User Avatar System**: + - Avatar upload and deletion functionality + - Gravatar integration with fallback to user initials + - Dynamic web root detection for file uploads + - Avatar display in profile, navigation, and user listings + - File validation and security measures +- **WHOIS Parsing Improvements**: + - Enhanced WHOIS data parsing and processing + - Better referral server handling and following + - Improved domain availability detection + - Status parsing cleanup and consistency + - WHOIS server display improvements ### Changed - Profile page completely redesigned with sidebar layout @@ -81,6 +125,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile integrated with dashboard layout - Installation now via web UI instead of CLI - Auto-redirect to installer on first run +- Domain management enhanced with tagging system +- Error handling improved with comprehensive logging +- WHOIS parsing enhanced with better data extraction +- User interface updated with avatar display throughout ### Security - **Database Session Storage** - True session control with remote termination @@ -96,6 +144,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Email enumeration protection - Session-based verification resend - Admin-only route protection +- **Two-Factor Authentication** - TOTP and email backup codes for enhanced security +- **CAPTCHA Protection** - Anti-bot protection for login and registration +- **Advanced Error Logging** - Comprehensive error tracking and debugging +- **File Upload Security** - Avatar upload validation and secure file handling ### Technical - **MVC Architecture Refactoring** - Complete separation of concerns @@ -114,6 +166,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Notification system with 7 notification types - Welcome notifications on user creation and fresh install - Upgrade notifications for admins with version tracking +- **TwoFactorService** - Complete 2FA implementation with TOTP and backup codes +- **CaptchaService** - Multi-provider CAPTCHA verification system +- **ErrorHandler** - Centralized error handling with database logging +- **Logger** - Enhanced logging service with structured context +- **AvatarHelper** - User avatar management with Gravatar integration +- **Tag Model** - Domain tagging system with user isolation +- **ErrorLog Model** - Error tracking and deduplication system ### Contributors - Special thanks to @jadeops for auto-detected cron path improvement & XSS protection enhancement (PR #1) @@ -266,6 +325,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Advanced Session Management** - Database-backed sessions with geolocation (country, city, ISP) - **Remote Session Control** - Terminate any device instantly with immediate logout validation - **Enhanced Profile Page** - Sidebar navigation with 4 tabs, hash-based routing (#profile, #security, #sessions) +- **Two-Factor Authentication** - Complete TOTP implementation with email backup codes and rate limiting +- **CAPTCHA Security System** - Support for reCAPTCHA v2/v3 and Cloudflare Turnstile with admin configuration +- **Domain Tags System** - Organize domains with custom tags for better categorization and filtering +- **Advanced Error Logging** - Database-backed error tracking with deduplication, context capture, and admin interface +- **User Avatar System** - Avatar upload with Gravatar integration and fallback to user initials +- **Enhanced Logger Service** - Structured logging with context arrays and multiple log levels +- **WHOIS Parsing Improvements** - Enhanced domain data parsing, referral handling, and availability detection - **MVC Architecture Refactoring** - 3 new Helpers (Layout, Domain, Session), ~265 lines cleaned from views - **Geolocation Tracking** - IP-based location detection using ip-api.com, country flags with flag-icons - **Device Detection** - Browser & device type parsing (Chrome/Firefox/Safari, Desktop/Mobile/Tablet) diff --git a/app/Controllers/DebugController.php b/app/Controllers/DebugController.php index 069cd30..883670d 100644 --- a/app/Controllers/DebugController.php +++ b/app/Controllers/DebugController.php @@ -327,9 +327,11 @@ class DebugController extends Controller $response .= $whoisResponse; - // Check if domain is not found/available + // Check if domain is not found/available (using improved pattern) $whoisResponseLower = strtolower($whoisResponse); - if (preg_match('/not found|no match|no entries found|no data found|domain not found|no such domain|not registered|available for registration/i', $whoisResponseLower)) { + if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisResponseLower) || + preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisResponseLower) || + preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered)$/m', $whoisResponseLower)) { $response .= "\n\n=== DOMAIN STATUS DETECTED ===\n"; $response .= "✓ Domain is AVAILABLE (not registered)\n"; $parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE']; diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index b33b119..a66e779 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -51,6 +51,7 @@ class InstallerController extends Controller '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', '020_create_tags_system.sql', + '021_add_avatar_field.sql', ]; try { @@ -187,7 +188,8 @@ class InstallerController extends Controller '017_add_two_factor_authentication.sql', '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', - '020_create_tags_system.sql' + '020_create_tags_system.sql', + '021_add_avatar_field.sql' ]; } @@ -370,6 +372,7 @@ class InstallerController extends Controller '018_add_user_isolation.sql', '019_add_webhook_channel_type.sql', '020_create_tags_system.sql', + '021_add_avatar_field.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index 4b7c856..a2b853f 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\SessionManager; use App\Models\RememberToken; use App\Services\Logger; +use App\Helpers\AvatarHelper; class ProfileController extends Controller { @@ -404,4 +405,213 @@ class ProfileController extends Controller $this->redirect('/profile#sessions'); } + + /** + * Upload avatar + */ + public function uploadAvatar() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/profile'); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/profile'); + return; + } + + // Check if file was uploaded + if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] === UPLOAD_ERR_NO_FILE) { + $_SESSION['error'] = 'Please select a file to upload'; + $this->logger->warning("Avatar upload attempted without file", [ + 'user_id' => $userId, + 'files' => $_FILES + ]); + $this->redirect('/profile'); + return; + } + + $file = $_FILES['avatar']; + + // Log file details for debugging + $this->logger->info("Avatar upload attempt", [ + 'user_id' => $userId, + 'file_name' => $file['name'], + 'file_size' => $file['size'], + 'file_type' => $file['type'], + 'file_error' => $file['error'], + 'tmp_name' => $file['tmp_name'] + ]); + + // Validate the uploaded file + $validation = AvatarHelper::validateAvatarFile($file); + if (!$validation['valid']) { + $_SESSION['error'] = $validation['error']; + $this->logger->warning("Avatar upload validation failed", [ + 'user_id' => $userId, + 'file_name' => $file['name'], + 'validation_error' => $validation['error'] + ]); + $this->redirect('/profile'); + return; + } + + try { + // Ensure upload directory exists + $this->logger->info("Ensuring upload directory exists", [ + 'detected_web_root' => AvatarHelper::getDetectedWebRoot() + ]); + if (!AvatarHelper::ensureUploadDirectory()) { + throw new \Exception('Failed to create upload directory: ' . AvatarHelper::getAvatarPath('')); + } + + // Generate unique filename + $newFilename = AvatarHelper::generateAvatarFilename($file['name'], $userId); + $uploadPath = AvatarHelper::getAvatarPath($newFilename); + + $this->logger->info("Generated avatar filename", [ + 'user_id' => $userId, + 'original_name' => $file['name'], + 'new_filename' => $newFilename, + 'upload_path' => $uploadPath + ]); + + // Check if temp file exists and is readable + if (!file_exists($file['tmp_name'])) { + throw new \Exception('Temporary file does not exist: ' . $file['tmp_name']); + } + + if (!is_readable($file['tmp_name'])) { + throw new \Exception('Temporary file is not readable: ' . $file['tmp_name']); + } + + // Move uploaded file + $this->logger->info("Attempting to move uploaded file", [ + 'from' => $file['tmp_name'], + 'to' => $uploadPath + ]); + + if (!move_uploaded_file($file['tmp_name'], $uploadPath)) { + throw new \Exception('Failed to save uploaded file from ' . $file['tmp_name'] . ' to ' . $uploadPath); + } + + // Verify file was actually saved + if (!file_exists($uploadPath)) { + throw new \Exception('File was not saved to expected location: ' . $uploadPath); + } + + // Delete old avatar if it exists + if (!empty($user['avatar']) && $user['avatar'] !== 'gravatar' && $user['avatar'] !== 'no_gravatar') { + $this->logger->info("Deleting old avatar", [ + 'user_id' => $userId, + 'old_avatar' => $user['avatar'] + ]); + AvatarHelper::deleteAvatarFile($user['avatar']); + } + + // Update user record with new avatar filename + $this->logger->info("Updating user record with new avatar", [ + 'user_id' => $userId, + 'new_avatar' => $newFilename + ]); + + $updateResult = $this->userModel->update($userId, ['avatar' => $newFilename]); + + if (!$updateResult) { + throw new \Exception('Failed to update user record in database'); + } + + $_SESSION['success'] = 'Avatar updated successfully!'; + + $this->logger->info("Avatar upload completed successfully", [ + 'user_id' => $userId, + 'filename' => $newFilename, + 'file_size' => filesize($uploadPath) + ]); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to upload avatar: ' . $e->getMessage(); + $this->logger->error("Avatar upload failed", [ + 'user_id' => $userId, + 'file_name' => $file['name'], + 'file_size' => $file['size'], + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + + $this->redirect('/profile'); + } + + /** + * Delete avatar + */ + public function deleteAvatar() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/profile'); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/profile'); + return; + } + + try { + // Delete avatar file if it exists (only if it's an uploaded file) + if (!empty($user['avatar']) && $user['avatar'] !== 'gravatar' && $user['avatar'] !== 'no_gravatar') { + $this->logger->info("Deleting avatar file", [ + 'user_id' => $userId, + 'avatar_file' => $user['avatar'] + ]); + AvatarHelper::deleteAvatarFile($user['avatar']); + } + + // Clear avatar field in database + $this->logger->info("Clearing avatar field in database", [ + 'user_id' => $userId, + 'current_avatar' => $user['avatar'] + ]); + + $updateResult = $this->userModel->update($userId, ['avatar' => null]); + + if (!$updateResult) { + throw new \Exception('Failed to update user record in database'); + } + + $_SESSION['success'] = 'Avatar removed successfully!'; + + $this->logger->info("Avatar deletion completed successfully", [ + 'user_id' => $userId, + 'previous_avatar' => $user['avatar'] + ]); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to delete avatar: ' . $e->getMessage(); + $this->logger->error("Avatar deletion failed", [ + 'user_id' => $userId, + 'current_avatar' => $user['avatar'] ?? 'none', + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } + + $this->redirect('/profile'); + } } diff --git a/app/Helpers/AvatarHelper.php b/app/Helpers/AvatarHelper.php new file mode 100644 index 0000000..64b38db --- /dev/null +++ b/app/Helpers/AvatarHelper.php @@ -0,0 +1,453 @@ + Gravatar -> Initials + * + * @param array $user User data array + * @param int $size Avatar size in pixels (default: 40) + * @param string $default Default Gravatar type (default: 'identicon') + * @param bool $checkGravatar Whether to check Gravatar (default: true) + * @return array Avatar data with 'type', 'url', 'initials', 'size' + */ + public static function getAvatar(array $user, int $size = 40, string $default = 'identicon', bool $checkGravatar = true): array + { + $username = $user['username'] ?? 'U'; + $email = $user['email'] ?? ''; + $avatar = $user['avatar'] ?? null; + + // Priority 1: Check for uploaded avatar + if (!empty($avatar) && self::avatarFileExists($avatar)) { + return [ + 'type' => 'uploaded', + 'url' => self::getAvatarUrl($avatar), + 'initials' => strtoupper(substr($username, 0, 1)), + 'size' => $size, + 'alt' => $user['full_name'] ?? $username + ]; + } + + // Priority 2: Check for Gravatar (if email exists and user has actual Gravatar) + if (!empty($email) && $checkGravatar) { + // Check if we know the user has Gravatar from avatar field + // 'gravatar' in avatar field means user has Gravatar + // null/empty means unknown status + // any other value means uploaded avatar (handled above) + + if ($avatar === 'gravatar') { + // User has Gravatar, generate URL + $gravatarUrl = self::getGravatarUrl($email, $size, 'identicon'); + return [ + 'type' => 'gravatar', + 'url' => $gravatarUrl, + 'initials' => strtoupper(substr($username, 0, 1)), + 'size' => $size, + 'alt' => $user['full_name'] ?? $username + ]; + } elseif ($avatar === 'no_gravatar') { + // We know user doesn't have Gravatar, skip to initials + // Fall through to initials + } else { + // Unknown status - check Gravatar and update database + $gravatarUrl = self::getGravatarUrl($email, $size, '404'); + if (self::gravatarExists($gravatarUrl)) { + // Update database to remember this user has Gravatar + self::updateGravatarStatus($user['id'], 'gravatar'); + return [ + 'type' => 'gravatar', + 'url' => $gravatarUrl, + 'initials' => strtoupper(substr($username, 0, 1)), + 'size' => $size, + 'alt' => $user['full_name'] ?? $username + ]; + } else { + // Update database to remember this user doesn't have Gravatar + self::updateGravatarStatus($user['id'], 'no_gravatar'); + } + } + } + + // Priority 3: Fallback to initials + return [ + 'type' => 'initials', + 'url' => null, + 'initials' => strtoupper(substr($username, 0, 1)), + 'size' => $size, + 'alt' => $user['full_name'] ?? $username + ]; + } + + /** + * Get Gravatar URL for email + * + * @param string $email User email + * @param int $size Avatar size in pixels + * @param string $default Default Gravatar type + * @return string Gravatar URL + */ + public static function getGravatarUrl(string $email, int $size = 40, string $default = 'identicon'): string + { + $hash = md5(strtolower(trim($email))); + $size = max(1, min(2048, $size)); // Gravatar size limits + $default = urlencode($default); + + return "https://www.gravatar.com/avatar/{$hash}?s={$size}&d={$default}&r=g"; + } + + /** + * Check if Gravatar exists for the given URL (with caching) + * + * @param string $gravatarUrl Gravatar URL to check + * @return bool True if Gravatar exists + */ + public static function gravatarExists(string $gravatarUrl): bool + { + // Extract email from Gravatar URL for caching + if (preg_match('/avatar\/([a-f0-9]{32})/', $gravatarUrl, $matches)) { + $emailHash = $matches[1]; + } else { + return false; + } + + // Load cache if not already loaded + if (empty(self::$gravatarCache)) { + self::loadCache(); + } + + // Check cache first + if (isset(self::$gravatarCache[$emailHash])) { + return self::$gravatarCache[$emailHash]; + } + + // Use a simple HTTP HEAD request to check if the Gravatar exists + $context = stream_context_create([ + 'http' => [ + 'method' => 'HEAD', + 'timeout' => 3, // Reduced timeout for better performance + 'user_agent' => 'Domain Monitor Avatar Checker' + ] + ]); + + $headers = @get_headers($gravatarUrl, 1, $context); + + $exists = false; + if ($headers !== false) { + $statusCode = $headers[0] ?? ''; + // Return true only if we get a 200 status (user has actual Gravatar) + $exists = strpos($statusCode, '200') !== false; + } + + // Cache the result + self::$gravatarCache[$emailHash] = $exists; + self::saveCache(); + + return $exists; + } + + /** + * Check if uploaded avatar file exists + * + * @param string $avatarFilename Avatar filename + * @return bool True if file exists + */ + public static function avatarFileExists(string $avatarFilename): bool + { + $avatarPath = self::getAvatarPath($avatarFilename); + return file_exists($avatarPath); + } + + /** + * Get avatar file path + * + * @param string $avatarFilename Avatar filename + * @return string Full path to avatar file + */ + public static function getAvatarPath(string $avatarFilename): string + { + // Get the web root directory dynamically + $webRoot = self::getWebRoot(); + return $webRoot . '/assets/uploads/avatars/' . $avatarFilename; + } + + /** + * Get avatar URL for display + * + * @param string $avatarFilename Avatar filename + * @return string Avatar URL + */ + public static function getAvatarUrl(string $avatarFilename): string + { + return '/assets/uploads/avatars/' . $avatarFilename; + } + + /** + * Get the web root directory dynamically + * + * @return string Path to web root directory + */ + private static function getWebRoot(): string + { + // Use the document root from the web server - this is the most reliable way + return $_SERVER['DOCUMENT_ROOT'] ?? dirname(__DIR__, 3) . '/public_html'; + } + + /** + * Generate unique avatar filename + * + * @param string $originalFilename Original uploaded filename + * @param int $userId User ID + * @return string Unique filename + */ + public static function generateAvatarFilename(string $originalFilename, int $userId): string + { + $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); + $timestamp = time(); + $random = bin2hex(random_bytes(8)); + + return "user_{$userId}_{$timestamp}_{$random}.{$extension}"; + } + + /** + * Validate uploaded avatar file + * + * @param array $file $_FILES array element + * @return array Validation result with 'valid' boolean and 'error' message + */ + public static function validateAvatarFile(array $file): array + { + + // Check for upload errors + if ($file['error'] !== UPLOAD_ERR_OK) { + $errorMessages = [ + UPLOAD_ERR_INI_SIZE => 'File too large (server limit)', + UPLOAD_ERR_FORM_SIZE => 'File too large (form limit)', + UPLOAD_ERR_PARTIAL => 'File upload incomplete', + UPLOAD_ERR_NO_FILE => 'No file uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file', + UPLOAD_ERR_EXTENSION => 'File upload blocked by extension' + ]; + + $error = $errorMessages[$file['error']] ?? 'Unknown upload error'; + + return [ + 'valid' => false, + 'error' => $error + ]; + } + + // Check file size (2MB limit) + $maxSize = 2 * 1024 * 1024; // 2MB + if ($file['size'] > $maxSize) { + return [ + 'valid' => false, + 'error' => 'File too large. Maximum size is 2MB.' + ]; + } + + // Check file type + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mimeType, $allowedTypes)) { + return [ + 'valid' => false, + 'error' => 'Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed. Detected: ' . $mimeType + ]; + } + + // Check file extension + $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if (!in_array($extension, $allowedExtensions)) { + return [ + 'valid' => false, + 'error' => 'Invalid file extension. Only .jpg, .jpeg, .png, .gif, and .webp files are allowed.' + ]; + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Create avatar uploads directory if it doesn't exist + * + * @return bool True if directory exists or was created successfully + */ + public static function ensureUploadDirectory(): bool + { + $webRoot = self::getWebRoot(); + $uploadDir = $webRoot . '/assets/uploads/avatars'; + + if (!is_dir($uploadDir)) { + $result = mkdir($uploadDir, 0755, true); + return $result; + } + + return true; + } + + /** + * Delete old avatar file + * + * @param string $avatarFilename Avatar filename to delete + * @return bool True if file was deleted or didn't exist + */ + public static function deleteAvatarFile(string $avatarFilename): bool + { + if (empty($avatarFilename)) { + return true; + } + + $avatarPath = self::getAvatarPath($avatarFilename); + + if (file_exists($avatarPath)) { + return unlink($avatarPath); + } + + return true; + } + + /** + * Render avatar HTML + * + * @param array $user User data array + * @param int $size Avatar size in pixels + * @param string $cssClass Additional CSS classes + * @param bool $showOnlineStatus Show online status indicator + * @return string HTML for avatar + */ + public static function renderAvatar(array $user, int $size = 40, string $cssClass = '', bool $showOnlineStatus = false): string + { + $avatar = self::getAvatar($user, $size); + $sizeClass = "w-{$size} h-{$size}"; + + $html = '