Add user avatar system and fix WHOIS parsing/cron synchronization

- Add avatar upload with Gravatar fallback and initials
- Fix false "available" detection for registered domains
- Clean up WHOIS status parsing and server display
- Update cron job to sync all WHOIS fields
- Fix TLD cache and .me domain parsing issues
This commit is contained in:
Hosteroid
2025-10-27 18:13:38 +02:00
parent bbb1be1cf5
commit 67bacc36e3
15 changed files with 1060 additions and 45 deletions

View File

@@ -67,6 +67,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Migration tracking system - Migration tracking system
- Consolidated database schema for v1.1.0 fresh installs - Consolidated database schema for v1.1.0 fresh installs
- Smart migration system (consolidated for new, incremental for upgrades) - 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 ### Changed
- Profile page completely redesigned with sidebar layout - 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 - Profile integrated with dashboard layout
- Installation now via web UI instead of CLI - Installation now via web UI instead of CLI
- Auto-redirect to installer on first run - 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 ### Security
- **Database Session Storage** - True session control with remote termination - **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 - Email enumeration protection
- Session-based verification resend - Session-based verification resend
- Admin-only route protection - 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 ### Technical
- **MVC Architecture Refactoring** - Complete separation of concerns - **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 - Notification system with 7 notification types
- Welcome notifications on user creation and fresh install - Welcome notifications on user creation and fresh install
- Upgrade notifications for admins with version tracking - 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 ### Contributors
- Special thanks to @jadeops for auto-detected cron path improvement & XSS protection enhancement (PR #1) - 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) - **Advanced Session Management** - Database-backed sessions with geolocation (country, city, ISP)
- **Remote Session Control** - Terminate any device instantly with immediate logout validation - **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) - **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 - **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 - **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) - **Device Detection** - Browser & device type parsing (Chrome/Firefox/Safari, Desktop/Mobile/Tablet)

View File

@@ -327,9 +327,11 @@ class DebugController extends Controller
$response .= $whoisResponse; $response .= $whoisResponse;
// Check if domain is not found/available // Check if domain is not found/available (using improved pattern)
$whoisResponseLower = strtolower($whoisResponse); $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 .= "\n\n=== DOMAIN STATUS DETECTED ===\n";
$response .= "✓ Domain is AVAILABLE (not registered)\n"; $response .= "✓ Domain is AVAILABLE (not registered)\n";
$parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE']; $parsedData[] = ['key' => 'Status', 'value' => 'AVAILABLE'];

View File

@@ -51,6 +51,7 @@ class InstallerController extends Controller
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.sql', '020_create_tags_system.sql',
'021_add_avatar_field.sql',
]; ];
try { try {
@@ -187,7 +188,8 @@ class InstallerController extends Controller
'017_add_two_factor_authentication.sql', '017_add_two_factor_authentication.sql',
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.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', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.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"); $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");

View File

@@ -8,6 +8,7 @@ use App\Models\User;
use App\Models\SessionManager; use App\Models\SessionManager;
use App\Models\RememberToken; use App\Models\RememberToken;
use App\Services\Logger; use App\Services\Logger;
use App\Helpers\AvatarHelper;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -404,4 +405,213 @@ class ProfileController extends Controller
$this->redirect('/profile#sessions'); $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');
}
} }

View File

@@ -0,0 +1,453 @@
<?php
namespace App\Helpers;
class AvatarHelper
{
/**
* Cache for Gravatar existence checks to avoid repeated HTTP requests
*/
private static array $gravatarCache = [];
/**
* Cache file path for persistent Gravatar cache
*/
private static string $cacheFile = __DIR__ . '/../../cache/gravatar_cache.json';
/**
* Load Gravatar cache from file
*/
private static function loadCache(): void
{
if (file_exists(self::$cacheFile)) {
$cacheData = json_decode(file_get_contents(self::$cacheFile), true);
if (is_array($cacheData)) {
self::$gravatarCache = $cacheData;
}
}
}
/**
* Save Gravatar cache to file
*/
private static function saveCache(): void
{
// Ensure cache directory exists
$cacheDir = dirname(self::$cacheFile);
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
file_put_contents(self::$cacheFile, json_encode(self::$gravatarCache, JSON_PRETTY_PRINT));
}
/**
* Get cache key for email
*/
private static function getCacheKey(string $email): string
{
return md5(strtolower(trim($email)));
}
/**
* Get user avatar with fallback logic
* Priority: Uploaded avatar -> 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 = '<div class="' . $sizeClass . ' rounded-full flex items-center justify-center text-white font-semibold ' . $cssClass . '">';
if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar') {
$html .= '<img src="' . htmlspecialchars($avatar['url']) . '" ';
$html .= 'alt="' . htmlspecialchars($avatar['alt']) . '" ';
$html .= 'class="w-full h-full rounded-full object-cover" ';
$html .= 'loading="lazy">';
} else {
$html .= $avatar['initials'];
}
if ($showOnlineStatus) {
$html .= '<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white flex items-center justify-center">';
$html .= '<div class="w-2 h-2 bg-white rounded-full"></div>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* Clear Gravatar cache
*/
public static function clearCache(): void
{
self::$gravatarCache = [];
if (file_exists(self::$cacheFile)) {
unlink(self::$cacheFile);
}
}
/**
* Update Gravatar status in database using avatar field
*/
private static function updateGravatarStatus(int $userId, string $status): void
{
try {
$pdo = \Core\Database::getConnection();
$stmt = $pdo->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()
];
}
}

