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 = '
'; + + if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar') { + $html .= 'prepare("UPDATE users SET avatar = ? WHERE id = ?"); + $stmt->execute([$status, $userId]); + } catch (\Exception $e) { + // Silently fail - don't break avatar display + } + } + + /** + * Get detected web root for debugging + */ + public static function getDetectedWebRoot(): string + { + return self::getWebRoot(); + } + + /** + * Get cache statistics + */ + public static function getCacheStats(): array + { + if (empty(self::$gravatarCache)) { + self::loadCache(); + } + + $total = count(self::$gravatarCache); + $withGravatar = array_sum(self::$gravatarCache); + $withoutGravatar = $total - $withGravatar; + + return [ + 'total_checked' => $total, + 'with_gravatar' => $withGravatar, + 'without_gravatar' => $withoutGravatar, + 'cache_file' => self::$cacheFile, + 'cache_size' => file_exists(self::$cacheFile) ? filesize(self::$cacheFile) : 0, + 'web_root' => self::getWebRoot() + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 37724ca..9afd214 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -39,7 +39,7 @@ class User extends Model /** * Create user with hashed password */ - public function createUser(string $username, string $password, ?string $email = null, ?string $fullName = null): int + public function createUser(string $username, string $password, ?string $email = null, ?string $fullName = null, ?string $avatar = null): int { $hashedPassword = password_hash($password, PASSWORD_DEFAULT); @@ -48,6 +48,7 @@ class User extends Model 'password' => $hashedPassword, 'email' => $email, 'full_name' => $fullName, + 'avatar' => $avatar, 'is_active' => 1 ]); } diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index a0e9bba..5c46442 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -9,7 +9,18 @@ class WhoisService { // Cache for discovered TLD servers to avoid repeated IANA queries private static array $tldCache = []; + + // Cache TTL in seconds (24 hours) + private const CACHE_TTL = 86400; private TldRegistry $tldModel; + + /** + * Clear TLD cache (useful for testing or forcing fresh lookups) + */ + public static function clearTldCache(): void + { + self::$tldCache = []; + } public function __construct() { @@ -101,26 +112,84 @@ class WhoisService $whoisData = $this->queryWhois($domain, $whoisServer); if (!$whoisData) { + $logger = new \App\Services\Logger(); + $logger->warning('No WHOIS data received', [ + 'domain' => $domain, + 'server' => $whoisServer + ]); return null; } + + $logger = new \App\Services\Logger(); + $logger->debug('WHOIS data received', [ + 'domain' => $domain, + 'server' => $whoisServer, + 'data_length' => strlen($whoisData), + 'first_200_chars' => substr($whoisData, 0, 200) + ]); // Check if we got a referral to another WHOIS server $referralServer = $this->extractReferralServer($whoisData); if ($referralServer && $referralServer !== $whoisServer) { - // Query the referred server - $whoisData = $this->queryWhois($domain, $referralServer); - if (!$whoisData) { - return null; + // Check if the original response already has complete data + $originalInfo = $this->parseWhoisData($domain, $whoisData, $whoisServer); + $hasCompleteData = !empty($originalInfo['registrar']) && + $originalInfo['registrar'] !== 'Unknown' && + !empty($originalInfo['expiration_date']); + + if (!$hasCompleteData) { + // Only query the referred server if original data is incomplete + $logger = new \App\Services\Logger(); + $logger->debug('Following WHOIS referral', [ + 'domain' => $domain, + 'original_server' => $whoisServer, + 'referral_server' => $referralServer + ]); + + $referralData = $this->queryWhois($domain, $referralServer); + if ($referralData) { + $whoisData = $referralData; + } + } else { + $logger = new \App\Services\Logger(); + $logger->debug('Skipping WHOIS referral - original data is complete', [ + 'domain' => $domain, + 'original_server' => $whoisServer, + 'referral_server' => $referralServer, + 'original_registrar' => $originalInfo['registrar'], + 'original_expiration' => $originalInfo['expiration_date'] + ]); + $referralServer = null; // Don't use referral server } } // Parse the response - $info = $this->parseWhoisData($domain, $whoisData, $referralServer ?? $whoisServer); + $actualServer = $referralServer ?? $whoisServer; + $info = $this->parseWhoisData($domain, $whoisData, $actualServer); + + // Override whois_server to reflect the actual server that provided the data + $info['whois_server'] = $actualServer; + + // Debug logging using proper Logger service + $logger = new \App\Services\Logger(); + $logger->debug('WHOIS parsing completed', [ + 'domain' => $domain, + 'server' => $referralServer ?? $whoisServer, + 'raw_data_length' => strlen($whoisData), + 'parsed_registrar' => $info['registrar'] ?? 'null', + 'parsed_expiration' => $info['expiration_date'] ?? 'null', + 'parsed_nameservers_count' => count($info['nameservers'] ?? []) + ]); return $info; } catch (Exception $e) { - error_log("WHOIS lookup failed for $domain: " . $e->getMessage()); + $logger = new \App\Services\Logger(); + $logger->error('WHOIS lookup failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); return null; } } @@ -131,9 +200,14 @@ class WhoisService */ private function discoverTldServers(string $tld): array { - // Check cache first + // Check cache first (with TTL) if (isset(self::$tldCache[$tld])) { - return self::$tldCache[$tld]; + $cached = self::$tldCache[$tld]; + if (isset($cached['timestamp']) && (time() - $cached['timestamp']) < self::CACHE_TTL) { + return $cached['data']; + } + // Cache expired, remove it + unset(self::$tldCache[$tld]); } $result = [ @@ -160,7 +234,10 @@ class WhoisService } // Cache the result - self::$tldCache[$tld] = $result; + self::$tldCache[$tld] = [ + 'data' => $result, + 'timestamp' => time() + ]; return $result; } @@ -169,7 +246,10 @@ class WhoisService $response = $this->queryWhois($tld, 'whois.iana.org'); if (!$response) { - self::$tldCache[$tld] = $result; + self::$tldCache[$tld] = [ + 'data' => $result, + 'timestamp' => time() + ]; return $result; } @@ -252,11 +332,17 @@ class WhoisService // Guessing often creates invalid URLs that don't resolve in DNS // Cache the result - self::$tldCache[$tld] = $result; + self::$tldCache[$tld] = [ + 'data' => $result, + 'timestamp' => time() + ]; return $result; } catch (Exception $e) { - self::$tldCache[$tld] = $result; + self::$tldCache[$tld] = [ + 'data' => $result, + 'timestamp' => time() + ]; return $result; } } @@ -579,7 +665,10 @@ class WhoisService // Check if domain is not found/available $whoisDataLower = strtolower($whoisData); - if (preg_match('/not found|no match|no entries found|no data found|domain not found|no such domain|not registered|available for registration|does not exist|queried object does not exist|is free/i', $whoisDataLower)) { + // More specific patterns to avoid false positives + 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', $whoisDataLower) || + 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', $whoisDataLower) || + 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', $whoisDataLower)) { $data['status'][] = 'AVAILABLE'; $data['registrar'] = 'Not Registered'; return $data; @@ -639,17 +728,17 @@ class WhoisService } // Expiration date - if (preg_match('/(expir|expiry|expire|paid-till|renewal)/i', $key) && !empty($value)) { + if (preg_match('/(expir|expiry|expire|paid-till|renewal|registry.*expir)/i', $key) && !empty($value)) { $data['expiration_date'] = $this->parseDate($value); } // Updated date (UK format: "Last updated") - if (preg_match('/(updated date|last updated)/i', $key) && !empty($value)) { + if (preg_match('/(updated date|last updated|updated)/i', $key) && !empty($value)) { $data['updated_date'] = $this->parseDate($value); } // Creation date (UK format: "Registered on") - if (preg_match('/(creat|registered|registered on)/i', $key) && !empty($value)) { + if (preg_match('/(creat|registered|registered on|creation)/i', $key) && !empty($value)) { $data['creation_date'] = $this->parseDate($value); } @@ -675,8 +764,22 @@ class WhoisService // Status (UK format: "Registration status") if (preg_match('/(status|state|registration status)/i', $key) && !empty($value)) { - if (!in_array($value, $data['status'])) { - $data['status'][] = $value; + // Filter out invalid status values and extract just the status name + $cleanValue = trim($value); + if (!empty($cleanValue) && + !preg_match('/^(NA|REDACTED|N\/A)$/i', $cleanValue) && + !preg_match('/^\/\//', $cleanValue) && + !preg_match('/^https?:\/\//', $cleanValue) && + strlen($cleanValue) > 2) { + + // Extract just the status name, removing URLs and references + $statusName = preg_replace('/\s+https?:\/\/[^\s]+.*$/', '', $cleanValue); + $statusName = preg_replace('/\s+[a-z]+:\/\/[^\s]+.*$/', '', $statusName); + $statusName = trim($statusName); + + if (!empty($statusName) && !in_array($statusName, $data['status'])) { + $data['status'][] = $statusName; + } } } diff --git a/app/Views/layout/top-nav.php b/app/Views/layout/top-nav.php index 8bdff0f..eaa499b 100644 --- a/app/Views/layout/top-nav.php +++ b/app/Views/layout/top-nav.php @@ -124,30 +124,59 @@
-