diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index dbd9b24..71f5378 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -33,10 +33,14 @@ class AuthController extends Controller // Check if registration is enabled $registrationEnabled = $this->settingModel->getValue('registration_enabled'); + + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); $this->view('auth/login', [ 'title' => 'Login', - 'registrationEnabled' => $registrationEnabled + 'registrationEnabled' => $registrationEnabled, + 'captchaSettings' => $captchaSettings ]); } @@ -50,10 +54,25 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/login'); + $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; // Don't trim - passwords may have intentional spaces $remember = isset($_POST['remember']); + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/login'); + return; + } + // Validate input if (empty($username) || empty($password)) { $_SESSION['error'] = 'Username and password are required'; @@ -140,8 +159,12 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/register', [ - 'title' => 'Register' + 'title' => 'Register', + 'captchaSettings' => $captchaSettings ]); } @@ -155,6 +178,9 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/register'); + // Check if registration is enabled $registrationEnabled = $this->settingModel->getValue('registration_enabled'); if (!$registrationEnabled) { @@ -163,6 +189,18 @@ class AuthController extends Controller return; } + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/register'); + return; + } + $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $fullName = trim($_POST['full_name'] ?? ''); @@ -176,6 +214,22 @@ class AuthController extends Controller return; } + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($username, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; + $this->redirect('/register'); + return; + } + + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/register'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid email address'; $this->redirect('/register'); @@ -398,8 +452,12 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/forgot-password', [ - 'title' => 'Forgot Password' + 'title' => 'Forgot Password', + 'captchaSettings' => $captchaSettings ]); } @@ -413,6 +471,21 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/forgot-password'); + + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/forgot-password'); + return; + } + $email = trim($_POST['email'] ?? ''); if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -478,9 +551,13 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/reset-password', [ 'title' => 'Reset Password', - 'token' => $token + 'token' => $token, + 'captchaSettings' => $captchaSettings ]); } @@ -494,6 +571,23 @@ class AuthController extends Controller return; } + // CSRF Protection + $token = $_POST['token'] ?? ''; + $this->verifyCsrf('/reset-password?token=' . urlencode($token)); + + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $token = $_POST['token'] ?? ''; + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/reset-password?token=' . urlencode($token)); + return; + } + $token = $_POST['token'] ?? ''; $password = $_POST['password'] ?? ''; $passwordConfirm = $_POST['password_confirm'] ?? ''; diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 5e4a630..1c16c38 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -23,7 +23,7 @@ class DomainController extends Controller public function index() { // Get filter parameters - $search = $_GET['search'] ?? ''; + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $status = $_GET['status'] ?? ''; $groupId = $_GET['group'] ?? ''; $sortBy = $_GET['sort'] ?? 'domain_name'; @@ -131,6 +131,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains/create'); + $domainName = trim($_POST['domain_name'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; @@ -141,6 +144,13 @@ class DomainController extends Controller return; } + // Validate domain format + if (!\App\Helpers\InputValidator::validateDomain($domainName)) { + $_SESSION['error'] = 'Invalid domain name format (e.g., example.com)'; + $this->redirect('/domains/create'); + return; + } + // Check if domain already exists if ($this->domainModel->existsByDomain($domainName)) { $_SESSION['error'] = 'Domain already exists'; @@ -212,6 +222,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $id = (int)($params['id'] ?? 0); $domain = $this->domainModel->find($id); @@ -391,6 +404,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains/bulk-add'); + // POST - Process bulk add $domainsText = trim($_POST['domains'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; @@ -467,6 +483,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; if (empty($domainIds)) { @@ -475,6 +494,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $refreshed = 0; $failed = 0; @@ -516,6 +543,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; if (empty($domainIds)) { @@ -524,6 +554,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $deleted = 0; foreach ($domainIds as $id) { if ($this->domainModel->delete($id)) { @@ -542,6 +580,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; $groupId = !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null; @@ -551,6 +592,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $updated = 0; foreach ($domainIds as $id) { if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) { @@ -569,6 +618,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; $isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1; @@ -578,6 +630,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $updated = 0; foreach ($domainIds as $id) { if ($this->domainModel->update($id, ['is_active' => $isActive])) { @@ -597,6 +657,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $id = (int)($params['id'] ?? 0); $domain = $this->domainModel->find($id); @@ -608,6 +671,14 @@ class DomainController extends Controller $notes = $_POST['notes'] ?? ''; + // Validate notes length + $lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes'); + if ($lengthError) { + $_SESSION['error'] = $lengthError; + $this->redirect('/domains/' . $id); + return; + } + $this->domainModel->update($id, [ 'notes' => $notes ]); diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index afebf7c..38a3ba6 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -45,6 +45,7 @@ class InstallerController extends Controller '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql', ]; try { @@ -103,7 +104,8 @@ class InstallerController extends Controller '010_add_app_version_setting.sql', '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', - '013_create_user_notifications_table.sql' + '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql' ]; } @@ -179,9 +181,10 @@ class InstallerController extends Controller $adminPassword = trim($_POST['admin_password'] ?? ''); $adminEmail = trim($_POST['admin_email'] ?? ''); - // Validate - if (empty($adminUsername) || !preg_match('/^[a-zA-Z0-9_]+$/', $adminUsername)) { - $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($adminUsername, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; $this->redirect('/install'); return; } @@ -257,7 +260,8 @@ class InstallerController extends Controller '010_add_app_version_setting.sql', '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', - '013_create_user_notifications_table.sql' + '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql' ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index a01a6bd..953880f 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -41,6 +41,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups/create'); + $name = trim($_POST['name'] ?? ''); $description = trim($_POST['description'] ?? ''); @@ -50,6 +53,21 @@ class NotificationGroupController extends Controller return; } + // Validate length + $nameError = \App\Helpers\InputValidator::validateLength($name, 255, 'Group name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/groups/create'); + return; + } + + $descError = \App\Helpers\InputValidator::validateLength($description, 1000, 'Description'); + if ($descError) { + $_SESSION['error'] = $descError; + $this->redirect('/groups/create'); + return; + } + $id = $this->groupModel->create([ 'name' => $name, 'description' => $description @@ -83,6 +101,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups'); + $id = (int)$_POST['id']; $name = trim($_POST['name'] ?? ''); $description = trim($_POST['description'] ?? ''); @@ -93,6 +114,21 @@ class NotificationGroupController extends Controller return; } + // Validate length + $nameError = \App\Helpers\InputValidator::validateLength($name, 255, 'Group name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect("/groups/edit?id=$id"); + return; + } + + $descError = \App\Helpers\InputValidator::validateLength($description, 1000, 'Description'); + if ($descError) { + $_SESSION['error'] = $descError; + $this->redirect("/groups/edit?id=$id"); + return; + } + $this->groupModel->update($id, [ 'name' => $name, 'description' => $description @@ -125,6 +161,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups'); + $groupId = (int)$_POST['group_id']; $channelType = $_POST['channel_type'] ?? ''; diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index d672cbb..e12d6e4 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -80,6 +80,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $fullName = trim($_POST['full_name'] ?? ''); $email = trim($_POST['email'] ?? ''); @@ -91,6 +94,14 @@ class ProfileController extends Controller return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/profile'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid email address'; $this->redirect('/profile'); @@ -131,6 +142,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $currentPassword = $_POST['current_password'] ?? ''; $newPassword = $_POST['new_password'] ?? ''; @@ -226,6 +240,14 @@ class ProfileController extends Controller */ public function logoutOtherSessions() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $currentSessionId = session_id(); @@ -273,6 +295,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $sessionId = $params['sessionId'] ?? ''; $userId = Auth::id(); $currentSessionId = session_id(); diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index b74de19..694109d 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -19,7 +19,7 @@ class SearchController extends Controller public function index() { - $query = trim($_GET['q'] ?? ''); + $query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100); if (empty($query)) { $_SESSION['error'] = 'Please enter a search term'; @@ -86,7 +86,7 @@ class SearchController extends Controller { header('Content-Type: application/json'); - $query = trim($_GET['q'] ?? ''); + $query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100); if (empty($query)) { echo json_encode(['domains' => [], 'isDomainLike' => false]); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 5a40747..3672a93 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -26,6 +26,7 @@ class SettingsController extends Controller $settings = $this->settingModel->getAllAsKeyValue(); $appSettings = $this->settingModel->getAppSettings(); $emailSettings = $this->settingModel->getEmailSettings(); + $captchaSettings = $this->settingModel->getCaptchaSettings(); // Predefined notification day options $notificationPresets = [ @@ -68,6 +69,7 @@ class SettingsController extends Controller 'settings' => $settings, 'appSettings' => $appSettings, 'emailSettings' => $emailSettings, + 'captchaSettings' => $captchaSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, 'title' => 'Settings' @@ -81,6 +83,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#monitoring'); + try { // Update notification days $notificationPreset = $_POST['notification_preset'] ?? 'standard'; @@ -144,6 +149,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings'); + // Update last check run time to show the test worked $this->settingModel->updateLastCheckRun(); @@ -158,6 +166,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#maintenance'); + try { // Clear notification logs older than 30 days $stmt = $this->settingModel->db->prepare( @@ -182,6 +193,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#app'); + try { $appSettings = [ 'app_name' => trim($_POST['app_name'] ?? 'Domain Monitor'), @@ -237,6 +251,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#email'); + try { $emailSettings = [ 'mail_host' => trim($_POST['mail_host'] ?? ''), @@ -278,6 +295,79 @@ class SettingsController extends Controller } } + public function updateCaptcha() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/settings'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/settings#security'); + + try { + $captchaProvider = trim($_POST['captcha_provider'] ?? 'disabled'); + $captchaSiteKey = trim($_POST['captcha_site_key'] ?? ''); + $captchaSecretKey = trim($_POST['captcha_secret_key'] ?? ''); + $recaptchaV3Threshold = trim($_POST['recaptcha_v3_score_threshold'] ?? '0.5'); + + // Validate provider + $validProviders = ['disabled', 'recaptcha_v2', 'recaptcha_v3', 'turnstile']; + if (!in_array($captchaProvider, $validProviders)) { + $_SESSION['error'] = 'Invalid CAPTCHA provider selected'; + $this->redirect('/settings#security'); + return; + } + + // If CAPTCHA is enabled, validate keys + if ($captchaProvider !== 'disabled') { + if (empty($captchaSiteKey)) { + $_SESSION['error'] = 'Site key is required when CAPTCHA is enabled'; + $this->redirect('/settings#security'); + return; + } + + if (empty($captchaSecretKey)) { + $_SESSION['error'] = 'Secret key is required when CAPTCHA is enabled'; + $this->redirect('/settings#security'); + return; + } + } + + // Validate v3 score threshold + if ($captchaProvider === 'recaptcha_v3') { + $threshold = floatval($recaptchaV3Threshold); + if ($threshold < 0.0 || $threshold > 1.0) { + $_SESSION['error'] = 'reCAPTCHA v3 score threshold must be between 0.0 and 1.0'; + $this->redirect('/settings#security'); + return; + } + } + + // Prepare settings array + $captchaSettings = [ + 'captcha_provider' => $captchaProvider, + 'captcha_site_key' => $captchaSiteKey, + 'recaptcha_v3_score_threshold' => $recaptchaV3Threshold + ]; + + // Only update secret key if provided (to allow updating other settings without re-entering secret) + if (!empty($captchaSecretKey)) { + $captchaSettings['captcha_secret_key'] = $captchaSecretKey; + } + + // Update CAPTCHA settings + $this->settingModel->updateCaptchaSettings($captchaSettings); + + $_SESSION['success'] = 'CAPTCHA settings updated successfully'; + $this->redirect('/settings#security'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to update CAPTCHA settings: ' . $e->getMessage(); + $this->redirect('/settings#security'); + } + } + public function testEmail() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { @@ -285,6 +375,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#email'); + $testEmail = trim($_POST['test_email'] ?? ''); if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { diff --git a/app/Controllers/TldRegistryController.php b/app/Controllers/TldRegistryController.php index f77f185..2b9f85d 100644 --- a/app/Controllers/TldRegistryController.php +++ b/app/Controllers/TldRegistryController.php @@ -6,18 +6,21 @@ use Core\Controller; use App\Models\TldRegistry; use App\Models\TldImportLog; use App\Services\TldRegistryService; +use App\Services\Logger; class TldRegistryController extends Controller { private TldRegistry $tldModel; private TldImportLog $importLogModel; private TldRegistryService $tldService; + private Logger $logger; public function __construct() { $this->tldModel = new TldRegistry(); $this->importLogModel = new TldImportLog(); $this->tldService = new TldRegistryService(); + $this->logger = new Logger('tld_registry_controller'); } /** @@ -37,7 +40,7 @@ class TldRegistryController extends Controller */ public function index() { - $search = $_GET['search'] ?? ''; + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $status = $_GET['status'] ?? ''; $dataType = $_GET['data_type'] ?? ''; $page = max(1, (int)($_GET['page'] ?? 1)); @@ -95,6 +98,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importTldList(); @@ -130,6 +136,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importRdapData(); @@ -165,6 +174,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importWhoisDataForMissingTlds(); $remainingCount = $this->tldService->getTldsNeedingWhoisCount(); @@ -246,9 +258,20 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + $importType = $_POST['import_type'] ?? ''; + $this->logger->separator('Start Progressive Import'); + $this->logger->info('Import requested', [ + 'type' => $importType, + 'user_id' => $_SESSION['user_id'] ?? 'unknown', + 'username' => $_SESSION['username'] ?? 'unknown' + ]); + if (!in_array($importType, ['tld_list', 'rdap', 'whois', 'check_updates', 'complete_workflow'])) { + $this->logger->warning('Invalid import type provided', ['type' => $importType]); $_SESSION['error'] = 'Invalid import type'; $this->redirect('/tld-registry'); return; @@ -257,15 +280,27 @@ class TldRegistryController extends Controller try { $result = $this->tldService->startProgressiveImport($importType); + $this->logger->info('Import started', [ + 'status' => $result['status'], + 'log_id' => $result['log_id'] ?? null, + 'message' => $result['message'] ?? '' + ]); + if ($result['status'] === 'complete') { $_SESSION['success'] = $result['message']; $this->redirect('/tld-registry'); } else { // Redirect to progress page + $this->logger->info('Redirecting to progress page', ['log_id' => $result['log_id']]); $this->redirect('/tld-registry/import-progress/' . $result['log_id']); } } catch (\Exception $e) { + $this->logger->error('Failed to start import', [ + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); $_SESSION['error'] = 'Failed to start import: ' . $e->getMessage(); $this->redirect('/tld-registry'); } @@ -278,7 +313,10 @@ class TldRegistryController extends Controller { $logId = $params['log_id'] ?? 0; + $this->logger->info('Import progress page requested', ['log_id' => $logId]); + if (!$logId) { + $this->logger->warning('Progress page requested with no log_id'); $_SESSION['error'] = 'Invalid import session'; $this->redirect('/tld-registry'); return; @@ -287,17 +325,25 @@ class TldRegistryController extends Controller // Get import type from log $log = $this->importLogModel->find($logId); if (!$log) { + $this->logger->error('Import log not found', ['log_id' => $logId]); $_SESSION['error'] = 'Import log not found'; $this->redirect('/tld-registry'); return; } $importType = $log['import_type']; + $this->logger->info('Showing progress page', [ + 'log_id' => $logId, + 'import_type' => $importType, + 'status' => $log['status'] + ]); + $titles = [ 'tld_list' => 'TLD List Import Progress', 'rdap' => 'RDAP Import Progress', 'whois' => 'WHOIS Import Progress', - 'check_updates' => 'Update Check Progress' + 'check_updates' => 'Update Check Progress', + 'complete_workflow' => 'Complete TLD Import Workflow' ]; $this->view('tld-registry/import-progress', [ @@ -312,20 +358,104 @@ class TldRegistryController extends Controller */ public function apiGetImportProgress() { - $logId = $_GET['log_id'] ?? 0; + // Start detailed logging + $this->logger->separator('API Import Progress Request'); + $this->logger->info('API called', [ + 'log_id' => $_GET['log_id'] ?? 'none', + 'user_id' => $_SESSION['user_id'] ?? 'not set', + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); + + // Start output buffering to catch any accidental output + ob_start(); - if (!$logId) { - http_response_code(400); - echo json_encode(['error' => 'Log ID required']); - return; - } - try { - $result = $this->tldService->processNextBatch($logId); - echo json_encode($result); - } catch (\Exception $e) { - http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + // Clear any previous output + ob_clean(); + + // Set JSON header immediately + header('Content-Type: application/json'); + + $logId = $_GET['log_id'] ?? 0; + + if (!$logId) { + $this->logger->warning('API call with missing log_id'); + ob_end_clean(); + $this->json(['error' => 'Log ID required'], 400); + return; + } + + // Ensure user is authenticated + if (!isset($_SESSION['user_id'])) { + $this->logger->warning('Unauthenticated API call attempt', ['log_id' => $logId]); + ob_end_clean(); + $this->json(['error' => 'Authentication required'], 401); + return; + } + + $this->logger->info('Processing batch', ['log_id' => $logId]); + + // Add detailed logging around the service call + $this->logger->info('About to call TldRegistryService::processNextBatch', [ + 'log_id' => $logId, + 'service_class' => get_class($this->tldService) + ]); + + try { + $result = $this->tldService->processNextBatch($logId); + $this->logger->info('processNextBatch returned successfully'); + } catch (\Throwable $e) { + $this->logger->critical('processNextBatch threw exception', [ + 'type' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + throw $e; + } + + $this->logger->info('Batch processing result', [ + 'status' => $result['status'] ?? 'unknown', + 'processed' => $result['processed'] ?? 0, + 'remaining' => $result['remaining'] ?? 0 + ]); + + // Clean output buffer before sending JSON + ob_end_clean(); + $this->json($result); + + } catch (\Throwable $e) { + // Catch ALL errors including fatal errors + // Clean any buffered output + if (ob_get_level() > 0) { + ob_end_clean(); + } + + // Detailed error logging + $this->logger->critical('API Import Progress Fatal Error', [ + 'type' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'log_id' => $_GET['log_id'] ?? 'none' + ]); + $this->logger->error('Stack trace', ['trace' => $e->getTraceAsString()]); + $this->logger->separator('API Error End'); + + // Ensure we always send JSON + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json'); + } + + echo json_encode([ + 'error' => 'An error occurred while processing the import', + 'message' => $e->getMessage(), + 'status' => 'error', + 'log_id' => $_GET['log_id'] ?? null, + 'error_type' => get_class($e) + ]); + exit; } } @@ -341,6 +471,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + $tldIds = $_POST['tld_ids'] ?? []; if (empty($tldIds)) { @@ -349,6 +482,14 @@ class TldRegistryController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($tldIds, 500, 'TLD selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/tld-registry'); + return; + } + try { $deletedCount = 0; foreach ($tldIds as $id) { @@ -468,8 +609,7 @@ class TldRegistryController extends Controller $domain = $_GET['domain'] ?? ''; if (empty($domain)) { - http_response_code(400); - echo json_encode(['error' => 'Domain parameter is required']); + $this->json(['error' => 'Domain parameter is required'], 400); return; } @@ -477,23 +617,22 @@ class TldRegistryController extends Controller $tldInfo = $this->tldService->getTldInfo($domain); if ($tldInfo) { - echo json_encode([ + $this->json([ 'success' => true, 'data' => $tldInfo ]); } else { - echo json_encode([ + $this->json([ 'success' => false, 'message' => 'TLD information not found' ]); } } catch (\Exception $e) { - http_response_code(500); - echo json_encode([ + $this->json([ 'success' => false, 'error' => $e->getMessage() - ]); + ], 500); } } diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php index cb1a8c9..7d00cb9 100644 --- a/app/Controllers/UserController.php +++ b/app/Controllers/UserController.php @@ -28,7 +28,7 @@ class UserController extends Controller public function index() { // Get filter parameters - $search = trim($_GET['search'] ?? ''); + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $roleFilter = $_GET['role'] ?? ''; $statusFilter = $_GET['status'] ?? ''; $sort = $_GET['sort'] ?? 'username'; @@ -97,6 +97,9 @@ class UserController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/users/create'); + $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $fullName = trim($_POST['full_name'] ?? ''); @@ -111,12 +114,28 @@ class UserController extends Controller return; } + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($username, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; + $this->redirect('/users/create'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Invalid email address'; $this->redirect('/users/create'); return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/users/create'); + return; + } + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; $this->redirect('/users/create'); @@ -208,6 +227,9 @@ class UserController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/users'); + $userId = (int)($_POST['id'] ?? 0); $user = $this->userModel->find($userId); @@ -236,6 +258,14 @@ class UserController extends Controller return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/users/edit?id=' . $userId); + return; + } + // Check if email is taken by another user $existingUsers = $this->userModel->where('email', $email); if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) { diff --git a/app/Helpers/CsrfHelper.php b/app/Helpers/CsrfHelper.php new file mode 100644 index 0000000..f847386 --- /dev/null +++ b/app/Helpers/CsrfHelper.php @@ -0,0 +1,32 @@ + 253 || strlen($domain) < 3) { + return false; + } + + // Validate domain format + // Allows: example.com, sub.example.com, example.co.uk + // Pattern: alphanumeric with hyphens, dots between labels, valid TLD + return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain); + } + + /** + * Validate text field length + * + * @param string $value Value to validate + * @param int $max Maximum length + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateLength(string $value, int $max, string $fieldName = 'Field'): ?string + { + if (strlen($value) > $max) { + return "$fieldName is too long (maximum $max characters)"; + } + return null; + } + + /** + * Validate string is within min and max length + * + * @param string $value Value to validate + * @param int $min Minimum length + * @param int $max Maximum length + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateLengthRange(string $value, int $min, int $max, string $fieldName = 'Field'): ?string + { + $len = strlen($value); + + if ($len < $min) { + return "$fieldName is too short (minimum $min characters)"; + } + + if ($len > $max) { + return "$fieldName is too long (maximum $max characters)"; + } + + return null; + } + + /** + * Sanitize text input (remove control characters) + * + * @param string $text Text to sanitize + * @return string Sanitized text + */ + public static function sanitizeText(string $text): string + { + // Remove control characters (except tabs and newlines for text areas) + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + return trim($text); + } + + /** + * Validate array size is within limits + * + * @param array $array Array to validate + * @param int $max Maximum number of elements + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateArraySize(array $array, int $max, string $fieldName = 'Selection'): ?string + { + if (count($array) > $max) { + return "$fieldName exceeds maximum of $max items"; + } + return null; + } + + /** + * Sanitize search query + * + * @param string $query Search query + * @param int $maxLength Maximum length (default 100) + * @return string Sanitized query + */ + public static function sanitizeSearch(string $query, int $maxLength = 100): string + { + // Remove control characters + $query = self::sanitizeText($query); + + // Limit length + if (strlen($query) > $maxLength) { + $query = substr($query, 0, $maxLength); + } + + return $query; + } + + /** + * Validate username format + * + * @param string $username Username to validate + * @param int $minLength Minimum length (default 3) + * @param int $maxLength Maximum length (default 50) + * @return string|null Error message or null if valid + */ + public static function validateUsername(string $username, int $minLength = 3, int $maxLength = 50): ?string + { + if (strlen($username) < $minLength) { + return "Username must be at least $minLength characters"; + } + + if (strlen($username) > $maxLength) { + return "Username must not exceed $maxLength characters"; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + return 'Username can only contain letters, numbers, and underscores'; + } + + return null; + } + + /** + * Validate URL format + * + * @param string $url URL to validate + * @return bool True if valid, false otherwise + */ + public static function validateUrl(string $url): bool + { + return filter_var($url, FILTER_VALIDATE_URL) !== false; + } + + /** + * Validate email format + * + * @param string $email Email to validate + * @return bool True if valid, false otherwise + */ + public static function validateEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * Validate numeric value is within range + * + * @param mixed $value Value to validate + * @param int|float $min Minimum value + * @param int|float $max Maximum value + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateRange($value, $min, $max, string $fieldName = 'Value'): ?string + { + if (!is_numeric($value)) { + return "$fieldName must be a number"; + } + + $numValue = is_float($min) || is_float($max) ? floatval($value) : intval($value); + + if ($numValue < $min || $numValue > $max) { + return "$fieldName must be between $min and $max"; + } + + return null; + } + + /** + * Validate value is in allowed list (whitelist) + * + * @param mixed $value Value to validate + * @param array $allowed Allowed values + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateInList($value, array $allowed, string $fieldName = 'Value'): ?string + { + if (!in_array($value, $allowed, true)) { + return "$fieldName has an invalid value"; + } + return null; + } +} + diff --git a/app/Models/Setting.php b/app/Models/Setting.php index a35a9af..a16ca89 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -201,5 +201,60 @@ class Setting extends Model } return $result; } + + /** + * Get CAPTCHA settings + */ + public function getCaptchaSettings(): array + { + $encryptedSecret = $this->getValue('captcha_secret_key', ''); + + // Decrypt secret key if it's encrypted + $decryptedSecret = ''; + if (!empty($encryptedSecret)) { + try { + $encryption = new \Core\Encryption(); + $decryptedSecret = $encryption->decrypt($encryptedSecret); + } catch (\Exception $e) { + // If decryption fails, it might be plaintext (migration scenario) + error_log("Failed to decrypt captcha_secret_key: " . $e->getMessage()); + $decryptedSecret = $encryptedSecret; + } + } + + return [ + 'provider' => $this->getValue('captcha_provider', 'disabled'), + 'site_key' => $this->getValue('captcha_site_key', ''), + 'secret_key' => $decryptedSecret, + 'score_threshold' => $this->getValue('recaptcha_v3_score_threshold', '0.5') + ]; + } + + /** + * Update CAPTCHA settings + */ + public function updateCaptchaSettings(array $settings): bool + { + $result = true; + + // Encrypt secret key before storing + if (isset($settings['captcha_secret_key']) && !empty($settings['captcha_secret_key'])) { + try { + $encryption = new \Core\Encryption(); + $settings['captcha_secret_key'] = $encryption->encrypt($settings['captcha_secret_key']); + } catch (\Exception $e) { + error_log("Failed to encrypt captcha_secret_key: " . $e->getMessage()); + return false; + } + } + + foreach ($settings as $key => $value) { + if (!$this->setValue($key, $value)) { + $result = false; + } + } + + return $result; + } } diff --git a/app/Models/TldImportLog.php b/app/Models/TldImportLog.php index 90e8eb8..4a0fd09 100644 --- a/app/Models/TldImportLog.php +++ b/app/Models/TldImportLog.php @@ -152,4 +152,13 @@ class TldImportLog extends Model ] ]; } + + /** + * Execute a custom SQL query + */ + public function query(string $sql): array + { + $stmt = $this->db->query($sql); + return $stmt->fetchAll(); + } } diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php new file mode 100644 index 0000000..4545380 --- /dev/null +++ b/app/Services/CaptchaService.php @@ -0,0 +1,227 @@ +settingModel = new Setting(); + $this->captchaSettings = $this->settingModel->getCaptchaSettings(); + } + + /** + * Verify CAPTCHA response based on configured provider + * + * @param string|null $response CAPTCHA response token from client + * @param string|null $remoteIp Remote IP address of the user + * @return array ['success' => bool, 'error' => string|null, 'score' => float|null] + */ + public function verifyCaptcha(?string $response, ?string $remoteIp = null): array + { + $provider = $this->captchaSettings['provider'] ?? 'disabled'; + + // If CAPTCHA is disabled, always return success + if ($provider === 'disabled') { + return ['success' => true, 'error' => null, 'score' => null]; + } + + // Validate that response token is provided + if (empty($response)) { + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + // Verify based on provider + switch ($provider) { + case 'recaptcha_v2': + return $this->verifyRecaptchaV2($response, $remoteIp); + + case 'recaptcha_v3': + return $this->verifyRecaptchaV3($response, $remoteIp); + + case 'turnstile': + return $this->verifyTurnstile($response, $remoteIp); + + default: + // Unknown provider - allow through but log + error_log("Unknown CAPTCHA provider: $provider"); + return ['success' => true, 'error' => null, 'score' => null]; + } + } + + /** + * Verify reCAPTCHA v2 response + */ + private function verifyRecaptchaV2(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + + if (empty($secretKey)) { + error_log('reCAPTCHA v2 secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('reCAPTCHA v2 verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + return ['success' => true, 'error' => null, 'score' => null]; + } + + /** + * Verify reCAPTCHA v3 response (score-based) + */ + private function verifyRecaptchaV3(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + $threshold = floatval($this->captchaSettings['score_threshold'] ?? 0.5); + + if (empty($secretKey)) { + error_log('reCAPTCHA v3 secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('reCAPTCHA v3 verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + // Check score + $score = floatval($result['score'] ?? 0); + + if ($score < $threshold) { + error_log("reCAPTCHA v3 score too low: $score (threshold: $threshold)"); + return ['success' => false, 'error' => 'Security verification failed. Please try again or contact support.', 'score' => $score]; + } + + return ['success' => true, 'error' => null, 'score' => $score]; + } + + /** + * Verify Cloudflare Turnstile response + */ + private function verifyTurnstile(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + + if (empty($secretKey)) { + error_log('Turnstile secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::TURNSTILE_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('Turnstile verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + return ['success' => true, 'error' => null, 'score' => null]; + } + + /** + * Send verification request to CAPTCHA provider API + */ + private function sendVerificationRequest(string $url, array $data): ?array + { + $options = [ + 'http' => [ + 'method' => 'POST', + 'header' => 'Content-Type: application/x-www-form-urlencoded', + 'content' => http_build_query($data), + 'timeout' => 10 + ] + ]; + + $context = stream_context_create($options); + $response = @file_get_contents($url, false, $context); + + if ($response === false) { + error_log("Failed to connect to CAPTCHA verification service: $url"); + return null; + } + + $result = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + error_log("Failed to parse CAPTCHA verification response: " . json_last_error_msg()); + return null; + } + + return $result; + } + + /** + * Get current CAPTCHA settings for view rendering + */ + public function getCaptchaSettings(): array + { + return $this->captchaSettings; + } + + /** + * Check if CAPTCHA is enabled + */ + public function isEnabled(): bool + { + $provider = $this->captchaSettings['provider'] ?? 'disabled'; + return $provider !== 'disabled'; + } +} + diff --git a/app/Services/TldRegistryService.php b/app/Services/TldRegistryService.php index ee212d8..70a8c20 100644 --- a/app/Services/TldRegistryService.php +++ b/app/Services/TldRegistryService.php @@ -196,9 +196,12 @@ class TldRegistryService $stats['total_tlds'] = count($tlds); + // Normalize last updated date to UTC format + $normalizedLastUpdated = $this->normalizeDate($lastUpdated); + // Update log with version and timestamp $this->importLogModel->update($logId, [ - 'iana_publication_date' => $lastUpdated, + 'iana_publication_date' => $normalizedLastUpdated, 'version' => $version ]); @@ -260,8 +263,11 @@ class TldRegistryService $publicationDate = $data['publication'] ?? null; $services = $data['services'] ?? []; + // Normalize publication date to UTC format before saving + $normalizedPublicationDate = $this->normalizeDate($publicationDate); + // Update log with publication date - $this->importLogModel->update($logId, ['iana_publication_date' => $publicationDate]); + $this->importLogModel->update($logId, ['iana_publication_date' => $normalizedPublicationDate]); foreach ($services as $service) { $tlds = $service[0] ?? []; // TLD patterns @@ -271,7 +277,7 @@ class TldRegistryService $stats['total_tlds']++; try { - $result = $this->processTldRdapData($tld, $rdapServers, $publicationDate); + $result = $this->processTldRdapData($tld, $rdapServers, $normalizedPublicationDate); if ($result['is_new']) { $stats['new_tlds']++; @@ -911,8 +917,28 @@ class TldRegistryService 'failed_tlds' => 0 ]); - $message = $updateInfo['overall_needs_update'] ? - 'Updates available' : 'TLD registry is up to date'; + // Build detailed message + $messages = []; + + if ($updateInfo['tld_list']['needs_update'] ?? false) { + $current = $updateInfo['tld_list']['current_version'] ?? 'Unknown'; + $last = $updateInfo['tld_list']['last_version'] ?? 'None'; + $messages[] = "TLD List: New version available (current: $current, previous: $last)"; + } + + if ($updateInfo['rdap']['needs_update'] ?? false) { + $current = $updateInfo['rdap']['current_publication'] ?? 'Unknown'; + $last = $updateInfo['rdap']['last_publication'] ?? 'None'; + $messages[] = "RDAP Data: New publication available (current: $current, previous: $last)"; + } + + if ($updateInfo['overall_needs_update']) { + $message = "🔔 Updates Available! " . implode(' • ', $messages) . " Click 'Import TLDs' to update your database."; + } else { + $tldVersion = $updateInfo['tld_list']['current_version'] ?? 'N/A'; + $rdapDate = $updateInfo['rdap']['current_publication'] ?? 'N/A'; + $message = "✅ TLD Registry is Up to Date! (TLD List version: $tldVersion, RDAP publication: $rdapDate)"; + } return [ 'status' => 'complete', @@ -921,7 +947,8 @@ class TldRegistryService 'processed' => 2, 'failed' => 0, 'remaining' => 0, - 'message' => $message + 'message' => $message, + 'update_info' => $updateInfo ]; } catch (\Exception $e) { $this->importLogModel->completeImport($logId, [ @@ -1692,21 +1719,28 @@ class TldRegistryService $data = json_decode($response->getBody()->getContents(), true); $currentPublication = $data['publication'] ?? null; - // Get last RDAP import - $lastRdapImport = $this->importLogModel->query( + // Get last RDAP import using database directly + $db = \Core\Database::getConnection(); + $stmt = $db->prepare( "SELECT iana_publication_date FROM tld_import_logs WHERE import_type = 'rdap' AND status = 'completed' ORDER BY started_at DESC LIMIT 1" ); + $stmt->execute(); + $lastRdapImport = $stmt->fetch(); - $lastPublication = $lastRdapImport[0]['iana_publication_date'] ?? null; + $lastPublication = $lastRdapImport['iana_publication_date'] ?? null; - $needsUpdate = $currentPublication !== $lastPublication; + // Normalize date formats for comparison (ISO 8601 vs MySQL datetime) + $currentNormalized = $this->normalizeDate($currentPublication); + $lastNormalized = $this->normalizeDate($lastPublication); + + $needsUpdate = ($currentNormalized !== $lastNormalized) && ($currentNormalized !== null); return [ 'needs_update' => $needsUpdate, - 'current_publication' => $currentPublication, - 'last_publication' => $lastPublication + 'current_publication' => $currentNormalized ?: $currentPublication, + 'last_publication' => $lastNormalized ?: $lastPublication ]; } catch (\Exception $e) { @@ -1863,4 +1897,32 @@ class TldRegistryService return $stats; } + + /** + * Normalize date string for comparison + * Converts both ISO 8601 and MySQL datetime to same format (UTC) + * + * @param string|null $date Date string to normalize + * @return string|null Normalized date in UTC (Y-m-d H:i:s) or null + */ + private function normalizeDate(?string $date): ?string + { + if (empty($date)) { + return null; + } + + try { + // Create DateTime object (handles both ISO 8601 and MySQL datetime) + $dateTime = new \DateTime($date); + + // Convert to UTC to ensure consistent comparison + $dateTime->setTimezone(new \DateTimeZone('UTC')); + + // Return in MySQL datetime format (Y-m-d H:i:s) in UTC + return $dateTime->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + // Fallback to null if date parsing fails + return null; + } + } } diff --git a/app/Views/auth/captcha-widget.php b/app/Views/auth/captcha-widget.php new file mode 100644 index 0000000..4be7b22 --- /dev/null +++ b/app/Views/auth/captcha-widget.php @@ -0,0 +1,86 @@ + + + +