View File

@@ -39,7 +39,7 @@ class User extends Model
/** /**
* Create user with hashed password * 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); $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
@@ -48,6 +48,7 @@ class User extends Model
'password' => $hashedPassword, 'password' => $hashedPassword,
'email' => $email, 'email' => $email,
'full_name' => $fullName, 'full_name' => $fullName,
'avatar' => $avatar,
'is_active' => 1 'is_active' => 1
]); ]);
} }

View File

@@ -9,8 +9,19 @@ class WhoisService
{ {
// Cache for discovered TLD servers to avoid repeated IANA queries // Cache for discovered TLD servers to avoid repeated IANA queries
private static array $tldCache = []; private static array $tldCache = [];
// Cache TTL in seconds (24 hours)
private const CACHE_TTL = 86400;
private TldRegistry $tldModel; private TldRegistry $tldModel;
/**
* Clear TLD cache (useful for testing or forcing fresh lookups)
*/
public static function clearTldCache(): void
{
self::$tldCache = [];
}
public function __construct() public function __construct()
{ {
$this->tldModel = new TldRegistry(); $this->tldModel = new TldRegistry();
@@ -101,26 +112,84 @@ class WhoisService
$whoisData = $this->queryWhois($domain, $whoisServer); $whoisData = $this->queryWhois($domain, $whoisServer);
if (!$whoisData) { if (!$whoisData) {
$logger = new \App\Services\Logger();
$logger->warning('No WHOIS data received', [
'domain' => $domain,
'server' => $whoisServer
]);
return null; 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 // Check if we got a referral to another WHOIS server
$referralServer = $this->extractReferralServer($whoisData); $referralServer = $this->extractReferralServer($whoisData);
if ($referralServer && $referralServer !== $whoisServer) { if ($referralServer && $referralServer !== $whoisServer) {
// Query the referred server // Check if the original response already has complete data
$whoisData = $this->queryWhois($domain, $referralServer); $originalInfo = $this->parseWhoisData($domain, $whoisData, $whoisServer);
if (!$whoisData) { $hasCompleteData = !empty($originalInfo['registrar']) &&
return null; $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 // 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; return $info;
} catch (Exception $e) { } 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; return null;
} }
} }
@@ -131,9 +200,14 @@ class WhoisService
*/ */
private function discoverTldServers(string $tld): array private function discoverTldServers(string $tld): array
{ {
// Check cache first // Check cache first (with TTL)
if (isset(self::$tldCache[$tld])) { 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 = [ $result = [
@@ -160,7 +234,10 @@ class WhoisService
} }
// Cache the result // Cache the result
self::$tldCache[$tld] = $result; self::$tldCache[$tld] = [
'data' => $result,
'timestamp' => time()
];
return $result; return $result;
} }
@@ -169,7 +246,10 @@ class WhoisService
$response = $this->queryWhois($tld, 'whois.iana.org'); $response = $this->queryWhois($tld, 'whois.iana.org');
if (!$response) { if (!$response) {
self::$tldCache[$tld] = $result; self::$tldCache[$tld] = [
'data' => $result,
'timestamp' => time()
];
return $result; return $result;
} }
@@ -252,11 +332,17 @@ class WhoisService
// Guessing often creates invalid URLs that don't resolve in DNS // Guessing often creates invalid URLs that don't resolve in DNS
// Cache the result // Cache the result
self::$tldCache[$tld] = $result; self::$tldCache[$tld] = [
'data' => $result,
'timestamp' => time()
];
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
self::$tldCache[$tld] = $result; self::$tldCache[$tld] = [
'data' => $result,
'timestamp' => time()
];
return $result; return $result;
} }
} }
@@ -579,7 +665,10 @@ class WhoisService
// Check if domain is not found/available // Check if domain is not found/available
$whoisDataLower = strtolower($whoisData); $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['status'][] = 'AVAILABLE';
$data['registrar'] = 'Not Registered'; $data['registrar'] = 'Not Registered';
return $data; return $data;
@@ -639,17 +728,17 @@ class WhoisService
} }
// Expiration date // 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); $data['expiration_date'] = $this->parseDate($value);
} }
// Updated date (UK format: "Last updated") // 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); $data['updated_date'] = $this->parseDate($value);
} }
// Creation date (UK format: "Registered on") // 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); $data['creation_date'] = $this->parseDate($value);
} }
@@ -675,8 +764,22 @@ class WhoisService
// Status (UK format: "Registration status") // Status (UK format: "Registration status")
if (preg_match('/(status|state|registration status)/i', $key) && !empty($value)) { if (preg_match('/(status|state|registration status)/i', $key) && !empty($value)) {
if (!in_array($value, $data['status'])) { // Filter out invalid status values and extract just the status name
$data['status'][] = $value; $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;
}
} }
} }

