Add CSRF, CAPTCHA, and input validation improvements
Introduces CSRF protection to all sensitive controller actions, integrates configurable CAPTCHA (reCAPTCHA v2/v3, Turnstile) for authentication and registration flows, and centralizes input validation via a new InputValidator helper. Adds new helpers and services for CSRF and CAPTCHA, updates settings and migration for CAPTCHA configuration, and enhances logging and error handling in TLD registry import processes. Also improves validation for user, domain, group, and profile inputs throughout the application.
This commit is contained in:
@@ -34,9 +34,13 @@ class AuthController extends Controller
|
|||||||
// Check if registration is enabled
|
// Check if registration is enabled
|
||||||
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
||||||
|
|
||||||
|
// Get CAPTCHA settings
|
||||||
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
|
||||||
$this->view('auth/login', [
|
$this->view('auth/login', [
|
||||||
'title' => 'Login',
|
'title' => 'Login',
|
||||||
'registrationEnabled' => $registrationEnabled
|
'registrationEnabled' => $registrationEnabled,
|
||||||
|
'captchaSettings' => $captchaSettings
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +54,25 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/login');
|
||||||
|
|
||||||
$username = trim($_POST['username'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$password = $_POST['password'] ?? ''; // Don't trim - passwords may have intentional spaces
|
$password = $_POST['password'] ?? ''; // Don't trim - passwords may have intentional spaces
|
||||||
$remember = isset($_POST['remember']);
|
$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
|
// Validate input
|
||||||
if (empty($username) || empty($password)) {
|
if (empty($username) || empty($password)) {
|
||||||
$_SESSION['error'] = 'Username and password are required';
|
$_SESSION['error'] = 'Username and password are required';
|
||||||
@@ -140,8 +159,12 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get CAPTCHA settings
|
||||||
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
|
||||||
$this->view('auth/register', [
|
$this->view('auth/register', [
|
||||||
'title' => 'Register'
|
'title' => 'Register',
|
||||||
|
'captchaSettings' => $captchaSettings
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +178,9 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/register');
|
||||||
|
|
||||||
// Check if registration is enabled
|
// Check if registration is enabled
|
||||||
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
||||||
if (!$registrationEnabled) {
|
if (!$registrationEnabled) {
|
||||||
@@ -163,6 +189,18 @@ class AuthController extends Controller
|
|||||||
return;
|
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'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
$fullName = trim($_POST['full_name'] ?? '');
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
@@ -176,6 +214,22 @@ class AuthController extends Controller
|
|||||||
return;
|
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)) {
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$_SESSION['error'] = 'Please enter a valid email address';
|
$_SESSION['error'] = 'Please enter a valid email address';
|
||||||
$this->redirect('/register');
|
$this->redirect('/register');
|
||||||
@@ -398,8 +452,12 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get CAPTCHA settings
|
||||||
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
|
||||||
$this->view('auth/forgot-password', [
|
$this->view('auth/forgot-password', [
|
||||||
'title' => 'Forgot Password'
|
'title' => 'Forgot Password',
|
||||||
|
'captchaSettings' => $captchaSettings
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +471,21 @@ class AuthController extends Controller
|
|||||||
return;
|
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'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
|
||||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
@@ -478,9 +551,13 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get CAPTCHA settings
|
||||||
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
|
||||||
$this->view('auth/reset-password', [
|
$this->view('auth/reset-password', [
|
||||||
'title' => 'Reset Password',
|
'title' => 'Reset Password',
|
||||||
'token' => $token
|
'token' => $token,
|
||||||
|
'captchaSettings' => $captchaSettings
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +571,23 @@ class AuthController extends Controller
|
|||||||
return;
|
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'] ?? '';
|
$token = $_POST['token'] ?? '';
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class DomainController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
// Get filter parameters
|
// Get filter parameters
|
||||||
$search = $_GET['search'] ?? '';
|
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
|
||||||
$status = $_GET['status'] ?? '';
|
$status = $_GET['status'] ?? '';
|
||||||
$groupId = $_GET['group'] ?? '';
|
$groupId = $_GET['group'] ?? '';
|
||||||
$sortBy = $_GET['sort'] ?? 'domain_name';
|
$sortBy = $_GET['sort'] ?? 'domain_name';
|
||||||
@@ -131,6 +131,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains/create');
|
||||||
|
|
||||||
$domainName = trim($_POST['domain_name'] ?? '');
|
$domainName = trim($_POST['domain_name'] ?? '');
|
||||||
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
||||||
|
|
||||||
@@ -141,6 +144,13 @@ class DomainController extends Controller
|
|||||||
return;
|
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
|
// Check if domain already exists
|
||||||
if ($this->domainModel->existsByDomain($domainName)) {
|
if ($this->domainModel->existsByDomain($domainName)) {
|
||||||
$_SESSION['error'] = 'Domain already exists';
|
$_SESSION['error'] = 'Domain already exists';
|
||||||
@@ -212,6 +222,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$domain = $this->domainModel->find($id);
|
$domain = $this->domainModel->find($id);
|
||||||
|
|
||||||
@@ -391,6 +404,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains/bulk-add');
|
||||||
|
|
||||||
// POST - Process bulk add
|
// POST - Process bulk add
|
||||||
$domainsText = trim($_POST['domains'] ?? '');
|
$domainsText = trim($_POST['domains'] ?? '');
|
||||||
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
||||||
@@ -467,6 +483,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$domainIds = $_POST['domain_ids'] ?? [];
|
$domainIds = $_POST['domain_ids'] ?? [];
|
||||||
|
|
||||||
if (empty($domainIds)) {
|
if (empty($domainIds)) {
|
||||||
@@ -475,6 +494,14 @@ class DomainController extends Controller
|
|||||||
return;
|
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;
|
$refreshed = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
@@ -516,6 +543,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$domainIds = $_POST['domain_ids'] ?? [];
|
$domainIds = $_POST['domain_ids'] ?? [];
|
||||||
|
|
||||||
if (empty($domainIds)) {
|
if (empty($domainIds)) {
|
||||||
@@ -524,6 +554,14 @@ class DomainController extends Controller
|
|||||||
return;
|
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;
|
$deleted = 0;
|
||||||
foreach ($domainIds as $id) {
|
foreach ($domainIds as $id) {
|
||||||
if ($this->domainModel->delete($id)) {
|
if ($this->domainModel->delete($id)) {
|
||||||
@@ -542,6 +580,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$domainIds = $_POST['domain_ids'] ?? [];
|
$domainIds = $_POST['domain_ids'] ?? [];
|
||||||
$groupId = !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null;
|
$groupId = !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null;
|
||||||
|
|
||||||
@@ -551,6 +592,14 @@ class DomainController extends Controller
|
|||||||
return;
|
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;
|
$updated = 0;
|
||||||
foreach ($domainIds as $id) {
|
foreach ($domainIds as $id) {
|
||||||
if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) {
|
if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) {
|
||||||
@@ -569,6 +618,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$domainIds = $_POST['domain_ids'] ?? [];
|
$domainIds = $_POST['domain_ids'] ?? [];
|
||||||
$isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
|
$isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
|
||||||
|
|
||||||
@@ -578,6 +630,14 @@ class DomainController extends Controller
|
|||||||
return;
|
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;
|
$updated = 0;
|
||||||
foreach ($domainIds as $id) {
|
foreach ($domainIds as $id) {
|
||||||
if ($this->domainModel->update($id, ['is_active' => $isActive])) {
|
if ($this->domainModel->update($id, ['is_active' => $isActive])) {
|
||||||
@@ -597,6 +657,9 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
$domain = $this->domainModel->find($id);
|
$domain = $this->domainModel->find($id);
|
||||||
|
|
||||||
@@ -608,6 +671,14 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
$notes = $_POST['notes'] ?? '';
|
$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, [
|
$this->domainModel->update($id, [
|
||||||
'notes' => $notes
|
'notes' => $notes
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class InstallerController extends Controller
|
|||||||
'011_create_sessions_table.sql',
|
'011_create_sessions_table.sql',
|
||||||
'012_link_remember_tokens_to_sessions.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',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +104,8 @@ class InstallerController extends Controller
|
|||||||
'010_add_app_version_setting.sql',
|
'010_add_app_version_setting.sql',
|
||||||
'011_create_sessions_table.sql',
|
'011_create_sessions_table.sql',
|
||||||
'012_link_remember_tokens_to_sessions.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'] ?? '');
|
$adminPassword = trim($_POST['admin_password'] ?? '');
|
||||||
$adminEmail = trim($_POST['admin_email'] ?? '');
|
$adminEmail = trim($_POST['admin_email'] ?? '');
|
||||||
|
|
||||||
// Validate
|
// Validate username format and length
|
||||||
if (empty($adminUsername) || !preg_match('/^[a-zA-Z0-9_]+$/', $adminUsername)) {
|
$usernameError = \App\Helpers\InputValidator::validateUsername($adminUsername, 3, 50);
|
||||||
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
if ($usernameError) {
|
||||||
|
$_SESSION['error'] = $usernameError;
|
||||||
$this->redirect('/install');
|
$this->redirect('/install');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -257,7 +260,8 @@ class InstallerController extends Controller
|
|||||||
'010_add_app_version_setting.sql',
|
'010_add_app_version_setting.sql',
|
||||||
'011_create_sessions_table.sql',
|
'011_create_sessions_table.sql',
|
||||||
'012_link_remember_tokens_to_sessions.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");
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/groups/create');
|
||||||
|
|
||||||
$name = trim($_POST['name'] ?? '');
|
$name = trim($_POST['name'] ?? '');
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
|
||||||
@@ -50,6 +53,21 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
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([
|
$id = $this->groupModel->create([
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'description' => $description
|
'description' => $description
|
||||||
@@ -83,6 +101,9 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/groups');
|
||||||
|
|
||||||
$id = (int)$_POST['id'];
|
$id = (int)$_POST['id'];
|
||||||
$name = trim($_POST['name'] ?? '');
|
$name = trim($_POST['name'] ?? '');
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = trim($_POST['description'] ?? '');
|
||||||
@@ -93,6 +114,21 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
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, [
|
$this->groupModel->update($id, [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'description' => $description
|
'description' => $description
|
||||||
@@ -125,6 +161,9 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/groups');
|
||||||
|
|
||||||
$groupId = (int)$_POST['group_id'];
|
$groupId = (int)$_POST['group_id'];
|
||||||
$channelType = $_POST['channel_type'] ?? '';
|
$channelType = $_POST['channel_type'] ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ class ProfileController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
$fullName = trim($_POST['full_name'] ?? '');
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
@@ -91,6 +94,14 @@ class ProfileController extends Controller
|
|||||||
return;
|
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)) {
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$_SESSION['error'] = 'Please enter a valid email address';
|
$_SESSION['error'] = 'Please enter a valid email address';
|
||||||
$this->redirect('/profile');
|
$this->redirect('/profile');
|
||||||
@@ -131,6 +142,9 @@ class ProfileController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
$currentPassword = $_POST['current_password'] ?? '';
|
$currentPassword = $_POST['current_password'] ?? '';
|
||||||
$newPassword = $_POST['new_password'] ?? '';
|
$newPassword = $_POST['new_password'] ?? '';
|
||||||
@@ -226,6 +240,14 @@ class ProfileController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function logoutOtherSessions()
|
public function logoutOtherSessions()
|
||||||
{
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
$currentSessionId = session_id();
|
$currentSessionId = session_id();
|
||||||
|
|
||||||
@@ -273,6 +295,9 @@ class ProfileController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/profile');
|
||||||
|
|
||||||
$sessionId = $params['sessionId'] ?? '';
|
$sessionId = $params['sessionId'] ?? '';
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
$currentSessionId = session_id();
|
$currentSessionId = session_id();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SearchController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$query = trim($_GET['q'] ?? '');
|
$query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100);
|
||||||
|
|
||||||
if (empty($query)) {
|
if (empty($query)) {
|
||||||
$_SESSION['error'] = 'Please enter a search term';
|
$_SESSION['error'] = 'Please enter a search term';
|
||||||
@@ -86,7 +86,7 @@ class SearchController extends Controller
|
|||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$query = trim($_GET['q'] ?? '');
|
$query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100);
|
||||||
|
|
||||||
if (empty($query)) {
|
if (empty($query)) {
|
||||||
echo json_encode(['domains' => [], 'isDomainLike' => false]);
|
echo json_encode(['domains' => [], 'isDomainLike' => false]);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class SettingsController extends Controller
|
|||||||
$settings = $this->settingModel->getAllAsKeyValue();
|
$settings = $this->settingModel->getAllAsKeyValue();
|
||||||
$appSettings = $this->settingModel->getAppSettings();
|
$appSettings = $this->settingModel->getAppSettings();
|
||||||
$emailSettings = $this->settingModel->getEmailSettings();
|
$emailSettings = $this->settingModel->getEmailSettings();
|
||||||
|
$captchaSettings = $this->settingModel->getCaptchaSettings();
|
||||||
|
|
||||||
// Predefined notification day options
|
// Predefined notification day options
|
||||||
$notificationPresets = [
|
$notificationPresets = [
|
||||||
@@ -68,6 +69,7 @@ class SettingsController extends Controller
|
|||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'appSettings' => $appSettings,
|
'appSettings' => $appSettings,
|
||||||
'emailSettings' => $emailSettings,
|
'emailSettings' => $emailSettings,
|
||||||
|
'captchaSettings' => $captchaSettings,
|
||||||
'notificationPresets' => $notificationPresets,
|
'notificationPresets' => $notificationPresets,
|
||||||
'checkIntervalPresets' => $checkIntervalPresets,
|
'checkIntervalPresets' => $checkIntervalPresets,
|
||||||
'title' => 'Settings'
|
'title' => 'Settings'
|
||||||
@@ -81,6 +83,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#monitoring');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update notification days
|
// Update notification days
|
||||||
$notificationPreset = $_POST['notification_preset'] ?? 'standard';
|
$notificationPreset = $_POST['notification_preset'] ?? 'standard';
|
||||||
@@ -144,6 +149,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings');
|
||||||
|
|
||||||
// Update last check run time to show the test worked
|
// Update last check run time to show the test worked
|
||||||
$this->settingModel->updateLastCheckRun();
|
$this->settingModel->updateLastCheckRun();
|
||||||
|
|
||||||
@@ -158,6 +166,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#maintenance');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clear notification logs older than 30 days
|
// Clear notification logs older than 30 days
|
||||||
$stmt = $this->settingModel->db->prepare(
|
$stmt = $this->settingModel->db->prepare(
|
||||||
@@ -182,6 +193,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#app');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$appSettings = [
|
$appSettings = [
|
||||||
'app_name' => trim($_POST['app_name'] ?? 'Domain Monitor'),
|
'app_name' => trim($_POST['app_name'] ?? 'Domain Monitor'),
|
||||||
@@ -237,6 +251,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#email');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$emailSettings = [
|
$emailSettings = [
|
||||||
'mail_host' => trim($_POST['mail_host'] ?? ''),
|
'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()
|
public function testEmail()
|
||||||
{
|
{
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
@@ -285,6 +375,9 @@ class SettingsController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/settings#email');
|
||||||
|
|
||||||
$testEmail = trim($_POST['test_email'] ?? '');
|
$testEmail = trim($_POST['test_email'] ?? '');
|
||||||
|
|
||||||
if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
|
if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
|||||||
@@ -6,18 +6,21 @@ use Core\Controller;
|
|||||||
use App\Models\TldRegistry;
|
use App\Models\TldRegistry;
|
||||||
use App\Models\TldImportLog;
|
use App\Models\TldImportLog;
|
||||||
use App\Services\TldRegistryService;
|
use App\Services\TldRegistryService;
|
||||||
|
use App\Services\Logger;
|
||||||
|
|
||||||
class TldRegistryController extends Controller
|
class TldRegistryController extends Controller
|
||||||
{
|
{
|
||||||
private TldRegistry $tldModel;
|
private TldRegistry $tldModel;
|
||||||
private TldImportLog $importLogModel;
|
private TldImportLog $importLogModel;
|
||||||
private TldRegistryService $tldService;
|
private TldRegistryService $tldService;
|
||||||
|
private Logger $logger;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tldModel = new TldRegistry();
|
$this->tldModel = new TldRegistry();
|
||||||
$this->importLogModel = new TldImportLog();
|
$this->importLogModel = new TldImportLog();
|
||||||
$this->tldService = new TldRegistryService();
|
$this->tldService = new TldRegistryService();
|
||||||
|
$this->logger = new Logger('tld_registry_controller');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +40,7 @@ class TldRegistryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$search = $_GET['search'] ?? '';
|
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
|
||||||
$status = $_GET['status'] ?? '';
|
$status = $_GET['status'] ?? '';
|
||||||
$dataType = $_GET['data_type'] ?? '';
|
$dataType = $_GET['data_type'] ?? '';
|
||||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
@@ -95,6 +98,9 @@ class TldRegistryController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/tld-registry');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stats = $this->tldService->importTldList();
|
$stats = $this->tldService->importTldList();
|
||||||
|
|
||||||
@@ -130,6 +136,9 @@ class TldRegistryController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/tld-registry');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stats = $this->tldService->importRdapData();
|
$stats = $this->tldService->importRdapData();
|
||||||
|
|
||||||
@@ -165,6 +174,9 @@ class TldRegistryController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/tld-registry');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stats = $this->tldService->importWhoisDataForMissingTlds();
|
$stats = $this->tldService->importWhoisDataForMissingTlds();
|
||||||
$remainingCount = $this->tldService->getTldsNeedingWhoisCount();
|
$remainingCount = $this->tldService->getTldsNeedingWhoisCount();
|
||||||
@@ -246,9 +258,20 @@ class TldRegistryController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/tld-registry');
|
||||||
|
|
||||||
$importType = $_POST['import_type'] ?? '';
|
$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'])) {
|
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';
|
$_SESSION['error'] = 'Invalid import type';
|
||||||
$this->redirect('/tld-registry');
|
$this->redirect('/tld-registry');
|
||||||
return;
|
return;
|
||||||
@@ -257,15 +280,27 @@ class TldRegistryController extends Controller
|
|||||||
try {
|
try {
|
||||||
$result = $this->tldService->startProgressiveImport($importType);
|
$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') {
|
if ($result['status'] === 'complete') {
|
||||||
$_SESSION['success'] = $result['message'];
|
$_SESSION['success'] = $result['message'];
|
||||||
$this->redirect('/tld-registry');
|
$this->redirect('/tld-registry');
|
||||||
} else {
|
} else {
|
||||||
// Redirect to progress page
|
// 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']);
|
$this->redirect('/tld-registry/import-progress/' . $result['log_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} 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();
|
$_SESSION['error'] = 'Failed to start import: ' . $e->getMessage();
|
||||||
$this->redirect('/tld-registry');
|
$this->redirect('/tld-registry');
|
||||||
}
|
}
|
||||||
@@ -278,7 +313,10 @@ class TldRegistryController extends Controller
|
|||||||
{
|
{
|
||||||
$logId = $params['log_id'] ?? 0;
|
$logId = $params['log_id'] ?? 0;
|
||||||
|
|
||||||
|
$this->logger->info('Import progress page requested', ['log_id' => $logId]);
|
||||||
|
|
||||||
if (!$logId) {
|
if (!$logId) {
|
||||||
|
$this->logger->warning('Progress page requested with no log_id');
|
||||||
$_SESSION['error'] = 'Invalid import session';
|
$_SESSION['error'] = 'Invalid import session';
|
||||||
$this->redirect('/tld-registry');
|
$this->redirect('/tld-registry');
|
||||||
return;
|
return;
|
||||||
@@ -287,17 +325,25 @@ class TldRegistryController extends Controller
|
|||||||
// Get import type from log
|
// Get import type from log
|
||||||
$log = $this->importLogModel->find($logId);
|
$log = $this->importLogModel->find($logId);
|
||||||
if (!$log) {
|
if (!$log) {
|
||||||
|
$this->logger->error('Import log not found', ['log_id' => $logId]);
|
||||||
$_SESSION['error'] = 'Import log not found';
|
$_SESSION['error'] = 'Import log not found';
|
||||||
$this->redirect('/tld-registry');
|
$this->redirect('/tld-registry');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$importType = $log['import_type'];
|
$importType = $log['import_type'];
|
||||||
|
$this->logger->info('Showing progress page', [
|
||||||
|
'log_id' => $logId,
|
||||||
|
'import_type' => $importType,
|
||||||
|
'status' => $log['status']
|
||||||
|
]);
|
||||||
|
|
||||||
$titles = [
|
$titles = [
|
||||||
'tld_list' => 'TLD List Import Progress',
|
'tld_list' => 'TLD List Import Progress',
|
||||||
'rdap' => 'RDAP Import Progress',
|
'rdap' => 'RDAP Import Progress',
|
||||||
'whois' => 'WHOIS 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', [
|
$this->view('tld-registry/import-progress', [
|
||||||
@@ -312,20 +358,104 @@ class TldRegistryController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function apiGetImportProgress()
|
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'
|
||||||
|
]);
|
||||||
|
|
||||||
if (!$logId) {
|
// Start output buffering to catch any accidental output
|
||||||
http_response_code(400);
|
ob_start();
|
||||||
echo json_encode(['error' => 'Log ID required']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->tldService->processNextBatch($logId);
|
// Clear any previous output
|
||||||
echo json_encode($result);
|
ob_clean();
|
||||||
} catch (\Exception $e) {
|
|
||||||
http_response_code(500);
|
// Set JSON header immediately
|
||||||
echo json_encode(['error' => $e->getMessage()]);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/tld-registry');
|
||||||
|
|
||||||
$tldIds = $_POST['tld_ids'] ?? [];
|
$tldIds = $_POST['tld_ids'] ?? [];
|
||||||
|
|
||||||
if (empty($tldIds)) {
|
if (empty($tldIds)) {
|
||||||
@@ -349,6 +482,14 @@ class TldRegistryController extends Controller
|
|||||||
return;
|
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 {
|
try {
|
||||||
$deletedCount = 0;
|
$deletedCount = 0;
|
||||||
foreach ($tldIds as $id) {
|
foreach ($tldIds as $id) {
|
||||||
@@ -468,8 +609,7 @@ class TldRegistryController extends Controller
|
|||||||
$domain = $_GET['domain'] ?? '';
|
$domain = $_GET['domain'] ?? '';
|
||||||
|
|
||||||
if (empty($domain)) {
|
if (empty($domain)) {
|
||||||
http_response_code(400);
|
$this->json(['error' => 'Domain parameter is required'], 400);
|
||||||
echo json_encode(['error' => 'Domain parameter is required']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,23 +617,22 @@ class TldRegistryController extends Controller
|
|||||||
$tldInfo = $this->tldService->getTldInfo($domain);
|
$tldInfo = $this->tldService->getTldInfo($domain);
|
||||||
|
|
||||||
if ($tldInfo) {
|
if ($tldInfo) {
|
||||||
echo json_encode([
|
$this->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $tldInfo
|
'data' => $tldInfo
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode([
|
$this->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'TLD information not found'
|
'message' => 'TLD information not found'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
http_response_code(500);
|
$this->json([
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class UserController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
// Get filter parameters
|
// Get filter parameters
|
||||||
$search = trim($_GET['search'] ?? '');
|
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
|
||||||
$roleFilter = $_GET['role'] ?? '';
|
$roleFilter = $_GET['role'] ?? '';
|
||||||
$statusFilter = $_GET['status'] ?? '';
|
$statusFilter = $_GET['status'] ?? '';
|
||||||
$sort = $_GET['sort'] ?? 'username';
|
$sort = $_GET['sort'] ?? 'username';
|
||||||
@@ -97,6 +97,9 @@ class UserController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/users/create');
|
||||||
|
|
||||||
$username = trim($_POST['username'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
$fullName = trim($_POST['full_name'] ?? '');
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
@@ -111,12 +114,28 @@ class UserController extends Controller
|
|||||||
return;
|
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)) {
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$_SESSION['error'] = 'Invalid email address';
|
$_SESSION['error'] = 'Invalid email address';
|
||||||
$this->redirect('/users/create');
|
$this->redirect('/users/create');
|
||||||
return;
|
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)) {
|
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
||||||
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
||||||
$this->redirect('/users/create');
|
$this->redirect('/users/create');
|
||||||
@@ -208,6 +227,9 @@ class UserController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/users');
|
||||||
|
|
||||||
$userId = (int)($_POST['id'] ?? 0);
|
$userId = (int)($_POST['id'] ?? 0);
|
||||||
$user = $this->userModel->find($userId);
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
@@ -236,6 +258,14 @@ class UserController extends Controller
|
|||||||
return;
|
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
|
// Check if email is taken by another user
|
||||||
$existingUsers = $this->userModel->where('email', $email);
|
$existingUsers = $this->userModel->where('email', $email);
|
||||||
if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) {
|
if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) {
|
||||||
|
|||||||
32
app/Helpers/CsrfHelper.php
Normal file
32
app/Helpers/CsrfHelper.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF Helper Functions
|
||||||
|
*
|
||||||
|
* Global helper functions for CSRF protection in views
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('csrf_field')) {
|
||||||
|
/**
|
||||||
|
* Generate HTML for CSRF token hidden field
|
||||||
|
*
|
||||||
|
* @return string HTML input field
|
||||||
|
*/
|
||||||
|
function csrf_field(): string
|
||||||
|
{
|
||||||
|
return \Core\Csrf::field();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('csrf_token')) {
|
||||||
|
/**
|
||||||
|
* Get the current CSRF token value
|
||||||
|
*
|
||||||
|
* @return string The CSRF token
|
||||||
|
*/
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
return \Core\Csrf::getToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
207
app/Helpers/InputValidator.php
Normal file
207
app/Helpers/InputValidator.php
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input Validator
|
||||||
|
*
|
||||||
|
* Centralized input validation helper for consistent validation across the application
|
||||||
|
*/
|
||||||
|
class InputValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate domain name format
|
||||||
|
*
|
||||||
|
* @param string $domain Domain name to validate
|
||||||
|
* @return bool True if valid, false otherwise
|
||||||
|
*/
|
||||||
|
public static function validateDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
// Check length (max 253 characters per RFC 1035)
|
||||||
|
if (strlen($domain) > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -201,5 +201,60 @@ class Setting extends Model
|
|||||||
}
|
}
|
||||||
return $result;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
227
app/Services/CaptchaService.php
Normal file
227
app/Services/CaptchaService.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
|
||||||
|
class CaptchaService
|
||||||
|
{
|
||||||
|
private Setting $settingModel;
|
||||||
|
private array $captchaSettings;
|
||||||
|
|
||||||
|
// Verification endpoints
|
||||||
|
private const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||||
|
private const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -196,9 +196,12 @@ class TldRegistryService
|
|||||||
|
|
||||||
$stats['total_tlds'] = count($tlds);
|
$stats['total_tlds'] = count($tlds);
|
||||||
|
|
||||||
|
// Normalize last updated date to UTC format
|
||||||
|
$normalizedLastUpdated = $this->normalizeDate($lastUpdated);
|
||||||
|
|
||||||
// Update log with version and timestamp
|
// Update log with version and timestamp
|
||||||
$this->importLogModel->update($logId, [
|
$this->importLogModel->update($logId, [
|
||||||
'iana_publication_date' => $lastUpdated,
|
'iana_publication_date' => $normalizedLastUpdated,
|
||||||
'version' => $version
|
'version' => $version
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -260,8 +263,11 @@ class TldRegistryService
|
|||||||
$publicationDate = $data['publication'] ?? null;
|
$publicationDate = $data['publication'] ?? null;
|
||||||
$services = $data['services'] ?? [];
|
$services = $data['services'] ?? [];
|
||||||
|
|
||||||
|
// Normalize publication date to UTC format before saving
|
||||||
|
$normalizedPublicationDate = $this->normalizeDate($publicationDate);
|
||||||
|
|
||||||
// Update log with publication date
|
// 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) {
|
foreach ($services as $service) {
|
||||||
$tlds = $service[0] ?? []; // TLD patterns
|
$tlds = $service[0] ?? []; // TLD patterns
|
||||||
@@ -271,7 +277,7 @@ class TldRegistryService
|
|||||||
$stats['total_tlds']++;
|
$stats['total_tlds']++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->processTldRdapData($tld, $rdapServers, $publicationDate);
|
$result = $this->processTldRdapData($tld, $rdapServers, $normalizedPublicationDate);
|
||||||
|
|
||||||
if ($result['is_new']) {
|
if ($result['is_new']) {
|
||||||
$stats['new_tlds']++;
|
$stats['new_tlds']++;
|
||||||
@@ -911,8 +917,28 @@ class TldRegistryService
|
|||||||
'failed_tlds' => 0
|
'failed_tlds' => 0
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $updateInfo['overall_needs_update'] ?
|
// Build detailed message
|
||||||
'Updates available' : 'TLD registry is up to date';
|
$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 [
|
return [
|
||||||
'status' => 'complete',
|
'status' => 'complete',
|
||||||
@@ -921,7 +947,8 @@ class TldRegistryService
|
|||||||
'processed' => 2,
|
'processed' => 2,
|
||||||
'failed' => 0,
|
'failed' => 0,
|
||||||
'remaining' => 0,
|
'remaining' => 0,
|
||||||
'message' => $message
|
'message' => $message,
|
||||||
|
'update_info' => $updateInfo
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->importLogModel->completeImport($logId, [
|
$this->importLogModel->completeImport($logId, [
|
||||||
@@ -1692,21 +1719,28 @@ class TldRegistryService
|
|||||||
$data = json_decode($response->getBody()->getContents(), true);
|
$data = json_decode($response->getBody()->getContents(), true);
|
||||||
$currentPublication = $data['publication'] ?? null;
|
$currentPublication = $data['publication'] ?? null;
|
||||||
|
|
||||||
// Get last RDAP import
|
// Get last RDAP import using database directly
|
||||||
$lastRdapImport = $this->importLogModel->query(
|
$db = \Core\Database::getConnection();
|
||||||
|
$stmt = $db->prepare(
|
||||||
"SELECT iana_publication_date FROM tld_import_logs
|
"SELECT iana_publication_date FROM tld_import_logs
|
||||||
WHERE import_type = 'rdap' AND status = 'completed'
|
WHERE import_type = 'rdap' AND status = 'completed'
|
||||||
ORDER BY started_at DESC LIMIT 1"
|
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 [
|
return [
|
||||||
'needs_update' => $needsUpdate,
|
'needs_update' => $needsUpdate,
|
||||||
'current_publication' => $currentPublication,
|
'current_publication' => $currentNormalized ?: $currentPublication,
|
||||||
'last_publication' => $lastPublication
|
'last_publication' => $lastNormalized ?: $lastPublication
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -1863,4 +1897,32 @@ class TldRegistryService
|
|||||||
|
|
||||||
return $stats;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
app/Views/auth/captcha-widget.php
Normal file
86
app/Views/auth/captcha-widget.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CAPTCHA Widget Component
|
||||||
|
* Renders the appropriate CAPTCHA widget based on settings
|
||||||
|
*
|
||||||
|
* Required variables:
|
||||||
|
* - $captchaSettings: Array with 'provider' and 'site_key'
|
||||||
|
*/
|
||||||
|
|
||||||
|
$provider = $captchaSettings['provider'] ?? 'disabled';
|
||||||
|
$siteKey = $captchaSettings['site_key'] ?? '';
|
||||||
|
|
||||||
|
if ($provider === 'disabled' || empty($siteKey)) {
|
||||||
|
return; // No CAPTCHA to render
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Widget -->
|
||||||
|
<div class="captcha-container mb-4">
|
||||||
|
<?php if ($provider === 'recaptcha_v2'): ?>
|
||||||
|
<!-- reCAPTCHA v2 -->
|
||||||
|
<div class="g-recaptcha" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div>
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||||
|
|
||||||
|
<?php elseif ($provider === 'recaptcha_v3'): ?>
|
||||||
|
<!-- reCAPTCHA v3 (Invisible) -->
|
||||||
|
<input type="hidden" id="captcha_response" name="captcha_response">
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js?render=<?= htmlspecialchars($siteKey) ?>"></script>
|
||||||
|
|
||||||
|
<?php elseif ($provider === 'turnstile'): ?>
|
||||||
|
<!-- Cloudflare Turnstile -->
|
||||||
|
<div class="cf-turnstile" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($provider === 'recaptcha_v3'): ?>
|
||||||
|
<!-- reCAPTCHA v3 Form Submission Handler -->
|
||||||
|
<script>
|
||||||
|
// Store the original form submission handler
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const originalSubmit = form.onsubmit;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
grecaptcha.ready(function() {
|
||||||
|
grecaptcha.execute('<?= htmlspecialchars($siteKey) ?>', {action: 'submit'}).then(function(token) {
|
||||||
|
document.getElementById('captcha_response').value = token;
|
||||||
|
|
||||||
|
// Call original submit handler if it exists
|
||||||
|
if (originalSubmit && originalSubmit.call(form, e) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php elseif ($provider === 'recaptcha_v2' || $provider === 'turnstile'): ?>
|
||||||
|
<!-- reCAPTCHA v2 / Turnstile Response Handler -->
|
||||||
|
<script>
|
||||||
|
// Add hidden input to capture response
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const captchaInput = document.createElement('input');
|
||||||
|
captchaInput.type = 'hidden';
|
||||||
|
captchaInput.name = 'captcha_response';
|
||||||
|
captchaInput.id = 'captcha_response';
|
||||||
|
form.appendChild(captchaInput);
|
||||||
|
|
||||||
|
// Capture response on form submit
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
<?php if ($provider === 'recaptcha_v2'): ?>
|
||||||
|
const response = grecaptcha.getResponse();
|
||||||
|
<?php else: // turnstile ?>
|
||||||
|
const response = turnstile.getResponse();
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
captchaInput.value = response;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ ob_start();
|
|||||||
|
|
||||||
<!-- Forgot Password Form -->
|
<!-- Forgot Password Form -->
|
||||||
<form method="POST" action="/forgot-password" class="space-y-5">
|
<form method="POST" action="/forgot-password" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Email Field -->
|
<!-- Email Field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
@@ -56,6 +57,9 @@ ob_start();
|
|||||||
<p class="text-xs text-gray-500 mt-1">Enter the email associated with your account</p>
|
<p class="text-xs text-gray-500 mt-1">Enter the email associated with your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Widget -->
|
||||||
|
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ ob_start();
|
|||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form method="POST" action="/login" class="space-y-5">
|
<form method="POST" action="/login" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Username Field -->
|
<!-- Username Field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
@@ -85,6 +86,9 @@ ob_start();
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Widget -->
|
||||||
|
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ ob_start();
|
|||||||
|
|
||||||
<!-- Registration Form -->
|
<!-- Registration Form -->
|
||||||
<form method="POST" action="/register" class="space-y-4">
|
<form method="POST" action="/register" class="space-y-4">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Full Name Field -->
|
<!-- Full Name Field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
@@ -163,6 +164,9 @@ ob_start();
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Widget -->
|
||||||
|
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ ob_start();
|
|||||||
|
|
||||||
<!-- Reset Password Form -->
|
<!-- Reset Password Form -->
|
||||||
<form method="POST" action="/reset-password" class="space-y-4">
|
<form method="POST" action="/reset-password" class="space-y-4">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Hidden token field -->
|
<!-- Hidden token field -->
|
||||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
|
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
|
||||||
|
|
||||||
@@ -95,6 +96,9 @@ ob_start();
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Widget -->
|
||||||
|
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ob_start();
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Domains Textarea -->
|
<!-- Domains Textarea -->
|
||||||
<div>
|
<div>
|
||||||
<label for="domains" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="domains" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ob_start();
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="/domains/store" class="space-y-5">
|
<form method="POST" action="/domains/store" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Domain Name -->
|
<!-- Domain Name -->
|
||||||
<div>
|
<div>
|
||||||
<label for="domain_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="domain_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ob_start();
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/update" class="space-y-5">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/update" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<!-- Domain Name (Read-only) -->
|
<!-- Domain Name (Read-only) -->
|
||||||
<div>
|
<div>
|
||||||
@@ -98,6 +99,7 @@ ob_start();
|
|||||||
<span class="text-sm font-medium text-gray-700">View Details</span>
|
<span class="text-sm font-medium text-gray-700">View Details</span>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="m-0">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="m-0">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors group">
|
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors group">
|
||||||
<i class="fas fa-sync-alt text-green-600 mr-2 text-sm"></i>
|
<i class="fas fa-sync-alt text-green-600 mr-2 text-sm"></i>
|
||||||
@@ -105,6 +107,7 @@ ob_start();
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-red-300 hover:bg-red-50 transition-colors group">
|
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-red-300 hover:bg-red-50 transition-colors group">
|
||||||
<i class="fas fa-trash text-red-600 mr-2 text-sm"></i>
|
<i class="fas fa-trash text-red-600 mr-2 text-sm"></i>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
|||||||
</button>
|
</button>
|
||||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="domain_ids" id="bulk-assign-ids">
|
<input type="hidden" name="domain_ids" id="bulk-assign-ids">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
@@ -83,6 +84,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<?php if (!empty($domains)): ?>
|
<?php if (!empty($domains)): ?>
|
||||||
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
|
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<?php foreach ($domains as $domain): ?>
|
<?php foreach ($domains as $domain): ?>
|
||||||
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>">
|
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>">
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -299,6 +301,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -307,6 +310,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
|||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete">
|
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
Refresh
|
Refresh
|
||||||
@@ -49,6 +50,7 @@ ob_start();
|
|||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
<i class="fas fa-trash mr-1.5"></i>
|
<i class="fas fa-trash mr-1.5"></i>
|
||||||
Delete
|
Delete
|
||||||
@@ -293,6 +295,7 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/update-notes" id="notes-form">
|
<form method="POST" action="/domains/<?= $domain['id'] ?>/update-notes" id="notes-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<textarea
|
<textarea
|
||||||
name="notes"
|
name="notes"
|
||||||
id="notes-textarea"
|
id="notes-textarea"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ob_start();
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="/groups/store" class="space-y-5">
|
<form method="POST" action="/groups/store" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<!-- Group Name -->
|
<!-- Group Name -->
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ob_start();
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form method="POST" action="/groups/update" class="space-y-5">
|
<form method="POST" action="/groups/update" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $group['id'] ?>">
|
<input type="hidden" name="id" value="<?= $group['id'] ?>">
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
@@ -129,6 +130,7 @@ ob_start();
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form method="POST" action="/channels/add" id="channelForm" class="space-y-5">
|
<form method="POST" action="/channels/add" id="channelForm" class="space-y-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="group_id" value="<?= $group['id'] ?>">
|
<input type="hidden" name="group_id" value="<?= $group['id'] ?>">
|
||||||
|
|
||||||
<!-- Channel Type -->
|
<!-- Channel Type -->
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/profile/update" class="p-6">
|
<form method="POST" action="/profile/update" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<!-- Full Name -->
|
<!-- Full Name -->
|
||||||
<div>
|
<div>
|
||||||
@@ -180,6 +181,7 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/profile/change-password" class="p-6">
|
<form method="POST" action="/profile/change-password" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Current Password -->
|
<!-- Current Password -->
|
||||||
<div>
|
<div>
|
||||||
@@ -242,6 +244,7 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
<?php if (count($sessions ?? []) > 1): ?>
|
<?php if (count($sessions ?? []) > 1): ?>
|
||||||
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
|
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
||||||
Logout Others
|
Logout Others
|
||||||
@@ -332,6 +335,7 @@ ob_start();
|
|||||||
<!-- Delete Button (only for non-current sessions) -->
|
<!-- Delete Button (only for non-current sessions) -->
|
||||||
<?php if (!$isCurrent): ?>
|
<?php if (!$isCurrent): ?>
|
||||||
<form method="POST" action="/profile/logout-session/<?= htmlspecialchars($session['id']) ?>" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
<form method="POST" action="/profile/logout-session/<?= htmlspecialchars($session['id']) ?>" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 text-red-600 rounded-lg hover:bg-red-600 hover:text-white transition-colors" title="Terminate session">
|
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 text-red-600 rounded-lg hover:bg-red-600 hover:text-white transition-colors" title="Terminate session">
|
||||||
<i class="fas fa-times text-sm"></i>
|
<i class="fas fa-times text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
<i class="fas fa-bell mr-2"></i>
|
<i class="fas fa-bell mr-2"></i>
|
||||||
Monitoring
|
Monitoring
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('security')" id="tab-security" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('system')" id="tab-system" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap">
|
<button onclick="switchTab('system')" id="tab-system" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap">
|
||||||
<i class="fas fa-server mr-2"></i>
|
<i class="fas fa-server mr-2"></i>
|
||||||
System
|
System
|
||||||
@@ -69,6 +73,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/update-app" class="p-6">
|
<form method="POST" action="/settings/update-app" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="app_name" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="app_name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -174,6 +179,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/update-email" class="p-6">
|
<form method="POST" action="/settings/update-email" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="mail_host" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="mail_host" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
@@ -278,6 +284,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
Send a test email to verify your SMTP settings are configured correctly.
|
Send a test email to verify your SMTP settings are configured correctly.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="/settings/test-email" id="testEmailForm" class="flex gap-2">
|
<form method="POST" action="/settings/test-email" id="testEmailForm" class="flex gap-2">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="email" name="test_email" id="test_email" required
|
<input type="email" name="test_email" id="test_email" required
|
||||||
placeholder="Enter email address to receive test"
|
placeholder="Enter email address to receive test"
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||||
@@ -302,6 +309,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/update" id="settingsForm" class="p-6">
|
<form method="POST" action="/settings/update" id="settingsForm" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<!-- Notification Settings -->
|
<!-- Notification Settings -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -411,6 +419,120 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content: Security Settings -->
|
||||||
|
<div id="content-security" class="tab-content hidden">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Security Settings</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Configure CAPTCHA protection for authentication forms</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/settings/update-captcha" class="p-6">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- CAPTCHA Provider Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="captcha_provider" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
CAPTCHA Provider
|
||||||
|
</label>
|
||||||
|
<select id="captcha_provider" name="captcha_provider"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="disabled" <?= ($captchaSettings['provider'] ?? 'disabled') === 'disabled' ? 'selected' : '' ?>>
|
||||||
|
Disabled (No CAPTCHA)
|
||||||
|
</option>
|
||||||
|
<option value="recaptcha_v2" <?= ($captchaSettings['provider'] ?? '') === 'recaptcha_v2' ? 'selected' : '' ?>>
|
||||||
|
Google reCAPTCHA v2 (Checkbox)
|
||||||
|
</option>
|
||||||
|
<option value="recaptcha_v3" <?= ($captchaSettings['provider'] ?? '') === 'recaptcha_v3' ? 'selected' : '' ?>>
|
||||||
|
Google reCAPTCHA v3 (Invisible)
|
||||||
|
</option>
|
||||||
|
<option value="turnstile" <?= ($captchaSettings['provider'] ?? '') === 'turnstile' ? 'selected' : '' ?>>
|
||||||
|
Cloudflare Turnstile
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">CAPTCHA protects login, registration, and password reset forms</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPTCHA Configuration Fields (shown when enabled) -->
|
||||||
|
<div id="captcha_config" style="display: <?= ($captchaSettings['provider'] ?? 'disabled') !== 'disabled' ? 'block' : 'none' ?>;">
|
||||||
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<!-- Site Key -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="captcha_site_key" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Site Key (Public Key)
|
||||||
|
</label>
|
||||||
|
<input type="text" id="captcha_site_key" name="captcha_site_key"
|
||||||
|
value="<?= htmlspecialchars($captchaSettings['site_key'] ?? '') ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||||
|
placeholder="Enter your site/public key">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Public key visible in HTML source</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secret Key -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="captcha_secret_key" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Secret Key
|
||||||
|
</label>
|
||||||
|
<input type="password" id="captcha_secret_key" name="captcha_secret_key"
|
||||||
|
value=""
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||||
|
placeholder="<?= !empty($captchaSettings['secret_key']) ? '••••••••••••••••' : 'Enter your secret key' ?>">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
<i class="fas fa-lock text-green-600 mr-1"></i>
|
||||||
|
Encrypted before storing in database. Leave blank to keep existing key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- reCAPTCHA v3 Score Threshold (only for v3) -->
|
||||||
|
<div id="recaptcha_v3_threshold" style="display: <?= ($captchaSettings['provider'] ?? '') === 'recaptcha_v3' ? 'block' : 'none' ?>;">
|
||||||
|
<label for="recaptcha_v3_score_threshold" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
reCAPTCHA v3 Score Threshold
|
||||||
|
</label>
|
||||||
|
<input type="number" id="recaptcha_v3_score_threshold" name="recaptcha_v3_score_threshold"
|
||||||
|
value="<?= htmlspecialchars($captchaSettings['score_threshold'] ?? '0.5') ?>"
|
||||||
|
min="0.0" max="1.0" step="0.1"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Minimum score required (0.0 to 1.0). Default: 0.5. Lower = more permissive.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider-specific Documentation -->
|
||||||
|
<div id="captcha_docs" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||||
|
<p class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||||
|
<span id="captcha_docs_title">Setup Instructions</span>
|
||||||
|
</p>
|
||||||
|
<div id="docs_recaptcha_v2" class="text-sm text-gray-700" style="display: none;">
|
||||||
|
<p class="mb-1">1. Visit <a href="https://www.google.com/recaptcha/admin" target="_blank" class="text-primary hover:underline">Google reCAPTCHA Admin Console</a></p>
|
||||||
|
<p class="mb-1">2. Register a new site with reCAPTCHA v2 "I'm not a robot" Checkbox</p>
|
||||||
|
<p>3. Copy the Site Key and Secret Key to the fields above</p>
|
||||||
|
</div>
|
||||||
|
<div id="docs_recaptcha_v3" class="text-sm text-gray-700" style="display: none;">
|
||||||
|
<p class="mb-1">1. Visit <a href="https://www.google.com/recaptcha/admin" target="_blank" class="text-primary hover:underline">Google reCAPTCHA Admin Console</a></p>
|
||||||
|
<p class="mb-1">2. Register a new site with reCAPTCHA v3</p>
|
||||||
|
<p class="mb-1">3. Copy the Site Key and Secret Key to the fields above</p>
|
||||||
|
<p>4. Adjust the score threshold based on your security needs (0.5 is recommended)</p>
|
||||||
|
</div>
|
||||||
|
<div id="docs_turnstile" class="text-sm text-gray-700" style="display: none;">
|
||||||
|
<p class="mb-1">1. Visit <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank" class="text-primary hover:underline">Cloudflare Turnstile Dashboard</a></p>
|
||||||
|
<p class="mb-1">2. Create a new Turnstile widget</p>
|
||||||
|
<p class="mb-1">3. Choose "Managed" mode for best user experience</p>
|
||||||
|
<p>4. Copy the Site Key and Secret Key to the fields above</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Save Security Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content: System Information -->
|
<!-- Tab Content: System Information -->
|
||||||
<div id="content-system" class="tab-content hidden">
|
<div id="content-system" class="tab-content hidden">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
@@ -499,6 +621,7 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/clear-logs" onsubmit="return confirm('Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
<form method="POST" action="/settings/clear-logs" onsubmit="return confirm('Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-trash-alt mr-2"></i>
|
<i class="fas fa-trash-alt mr-2"></i>
|
||||||
Clear Old Logs
|
Clear Old Logs
|
||||||
@@ -547,7 +670,7 @@ function switchTab(tabName) {
|
|||||||
// Load tab from URL hash on page load
|
// Load tab from URL hash on page load
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
const hash = window.location.hash.substring(1); // Remove the #
|
const hash = window.location.hash.substring(1); // Remove the #
|
||||||
const validTabs = ['app', 'email', 'monitoring', 'system', 'maintenance'];
|
const validTabs = ['app', 'email', 'monitoring', 'security', 'system', 'maintenance'];
|
||||||
|
|
||||||
if (hash && validTabs.includes(hash)) {
|
if (hash && validTabs.includes(hash)) {
|
||||||
switchTab(hash);
|
switchTab(hash);
|
||||||
@@ -610,6 +733,51 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CAPTCHA provider selection logic
|
||||||
|
const captchaProvider = document.getElementById('captcha_provider');
|
||||||
|
if (captchaProvider) {
|
||||||
|
const captchaConfig = document.getElementById('captcha_config');
|
||||||
|
const v3Threshold = document.getElementById('recaptcha_v3_threshold');
|
||||||
|
const docsV2 = document.getElementById('docs_recaptcha_v2');
|
||||||
|
const docsV3 = document.getElementById('docs_recaptcha_v3');
|
||||||
|
const docsTurnstile = document.getElementById('docs_turnstile');
|
||||||
|
|
||||||
|
function updateCaptchaUI() {
|
||||||
|
const selectedProvider = captchaProvider.value;
|
||||||
|
|
||||||
|
// Show/hide configuration section
|
||||||
|
if (selectedProvider === 'disabled') {
|
||||||
|
captchaConfig.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
captchaConfig.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide v3 threshold field
|
||||||
|
if (selectedProvider === 'recaptcha_v3') {
|
||||||
|
v3Threshold.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
v3Threshold.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update documentation
|
||||||
|
docsV2.style.display = 'none';
|
||||||
|
docsV3.style.display = 'none';
|
||||||
|
docsTurnstile.style.display = 'none';
|
||||||
|
|
||||||
|
if (selectedProvider === 'recaptcha_v2') {
|
||||||
|
docsV2.style.display = 'block';
|
||||||
|
} else if (selectedProvider === 'recaptcha_v3') {
|
||||||
|
docsV3.style.display = 'block';
|
||||||
|
} else if (selectedProvider === 'turnstile') {
|
||||||
|
docsTurnstile.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
captchaProvider.addEventListener('change', updateCaptchaUI);
|
||||||
|
// Initialize on page load
|
||||||
|
updateCaptchaUI();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ ob_start();
|
|||||||
'check_updates' => 'Checking for IANA updates',
|
'check_updates' => 'Checking for IANA updates',
|
||||||
'complete_workflow' => 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)'
|
'complete_workflow' => 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)'
|
||||||
];
|
];
|
||||||
echo $descriptions[$import_type] ?? 'Processing import';
|
echo htmlspecialchars($descriptions[$import_type] ?? 'Processing import');
|
||||||
?>
|
?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +194,10 @@ function updateProgress(data) {
|
|||||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800';
|
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800';
|
||||||
statusText.innerHTML = '<i class="fas fa-check mr-2"></i>Complete';
|
statusText.innerHTML = '<i class="fas fa-check mr-2"></i>Complete';
|
||||||
isComplete = true;
|
isComplete = true;
|
||||||
addLogMessage('Import completed successfully!', 'success');
|
|
||||||
|
// Show the actual completion message from API
|
||||||
|
const completionMessage = data.message || 'Import completed successfully!';
|
||||||
|
addLogMessage(completionMessage, 'success');
|
||||||
|
|
||||||
// Mark all steps as completed for complete workflow
|
// Mark all steps as completed for complete workflow
|
||||||
if (importType === 'complete_workflow') {
|
if (importType === 'complete_workflow') {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
|||||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="import_type" value="complete_workflow">
|
<input type="hidden" name="import_type" value="complete_workflow">
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||||
<i class="fas fa-rocket mr-2"></i>
|
<i class="fas fa-rocket mr-2"></i>
|
||||||
@@ -40,6 +41,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="import_type" value="check_updates">
|
<input type="hidden" name="import_type" value="check_updates">
|
||||||
<button type="submit" <?= $stats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2.5 <?= $stats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $stats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
<button type="submit" <?= $stats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2.5 <?= $stats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $stats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||||
<i class="fas fa-sync-alt mr-2"></i>
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
@@ -205,6 +207,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<span class="text-sm text-gray-600">Bulk Actions:</span>
|
<span class="text-sm text-gray-600">Bulk Actions:</span>
|
||||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-3 py-2 border border-red-300 text-red-700 text-sm rounded-lg hover:bg-red-50 transition-colors font-medium">
|
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-3 py-2 border border-red-300 text-red-700 text-sm rounded-lg hover:bg-red-50 transition-colors font-medium">
|
||||||
<i class="fas fa-trash mr-2"></i>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Delete Selected
|
Delete Selected
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ ob_start();
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<form method="POST" action="/users/store" class="max-w-2xl">
|
<form method="POST" action="/users/store" class="max-w-2xl">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">User Information</h3>
|
<h3 class="text-lg font-semibold text-gray-900">User Information</h3>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ ob_start();
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<form method="POST" action="/users/update" class="max-w-2xl">
|
<form method="POST" action="/users/update" class="max-w-2xl">
|
||||||
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $user['id'] ?>">
|
<input type="hidden" name="id" value="<?= $user['id'] ?>">
|
||||||
|
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
|||||||
@@ -29,5 +29,26 @@ abstract class Controller
|
|||||||
header("Location: $path");
|
header("Location: $path");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CSRF token and redirect with error if invalid
|
||||||
|
*
|
||||||
|
* @param string $redirectUrl URL to redirect to on failure
|
||||||
|
* @return bool True if valid, redirects on failure
|
||||||
|
*/
|
||||||
|
protected function verifyCsrf(string $redirectUrl = '/'): bool
|
||||||
|
{
|
||||||
|
return Csrf::verifyOrFail($redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token for forms
|
||||||
|
*
|
||||||
|
* @return string The CSRF token
|
||||||
|
*/
|
||||||
|
protected function getCsrfToken(): string
|
||||||
|
{
|
||||||
|
return Csrf::getToken();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
core/Csrf.php
Normal file
115
core/Csrf.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF Protection
|
||||||
|
*
|
||||||
|
* Provides Cross-Site Request Forgery (CSRF) protection for forms
|
||||||
|
*/
|
||||||
|
class Csrf
|
||||||
|
{
|
||||||
|
private const TOKEN_NAME = 'csrf_token';
|
||||||
|
private const TOKEN_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new CSRF token and store in session
|
||||||
|
*
|
||||||
|
* @return string The generated token
|
||||||
|
*/
|
||||||
|
public static function generateToken(): string
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(self::TOKEN_LENGTH));
|
||||||
|
$_SESSION[self::TOKEN_NAME] = $token;
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current CSRF token (generates if doesn't exist)
|
||||||
|
*
|
||||||
|
* @return string The CSRF token
|
||||||
|
*/
|
||||||
|
public static function getToken(): string
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION[self::TOKEN_NAME])) {
|
||||||
|
return self::generateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION[self::TOKEN_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate CSRF token from request
|
||||||
|
*
|
||||||
|
* @param string|null $token Token to validate (from POST/GET)
|
||||||
|
* @return bool True if valid, false otherwise
|
||||||
|
*/
|
||||||
|
public static function validateToken(?string $token): bool
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_SESSION[self::TOKEN_NAME])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use hash_equals to prevent timing attacks
|
||||||
|
return hash_equals($_SESSION[self::TOKEN_NAME], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CSRF token from POST request and redirect with error if invalid
|
||||||
|
*
|
||||||
|
* @param string $redirectUrl URL to redirect to on failure
|
||||||
|
* @return bool True if valid, redirects on failure
|
||||||
|
*/
|
||||||
|
public static function verifyOrFail(string $redirectUrl = '/'): bool
|
||||||
|
{
|
||||||
|
$token = $_POST[self::TOKEN_NAME] ?? '';
|
||||||
|
|
||||||
|
if (!self::validateToken($token)) {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$_SESSION['error'] = 'Security token validation failed. Please try again.';
|
||||||
|
header("Location: $redirectUrl");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML for hidden CSRF token field
|
||||||
|
*
|
||||||
|
* @return string HTML input field
|
||||||
|
*/
|
||||||
|
public static function field(): string
|
||||||
|
{
|
||||||
|
$token = self::getToken();
|
||||||
|
return '<input type="hidden" name="' . self::TOKEN_NAME . '" value="' . htmlspecialchars($token) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate CSRF token (useful after login/logout)
|
||||||
|
*/
|
||||||
|
public static function regenerateToken(): string
|
||||||
|
{
|
||||||
|
return self::generateToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
core/SessionConfig.php
Normal file
79
core/SessionConfig.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Configuration
|
||||||
|
*
|
||||||
|
* Handles session handler configuration and initialization
|
||||||
|
*/
|
||||||
|
class SessionConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Configure and initialize session handler
|
||||||
|
*
|
||||||
|
* Attempts to use database sessions if available, falls back to file sessions
|
||||||
|
*/
|
||||||
|
public static function configure(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Check if database sessions are available
|
||||||
|
if (self::isDatabaseSessionsAvailable()) {
|
||||||
|
$sessionLifetime = (int)($_ENV['SESSION_LIFETIME'] ?? 1440);
|
||||||
|
$handler = new DatabaseSessionHandler($sessionLifetime);
|
||||||
|
session_set_save_handler($handler, true);
|
||||||
|
}
|
||||||
|
// If not available, PHP will use default file-based sessions (no action needed)
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Fall back to default file-based sessions
|
||||||
|
error_log("Database session handler not available, using file sessions: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if database sessions are available
|
||||||
|
*
|
||||||
|
* @return bool True if sessions table exists and database is accessible
|
||||||
|
*/
|
||||||
|
private static function isDatabaseSessionsAvailable(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Check if database credentials are configured
|
||||||
|
if (empty($_ENV['DB_HOST']) || empty($_ENV['DB_DATABASE'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PDO connection
|
||||||
|
$pdo = new \PDO(
|
||||||
|
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_DATABASE']}",
|
||||||
|
$_ENV['DB_USERNAME'],
|
||||||
|
$_ENV['DB_PASSWORD'],
|
||||||
|
[
|
||||||
|
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||||
|
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if sessions table exists
|
||||||
|
$stmt = $pdo->query("SHOW TABLES LIKE 'sessions'");
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Database not available or sessions table doesn't exist
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start session with validation
|
||||||
|
*/
|
||||||
|
public static function start(): void
|
||||||
|
{
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Validate session exists in database (for database-backed sessions)
|
||||||
|
// This ensures deleted sessions are immediately invalidated
|
||||||
|
SessionValidator::validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -248,7 +248,13 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
|||||||
|
|
||||||
-- Authentication settings
|
-- Authentication settings
|
||||||
('registration_enabled', '0', 'boolean', 'Enable user registration'),
|
('registration_enabled', '0', 'boolean', 'Enable user registration'),
|
||||||
('require_email_verification', '1', 'boolean', 'Require email verification for new users')
|
('require_email_verification', '1', 'boolean', 'Require email verification for new users'),
|
||||||
|
|
||||||
|
-- CAPTCHA settings
|
||||||
|
('captcha_provider', 'disabled', 'string', 'CAPTCHA provider (disabled, recaptcha_v2, recaptcha_v3, turnstile)'),
|
||||||
|
('captcha_site_key', '', 'string', 'CAPTCHA site/public key'),
|
||||||
|
('captcha_secret_key', '', 'encrypted', 'CAPTCHA secret key (encrypted)'),
|
||||||
|
('recaptcha_v3_score_threshold', '0.5', 'string', 'reCAPTCHA v3 minimum score threshold (0.0 to 1.0)')
|
||||||
|
|
||||||
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||||
|
|
||||||
|
|||||||
24
database/migrations/014_add_captcha_settings.sql
Normal file
24
database/migrations/014_add_captcha_settings.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration: Add CAPTCHA settings
|
||||||
|
-- Version: 1.2.0
|
||||||
|
-- Description: Add support for CAPTCHA protection (reCAPTCHA v2, v3, Turnstile)
|
||||||
|
|
||||||
|
-- Add CAPTCHA provider setting (disabled, recaptcha_v2, recaptcha_v3, turnstile)
|
||||||
|
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||||
|
VALUES ('captcha_provider', 'disabled', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||||
|
|
||||||
|
-- Add CAPTCHA site key (public key)
|
||||||
|
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||||
|
VALUES ('captcha_site_key', '', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||||
|
|
||||||
|
-- Add CAPTCHA secret key (will be encrypted)
|
||||||
|
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||||
|
VALUES ('captcha_secret_key', '', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||||
|
|
||||||
|
-- Add reCAPTCHA v3 score threshold (minimum score required, 0.0 to 1.0)
|
||||||
|
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
|
||||||
|
VALUES ('recaptcha_v3_score_threshold', '0.5', NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key = setting_key;
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
|
|||||||
- `008_add_notes_to_domains.sql` - Domain notes field
|
- `008_add_notes_to_domains.sql` - Domain notes field
|
||||||
- `009_add_authentication_features.sql` - Authentication system
|
- `009_add_authentication_features.sql` - Authentication system
|
||||||
- `010_add_app_version_setting.sql` - Version setting
|
- `010_add_app_version_setting.sql` - Version setting
|
||||||
|
- `011_create_sessions_table.sql` - Session management table
|
||||||
|
- `012_link_remember_tokens_to_sessions.sql` - Remember token session linking
|
||||||
|
- `013_create_user_notifications_table.sql` - User notifications table
|
||||||
|
- `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile)
|
||||||
|
|
||||||
**Upgrade via:** Web updater at `/install/update`
|
**Upgrade via:** Web updater at `/install/update`
|
||||||
|
|
||||||
|
|||||||
@@ -12,34 +12,12 @@ define('PATH_ROOT', __DIR__ . '/../');
|
|||||||
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
$dotenv->load();
|
$dotenv->load();
|
||||||
|
|
||||||
// Configure database session handler
|
// Configure and start session (with database sessions if available)
|
||||||
try {
|
Core\SessionConfig::configure();
|
||||||
// Only use database sessions if sessions table exists
|
Core\SessionConfig::start();
|
||||||
$pdo = new PDO(
|
|
||||||
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_DATABASE']}",
|
|
||||||
$_ENV['DB_USERNAME'],
|
|
||||||
$_ENV['DB_PASSWORD']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if sessions table exists
|
// Load CSRF helper functions
|
||||||
$stmt = $pdo->query("SHOW TABLES LIKE 'sessions'");
|
require_once __DIR__ . '/../app/Helpers/CsrfHelper.php';
|
||||||
if ($stmt->rowCount() > 0) {
|
|
||||||
// Use database session handler
|
|
||||||
$sessionLifetime = (int)($_ENV['SESSION_LIFETIME'] ?? 1440);
|
|
||||||
$handler = new Core\DatabaseSessionHandler($sessionLifetime);
|
|
||||||
session_set_save_handler($handler, true);
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Fall back to default file-based sessions
|
|
||||||
error_log("Database session handler not available, using file sessions: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start session
|
|
||||||
session_start();
|
|
||||||
|
|
||||||
// Validate session exists in database (for database-backed sessions)
|
|
||||||
// This ensures deleted sessions are immediately invalidated
|
|
||||||
Core\SessionValidator::validate();
|
|
||||||
|
|
||||||
// Check if system is installed (using flag file - no DB queries!)
|
// Check if system is installed (using flag file - no DB queries!)
|
||||||
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ $router->get('/settings', [SettingsController::class, 'index']);
|
|||||||
$router->post('/settings/update', [SettingsController::class, 'update']);
|
$router->post('/settings/update', [SettingsController::class, 'update']);
|
||||||
$router->post('/settings/update-app', [SettingsController::class, 'updateApp']);
|
$router->post('/settings/update-app', [SettingsController::class, 'updateApp']);
|
||||||
$router->post('/settings/update-email', [SettingsController::class, 'updateEmail']);
|
$router->post('/settings/update-email', [SettingsController::class, 'updateEmail']);
|
||||||
|
$router->post('/settings/update-captcha', [SettingsController::class, 'updateCaptcha']);
|
||||||
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
$router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
|
||||||
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
|
||||||
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
|
||||||
|
|||||||
Reference in New Issue
Block a user