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:
@@ -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'];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
453
app/Helpers/AvatarHelper.php
Normal file
453
app/Helpers/AvatarHelper.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,30 +124,59 @@
|
||||
<!-- User Dropdown -->
|
||||
<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">
|
||||
<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
|
||||
// Get user data for avatar
|
||||
$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">
|
||||
<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>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
|
||||
</button>
|
||||
|
||||
<!-- 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 class="px-4 py-3 border-b border-gray-200">
|
||||
<p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'user@example.com') ?></p>
|
||||
<div class="flex items-center gap-2 mt-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">
|
||||
<i class="fas fa-<?= $_SESSION['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
|
||||
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
|
||||
</span>
|
||||
<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">
|
||||
<i class="fas fa-circle text-xs mr-1"></i>
|
||||
Online
|
||||
</span>
|
||||
<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">
|
||||
<!-- Welcome Header -->
|
||||
<div class="px-4 py-4 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||
<div class="text-center">
|
||||
<div class="relative w-12 h-12 mx-auto mb-2">
|
||||
<?php if ($avatar && ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar')): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
|
||||
<?= 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>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ ob_start();
|
||||
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']);
|
||||
$twoFactorService = new \App\Services\TwoFactorService();
|
||||
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
|
||||
// Get avatar data
|
||||
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
?>
|
||||
|
||||
<!-- Main Profile Layout -->
|
||||
@@ -19,8 +22,28 @@ $twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
<!-- User Info Section -->
|
||||
<div class="p-6 border-b border-gray-200 bg-gray-50">
|
||||
<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">
|
||||
<?= strtoupper(substr($user['username'] ?? 'U', 0, 1)) ?>
|
||||
<div class="relative">
|
||||
<?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>
|
||||
<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>
|
||||
@@ -90,6 +113,93 @@ $twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
<p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p>
|
||||
</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">
|
||||
<?= csrf_field() ?>
|
||||
<div class="space-y-5">
|
||||
|
||||
@@ -207,10 +207,21 @@ $pagination = $pagination ?? [
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<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">
|
||||
<span class="text-primary font-semibold text-sm">
|
||||
<?= strtoupper(substr($user['username'], 0, 1)) ?>
|
||||
</span>
|
||||
<?php
|
||||
// Get avatar data for this user (now fast with database caching)
|
||||
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 40);
|
||||
?>
|
||||
<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 class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? 'N/A') ?></div>
|
||||
|
||||
Reference in New Issue
Block a user