View File

@@ -124,30 +124,59 @@
<!-- User Dropdown --> <!-- User Dropdown -->
<div class="relative"> <div class="relative">
<button onclick="toggleDropdown()" class="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg transition-colors duration-150 focus:outline-none"> <button onclick="toggleDropdown()" class="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg transition-colors duration-150 focus:outline-none">
<div class="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white font-semibold"> <?php
<?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?> // Get user data for avatar
</div> $userModel = new \App\Models\User();
$user = $userModel->find($_SESSION['user_id'] ?? 0);
$avatar = $user ? \App\Helpers\AvatarHelper::getAvatar($user, 36) : null;
?>
<?php if ($avatar && ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar')): ?>
<img src="<?= htmlspecialchars($avatar['url']) ?>"
alt="<?= htmlspecialchars($avatar['alt']) ?>"
class="w-9 h-9 rounded-full object-cover"
loading="lazy">
<?php else: ?>
<div class="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white font-semibold">
<?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?>
</div>
<?php endif; ?>
<div class="hidden lg:block text-left"> <div class="hidden lg:block text-left">
<p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['username'] ?? 'User') ?></p> <p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
<p class="text-xs text-gray-500"><?= htmlspecialchars($_SESSION['email'] ?? '') ?></p> <p class="text-xs text-gray-500"><?= htmlspecialchars($_SESSION['email'] ?? '') ?></p>
</div> </div>
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i> <i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
</button> </button>
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 border border-gray-200"> <div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden pb-2">
<div class="px-4 py-3 border-b border-gray-200"> <!-- Welcome Header -->
<p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p> <div class="px-4 py-4 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100">
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'user@example.com') ?></p> <div class="text-center">
<div class="flex items-center gap-2 mt-2"> <div class="relative w-12 h-12 mx-auto mb-2">
<span class="inline-flex items-center px-2.5 py-1 bg-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-800 text-xs font-semibold rounded border border-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-200"> <?php if ($avatar && ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar')): ?>
<i class="fas fa-<?= $_SESSION['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i> <img src="<?= htmlspecialchars($avatar['url']) ?>"
<?= ucfirst($_SESSION['role'] ?? 'user') ?> alt="<?= htmlspecialchars($avatar['alt']) ?>"
</span> class="w-12 h-12 rounded-full object-cover"
<span class="inline-flex items-center px-2.5 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded border border-green-200"> loading="lazy">
<i class="fas fa-circle text-xs mr-1"></i> <?php else: ?>
Online <div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
</span> <?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?>
</div>
<?php endif; ?>
<!-- Online status dot -->
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white flex items-center justify-center">
<div class="w-2 h-2 bg-white rounded-full"></div>
</div>
</div>
<p class="text-sm font-semibold text-gray-900">Welcome back!</p>
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
<!-- Role indicator -->
<div class="mt-2">
<span class="inline-flex items-center px-2 py-1 bg-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-700 text-xs font-medium rounded-full">
<i class="fas fa-<?= $_SESSION['role'] === 'admin' ? 'crown' : 'user' ?> mr-1"></i>
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
</span>
</div>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,9 @@ ob_start();
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']); $twoFactorStatus = $userModel->getTwoFactorStatus($user['id']);
$twoFactorService = new \App\Services\TwoFactorService(); $twoFactorService = new \App\Services\TwoFactorService();
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy(); $twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
// Get avatar data
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
?> ?>
<!-- Main Profile Layout --> <!-- Main Profile Layout -->
@@ -19,8 +22,28 @@ $twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
<!-- User Info Section --> <!-- User Info Section -->
<div class="p-6 border-b border-gray-200 bg-gray-50"> <div class="p-6 border-b border-gray-200 bg-gray-50">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<div class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white text-2xl font-bold"> <div class="relative">
<?= strtoupper(substr($user['username'] ?? 'U', 0, 1)) ?> <?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
<img src="<?= htmlspecialchars($avatar['url']) ?>"
alt="<?= htmlspecialchars($avatar['alt']) ?>"
class="w-20 h-20 rounded-full object-cover border-2 border-white shadow-sm"
loading="lazy">
<?php else: ?>
<div class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white text-2xl font-bold border-2 border-white shadow-sm">
<?= $avatar['initials'] ?>
</div>
<?php endif; ?>
<!-- Avatar type indicator -->
<div class="absolute -bottom-1 -right-1 w-6 h-6 bg-white rounded-full border-2 border-gray-200 flex items-center justify-center">
<?php if ($avatar['type'] === 'uploaded'): ?>
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i>
<?php elseif ($avatar['type'] === 'gravatar'): ?>
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i>
<?php else: ?>
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i>
<?php endif; ?>
</div>
</div> </div>
<h3 class="mt-4 text-base font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></h3> <h3 class="mt-4 text-base font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></h3>
<p class="text-sm text-gray-500 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p> <p class="text-sm text-gray-500 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p>
@@ -90,6 +113,93 @@ $twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
<p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p> <p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p>
</div> </div>
<!-- Avatar Upload Section -->
<div class="px-6 py-4 border-b border-gray-200">
<h4 class="text-base font-semibold text-gray-900 mb-3">Profile Picture</h4>
<div class="flex items-center space-x-4">
<!-- Current Avatar Display -->
<div class="relative">
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
<img src="<?= htmlspecialchars($avatar['url']) ?>"
alt="<?= htmlspecialchars($avatar['alt']) ?>"
class="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
loading="lazy">
<?php else: ?>
<div class="w-16 h-16 rounded-full bg-primary flex items-center justify-center text-white text-lg font-bold border-2 border-gray-200">
<?= $avatar['initials'] ?>
</div>
<?php endif; ?>
<!-- Avatar type indicator -->
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-white rounded-full border-2 border-gray-200 flex items-center justify-center">
<?php if ($avatar['type'] === 'uploaded'): ?>
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i>
<?php elseif ($avatar['type'] === 'gravatar'): ?>
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i>
<?php else: ?>
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i>
<?php endif; ?>
</div>
</div>
<!-- Avatar Controls -->
<div class="flex-1">
<div class="space-y-2">
<!-- Upload Form -->
<form method="POST" action="/profile/upload-avatar" enctype="multipart/form-data" class="inline-block">
<?= csrf_field() ?>
<div class="flex items-center space-x-2">
<input type="file"
id="avatar"
name="avatar"
accept="image/jpeg,image/png,image/gif,image/webp"
class="hidden"
onchange="this.form.submit()">
<label for="avatar"
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer">
<i class="fas fa-upload mr-2"></i>
Upload New
</label>
</div>
</form>
<!-- Delete Avatar Button -->
<?php if ($avatar['type'] === 'uploaded'): ?>
<form method="POST" action="/profile/delete-avatar" class="inline-block ml-2">
<?= csrf_field() ?>
<button type="submit"
class="inline-flex items-center px-3 py-2 border border-red-300 rounded-lg text-sm font-medium text-red-700 bg-white hover:bg-red-50"
onclick="return confirm('Are you sure you want to remove your avatar?')">
<i class="fas fa-trash mr-2"></i>
Remove
</button>
</form>
<?php endif; ?>
</div>
<!-- Avatar Info -->
<div class="mt-2 text-xs text-gray-500">
<?php if ($avatar['type'] === 'uploaded'): ?>
Using uploaded image
<?php elseif ($avatar['type'] === 'gravatar'): ?>
Using Gravatar from <?= htmlspecialchars($user['email'] ?? '') ?>
<?php else: ?>
Using initials (upload an image or set up Gravatar)
<?php endif; ?>
</div>
<!-- Gravatar Info -->
<?php if ($avatar['type'] !== 'gravatar' && !empty($user['email'])): ?>
<div class="mt-1 text-xs text-gray-400">
<a href="https://gravatar.com" target="_blank" class="text-blue-600 hover:text-blue-800">
Set up Gravatar for automatic avatar
</a>
</div>
<?php endif; ?>
</div>
</div>
</div>
<form method="POST" action="/profile/update" class="p-6"> <form method="POST" action="/profile/update" class="p-6">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="space-y-5"> <div class="space-y-5">

View File

@@ -207,10 +207,21 @@ $pagination = $pagination ?? [
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center"> <?php
<span class="text-primary font-semibold text-sm"> // Get avatar data for this user (now fast with database caching)
<?= strtoupper(substr($user['username'], 0, 1)) ?> $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 40);
</span> ?>
<div class="flex-shrink-0 h-10 w-10 rounded-lg overflow-hidden bg-primary bg-opacity-10 flex items-center justify-center">
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
<img src="<?= htmlspecialchars($avatar['url']) ?>"
alt="<?= htmlspecialchars($avatar['alt']) ?>"
class="w-full h-full object-cover"
loading="lazy">
<?php else: ?>
<span class="text-primary font-semibold text-sm">
<?= $avatar['initials'] ?>
</span>
<?php endif; ?>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? 'N/A') ?></div> <div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? 'N/A') ?></div>

View File

@@ -37,6 +37,9 @@ $settingModel = new Setting();
$whoisService = new WhoisService(); $whoisService = new WhoisService();
$notificationService = new NotificationService(); $notificationService = new NotificationService();
// Clear TLD cache to ensure fresh server discovery
WhoisService::clearTldCache();
// Set timezone from settings // Set timezone from settings
try { try {
$appSettings = $settingModel->getAppSettings(); $appSettings = $settingModel->getAppSettings();
@@ -116,7 +119,10 @@ foreach ($domains as $domain) {
$status = $whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []); $status = $whoisService->getDomainStatus($whoisData['expiration_date'], $whoisData['status'] ?? []);
$domainModel->update($domain['id'], [ $domainModel->update($domain['id'], [
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],
'registrar_url' => $whoisData['registrar_url'] ?? null,
'expiration_date' => $whoisData['expiration_date'], 'expiration_date' => $whoisData['expiration_date'],
'updated_date' => $whoisData['updated_date'] ?? null,
'abuse_email' => $whoisData['abuse_email'] ?? null,
'last_checked' => date('Y-m-d H:i:s'), 'last_checked' => date('Y-m-d H:i:s'),
'status' => $status, 'status' => $status,
'whois_data' => json_encode($whoisData) 'whois_data' => json_encode($whoisData)

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
two_factor_backup_codes TEXT NULL, two_factor_backup_codes TEXT NULL,
two_factor_setup_at TIMESTAMP NULL, two_factor_setup_at TIMESTAMP NULL,
full_name VARCHAR(255), full_name VARCHAR(255),
avatar VARCHAR(255) NULL,
role VARCHAR(50) DEFAULT 'user', role VARCHAR(50) DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
last_login TIMESTAMP NULL, last_login TIMESTAMP NULL,

View File

@@ -0,0 +1,4 @@
-- Add avatar field to users table
-- This field will store the filename of the uploaded avatar image
ALTER TABLE users
ADD COLUMN avatar VARCHAR(255) NULL AFTER full_name;

View File

@@ -0,0 +1,14 @@
# Protect uploads directory
Options -Indexes
# Allow only image files
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
Order Allow,Deny
Allow from all
</FilesMatch>
# Deny all other file types
<FilesMatch "^(?!.*\.(jpg|jpeg|png|gif|webp)$).*$">
Order Deny,Allow
Deny from all
</FilesMatch>

View File

@@ -137,6 +137,8 @@ $router->get('/profile/delete', [ProfileController::class, 'delete']);
$router->get('/profile/resend-verification', [ProfileController::class, 'resendVerification']); $router->get('/profile/resend-verification', [ProfileController::class, 'resendVerification']);
$router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']); $router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']);
$router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']); $router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']);
$router->post('/profile/upload-avatar', [ProfileController::class, 'uploadAvatar']);
$router->post('/profile/delete-avatar', [ProfileController::class, 'deleteAvatar']);
// Two-Factor Authentication management (protected) // Two-Factor Authentication management (protected)
$router->get('/2fa/setup', [TwoFactorController::class, 'setup']); $router->get('/2fa/setup', [TwoFactorController::class, 'setup']);