Enhance DNS discovery, validation & transfers
Add comprehensive DNS management and input validation, plus safer transfer and logging behavior. - Add CronHelper utilities for cron scripts and unify logging/formatting. - Improve InputValidator: sanitizeDomainInput and validateRootDomain (handles multi-level TLDs) and use throughout domain import/create flows to reject subdomains. - DomainController: refactor DNS refresh to support quick/deep discovery (background deep scans), add endpoints to discover, add/delete/bulk-delete DNS records, import BIND zone files, enrich IP metadata via enrichIpDetails, and strengthen bulk import/reporting messages. - DnsRecord model: add source column handling (discovered/manual/imported), avoid auto-deleting manual/imported records, and add helpers for deleting, bulk deleting, manual adding and importing zone records. - Tag, NotificationGroup and Domain transfer logic: unlink groups when ownership changes, remove tags that belong to other users, add audit logging via Logger and improved bulk transfer reporting. TagController/View: show transferable users for admins and skip global tags on transfer. - Notification channels (Discord, Mattermost, etc.) and EmailHelper: allow explicit subjects and improve payload fields based on notification type. - Add new migration 029_add_dns_record_source.sql and wire it into the installer; update migrations detection. - Add new views/partials for confirm/import/transfer modals, update various domain/group/tag templates, and update cron scripts and routes for discovery. These changes preserve manual/imported DNS records, improve root-domain validation, enable background deep discovery, and add better logging/audit trails for transfers and imports.
This commit is contained in:
@@ -312,16 +312,20 @@ class DomainController extends Controller
|
|||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
|
$invalidImported = 0;
|
||||||
|
|
||||||
foreach ($domainsData as $row) {
|
foreach ($domainsData as $row) {
|
||||||
$domainName = strtolower(trim($row['domain_name'] ?? ''));
|
$domainName = trim($row['domain_name'] ?? '');
|
||||||
if (empty($domainName)) {
|
if (empty($domainName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove protocol/www
|
$domainCheck = \App\Helpers\InputValidator::validateRootDomain($domainName);
|
||||||
$domainName = preg_replace('#^https?://#', '', $domainName);
|
if (!$domainCheck['valid']) {
|
||||||
$domainName = preg_replace('#^www\.#', '', $domainName);
|
$invalidImported++;
|
||||||
$domainName = rtrim($domainName, '/');
|
continue;
|
||||||
|
}
|
||||||
|
$domainName = $domainCheck['domain'];
|
||||||
|
|
||||||
if ($this->domainModel->existsByDomain($domainName)) {
|
if ($this->domainModel->existsByDomain($domainName)) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
@@ -394,6 +398,7 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
$msg = "{$added} domain(s) imported successfully";
|
$msg = "{$added} domain(s) imported successfully";
|
||||||
if ($skipped > 0) $msg .= ", {$skipped} skipped (already exist)";
|
if ($skipped > 0) $msg .= ", {$skipped} skipped (already exist)";
|
||||||
|
if ($invalidImported > 0) $msg .= ", {$invalidImported} rejected (not root domains)";
|
||||||
if (!empty($errors)) $msg .= ", " . count($errors) . " failed";
|
if (!empty($errors)) $msg .= ", " . count($errors) . " failed";
|
||||||
$_SESSION['success'] = $msg;
|
$_SESSION['success'] = $msg;
|
||||||
$this->redirect('/domains');
|
$this->redirect('/domains');
|
||||||
@@ -437,24 +442,19 @@ class DomainController extends Controller
|
|||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
$this->verifyCsrf('/domains/create');
|
$this->verifyCsrf('/domains/create');
|
||||||
|
|
||||||
$domainName = trim($_POST['domain_name'] ?? '');
|
$domainName = \App\Helpers\InputValidator::sanitizeDomainInput(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;
|
||||||
$tagsInput = trim($_POST['tags'] ?? '');
|
$tagsInput = trim($_POST['tags'] ?? '');
|
||||||
$userId = \Core\Auth::id();
|
$userId = \Core\Auth::id();
|
||||||
|
|
||||||
// Validate
|
// Validate root domain (not a subdomain, respects multi-level TLDs)
|
||||||
if (empty($domainName)) {
|
$domainCheck = \App\Helpers\InputValidator::validateRootDomain($domainName);
|
||||||
$_SESSION['error'] = 'Domain name is required';
|
if (!$domainCheck['valid']) {
|
||||||
$this->redirect('/domains/create');
|
$_SESSION['error'] = $domainCheck['error'];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate domain format
|
|
||||||
if (!\App\Helpers\InputValidator::validateDomain($domainName)) {
|
|
||||||
$_SESSION['error'] = 'Invalid domain name format (e.g., example.com)';
|
|
||||||
$this->redirect('/domains/create');
|
$this->redirect('/domains/create');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$domainName = $domainCheck['domain'];
|
||||||
|
|
||||||
// Validate tags
|
// Validate tags
|
||||||
$tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput);
|
$tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput);
|
||||||
@@ -799,9 +799,8 @@ class DomainController extends Controller
|
|||||||
$dnsService = new \App\Services\DnsService();
|
$dnsService = new \App\Services\DnsService();
|
||||||
$dnsModel = new \App\Models\DnsRecord();
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
// Feed previously known hosts so manual refresh doesn't lose crt.sh-discovered subdomains
|
|
||||||
$existingHosts = $dnsModel->getDistinctHosts($id);
|
$existingHosts = $dnsModel->getDistinctHosts($id);
|
||||||
$records = $dnsService->lookup($domain['domain_name'], $existingHosts);
|
$records = $dnsService->refreshExisting($domain['domain_name'], $existingHosts);
|
||||||
$totalRecords = array_sum(array_map('count', $records));
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
|
||||||
if ($totalRecords === 0) {
|
if ($totalRecords === 0) {
|
||||||
@@ -811,30 +810,7 @@ class DomainController extends Controller
|
|||||||
return 'DNS: no records found';
|
return 'DNS: no records found';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich A/AAAA records with IP details (PTR, ASN, geo) and store in raw_data
|
$this->enrichIpDetails($records, $dnsService);
|
||||||
$ips = [];
|
|
||||||
foreach (['A', 'AAAA'] as $type) {
|
|
||||||
if (!empty($records[$type])) {
|
|
||||||
foreach ($records[$type] as $r) {
|
|
||||||
if (!empty($r['value'])) {
|
|
||||||
$ips[] = $r['value'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!empty($ips)) {
|
|
||||||
$ipDetails = $dnsService->lookupIpDetails($ips);
|
|
||||||
foreach (['A', 'AAAA'] as $type) {
|
|
||||||
if (!empty($records[$type])) {
|
|
||||||
foreach ($records[$type] as &$rec) {
|
|
||||||
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
|
||||||
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($rec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats = $dnsModel->saveSnapshot($id, $records);
|
$stats = $dnsModel->saveSnapshot($id, $records);
|
||||||
$this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
$this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||||
@@ -1409,8 +1385,20 @@ class DomainController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by new lines and clean
|
// Split by new lines, sanitize each, and filter empties
|
||||||
$domainNames = array_filter(array_map('trim', explode("\n", $domainsText)));
|
$rawLines = array_filter(array_map('trim', explode("\n", $domainsText)));
|
||||||
|
$domainNames = [];
|
||||||
|
$invalidDomains = [];
|
||||||
|
foreach ($rawLines as $line) {
|
||||||
|
$cleaned = \App\Helpers\InputValidator::sanitizeDomainInput($line);
|
||||||
|
if (empty($cleaned)) continue;
|
||||||
|
$check = \App\Helpers\InputValidator::validateRootDomain($cleaned);
|
||||||
|
if (!$check['valid']) {
|
||||||
|
$invalidDomains[] = $cleaned;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$domainNames[] = $check['domain'];
|
||||||
|
}
|
||||||
|
|
||||||
$added = 0;
|
$added = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
@@ -1423,6 +1411,7 @@ class DomainController extends Controller
|
|||||||
$logger->info('Bulk domain add started', [
|
$logger->info('Bulk domain add started', [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'domain_count' => count($domainNames),
|
'domain_count' => count($domainNames),
|
||||||
|
'invalid_count' => count($invalidDomains),
|
||||||
'notification_group_id' => $groupId,
|
'notification_group_id' => $groupId,
|
||||||
'tags' => $tags
|
'tags' => $tags
|
||||||
]);
|
]);
|
||||||
@@ -1497,6 +1486,7 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
$message = "Added $added domain(s)";
|
$message = "Added $added domain(s)";
|
||||||
if ($skipped > 0) $message .= ", skipped $skipped duplicate(s)";
|
if ($skipped > 0) $message .= ", skipped $skipped duplicate(s)";
|
||||||
|
if (count($invalidDomains) > 0) $message .= ", " . count($invalidDomains) . " rejected (not root domains)";
|
||||||
if (count($errors) > 0) $message .= ", failed to add " . count($errors) . " domain(s)";
|
if (count($errors) > 0) $message .= ", failed to add " . count($errors) . " domain(s)";
|
||||||
|
|
||||||
if ($availableCount > 0) {
|
if ($availableCount > 0) {
|
||||||
@@ -2048,12 +2038,42 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Transfer domain
|
|
||||||
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$groupUnlinked = false;
|
||||||
|
if (!empty($domain['notification_group_id'])) {
|
||||||
|
$groupModel = new \App\Models\NotificationGroup();
|
||||||
|
$group = $groupModel->find($domain['notification_group_id']);
|
||||||
|
if ($group && $group['user_id'] != $targetUserId) {
|
||||||
|
$this->domainModel->update($domainId, ['notification_group_id' => null]);
|
||||||
|
$groupUnlinked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomain($domainId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Domain transferred', [
|
||||||
|
'domain_id' => $domainId,
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'from_user_id' => $domain['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'group_unlinked' => $groupUnlinked,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "Domain '{$domain['domain_name']}' transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "Domain '{$domain['domain_name']}' transferred to {$targetUser['username']}";
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
$logger->error('Domain transfer failed', [
|
||||||
|
'domain_id' => $domainId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
$_SESSION['error'] = 'Failed to transfer domain. Please try again.';
|
$_SESSION['error'] = 'Failed to transfer domain. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2092,19 +2112,59 @@ class DomainController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$groupModel = new \App\Models\NotificationGroup();
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
$transferred = 0;
|
$transferred = 0;
|
||||||
foreach ($domainIds as $domainId) {
|
foreach ($domainIds as $domainId) {
|
||||||
$domainId = (int)$domainId;
|
$domainId = (int)$domainId;
|
||||||
if ($domainId > 0) {
|
if ($domainId > 0) {
|
||||||
try {
|
try {
|
||||||
|
$domain = $this->domainModel->find($domainId);
|
||||||
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
$this->domainModel->update($domainId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$groupUnlinked = false;
|
||||||
|
if ($domain && !empty($domain['notification_group_id'])) {
|
||||||
|
$group = $groupModel->find($domain['notification_group_id']);
|
||||||
|
if ($group && $group['user_id'] != $targetUserId) {
|
||||||
|
$this->domainModel->update($domainId, ['notification_group_id' => null]);
|
||||||
|
$groupUnlinked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomain($domainId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Domain transferred (bulk)', [
|
||||||
|
'domain_id' => $domainId,
|
||||||
|
'domain_name' => $domain['domain_name'] ?? 'unknown',
|
||||||
|
'from_user_id' => $domain['user_id'] ?? null,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'group_unlinked' => $groupUnlinked,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$transferred++;
|
$transferred++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Continue with other domains
|
$logger->error('Domain transfer failed (bulk)', [
|
||||||
|
'domain_id' => $domainId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger->info('Bulk domain transfer completed', [
|
||||||
|
'transferred' => $transferred,
|
||||||
|
'total_requested' => count($domainIds),
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}";
|
||||||
$this->redirect('/domains');
|
$this->redirect('/domains');
|
||||||
}
|
}
|
||||||
@@ -2142,6 +2202,295 @@ class DomainController extends Controller
|
|||||||
$this->redirectBackToDomain($id, '#dns');
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover DNS records via Quick Scan (synchronous) or Deep Scan (background).
|
||||||
|
*/
|
||||||
|
public function discoverDns($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
$mode = $_POST['mode'] ?? 'quick';
|
||||||
|
|
||||||
|
if ($mode === 'deep') {
|
||||||
|
$domainName = escapeshellarg($domain['domain_name']);
|
||||||
|
$scriptPath = realpath(__DIR__ . '/../../cron/discover_dns.php');
|
||||||
|
|
||||||
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||||
|
$cmd = "start /b php " . escapeshellarg($scriptPath) . " --domain $domainName";
|
||||||
|
} else {
|
||||||
|
$cmd = "nohup php " . escapeshellarg($scriptPath) . " --domain $domainName > /dev/null 2>&1 &";
|
||||||
|
}
|
||||||
|
exec($cmd);
|
||||||
|
|
||||||
|
$logger->info('Deep DNS scan started (background)', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
]);
|
||||||
|
$_SESSION['info'] = 'Deep scan started in background. New records will appear when discovery completes — refresh the page to see them.';
|
||||||
|
} else {
|
||||||
|
$dnsService = new \App\Services\DnsService();
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
$records = $dnsService->quickScan($domain['domain_name']);
|
||||||
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
|
||||||
|
$this->enrichIpDetails($records, $dnsService);
|
||||||
|
|
||||||
|
$stats = $dnsModel->saveSnapshot($id, $records);
|
||||||
|
$this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
|
$logger->info('Quick DNS scan completed', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'total' => $totalRecords,
|
||||||
|
'added' => $stats['added'],
|
||||||
|
]);
|
||||||
|
$_SESSION['success'] = "Quick scan complete: {$totalRecords} records found";
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single DNS record manually.
|
||||||
|
*/
|
||||||
|
public function addDnsRecord($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
$type = strtoupper(trim($_POST['record_type'] ?? ''));
|
||||||
|
$host = trim($_POST['host'] ?? '@');
|
||||||
|
$value = trim($_POST['value'] ?? '');
|
||||||
|
$ttl = !empty($_POST['ttl']) ? (int)$_POST['ttl'] : 3600;
|
||||||
|
$priority = !empty($_POST['priority']) ? (int)$_POST['priority'] : null;
|
||||||
|
|
||||||
|
$validTypes = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV', 'CAA'];
|
||||||
|
if (!in_array($type, $validTypes) || $value === '') {
|
||||||
|
$_SESSION['error'] = 'Invalid record type or missing value';
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dnsModel->addManualRecord($id, $type, $host, $value, $ttl, $priority);
|
||||||
|
|
||||||
|
$logger->info('Manual DNS record added', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'type' => $type,
|
||||||
|
'host' => $host,
|
||||||
|
'value' => $value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = "DNS record added: {$type} {$host}";
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single DNS record.
|
||||||
|
*/
|
||||||
|
public function deleteDnsRecord($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$recordId = (int)($params['recordId'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
if ($dnsModel->deleteRecord($recordId, $id)) {
|
||||||
|
$logger->info('DNS record deleted', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'record_id' => $recordId,
|
||||||
|
]);
|
||||||
|
$_SESSION['success'] = 'DNS record deleted';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'DNS record not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete DNS records.
|
||||||
|
*/
|
||||||
|
public function bulkDeleteDnsRecords($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
$ids = $_POST['record_ids'] ?? [];
|
||||||
|
if (!is_array($ids)) {
|
||||||
|
$ids = [];
|
||||||
|
}
|
||||||
|
$ids = array_map('intval', $ids);
|
||||||
|
|
||||||
|
$count = $dnsModel->bulkDeleteRecords($ids, $id);
|
||||||
|
|
||||||
|
$logger->info('Bulk DNS records deleted', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'count' => $count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = "Deleted {$count} DNS record(s)";
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import DNS records from a BIND zone file.
|
||||||
|
*/
|
||||||
|
public function importDnsZone($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/domains');
|
||||||
|
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
$dnsService = new \App\Services\DnsService();
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
$content = '';
|
||||||
|
if (!empty($_FILES['zone_file']['tmp_name'])) {
|
||||||
|
$content = file_get_contents($_FILES['zone_file']['tmp_name']);
|
||||||
|
} elseif (!empty($_POST['zone_content'])) {
|
||||||
|
$content = $_POST['zone_content'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($content) === '') {
|
||||||
|
$_SESSION['error'] = 'No zone file content provided';
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parsed = $dnsService->parseBindZone($content, $domain['domain_name']);
|
||||||
|
$totalParsed = array_sum(array_map('count', $parsed));
|
||||||
|
|
||||||
|
if ($totalParsed === 0) {
|
||||||
|
$_SESSION['error'] = 'No valid DNS records found in zone file';
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $dnsModel->addImportedRecords($id, $parsed);
|
||||||
|
|
||||||
|
$logger->info('DNS zone file imported', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'parsed' => $totalParsed,
|
||||||
|
'imported' => $count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = "Imported {$count} DNS records from zone file";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to parse zone file: ' . $e->getMessage();
|
||||||
|
$logger->error('DNS zone import failed', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
||||||
|
*/
|
||||||
|
private function enrichIpDetails(array &$records, \App\Services\DnsService $dnsService): void
|
||||||
|
{
|
||||||
|
$ips = [];
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (!empty($records[$type])) {
|
||||||
|
foreach ($records[$type] as $r) {
|
||||||
|
if (!empty($r['value'])) {
|
||||||
|
$ips[] = $r['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($ips)) {
|
||||||
|
$ipDetails = $dnsService->lookupIpDetails($ips);
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (!empty($records[$type])) {
|
||||||
|
foreach ($records[$type] as &$rec) {
|
||||||
|
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
||||||
|
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a monitored SSL hostname and fetch its certificate immediately.
|
* Add a monitored SSL hostname and fetch its certificate immediately.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class InstallerController extends Controller
|
|||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
'028_add_ssl_monitoring.sql',
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -204,6 +205,7 @@ class InstallerController extends Controller
|
|||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
'028_add_ssl_monitoring.sql',
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.sql',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +431,7 @@ class InstallerController extends Controller
|
|||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
'028_add_ssl_monitoring.sql',
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.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");
|
||||||
@@ -664,7 +667,7 @@ class InstallerController extends Controller
|
|||||||
|
|
||||||
// Fallback: detect "to" version from which migrations were run
|
// Fallback: detect "to" version from which migrations were run
|
||||||
if ($toVersion === $fromVersion) {
|
if ($toVersion === $fromVersion) {
|
||||||
if (in_array('028_add_ssl_monitoring.sql', $executed)) {
|
if (in_array('029_add_dns_record_source.sql', $executed)) {
|
||||||
$toVersion = '1.1.5';
|
$toVersion = '1.1.5';
|
||||||
} elseif (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
} elseif (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
||||||
$toVersion = '1.1.4';
|
$toVersion = '1.1.4';
|
||||||
|
|||||||
@@ -1065,16 +1065,35 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Transfer group
|
|
||||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
// Also transfer all domains in this group
|
|
||||||
$domainModel = new \App\Models\Domain();
|
$domainModel = new \App\Models\Domain();
|
||||||
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
$domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Notification group transferred', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'group_name' => $group['name'],
|
||||||
|
'from_user_id' => $group['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domains_transferred' => $domainsTransferred,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}";
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
$logger->error('Notification group transfer failed', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
$_SESSION['error'] = 'Failed to transfer group. Please try again.';
|
$_SESSION['error'] = 'Failed to transfer group. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,25 +1132,51 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
$transferred = 0;
|
$transferred = 0;
|
||||||
foreach ($groupIds as $groupId) {
|
foreach ($groupIds as $groupId) {
|
||||||
$groupId = (int)$groupId;
|
$groupId = (int)$groupId;
|
||||||
if ($groupId > 0) {
|
if ($groupId > 0) {
|
||||||
try {
|
try {
|
||||||
// Transfer group
|
$group = $this->groupModel->find($groupId);
|
||||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
// Also transfer all domains in this group
|
$domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
$domainModel = new \App\Models\Domain();
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId);
|
||||||
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
|
||||||
|
$logger->info('Notification group transferred (bulk)', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'group_name' => $group['name'] ?? 'unknown',
|
||||||
|
'from_user_id' => $group['user_id'] ?? null,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domains_transferred' => $domainsTransferred,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$transferred++;
|
$transferred++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Continue with other groups
|
$logger->error('Notification group transfer failed (bulk)', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger->info('Bulk notification group transfer completed', [
|
||||||
|
'transferred' => $transferred,
|
||||||
|
'total_requested' => count($groupIds),
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}";
|
||||||
$this->redirect('/groups');
|
$this->redirect('/groups');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,11 +570,19 @@ class TagController extends Controller
|
|||||||
'showing_to' => min($offset + $perPage, $total)
|
'showing_to' => min($offset + $perPage, $total)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
if (\Core\Auth::isAdmin()) {
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$currentUserId = \Core\Auth::id();
|
||||||
|
$users = array_values(array_filter($userModel->all(), fn($u) => (int)$u['id'] !== $currentUserId));
|
||||||
|
}
|
||||||
|
|
||||||
$this->view('tags/view', [
|
$this->view('tags/view', [
|
||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
'domains' => $paginatedDomains,
|
'domains' => $paginatedDomains,
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'pagination' => $pagination
|
'pagination' => $pagination,
|
||||||
|
'users' => $users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +778,12 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($tag['user_id'] === null) {
|
||||||
|
$_SESSION['error'] = 'Global tags cannot be transferred';
|
||||||
|
$this->redirect('/tags');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$userModel = new \App\Models\User();
|
$userModel = new \App\Models\User();
|
||||||
$targetUser = $userModel->find($targetUserId);
|
$targetUser = $userModel->find($targetUserId);
|
||||||
if (!$targetUser) {
|
if (!$targetUser) {
|
||||||
@@ -778,9 +792,28 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||||
|
$domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Tag transferred', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'tag_name' => $tag['name'],
|
||||||
|
'from_user_id' => $tag['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domain_associations_removed' => $domainsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
|
||||||
} else {
|
} else {
|
||||||
|
$logger->error('Tag transfer failed', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => 'Model update returned false',
|
||||||
|
]);
|
||||||
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
|
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,18 +851,50 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
$transferred = 0;
|
$transferred = 0;
|
||||||
|
$skippedGlobal = 0;
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$tagId = (int)$tagId;
|
$tagId = (int)$tagId;
|
||||||
if ($tagId > 0) {
|
if ($tagId > 0) {
|
||||||
$tag = $this->tagModel->find($tagId);
|
$tag = $this->tagModel->find($tagId);
|
||||||
|
if ($tag && $tag['user_id'] === null) {
|
||||||
|
$skippedGlobal++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||||
|
$domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Tag transferred (bulk)', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'tag_name' => $tag['name'],
|
||||||
|
'from_user_id' => $tag['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domain_associations_removed' => $domainsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$transferred++;
|
$transferred++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['success'] = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
|
$logger->info('Bulk tag transfer completed', [
|
||||||
|
'transferred' => $transferred,
|
||||||
|
'skipped_global' => $skippedGlobal,
|
||||||
|
'total_requested' => count($tagIds),
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
|
||||||
|
if ($skippedGlobal > 0) {
|
||||||
|
$msg .= " ($skippedGlobal global tag(s) skipped)";
|
||||||
|
}
|
||||||
|
$_SESSION['success'] = $msg;
|
||||||
$this->redirect('/tags');
|
$this->redirect('/tags');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
app/Helpers/CronHelper.php
Normal file
90
app/Helpers/CronHelper.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utilities for cron scripts (logging, formatting, DNS checks).
|
||||||
|
*
|
||||||
|
* Replaces the standalone functions that were duplicated across
|
||||||
|
* check_dns.php, check_ssl.php, and check_domains.php.
|
||||||
|
*/
|
||||||
|
class CronHelper
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
|
||||||
|
public function __construct(string $logFile)
|
||||||
|
{
|
||||||
|
$this->logFile = $logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a timestamped message to the log file and echo it.
|
||||||
|
*/
|
||||||
|
public function log(string $message): void
|
||||||
|
{
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$line = "[{$timestamp}] {$message}\n";
|
||||||
|
file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||||
|
echo $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log elapsed time since a given microtime start.
|
||||||
|
*/
|
||||||
|
public function logTimeSince(float $since, string $prefix = ' ⏱ '): void
|
||||||
|
{
|
||||||
|
$this->log($prefix . self::formatDuration(microtime(true) - $since));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short human-readable duration: "3.2s" or "2m 14.1s".
|
||||||
|
*/
|
||||||
|
public static function formatDuration(float $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return sprintf('%.1fs', $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = (int) floor($seconds / 60);
|
||||||
|
$remaining = $seconds - ($minutes * 60);
|
||||||
|
return $minutes . 'm ' . sprintf('%.1fs', $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbose elapsed time: "3.25 seconds", "2 minutes 14.25 seconds", etc.
|
||||||
|
*/
|
||||||
|
public static function formatElapsedTime(float $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return sprintf('%.2f seconds', $seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds < 3600) {
|
||||||
|
$minutes = (int) floor($seconds / 60);
|
||||||
|
$remaining = $seconds - ($minutes * 60);
|
||||||
|
return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = (int) floor($seconds / 3600);
|
||||||
|
$minutes = (int) floor(($seconds - ($hours * 3600)) / 60);
|
||||||
|
$remaining = $seconds - ($hours * 3600) - ($minutes * 60);
|
||||||
|
return sprintf(
|
||||||
|
'%d hour%s %d minute%s %.2f seconds',
|
||||||
|
$hours,
|
||||||
|
$hours !== 1 ? 's' : '',
|
||||||
|
$minutes,
|
||||||
|
$minutes !== 1 ? 's' : '',
|
||||||
|
$remaining
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a hostname resolves at all (SOA, A, or AAAA).
|
||||||
|
*/
|
||||||
|
public static function hostnameResolves(string $hostname): bool
|
||||||
|
{
|
||||||
|
return @checkdnsrr($hostname, 'SOA')
|
||||||
|
|| @checkdnsrr($hostname, 'A')
|
||||||
|
|| @checkdnsrr($hostname, 'AAAA');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,16 +89,17 @@ class DomainHelper
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get CSS class for expiry date styling
|
* Get CSS class for expiry date styling
|
||||||
|
* Includes dark: variants for visibility on dark theme
|
||||||
*/
|
*/
|
||||||
private static function getExpiryClass(?int $daysLeft): string
|
private static function getExpiryClass(?int $daysLeft): string
|
||||||
{
|
{
|
||||||
if ($daysLeft === null) return '';
|
if ($daysLeft === null) return '';
|
||||||
|
|
||||||
if ($daysLeft < 0) return 'text-red-600 font-semibold';
|
if ($daysLeft < 0) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
if ($daysLeft <= 30) return 'text-orange-600 font-semibold';
|
if ($daysLeft <= 30) return 'text-orange-600 dark:text-orange-400 font-semibold';
|
||||||
if ($daysLeft <= 90) return 'text-yellow-600';
|
if ($daysLeft <= 90) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
|
||||||
return '';
|
return 'text-gray-600 dark:text-slate-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -507,9 +507,14 @@ class EmailHelper
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get email subject based on data
|
* Get email subject based on data
|
||||||
|
* Uses explicit subject when provided (DNS, SSL, etc.), otherwise domain expiration logic
|
||||||
*/
|
*/
|
||||||
public static function getEmailSubject(array $data): string
|
public static function getEmailSubject(array $data): string
|
||||||
{
|
{
|
||||||
|
if (!empty($data['subject'])) {
|
||||||
|
return $data['subject'];
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$daysLeft = $data['days_left'] ?? null;
|
$daysLeft = $data['days_left'] ?? null;
|
||||||
if ($daysLeft === null) {
|
if ($daysLeft === null) {
|
||||||
|
|||||||
@@ -17,17 +17,90 @@ class InputValidator
|
|||||||
*/
|
*/
|
||||||
public static function validateDomain(string $domain): bool
|
public static function validateDomain(string $domain): bool
|
||||||
{
|
{
|
||||||
// Check length (max 253 characters per RFC 1035)
|
|
||||||
if (strlen($domain) > 253 || strlen($domain) < 3) {
|
if (strlen($domain) > 253 || strlen($domain) < 3) {
|
||||||
return false;
|
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);
|
return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize raw domain input — strips protocol, www prefix, trailing dots/slashes, paths.
|
||||||
|
*
|
||||||
|
* @return string Cleaned, lowercased domain (may still be invalid)
|
||||||
|
*/
|
||||||
|
public static function sanitizeDomainInput(string $input): string
|
||||||
|
{
|
||||||
|
$input = strtolower(trim($input));
|
||||||
|
|
||||||
|
$input = preg_replace('#^https?://#', '', $input);
|
||||||
|
$input = preg_replace('#/.*$#', '', $input);
|
||||||
|
$input = rtrim($input, '.');
|
||||||
|
$input = trim($input);
|
||||||
|
|
||||||
|
if (str_starts_with($input, 'www.')) {
|
||||||
|
$stripped = substr($input, 4);
|
||||||
|
if (substr_count($stripped, '.') >= 1) {
|
||||||
|
$input = $stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a domain is a registrable root domain (not a subdomain).
|
||||||
|
* Uses the tld_registry table to identify multi-level TLDs like .co.uk.
|
||||||
|
*
|
||||||
|
* @return array{valid: bool, domain: string, error: string|null}
|
||||||
|
*/
|
||||||
|
public static function validateRootDomain(string $domain): array
|
||||||
|
{
|
||||||
|
$domain = self::sanitizeDomainInput($domain);
|
||||||
|
|
||||||
|
if (empty($domain)) {
|
||||||
|
return ['valid' => false, 'domain' => '', 'error' => 'Domain name is required'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::validateDomain($domain)) {
|
||||||
|
return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain format: $domain"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('.', $domain);
|
||||||
|
if (count($parts) < 2) {
|
||||||
|
return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain: $domain"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tldModel = new \App\Models\TldRegistry();
|
||||||
|
|
||||||
|
$matchedTld = null;
|
||||||
|
for ($i = 1; $i < count($parts); $i++) {
|
||||||
|
$candidate = '.' . implode('.', array_slice($parts, $i));
|
||||||
|
$tld = $tldModel->findByTld($candidate);
|
||||||
|
if ($tld) {
|
||||||
|
$matchedTld = $candidate;
|
||||||
|
$labelCount = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matchedTld) {
|
||||||
|
$matchedTld = '.' . $parts[count($parts) - 1];
|
||||||
|
$labelCount = count($parts) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($labelCount !== 1) {
|
||||||
|
$rootDomain = $parts[$labelCount - 1] . $matchedTld;
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'domain' => $domain,
|
||||||
|
'error' => "\"$domain\" looks like a subdomain. Did you mean the root domain \"$rootDomain\"?"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => true, 'domain' => $domain, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate text field length
|
* Validate text field length
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ class DnsRecord extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a snapshot of DNS records for a domain.
|
* Save a snapshot of DNS records for a domain.
|
||||||
* Updates existing records, inserts new ones, removes stale ones.
|
* Updates existing records, inserts new ones.
|
||||||
|
* Only auto-removes stale records whose source is 'discovered' — manual and imported records are preserved.
|
||||||
|
*
|
||||||
* @return array{added: int, updated: int, removed: int}
|
* @return array{added: int, updated: int, removed: int}
|
||||||
*/
|
*/
|
||||||
public function saveSnapshot(int $domainId, array $groupedRecords): array
|
public function saveSnapshot(int $domainId, array $groupedRecords, string $source = 'discovered'): array
|
||||||
{
|
{
|
||||||
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
@@ -108,27 +110,26 @@ class DnsRecord extends Model
|
|||||||
$stats['updated']++;
|
$stats['updated']++;
|
||||||
} else {
|
} else {
|
||||||
$stmt = $this->db->prepare(
|
$stmt = $this->db->prepare(
|
||||||
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at)
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $source, $now, $now, $now, $now]);
|
||||||
$seenIds[] = (int)$this->db->lastInsertId();
|
$seenIds[] = (int)$this->db->lastInsertId();
|
||||||
$stats['added']++;
|
$stats['added']++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove records that no longer exist
|
// Only auto-remove stale discovered records — manual/imported records are never auto-deleted
|
||||||
if (!empty($seenIds)) {
|
if (!empty($seenIds)) {
|
||||||
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
||||||
$deleteStmt = $this->db->prepare(
|
$deleteStmt = $this->db->prepare(
|
||||||
"DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})"
|
"DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered' AND id NOT IN ({$placeholders})"
|
||||||
);
|
);
|
||||||
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
||||||
$stats['removed'] = $deleteStmt->rowCount();
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
} else {
|
} else {
|
||||||
// No records found at all — remove everything
|
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered'");
|
||||||
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
|
||||||
$deleteStmt->execute([$domainId]);
|
$deleteStmt->execute([$domainId]);
|
||||||
$stats['removed'] = $deleteStmt->rowCount();
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
}
|
}
|
||||||
@@ -213,4 +214,82 @@ class DnsRecord extends Model
|
|||||||
|
|
||||||
return $grouped;
|
return $grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single DNS record belonging to a domain.
|
||||||
|
*/
|
||||||
|
public function deleteRecord(int $id, int $domainId): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE id = ? AND domain_id = ?");
|
||||||
|
$stmt->execute([$id, $domainId]);
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete DNS records belonging to a domain.
|
||||||
|
*
|
||||||
|
* @return int Number of records deleted
|
||||||
|
*/
|
||||||
|
public function bulkDeleteRecords(array $ids, int $domainId): int
|
||||||
|
{
|
||||||
|
if (empty($ids)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"DELETE FROM dns_records WHERE domain_id = ? AND id IN ({$placeholders})"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge([$domainId], $ids));
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single manually-created DNS record.
|
||||||
|
*/
|
||||||
|
public function addManualRecord(int $domainId, string $type, string $host, string $value, ?int $ttl = null, ?int $priority = null): int
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, 'manual', ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $now, $now, $now, $now]);
|
||||||
|
return (int)$this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-insert records from a zone file import.
|
||||||
|
*
|
||||||
|
* @return int Number of records imported
|
||||||
|
*/
|
||||||
|
public function addImportedRecords(int $domainId, array $groupedRecords): int
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($groupedRecords as $type => $records) {
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$host = $record['host'] ?? '@';
|
||||||
|
$value = $record['value'] ?? '';
|
||||||
|
$ttl = $record['ttl'] ?? null;
|
||||||
|
$priority = $record['priority'] ?? null;
|
||||||
|
$isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0;
|
||||||
|
$rawData = isset($record['raw']) ? json_encode($record['raw']) : null;
|
||||||
|
|
||||||
|
$existing = $this->findExisting($domainId, $type, $host, $value, $priority);
|
||||||
|
if ($existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,66 @@ class Tag extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove custom (non-global) tags from a domain that don't belong to the specified user.
|
||||||
|
* Global tags (user_id IS NULL) are preserved.
|
||||||
|
*/
|
||||||
|
public function removeOtherUserTagsFromDomain(int $domainId, int $keepUserId): int
|
||||||
|
{
|
||||||
|
$sql = "DELETE dt FROM domain_tags dt
|
||||||
|
JOIN tags t ON dt.tag_id = t.id
|
||||||
|
WHERE dt.domain_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?";
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$domainId, $keepUserId]);
|
||||||
|
$affected = $stmt->rowCount();
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$this->updateAllUsageCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove domain_tags for a tag where the domain doesn't belong to the specified user.
|
||||||
|
*/
|
||||||
|
public function removeTagFromOtherUserDomains(int $tagId, int $keepUserId): int
|
||||||
|
{
|
||||||
|
$sql = "DELETE dt FROM domain_tags dt
|
||||||
|
JOIN domains d ON dt.domain_id = d.id
|
||||||
|
WHERE dt.tag_id = ? AND d.user_id != ?";
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$tagId, $keepUserId]);
|
||||||
|
$affected = $stmt->rowCount();
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$this->updateUsageCount($tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove custom (non-global) tags from all domains in a notification group
|
||||||
|
* that don't belong to the specified user.
|
||||||
|
*/
|
||||||
|
public function removeOtherUserTagsFromDomainsByGroup(int $groupId, int $keepUserId): int
|
||||||
|
{
|
||||||
|
$sql = "DELETE dt FROM domain_tags dt
|
||||||
|
JOIN tags t ON dt.tag_id = t.id
|
||||||
|
JOIN domains d ON dt.domain_id = d.id
|
||||||
|
WHERE d.notification_group_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?";
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute([$groupId, $keepUserId]);
|
||||||
|
$affected = $stmt->rowCount();
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$this->updateAllUsageCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available colors for tags
|
* Get available colors for tags
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ class DiscordChannel implements NotificationChannelInterface
|
|||||||
|
|
||||||
private function createEmbed(string $message, array $data): array
|
private function createEmbed(string $message, array $data): array
|
||||||
{
|
{
|
||||||
|
$title = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||||
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
||||||
|
|
||||||
$embed = [
|
$embed = [
|
||||||
'title' => '🔔 Domain Expiration Alert',
|
'title' => $title,
|
||||||
'description' => $message,
|
'description' => $message,
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
'timestamp' => date('c'),
|
'timestamp' => date('c'),
|
||||||
@@ -65,23 +66,22 @@ class DiscordChannel implements NotificationChannelInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$embed['fields'] = [
|
$fields = [
|
||||||
[
|
['name' => 'Domain', 'value' => $data['domain'], 'inline' => true]
|
||||||
'name' => 'Domain',
|
|
||||||
'value' => $data['domain'],
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Days Left',
|
|
||||||
'value' => (string) ($data['days_left'] ?? 'N/A'),
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Expiration Date',
|
|
||||||
'value' => $data['expiration_date'] ?? 'N/A',
|
|
||||||
'inline' => true
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only add expiration fields for domain expiration alerts
|
||||||
|
if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) {
|
||||||
|
$fields[] = ['name' => 'Days Left', 'value' => (string) ($data['days_left'] ?? 'N/A'), 'inline' => true];
|
||||||
|
$fields[] = ['name' => 'Expiration Date', 'value' => $data['expiration_date'] ?? 'N/A', 'inline' => true];
|
||||||
|
} elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) {
|
||||||
|
$fields[] = ['name' => 'Hostname', 'value' => $data['hostname'], 'inline' => true];
|
||||||
|
}
|
||||||
|
if (isset($data['new_status'])) {
|
||||||
|
$fields[] = ['name' => 'Status', 'value' => $data['new_status'], 'inline' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$embed['fields'] = $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $embed;
|
return $embed;
|
||||||
|
|||||||
@@ -31,34 +31,30 @@ class MattermostChannel implements NotificationChannelInterface
|
|||||||
// Add attachments for richer formatting if domain data is available
|
// Add attachments for richer formatting if domain data is available
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
||||||
|
$title = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
['short' => true, 'title' => 'Domain', 'value' => $data['domain']]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add expiration fields for domain expiration alerts
|
||||||
|
if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) {
|
||||||
|
$fields[] = ['short' => true, 'title' => 'Days Left', 'value' => (string) ($data['days_left'] ?? 'N/A')];
|
||||||
|
$fields[] = ['short' => true, 'title' => 'Expiration Date', 'value' => $data['expiration_date'] ?? 'N/A'];
|
||||||
|
$fields[] = ['short' => true, 'title' => 'Registrar', 'value' => $data['registrar'] ?? 'N/A'];
|
||||||
|
} elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) {
|
||||||
|
$fields[] = ['short' => true, 'title' => 'Hostname', 'value' => $data['hostname']];
|
||||||
|
}
|
||||||
|
if (isset($data['new_status'])) {
|
||||||
|
$fields[] = ['short' => true, 'title' => 'Status', 'value' => $data['new_status']];
|
||||||
|
}
|
||||||
|
|
||||||
$payload['attachments'] = [
|
$payload['attachments'] = [
|
||||||
[
|
[
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
'title' => '🔔 Domain Expiration Alert',
|
'title' => $title,
|
||||||
'text' => $message,
|
'text' => $message,
|
||||||
'fields' => [
|
'fields' => $fields,
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Domain',
|
|
||||||
'value' => $data['domain']
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Days Left',
|
|
||||||
'value' => $data['days_left'] ?? 'N/A'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Expiration Date',
|
|
||||||
'value' => $data['expiration_date'] ?? 'N/A'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Registrar',
|
|
||||||
'value' => $data['registrar'] ?? 'N/A'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'footer' => 'Domain Monitor',
|
'footer' => 'Domain Monitor',
|
||||||
'ts' => time()
|
'ts' => time()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ class PushoverChannel implements NotificationChannelInterface
|
|||||||
'priority' => $priority,
|
'priority' => $priority,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optional: Add title
|
// Optional: Add title - use subject when provided (DNS, SSL, etc.)
|
||||||
if (isset($data['domain'])) {
|
if (!empty($data['subject'])) {
|
||||||
|
$payload['title'] = $data['subject'];
|
||||||
|
} elseif (isset($data['domain'])) {
|
||||||
$payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain'];
|
$payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain'];
|
||||||
} else {
|
} else {
|
||||||
$payload['title'] = '🔔 Domain Monitor Notification';
|
$payload['title'] = '🔔 Domain Monitor Notification';
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ class SlackChannel implements NotificationChannelInterface
|
|||||||
|
|
||||||
private function createBlocks(string $message, array $data): array
|
private function createBlocks(string $message, array $data): array
|
||||||
{
|
{
|
||||||
|
$headerText = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||||
|
|
||||||
$blocks = [
|
$blocks = [
|
||||||
[
|
[
|
||||||
'type' => 'header',
|
'type' => 'header',
|
||||||
'text' => [
|
'text' => [
|
||||||
'type' => 'plain_text',
|
'type' => 'plain_text',
|
||||||
'text' => '🔔 Domain Expiration Alert'
|
'text' => $headerText
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -71,26 +73,25 @@ class SlackChannel implements NotificationChannelInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
|
$fields = [
|
||||||
|
['type' => 'mrkdwn', 'text' => "*Domain:*\n{$data['domain']}"]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add expiration fields for domain expiration alerts
|
||||||
|
if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Days Left:*\n" . ($data['days_left'] ?? 'N/A')];
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Expiration:*\n" . ($data['expiration_date'] ?? 'N/A')];
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Registrar:*\n" . ($data['registrar'] ?? 'N/A')];
|
||||||
|
} elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Hostname:*\n{$data['hostname']}"];
|
||||||
|
}
|
||||||
|
if (isset($data['new_status'])) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Status:*\n{$data['new_status']}"];
|
||||||
|
}
|
||||||
|
|
||||||
$blocks[] = [
|
$blocks[] = [
|
||||||
'type' => 'section',
|
'type' => 'section',
|
||||||
'fields' => [
|
'fields' => $fields
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Domain:*\n{$data['domain']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Days Left:*\n{$data['days_left']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Expiration:*\n{$data['expiration_date']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Registrar:*\n{$data['registrar']}"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class WebhookChannel implements NotificationChannelInterface
|
|||||||
private function buildGenericPayload(string $message, array $data): array
|
private function buildGenericPayload(string $message, array $data): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'event' => 'domain_expiration_alert',
|
'event' => 'domain_monitor_alert',
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'sent_at' => date('c')
|
'sent_at' => date('c')
|
||||||
|
|||||||
@@ -86,124 +86,173 @@ class DnsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// MAIN LOOKUP
|
// DNS SCAN METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprehensive DNS lookup for a domain.
|
* Re-check only records that already exist in the database.
|
||||||
* Scans root + common subdomains + targets extracted from NS/MX/CNAME.
|
* Queries root domain for all types + known subdomain hosts.
|
||||||
* Resolves NS/MX targets to A/AAAA IPs.
|
* No wordlist brute force, no crt.sh. Used by the cron and Refresh button.
|
||||||
*
|
|
||||||
* @param string $domain The domain to scan
|
|
||||||
* @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans)
|
|
||||||
*/
|
*/
|
||||||
public function lookup(string $domain, array $extraSubdomains = []): array
|
public function refreshExisting(string $domain, array $existingHosts = []): array
|
||||||
{
|
{
|
||||||
$this->logger->info("DNS lookup started", ['domain' => $domain]);
|
$this->logger->info("DNS refresh started", ['domain' => $domain, 'known_hosts' => count($existingHosts)]);
|
||||||
|
|
||||||
$records = [
|
[$records, $seen] = $this->queryRootDomain($domain);
|
||||||
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
|
||||||
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
|
||||||
];
|
|
||||||
$seen = []; // "TYPE:host:value" dedup keys
|
|
||||||
|
|
||||||
// Phase 1: Root domain — query each type individually
|
// Query known subdomain hosts directly (no existence probe needed)
|
||||||
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
foreach ($existingHosts as $sub) {
|
||||||
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1b: DNS_ALL fallback to catch anything we missed
|
|
||||||
$this->queryAllFallback($domain, $domain, $records, $seen);
|
|
||||||
|
|
||||||
// Phase 1c: gethostbynamel fallback for A records
|
|
||||||
if (empty($records['A'])) {
|
|
||||||
$ips = @gethostbynamel($domain);
|
|
||||||
if (is_array($ips)) {
|
|
||||||
foreach ($ips as $ip) {
|
|
||||||
$this->addIfNew('A', [
|
|
||||||
'host' => '@', 'value' => $ip, 'ttl' => 0,
|
|
||||||
'is_cloudflare' => $this->isCloudflareIp($ip),
|
|
||||||
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
|
|
||||||
], $records, $seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV
|
|
||||||
$candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains);
|
|
||||||
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
|
|
||||||
foreach ($records[$type] as $rec) {
|
|
||||||
$target = rtrim($rec['value'] ?? '', '.');
|
|
||||||
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
|
||||||
$sub = str_replace('.' . $domain, '', strtolower($target));
|
|
||||||
if ($sub && !in_array($sub, $candidates)) {
|
|
||||||
$candidates[] = $sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$candidates = array_unique($candidates);
|
|
||||||
|
|
||||||
// Phase 3: Probe subdomains — fast checkdnsrr existence test first
|
|
||||||
$discovered = [];
|
|
||||||
foreach ($candidates as $sub) {
|
|
||||||
$fqdn = "{$sub}.{$domain}";
|
|
||||||
if ($this->subdomainExists($fqdn)) {
|
|
||||||
$discovered[] = $sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Deep scan discovered subdomains (A, AAAA, CNAME, TXT)
|
|
||||||
foreach ($discovered as $sub) {
|
|
||||||
$fqdn = "{$sub}.{$domain}";
|
$fqdn = "{$sub}.{$domain}";
|
||||||
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
// TXT only for known useful subdomains
|
|
||||||
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
|
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
|
||||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4b: Special TXT subdomains (always query even if not "discovered")
|
|
||||||
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
||||||
$fqdn = "{$sub}.{$domain}";
|
$fqdn = "{$sub}.{$domain}";
|
||||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Resolve MX targets that are under this domain — add their A/AAAA records
|
$this->resolveMxTargets($domain, $records, $seen);
|
||||||
foreach ($records['MX'] as $mxRec) {
|
$this->resolveNsIps($records);
|
||||||
$target = rtrim($mxRec['value'] ?? '', '.');
|
$this->sortRecords($records);
|
||||||
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
|
||||||
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
|
|
||||||
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 6: Resolve NS server IPs — store in raw data for display
|
|
||||||
foreach ($records['NS'] as &$nsRec) {
|
|
||||||
$nsHost = rtrim($nsRec['value'] ?? '', '.');
|
|
||||||
if ($nsHost) {
|
|
||||||
$nsIps = $this->resolveHostIps($nsHost);
|
|
||||||
$nsRec['raw']['_ns_ips'] = $nsIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($nsRec);
|
|
||||||
|
|
||||||
// Sort A/AAAA: root first, then alphabetical
|
|
||||||
foreach (['A', 'AAAA'] as $type) {
|
|
||||||
usort($records[$type], function ($a, $b) {
|
|
||||||
if ($a['host'] === '@') return -1;
|
|
||||||
if ($b['host'] === '@') return 1;
|
|
||||||
return strcmp($a['host'], $b['host']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalRecords = array_sum(array_map('count', $records));
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
$this->logger->info("DNS lookup completed", [
|
$this->logger->info("DNS refresh completed", [
|
||||||
|
'domain' => $domain,
|
||||||
|
'total_records' => $totalRecords,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard DNS lookup: root domain + resolve targets + special TXT.
|
||||||
|
* No subdomain brute force, no crt.sh. Like running nslookup/dig.
|
||||||
|
* Used by Discover > Quick Scan.
|
||||||
|
*/
|
||||||
|
public function quickScan(string $domain): array
|
||||||
|
{
|
||||||
|
$this->logger->info("DNS quick scan started", ['domain' => $domain]);
|
||||||
|
|
||||||
|
[$records, $seen] = $this->queryRootDomain($domain);
|
||||||
|
|
||||||
|
// Add subdomains found as NS/MX/CNAME/SRV targets
|
||||||
|
$targetSubs = $this->extractTargetSubdomains($domain, $records);
|
||||||
|
foreach ($targetSubs as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resolveMxTargets($domain, $records, $seen);
|
||||||
|
$this->resolveNsIps($records);
|
||||||
|
$this->sortRecords($records);
|
||||||
|
|
||||||
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
$this->logger->info("DNS quick scan completed", [
|
||||||
|
'domain' => $domain,
|
||||||
|
'total_records' => $totalRecords,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full discovery: root + wordlist brute force + crt.sh extras + wildcard detection.
|
||||||
|
* Used by Discover > Deep Scan and the discover_dns.php script.
|
||||||
|
*
|
||||||
|
* @param string $domain The domain to scan
|
||||||
|
* @param array $extraSubdomains Additional candidates (e.g. from crt.sh or previous scans)
|
||||||
|
* @param callable|null $onProgress Optional callback for progress messages: fn(string $msg)
|
||||||
|
*/
|
||||||
|
public function lookup(string $domain, array $extraSubdomains = [], ?callable $onProgress = null): array
|
||||||
|
{
|
||||||
|
$log = $onProgress ?? function (string $msg) {};
|
||||||
|
|
||||||
|
$this->logger->info("DNS deep lookup started", ['domain' => $domain]);
|
||||||
|
|
||||||
|
$log("Querying root domain...");
|
||||||
|
[$records, $seen] = $this->queryRootDomain($domain);
|
||||||
|
$rootCount = array_sum(array_map('count', $records));
|
||||||
|
$log("Root query done: {$rootCount} record(s)");
|
||||||
|
|
||||||
|
// Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV
|
||||||
|
$candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains);
|
||||||
|
$targetSubs = $this->extractTargetSubdomains($domain, $records);
|
||||||
|
$candidates = array_unique(array_merge($candidates, $targetSubs));
|
||||||
|
|
||||||
|
// Wildcard detection: probe a random nonsense subdomain
|
||||||
|
$wildcardDetected = false;
|
||||||
|
$probeHost = '_dm-wc-' . bin2hex(random_bytes(4)) . '.' . $domain;
|
||||||
|
$log("Wildcard detection: probing random subdomain...");
|
||||||
|
if ($this->subdomainExists($probeHost)) {
|
||||||
|
$wildcardDetected = true;
|
||||||
|
$this->logger->info("Wildcard DNS detected, skipping brute force", ['domain' => $domain]);
|
||||||
|
$log("⚠ Wildcard DNS detected — brute force skipped, using only crt.sh/known hosts");
|
||||||
|
// Only use crt.sh/extra candidates + DB hosts (real subdomains), not wordlist
|
||||||
|
$candidates = array_values(array_unique($extraSubdomains));
|
||||||
|
} else {
|
||||||
|
$log("No wildcard detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe subdomains — fast checkdnsrr existence test
|
||||||
|
$total = count($candidates);
|
||||||
|
$log("Probing {$total} subdomain candidate(s)...");
|
||||||
|
$discovered = [];
|
||||||
|
$probed = 0;
|
||||||
|
foreach ($candidates as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
if ($this->subdomainExists($fqdn)) {
|
||||||
|
$discovered[] = $sub;
|
||||||
|
}
|
||||||
|
$probed++;
|
||||||
|
if ($probed % 25 === 0 || $probed === $total) {
|
||||||
|
$log("Probed {$probed}/{$total} — found " . count($discovered) . " so far");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$log("Subdomain probe complete: " . count($discovered) . " found out of {$total}");
|
||||||
|
|
||||||
|
// Deep scan discovered subdomains (A, AAAA, CNAME, TXT)
|
||||||
|
if (!empty($discovered)) {
|
||||||
|
$log("Querying " . count($discovered) . " discovered subdomain(s)...");
|
||||||
|
}
|
||||||
|
foreach ($discovered as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
|
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
|
||||||
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$log("Querying special TXT subdomains...");
|
||||||
|
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
$log("Resolving MX/NS targets...");
|
||||||
|
$this->resolveMxTargets($domain, $records, $seen);
|
||||||
|
$this->resolveNsIps($records);
|
||||||
|
$this->sortRecords($records);
|
||||||
|
|
||||||
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
$this->logger->info("DNS deep lookup completed", [
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'total_records' => $totalRecords,
|
'total_records' => $totalRecords,
|
||||||
'subdomains_discovered' => count($discovered),
|
'subdomains_discovered' => count($discovered),
|
||||||
|
'wildcard_detected' => $wildcardDetected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $records;
|
return $records;
|
||||||
@@ -314,70 +363,572 @@ class DnsService
|
|||||||
return $ips;
|
return $ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SHARED SCAN HELPERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query root domain for all record types + DNS_ALL fallback + gethostbynamel fallback.
|
||||||
|
*
|
||||||
|
* @return array{0: array, 1: array} [$records, $seen]
|
||||||
|
*/
|
||||||
|
private function queryRootDomain(string $domain): array
|
||||||
|
{
|
||||||
|
$records = [
|
||||||
|
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
||||||
|
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
||||||
|
];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
||||||
|
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->queryAllFallback($domain, $domain, $records, $seen);
|
||||||
|
|
||||||
|
if (empty($records['A'])) {
|
||||||
|
$ips = @gethostbynamel($domain);
|
||||||
|
if (is_array($ips)) {
|
||||||
|
foreach ($ips as $ip) {
|
||||||
|
$this->addIfNew('A', [
|
||||||
|
'host' => '@', 'value' => $ip, 'ttl' => 0,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIp($ip),
|
||||||
|
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
|
||||||
|
], $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$records, $seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract subdomain labels found as NS/MX/CNAME/SRV targets under the given domain.
|
||||||
|
*/
|
||||||
|
private function extractTargetSubdomains(string $domain, array $records): array
|
||||||
|
{
|
||||||
|
$subs = [];
|
||||||
|
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
|
||||||
|
foreach ($records[$type] as $rec) {
|
||||||
|
$target = rtrim($rec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$sub = str_replace('.' . $domain, '', strtolower($target));
|
||||||
|
if ($sub && !in_array($sub, $subs)) {
|
||||||
|
$subs[] = $sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $subs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve MX targets that are under the domain — add their A/AAAA records.
|
||||||
|
*/
|
||||||
|
private function resolveMxTargets(string $domain, array &$records, array &$seen): void
|
||||||
|
{
|
||||||
|
foreach ($records['MX'] as $mxRec) {
|
||||||
|
$target = rtrim($mxRec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve NS server hostnames to their A/AAAA IPs (stored in raw data for display).
|
||||||
|
*/
|
||||||
|
private function resolveNsIps(array &$records): void
|
||||||
|
{
|
||||||
|
foreach ($records['NS'] as &$nsRec) {
|
||||||
|
$nsHost = rtrim($nsRec['value'] ?? '', '.');
|
||||||
|
if ($nsHost) {
|
||||||
|
$nsIps = $this->resolveHostIps($nsHost);
|
||||||
|
$nsRec['raw']['_ns_ips'] = $nsIps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($nsRec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort A/AAAA records: root (@) first, then alphabetical by host.
|
||||||
|
*/
|
||||||
|
private function sortRecords(array &$records): void
|
||||||
|
{
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
usort($records[$type], function ($a, $b) {
|
||||||
|
if ($a['host'] === '@') return -1;
|
||||||
|
if ($b['host'] === '@') return 1;
|
||||||
|
return strcmp($a['host'], $b['host']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// BIND ZONE FILE PARSER
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse BIND zone file content into grouped records matching our internal format.
|
||||||
|
*
|
||||||
|
* Handles standard BIND syntax:
|
||||||
|
* @ IN A 1.2.3.4
|
||||||
|
* www IN CNAME example.com.
|
||||||
|
* mail IN MX 10 mx.example.com.
|
||||||
|
* @ 3600 IN TXT "v=spf1 ..."
|
||||||
|
*
|
||||||
|
* @return array Grouped records ['A' => [...], 'MX' => [...], ...]
|
||||||
|
*/
|
||||||
|
public function parseBindZone(string $content, string $domain): array
|
||||||
|
{
|
||||||
|
$records = [
|
||||||
|
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
||||||
|
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
||||||
|
];
|
||||||
|
$seen = [];
|
||||||
|
$supportedTypes = array_keys($records);
|
||||||
|
|
||||||
|
$lines = preg_split('/\r?\n/', $content);
|
||||||
|
$lastHost = '@';
|
||||||
|
$defaultTtl = 3600;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
if ($line === '' || $line[0] === ';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $TTL directive
|
||||||
|
if (preg_match('/^\$TTL\s+(\d+)/i', $line, $m)) {
|
||||||
|
$defaultTtl = (int)$m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other directives ($ORIGIN, $INCLUDE, etc.)
|
||||||
|
if ($line[0] === '$') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip inline comments (not inside quotes)
|
||||||
|
$line = preg_replace('/\s;[^"]*$/', '', $line);
|
||||||
|
|
||||||
|
// Standard BIND format: [name] [ttl] [class] type rdata
|
||||||
|
$tokens = preg_split('/\s+/', $line);
|
||||||
|
if (count($tokens) < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = null;
|
||||||
|
$ttl = $defaultTtl;
|
||||||
|
$type = null;
|
||||||
|
$rdataStart = 0;
|
||||||
|
|
||||||
|
$idx = 0;
|
||||||
|
|
||||||
|
// First token: hostname, or continuation (starts with a type or digit)
|
||||||
|
if (!ctype_digit($tokens[0]) && !in_array(strtoupper($tokens[0]), $supportedTypes)
|
||||||
|
&& strtoupper($tokens[0]) !== 'IN') {
|
||||||
|
$host = $tokens[0];
|
||||||
|
$idx = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional TTL (numeric)
|
||||||
|
if (isset($tokens[$idx]) && ctype_digit($tokens[$idx])) {
|
||||||
|
$ttl = (int)$tokens[$idx];
|
||||||
|
$idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional class (IN)
|
||||||
|
if (isset($tokens[$idx]) && strtoupper($tokens[$idx]) === 'IN') {
|
||||||
|
$idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record type
|
||||||
|
if (!isset($tokens[$idx])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$type = strtoupper($tokens[$idx]);
|
||||||
|
$idx++;
|
||||||
|
|
||||||
|
if (!in_array($type, $supportedTypes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rdataStart = $idx;
|
||||||
|
$rdata = array_slice($tokens, $rdataStart);
|
||||||
|
if (empty($rdata)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve host
|
||||||
|
if ($host === null) {
|
||||||
|
$host = $lastHost;
|
||||||
|
} elseif ($host === '@') {
|
||||||
|
$lastHost = '@';
|
||||||
|
} else {
|
||||||
|
$host = rtrim($host, '.');
|
||||||
|
// Strip the domain suffix to get just the subdomain label
|
||||||
|
$lowerHost = strtolower($host);
|
||||||
|
$lowerDomain = strtolower($domain);
|
||||||
|
if ($lowerHost === $lowerDomain) {
|
||||||
|
$host = '@';
|
||||||
|
} elseif (str_ends_with($lowerHost, '.' . $lowerDomain)) {
|
||||||
|
$host = substr($host, 0, -(strlen($domain) + 1));
|
||||||
|
}
|
||||||
|
$lastHost = $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build record
|
||||||
|
$value = implode(' ', $rdata);
|
||||||
|
$priority = null;
|
||||||
|
$parsed = null;
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'A':
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => $rdata[0], 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIp($rdata[0]),
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'A', 'ip' => $rdata[0], 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'AAAA':
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => $rdata[0], 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'AAAA', 'ipv6' => $rdata[0], 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'CNAME':
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => rtrim($rdata[0], '.'), 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'CNAME', 'target' => rtrim($rdata[0], '.'), 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'MX':
|
||||||
|
$priority = (int)($rdata[0] ?? 0);
|
||||||
|
$target = rtrim($rdata[1] ?? '', '.');
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => $target, 'ttl' => $ttl,
|
||||||
|
'priority' => $priority, 'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'MX', 'pri' => $priority, 'target' => $target, 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'NS':
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => rtrim($rdata[0], '.'), 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'NS', 'target' => rtrim($rdata[0], '.'), 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'TXT':
|
||||||
|
$txtValue = implode(' ', $rdata);
|
||||||
|
$txtValue = trim($txtValue, '"');
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => $txtValue, 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'TXT', 'txt' => $txtValue, 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case 'SRV':
|
||||||
|
if (count($rdata) >= 4) {
|
||||||
|
$priority = (int)$rdata[0];
|
||||||
|
$weight = (int)$rdata[1];
|
||||||
|
$port = (int)$rdata[2];
|
||||||
|
$target = rtrim($rdata[3], '.');
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => $target, 'ttl' => $ttl,
|
||||||
|
'priority' => $priority, 'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'SRV', 'pri' => $priority, 'weight' => $weight, 'port' => $port, 'target' => $target, 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'CAA':
|
||||||
|
if (count($rdata) >= 3) {
|
||||||
|
$flags = (int)$rdata[0];
|
||||||
|
$tag = $rdata[1];
|
||||||
|
$caaValue = trim(implode(' ', array_slice($rdata, 2)), '"');
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => "{$flags} {$tag} \"{$caaValue}\"", 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'CAA', 'flags' => $flags, 'tag' => $tag, 'value' => $caaValue, 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'SOA':
|
||||||
|
if (count($rdata) >= 7) {
|
||||||
|
$parsed = [
|
||||||
|
'host' => $host, 'value' => implode(' ', $rdata), 'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => false,
|
||||||
|
'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'SOA', 'mname' => rtrim($rdata[0], '.'), 'rname' => rtrim($rdata[1], '.'), 'serial' => (int)$rdata[2], 'refresh' => (int)$rdata[3], 'retry' => (int)$rdata[4], 'expire' => (int)$rdata[5], 'minimum-ttl' => (int)$rdata[6], 'ttl' => $ttl],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed) {
|
||||||
|
$this->addIfNew($type, $parsed, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sortRecords($records);
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// CERTIFICATE TRANSPARENCY (crt.sh)
|
// CERTIFICATE TRANSPARENCY (crt.sh)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover subdomains via crt.sh Certificate Transparency logs.
|
* Discover subdomains via crt.sh Certificate Transparency logs.
|
||||||
* Returns an array of subdomain labels (e.g. ['www', 'mail', 'api']).
|
*
|
||||||
* Slow/unreliable — use only in cron, not on manual refresh.
|
* Spawns check_dns.php --crtsh as a subprocess with a hard timeout to
|
||||||
|
* protect against crt.sh hangs. The subprocess handles HTTP retries and
|
||||||
|
* streams debug output to stderr, relayed via the $onStderrLine callback.
|
||||||
|
*
|
||||||
|
* @param string $domain The domain to scan
|
||||||
|
* @param int $maxSubdomains Cap on returned subdomains (0 = no limit)
|
||||||
|
* @param int $timeoutSeconds Hard kill timeout for the subprocess
|
||||||
|
* @param callable|null $onStderrLine fn(string $line) for real-time stderr relay
|
||||||
|
* @return array{0: string[], 1: bool} [subdomains, serverResponded]
|
||||||
*/
|
*/
|
||||||
public function crtshSubdomains(string $domain): array
|
public function fetchCrtshSubdomains(
|
||||||
{
|
string $domain,
|
||||||
$url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json';
|
int $maxSubdomains = 100,
|
||||||
|
int $timeoutSeconds = 1800,
|
||||||
|
?callable $onStderrLine = null
|
||||||
|
): array {
|
||||||
|
$phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
|
||||||
|
$scriptPath = dirname(__DIR__, 2) . '/cron/check_dns.php';
|
||||||
|
$cmd = [$phpBin, $scriptPath, '--crtsh', $domain];
|
||||||
|
|
||||||
$ctx = stream_context_create([
|
if ($maxSubdomains > 0) {
|
||||||
'http' => [
|
$cmd[] = (string) $maxSubdomains;
|
||||||
'timeout' => 30,
|
|
||||||
'ignore_errors' => true,
|
|
||||||
'header' => "User-Agent: DomainMonitor/1.0\r\n",
|
|
||||||
],
|
|
||||||
'ssl' => [
|
|
||||||
'verify_peer' => false,
|
|
||||||
'verify_peer_name' => false,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$json = @file_get_contents($url, false, $ctx);
|
|
||||||
if ($json === false) {
|
|
||||||
$this->logger->warning('crt.sh request failed', ['domain' => $domain]);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$entries = @json_decode($json, true);
|
$projectRoot = dirname(__DIR__, 2);
|
||||||
if (!is_array($entries)) {
|
$proc = proc_open($cmd, [
|
||||||
return [];
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
], $pipes, $projectRoot);
|
||||||
|
|
||||||
|
if (!is_resource($proc)) {
|
||||||
|
$this->logger->error('Failed to spawn crt.sh subprocess', ['domain' => $domain]);
|
||||||
|
return [[], false];
|
||||||
}
|
}
|
||||||
|
|
||||||
$subdomains = [];
|
fclose($pipes[0]);
|
||||||
$domainLower = strtolower($domain);
|
stream_set_blocking($pipes[1], false);
|
||||||
|
stream_set_blocking($pipes[2], false);
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
$start = time();
|
||||||
$name = $entry['name_value'] ?? '';
|
$stdout = '';
|
||||||
foreach (explode("\n", $name) as $n) {
|
$stderrBuffer = '';
|
||||||
$n = strtolower(trim($n));
|
|
||||||
$n = ltrim($n, '*.');
|
|
||||||
if (empty($n)) continue;
|
|
||||||
|
|
||||||
if ($n === $domainLower) continue;
|
while (true) {
|
||||||
|
$status = proc_get_status($proc);
|
||||||
|
|
||||||
if (str_ends_with($n, '.' . $domainLower)) {
|
if (!$status['running']) {
|
||||||
$sub = str_replace('.' . $domainLower, '', $n);
|
break;
|
||||||
if ($sub !== '' && !isset($subdomains[$sub])) {
|
}
|
||||||
$subdomains[$sub] = true;
|
|
||||||
|
$elapsed = time() - $start;
|
||||||
|
|
||||||
|
if ($elapsed >= $timeoutSeconds) {
|
||||||
|
$stdout .= self::drainStream($pipes[1]);
|
||||||
|
$stderrBuffer .= self::drainStream($pipes[2]);
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
|
proc_terminate($proc, 9);
|
||||||
|
proc_close($proc);
|
||||||
|
$this->logger->warning("crt.sh subprocess killed after {$elapsed}s", ['domain' => $domain]);
|
||||||
|
return [[], false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$readable = [$pipes[1], $pipes[2]];
|
||||||
|
$w = $e = null;
|
||||||
|
if (@stream_select($readable, $w, $e, 0, 200000) > 0) {
|
||||||
|
foreach ($readable as $stream) {
|
||||||
|
$chunk = stream_get_contents($stream);
|
||||||
|
if ($stream === $pipes[1]) {
|
||||||
|
$stdout .= $chunk;
|
||||||
|
} else {
|
||||||
|
$stderrBuffer .= $chunk;
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usleep(100000);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = array_keys($subdomains);
|
$stdout .= stream_get_contents($pipes[1]);
|
||||||
|
$stderrBuffer .= stream_get_contents($pipes[2]);
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
proc_close($proc);
|
||||||
|
|
||||||
|
$decoded = json_decode($stdout, true);
|
||||||
|
$ok = is_array($decoded) && !empty($decoded['ok']);
|
||||||
|
$subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : [];
|
||||||
|
|
||||||
$this->logger->info('crt.sh discovery completed', [
|
$this->logger->info('crt.sh discovery completed', [
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'subdomains_found' => count($result),
|
'subdomains_found' => count($subs),
|
||||||
|
'server_ok' => $ok,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $result;
|
return [$subs, $ok];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a crt.sh URL with optional debug output to stderr.
|
||||||
|
*
|
||||||
|
* Called from the crt.sh subprocess where stderr is relayed to the parent
|
||||||
|
* in real-time. Pass $debug = true in subprocess context.
|
||||||
|
*
|
||||||
|
* @return array{status: int, body_length: int, data: array, time: float}
|
||||||
|
*/
|
||||||
|
public function fetchCrtshUrl(string $url, int $timeout = 900, bool $debug = false): array
|
||||||
|
{
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => $timeout,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'header' => implode("\r\n", [
|
||||||
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept: application/json, text/plain, */*',
|
||||||
|
'Accept-Language: en-US,en;q=0.9',
|
||||||
|
'Connection: keep-alive',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$http_response_header = null;
|
||||||
|
$body = @file_get_contents($url, false, $ctx);
|
||||||
|
$elapsed = microtime(true) - $start;
|
||||||
|
|
||||||
|
$bodyLen = is_string($body) ? strlen($body) : 0;
|
||||||
|
|
||||||
|
if ($debug) {
|
||||||
|
fwrite(STDERR, "--- response ---\n");
|
||||||
|
fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n");
|
||||||
|
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
foreach ($http_response_header as $h) {
|
||||||
|
fwrite(STDERR, "$h\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "(no response headers — connection failed or timeout)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "Body: $bodyLen bytes\n");
|
||||||
|
|
||||||
|
if (is_string($body) && $bodyLen > 0) {
|
||||||
|
$preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body;
|
||||||
|
fwrite(STDERR, $preview . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "--- end response ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = 0;
|
||||||
|
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
||||||
|
$status = (int) $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
if ($status === 200 && is_string($body) && $bodyLen > 2) {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$data = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'body_length' => $bodyLen,
|
||||||
|
'data' => $data,
|
||||||
|
'time' => $elapsed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract unique subdomain prefixes from raw crt.sh JSON response.
|
||||||
|
*
|
||||||
|
* Each entry has a name_value field that may contain multiple newline-separated
|
||||||
|
* names, including wildcards. Returns only the subdomain prefixes
|
||||||
|
* (e.g. "www", "mail", "api").
|
||||||
|
*
|
||||||
|
* @param array $crtshData Decoded JSON array from crt.sh
|
||||||
|
* @param string $domain The base domain (e.g. "example.com")
|
||||||
|
* @return string[] Unique subdomain prefixes
|
||||||
|
*/
|
||||||
|
public function extractCrtshSubdomains(array $crtshData, string $domain): array
|
||||||
|
{
|
||||||
|
$domainLower = strtolower($domain);
|
||||||
|
$suffix = '.' . $domainLower;
|
||||||
|
$suffixLen = strlen($suffix);
|
||||||
|
$subs = [];
|
||||||
|
|
||||||
|
foreach ($crtshData as $entry) {
|
||||||
|
if (empty($entry['name_value'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\n", $entry['name_value']) as $name) {
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
|
if (strpos($name, '*.') === 0) {
|
||||||
|
$name = substr($name, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === $domainLower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (substr($name, -$suffixLen) !== $suffix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = substr($name, 0, strlen($name) - $suffixLen);
|
||||||
|
if (!empty($sub)) {
|
||||||
|
$subs[$sub] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush complete stderr lines from buffer via callback.
|
||||||
|
*/
|
||||||
|
private function flushCrtshStderrLines(string &$buffer, ?callable $onLine): void
|
||||||
|
{
|
||||||
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||||
|
$line = trim(substr($buffer, 0, $pos));
|
||||||
|
$buffer = substr($buffer, $pos + 1);
|
||||||
|
if ($line !== '' && $onLine) {
|
||||||
|
$onLine($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain remaining data from a non-blocking stream and close it.
|
||||||
|
*/
|
||||||
|
private static function drainStream($stream): string
|
||||||
|
{
|
||||||
|
if (!is_resource($stream)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$data = stream_get_contents($stream);
|
||||||
|
fclose($stream);
|
||||||
|
return $data ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
||||||
</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 confirmSubmit(event, 'Delete this domain permanently?')" class="m-0">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
||||||
|
|||||||
@@ -375,10 +375,16 @@
|
|||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||||
<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 confirmSubmit(event, 'Delete this domain?')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
|
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -398,7 +404,18 @@
|
|||||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
|
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
|
||||||
<a href="/domains/{{ domain.id }}" class="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
<a href="/domains/{{ domain.id }}" class="flex-1 text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -566,13 +583,12 @@ function bulkRefresh() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' domain(s)? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -641,16 +657,15 @@ function bulkAddTag(tagName) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkRemoveAllTags() {
|
async function bulkRemoveAllTags() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
alert('Please select at least one domain');
|
alert('Please select at least one domain');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
|
var ok = await confirmAction({ message: 'Remove all tags from ' + ids.length + ' domain(s)?', title: 'Remove Tags', icon: 'fa-tags text-orange-500' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -680,51 +695,15 @@ function bulkTransfer() {
|
|||||||
alert('Please select at least one domain');
|
alert('Please select at least one domain');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer ' + ids.length + ' Domain(s)',
|
||||||
if (users.length === 0) {
|
description: 'Select the user to transfer the selected domains to.',
|
||||||
alert('No users available for transfer');
|
action: '/domains/bulk-transfer',
|
||||||
return;
|
fields: { 'domain_ids[]': ids },
|
||||||
}
|
submitText: 'Transfer Domains',
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
let userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || user.email})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
|
||||||
<div class="mt-3">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Transfer ${ids.length} Domain(s)</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Select the user to transfer the selected domains to:</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/domains/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${ids.map(id => `<input type="hidden" name="domain_ids[]" value="${id}">`).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="target_user_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User:</label>
|
|
||||||
<select name="target_user_id" id="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select a user...</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded-md hover:bg-gray-400 dark:hover:bg-slate-500">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
|
|
||||||
Transfer Domains
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
||||||
@@ -977,5 +956,26 @@ document.addEventListener('click', function(e) {
|
|||||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function transferDomain(domainId, domainName) {
|
||||||
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: domainId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('.domain-transfer-btn');
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -23,22 +23,32 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||||
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Click "Refresh DNS" to fetch the current DNS records for this domain.</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Run a quick scan, import a zone file, or add records manually.</p>
|
||||||
{% if domain %}
|
{% if domain %}
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
<div class="flex items-center justify-center gap-2 flex-wrap">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns" class="inline">
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
<input type="hidden" name="mode" value="quick">
|
||||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
<span class="btn-label">Refresh DNS</span>
|
<i class="fas fa-bolt mr-1.5" style="font-size: 10px;"></i>Quick Scan
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<!-- Action Bar -->
|
<!-- Action Bar -->
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||||
<i class="far fa-clock mr-1"></i>
|
<i class="far fa-clock mr-1"></i>
|
||||||
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||||
@@ -51,13 +61,63 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if domain and dnsMonitoringEnabled %}
|
{% if domain and dnsMonitoringEnabled %}
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{# Refresh DNS (re-check existing only) #}
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
<span class="btn-label">Refresh DNS</span>
|
<span class="btn-label">Refresh DNS</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{# Discover DNS dropdown #}
|
||||||
|
<div class="relative" id="discoverDropdown">
|
||||||
|
<button type="button" onclick="document.getElementById('discoverMenu').classList.toggle('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-search mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Discover DNS
|
||||||
|
<i class="fas fa-caret-down ml-1.5" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
<div id="discoverMenu" class="hidden absolute right-0 mt-1 w-56 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg shadow-lg z-30">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="mode" value="quick">
|
||||||
|
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-t-lg transition-colors">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-bolt text-amber-500 mr-1.5"></i>Quick Scan</div>
|
||||||
|
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Root domain + NS/MX targets</p>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="mode" value="deep">
|
||||||
|
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-b-lg border-t border-gray-100 dark:border-slate-700 transition-colors">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-microscope text-purple-500 mr-1.5"></i>Deep Scan</div>
|
||||||
|
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Brute force + crt.sh (background)</p>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Add Record #}
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Import Zone #}
|
||||||
|
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Bulk delete (shown when records are selected) #}
|
||||||
|
<button type="button" id="dnsBulkDeleteBtn" class="hidden inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||||
|
onclick="submitDnsBulkDelete()">
|
||||||
|
<i class="fas fa-trash-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Delete Selected (<span id="dnsSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,23 +191,31 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="A"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['A'] %}
|
{% for record in dnsRecords['A'] %}
|
||||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
{% if record.host == '@' %}
|
{% if record.host == '@' %}
|
||||||
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ record.host }}
|
{{ record.host }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs">
|
<td class="px-4 py-2 text-xs">
|
||||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
@@ -177,6 +245,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -199,23 +275,31 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="AAAA"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['AAAA'] %}
|
{% for record in dnsRecords['AAAA'] %}
|
||||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
{% if record.host == '@' %}
|
{% if record.host == '@' %}
|
||||||
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ record.host }}
|
{{ record.host }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs">
|
<td class="px-4 py-2 text-xs">
|
||||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
@@ -245,6 +329,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -267,17 +359,32 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CNAME"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['CNAME'] %}
|
{% for record in dnsRecords['CNAME'] %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -300,19 +407,34 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="MX"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['MX'] %}
|
{% for record in dnsRecords['MX'] %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
|
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -331,10 +453,19 @@
|
|||||||
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
|
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="TXT"></th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Type</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['TXT'] %}
|
{% for record in dnsRecords['TXT'] %}
|
||||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
|
|
||||||
<div class="flex items-start">
|
|
||||||
{% set val = record.value|lower %}
|
{% set val = record.value|lower %}
|
||||||
{% if val starts with 'v=spf1' %}
|
{% if val starts with 'v=spf1' %}
|
||||||
{% set txtType = 'SPF' %}
|
{% set txtType = 'SPF' %}
|
||||||
@@ -351,11 +482,29 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% set txtType = 'TXT' %}
|
{% set txtType = 'TXT' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded mr-2 flex-shrink-0">{{ txtType }}</span>
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
</div>
|
<td class="px-4 py-2">
|
||||||
</div>
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded">{{ txtType }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -374,11 +523,13 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="NS"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
@@ -386,10 +537,15 @@
|
|||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||||
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||||
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
||||||
{{ nsIps.ipv4|join(', ') }}
|
{{ nsIps.ipv4|join(', ') }}
|
||||||
@@ -401,6 +557,14 @@
|
|||||||
{% else %}-{% endif %}
|
{% else %}-{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -423,24 +587,39 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="SRV"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['SRV'] %}
|
{% for record in dnsRecords['SRV'] %}
|
||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -463,22 +642,37 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CAA"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Tag</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Tag</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['CAA'] %}
|
{% for record in dnsRecords['CAA'] %}
|
||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -491,6 +685,88 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== Add Record Modal ===== #}
|
||||||
|
{% if domain %}
|
||||||
|
<div id="addDnsRecordModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-plus text-primary mr-2"></i>Add DNS Record
|
||||||
|
</h3>
|
||||||
|
<button onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records" id="addDnsRecordForm">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Type</label>
|
||||||
|
<select name="record_type" id="dnsRecordType" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<option value="A">A (IPv4)</option>
|
||||||
|
<option value="AAAA">AAAA (IPv6)</option>
|
||||||
|
<option value="CNAME">CNAME (Alias)</option>
|
||||||
|
<option value="MX">MX (Mail)</option>
|
||||||
|
<option value="TXT">TXT (Text)</option>
|
||||||
|
<option value="NS">NS (Nameserver)</option>
|
||||||
|
<option value="SRV">SRV (Service)</option>
|
||||||
|
<option value="CAA">CAA (CA Auth)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Host</label>
|
||||||
|
<input type="text" name="host" id="dnsRecordHost" value="@" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="@ or subdomain">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5" id="dnsValueLabel">Value</label>
|
||||||
|
<input type="text" name="value" id="dnsRecordValue" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="IPv4 address (e.g. 1.2.3.4)">
|
||||||
|
<p class="mt-1 text-xs text-gray-400 dark:text-slate-500" id="dnsValueHint">Enter the IPv4 address this record points to.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">TTL <span class="text-gray-400 font-normal">(seconds)</span></label>
|
||||||
|
<input type="number" name="ttl" id="dnsRecordTtl" min="0" value="3600" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="3600">
|
||||||
|
</div>
|
||||||
|
<div id="dnsPriorityWrap">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Priority</label>
|
||||||
|
<input type="number" name="priority" id="dnsRecordPriority" min="0" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-plus mr-1.5"></i>Add Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ===== Import Zone Modal (shared partial) ===== #}
|
||||||
|
{% include 'partials/import-modal.twig' with {
|
||||||
|
prefix: 'dnsZone',
|
||||||
|
title: 'Import DNS Zone File',
|
||||||
|
action: '/domains/' ~ domain.id ~ '/dns-import',
|
||||||
|
accept: '.txt,.zone,.db,.bind',
|
||||||
|
file_hint: 'BIND zone file (.txt, .zone)',
|
||||||
|
input_name: 'zone_file',
|
||||||
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">Standard BIND zone file format:</p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5 font-mono">@ IN A 1.2.3.4</p><p class="text-xs text-gray-600 dark:text-slate-400 font-mono">www IN CNAME example.com.</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Duplicate records will be skipped. Imported records are tagged as "imported".</p>',
|
||||||
|
submit_label: 'Import Zone'
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{# ===== Bulk Delete Form (hidden, submitted via JS) ===== #}
|
||||||
|
<form id="dnsBulkDeleteForm" method="POST" action="/domains/{{ domain.id }}/dns-records/bulk-delete" class="hidden">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function handleDnsRefresh(form) {
|
function handleDnsRefresh(form) {
|
||||||
var btn = form.querySelector('.dns-refresh-btn');
|
var btn = form.querySelector('.dns-refresh-btn');
|
||||||
@@ -504,4 +780,137 @@ function handleDnsRefresh(form) {
|
|||||||
if (label) label.textContent = 'Scanning DNS...';
|
if (label) label.textContent = 'Scanning DNS...';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Close discover dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var dropdown = document.getElementById('discoverDropdown');
|
||||||
|
var menu = document.getElementById('discoverMenu');
|
||||||
|
if (dropdown && menu && !dropdown.contains(e.target)) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox select-all
|
||||||
|
document.querySelectorAll('.dns-select-all').forEach(function(selectAll) {
|
||||||
|
selectAll.addEventListener('change', function() {
|
||||||
|
var table = this.closest('table');
|
||||||
|
table.querySelectorAll('.dns-record-cb').forEach(function(cb) {
|
||||||
|
cb.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
updateBulkDeleteBtn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual checkbox change
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('dns-record-cb')) {
|
||||||
|
updateBulkDeleteBtn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var modals = ['addDnsRecordModal', 'dnsZoneImportModal'];
|
||||||
|
modals.forEach(function(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el && !el.classList.contains('hidden')) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var menu = document.getElementById('discoverMenu');
|
||||||
|
if (menu) menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function updateBulkDeleteBtn() {
|
||||||
|
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||||
|
var btn = document.getElementById('dnsBulkDeleteBtn');
|
||||||
|
var count = document.getElementById('dnsSelectedCount');
|
||||||
|
if (btn) {
|
||||||
|
if (checked.length > 0) {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
if (count) count.textContent = checked.length;
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDnsBulkDelete() {
|
||||||
|
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
var ok = await confirmAction({ message: 'Delete ' + checked.length + ' selected DNS record(s)?' });
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
var form = document.getElementById('dnsBulkDeleteForm');
|
||||||
|
// Remove any previous hidden inputs
|
||||||
|
form.querySelectorAll('input[name="record_ids[]"]').forEach(function(el) { el.remove(); });
|
||||||
|
checked.forEach(function(cb) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'record_ids[]';
|
||||||
|
input.value = cb.value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var typeSelect = document.getElementById('dnsRecordType');
|
||||||
|
if (!typeSelect) return;
|
||||||
|
|
||||||
|
var valueInput = document.getElementById('dnsRecordValue');
|
||||||
|
var valueLabel = document.getElementById('dnsValueLabel');
|
||||||
|
var valueHint = document.getElementById('dnsValueHint');
|
||||||
|
var hostInput = document.getElementById('dnsRecordHost');
|
||||||
|
var prioWrap = document.getElementById('dnsPriorityWrap');
|
||||||
|
var prioInput = document.getElementById('dnsRecordPriority');
|
||||||
|
|
||||||
|
var typeMeta = {
|
||||||
|
A: { label: 'IPv4 Address', placeholder: '1.2.3.4', hint: 'Enter the IPv4 address this record points to.', priority: false },
|
||||||
|
AAAA: { label: 'IPv6 Address', placeholder: '2001:db8::1', hint: 'Enter the full IPv6 address.', priority: false },
|
||||||
|
CNAME: { label: 'Target Hostname', placeholder: 'example.com', hint: 'The canonical hostname this alias resolves to (no trailing dot needed).', priority: false, host: 'subdomain' },
|
||||||
|
MX: { label: 'Mail Server', placeholder: 'mail.example.com', hint: 'Hostname of the mail server. Set priority (lower = higher preference).', priority: true, prioDefault: '10' },
|
||||||
|
TXT: { label: 'Text Value', placeholder: 'v=spf1 include:_spf.google.com ~all', hint: 'SPF, DKIM, verification tokens, or any text value.', priority: false },
|
||||||
|
NS: { label: 'Nameserver', placeholder: 'ns1.example.com', hint: 'Hostname of the authoritative nameserver.', priority: false },
|
||||||
|
SRV: { label: 'Target', placeholder: 'sipserver.example.com', hint: 'Target hostname. Host should be _service._proto format. Set priority & use value format: weight port target.', priority: true, prioDefault: '0', host: '_sip._tcp' },
|
||||||
|
CAA: { label: 'Value', placeholder: '0 issue "letsencrypt.org"', hint: 'Format: flags tag value (e.g. 0 issue "letsencrypt.org").', priority: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateFieldsForType() {
|
||||||
|
var t = typeSelect.value;
|
||||||
|
var meta = typeMeta[t] || typeMeta['A'];
|
||||||
|
|
||||||
|
valueLabel.textContent = meta.label;
|
||||||
|
valueInput.placeholder = meta.placeholder;
|
||||||
|
valueHint.textContent = meta.hint;
|
||||||
|
|
||||||
|
if (meta.priority) {
|
||||||
|
prioWrap.classList.remove('hidden');
|
||||||
|
if (prioInput && !prioInput.value) prioInput.placeholder = meta.prioDefault || '10';
|
||||||
|
} else {
|
||||||
|
prioWrap.classList.add('hidden');
|
||||||
|
if (prioInput) prioInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.host) {
|
||||||
|
hostInput.placeholder = meta.host;
|
||||||
|
} else {
|
||||||
|
hostInput.placeholder = '@ or subdomain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', updateFieldsForType);
|
||||||
|
updateFieldsForType();
|
||||||
|
|
||||||
|
document.getElementById('addDnsRecordForm').addEventListener('submit', function() {
|
||||||
|
var ttlInput = document.getElementById('dnsRecordTtl');
|
||||||
|
if (ttlInput && (!ttlInput.value || ttlInput.value.trim() === '')) {
|
||||||
|
ttlInput.value = '3600';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -319,7 +319,7 @@
|
|||||||
Check Now
|
Check Now
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/delete" class="inline" onsubmit="return confirm('Remove SSL monitoring for {{ certificate.display_target }}?');">
|
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Remove SSL monitoring for {{ certificate.display_target }}?')">
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-trash mr-1" style="font-size: 9px;"></i>
|
<i class="fas fa-trash mr-1" style="font-size: 9px;"></i>
|
||||||
@@ -433,14 +433,15 @@ function clearSSLSelection() {
|
|||||||
updateSSLBulkActions();
|
updateSSLBulkActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitBulkSslAction(action) {
|
async function submitBulkSslAction(action) {
|
||||||
const selectedIds = getSelectedSSLIds();
|
const selectedIds = getSelectedSSLIds();
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'delete' && !window.confirm(`Remove SSL monitoring for ${selectedIds.length} endpoint(s)?`)) {
|
if (action === 'delete') {
|
||||||
return;
|
var ok = await confirmAction({ message: 'Remove SSL monitoring for ' + selectedIds.length + ' endpoint(s)?' });
|
||||||
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = document.getElementById(`ssl-bulk-${action}-ids`);
|
const input = document.getElementById(`ssl-bulk-${action}-ids`);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<i class="fas fa-edit mr-1.5"></i>
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
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 confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
<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>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<i class="fas fa-edit mr-1.5"></i>
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
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 confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ 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>
|
||||||
|
|||||||
@@ -465,8 +465,9 @@ function submitResolution() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteError() {
|
async function deleteError() {
|
||||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/{{ error.error_id }}/delete';
|
form.action = '/errors/{{ error.error_id }}/delete';
|
||||||
|
|||||||
@@ -412,8 +412,9 @@ function submitResolution() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteError(errorId) {
|
async function deleteError(errorId) {
|
||||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/' + errorId + '/delete';
|
form.action = '/errors/' + errorId + '/delete';
|
||||||
@@ -457,10 +458,11 @@ function clearSelection() {
|
|||||||
updateBulkActions();
|
updateBulkActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const errorIds = getSelectedErrorIds();
|
const errorIds = getSelectedErrorIds();
|
||||||
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
||||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + errorIds.length + ' error(s) and all their occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/bulk-delete';
|
form.action = '/errors/bulk-delete';
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
|
class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
|
||||||
onclick="return confirm('Delete this channel?')">
|
onclick="return confirmClick(event, 'Delete this channel?')">
|
||||||
<i class="fas fa-trash mr-1"></i>
|
<i class="fas fa-trash mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<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"
|
||||||
aria-label="Delete group {{ group.name }}">
|
aria-label="Delete group {{ group.name }}">
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||||
<i class="fas fa-cog mr-1"></i> Manage
|
<i class="fas fa-cog mr-1"></i> Manage
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
||||||
<i class="fas fa-trash mr-1"></i> Delete
|
<i class="fas fa-trash mr-1"></i> Delete
|
||||||
@@ -252,7 +252,7 @@ function getSelectedGroupIds() {
|
|||||||
return Array.from(checkboxes).map(cb => cb.value);
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const groupIds = getSelectedGroupIds();
|
const groupIds = getSelectedGroupIds();
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
@@ -260,9 +260,8 @@ function bulkDelete() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + groupIds.length + ' group(s)? Domains will be unassigned from these groups.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -285,49 +284,15 @@ function bulkDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transferGroup(groupId, groupName) {
|
function transferGroup(groupId, groupName) {
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
openTransferModal({
|
||||||
if (users.length === 0) {
|
title: 'Transfer Group',
|
||||||
alert('No users available for transfer');
|
description: 'Transfer group <strong>' + esc(groupName) + '</strong> to another user.',
|
||||||
return;
|
action: '/groups/transfer',
|
||||||
}
|
fields: { group_id: groupId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
const userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Group</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer group "${groupName}" to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/groups/transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="group_id" value="${groupId}">
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkTransfer() {
|
function bulkTransfer() {
|
||||||
@@ -336,52 +301,15 @@ function bulkTransfer() {
|
|||||||
alert('Please select groups to transfer');
|
alert('Please select groups to transfer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer Groups',
|
||||||
|
description: 'Transfer ' + groupIds.length + ' selected group(s) to another user.',
|
||||||
if (users.length === 0) {
|
action: '/groups/bulk-transfer',
|
||||||
alert('No users available for transfer');
|
fields: { 'group_ids[]': groupIds },
|
||||||
return;
|
submitText: 'Transfer All',
|
||||||
}
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
const userOptions = users.map(user =>
|
});
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Groups</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/groups/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${groupIds.map(id =>
|
|
||||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
|
||||||
).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -398,140 +326,11 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{# Import Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'group',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import Notification Groups',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/groups/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
} %}
|
||||||
</h3>
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="groupDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="groupDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="groupFileSize"></p>
|
|
||||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('groupDropzone');
|
|
||||||
const fileInput = document.getElementById('groupFileInput');
|
|
||||||
const content = document.getElementById('groupDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
|
||||||
const fileName = document.getElementById('groupFileName');
|
|
||||||
const fileSize = document.getElementById('groupFileSize');
|
|
||||||
const removeBtn = document.getElementById('groupFileRemove');
|
|
||||||
const form = document.getElementById('groupImportForm');
|
|
||||||
const submitBtn = document.getElementById('groupImportBtn');
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -373,5 +373,7 @@
|
|||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
|
{% include 'partials/confirm-modal.twig' %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
<i class="fas fa-check text-xs"></i>
|
<i class="fas fa-check text-xs"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirm('Delete this notification?')">
|
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this notification?')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
||||||
<i class="fas fa-times text-xs"></i>
|
<i class="fas fa-times text-xs"></i>
|
||||||
@@ -407,16 +407,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function markAllAsRead() {
|
async function markAllAsRead() {
|
||||||
if (confirm('Mark all notifications as read?')) {
|
var ok = await confirmAction({ message: 'Mark all notifications as read?', title: 'Mark All Read', icon: 'fa-check-double text-primary', confirmText: 'Mark Read', confirmClass: 'bg-primary hover:bg-primary-dark' });
|
||||||
window.location.href = '/notifications/mark-all-read';
|
if (ok) window.location.href = '/notifications/mark-all-read';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
async function clearAll() {
|
||||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
var ok = await confirmAction({ message: 'Clear all notifications? This action cannot be undone.' });
|
||||||
document.getElementById('clearAllForm').submit();
|
if (ok) document.getElementById('clearAllForm').submit();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
122
app/Views/partials/confirm-modal.twig
Normal file
122
app/Views/partials/confirm-modal.twig
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{# Global confirmation modal — replaces native confirm() dialogs.
|
||||||
|
Included once in layout/base.twig. Provides:
|
||||||
|
|
||||||
|
confirmAction({ message, title, confirmText, cancelText, confirmClass, icon })
|
||||||
|
Returns a Promise<boolean>.
|
||||||
|
|
||||||
|
confirmSubmit(event, message, opts)
|
||||||
|
For onsubmit="return confirmSubmit(event, 'Delete?')"
|
||||||
|
|
||||||
|
confirmClick(event, message, opts)
|
||||||
|
For onclick="return confirmClick(event, 'Are you sure?')"
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" id="confirmModalTitle">
|
||||||
|
<i id="confirmModalIcon" class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||||
|
<span id="confirmModalTitleText">Confirm</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" id="confirmModalClose"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-slate-400" id="confirmModalMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" id="confirmModalCancel"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" id="confirmModalConfirm"
|
||||||
|
class="px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var modal = document.getElementById('confirmModal');
|
||||||
|
var titleText = document.getElementById('confirmModalTitleText');
|
||||||
|
var icon = document.getElementById('confirmModalIcon');
|
||||||
|
var message = document.getElementById('confirmModalMessage');
|
||||||
|
var confirmBtn = document.getElementById('confirmModalConfirm');
|
||||||
|
var cancelBtn = document.getElementById('confirmModalCancel');
|
||||||
|
var closeBtn = document.getElementById('confirmModalClose');
|
||||||
|
|
||||||
|
var _resolve = null;
|
||||||
|
|
||||||
|
function close(result) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
if (_resolve) { _resolve(result); _resolve = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', function() { close(true); });
|
||||||
|
cancelBtn.addEventListener('click', function() { close(false); });
|
||||||
|
closeBtn.addEventListener('click', function() { close(false); });
|
||||||
|
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||||
|
close(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.confirmAction = function(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
titleText.textContent = opts.title || 'Confirm';
|
||||||
|
message.innerHTML = opts.message || 'Are you sure?';
|
||||||
|
|
||||||
|
var iconClass = opts.icon || 'fa-exclamation-triangle text-red-500';
|
||||||
|
icon.className = 'fas ' + iconClass + ' mr-2';
|
||||||
|
|
||||||
|
confirmBtn.textContent = opts.confirmText || 'Confirm';
|
||||||
|
cancelBtn.textContent = opts.cancelText || 'Cancel';
|
||||||
|
|
||||||
|
var btnClass = opts.confirmClass || 'bg-red-600 hover:bg-red-700';
|
||||||
|
confirmBtn.className = 'px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ' + btnClass;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
confirmBtn.focus();
|
||||||
|
|
||||||
|
return new Promise(function(resolve) { _resolve = resolve; });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.confirmSubmit = function(e, msg, opts) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = e.target.closest('form') || e.target;
|
||||||
|
opts = opts || {};
|
||||||
|
opts.message = opts.message || msg;
|
||||||
|
confirmAction(opts).then(function(ok) {
|
||||||
|
if (ok) form.submit();
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.confirmClick = function(e, msg, opts) {
|
||||||
|
e.preventDefault();
|
||||||
|
var el = e.currentTarget || e.target.closest('a') || e.target;
|
||||||
|
opts = opts || {};
|
||||||
|
opts.message = opts.message || msg;
|
||||||
|
confirmAction(opts).then(function(ok) {
|
||||||
|
if (ok) {
|
||||||
|
if (el.tagName === 'A' && el.href) {
|
||||||
|
window.location.href = el.href;
|
||||||
|
} else if (el.form) {
|
||||||
|
el.form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
175
app/Views/partials/import-modal.twig
Normal file
175
app/Views/partials/import-modal.twig
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{#
|
||||||
|
# Shared import modal with drag & drop file upload.
|
||||||
|
#
|
||||||
|
# Parameters:
|
||||||
|
# prefix - Unique prefix for element IDs (e.g. 'tag', 'group', 'tld', 'dnsZone')
|
||||||
|
# title - Modal title (e.g. 'Import Tags')
|
||||||
|
# action - Form POST action URL
|
||||||
|
# accept - File input accept attribute (default: '.csv,.json')
|
||||||
|
# file_hint - Accepted file types hint (default: 'CSV, JSON')
|
||||||
|
# format_html - Raw HTML for the "Expected Format" info block
|
||||||
|
# submit_label - Submit button text (default: title)
|
||||||
|
# input_name - File input name attribute (default: 'import_file')
|
||||||
|
# extra_fields - Optional raw HTML for extra form fields (textarea, etc.)
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set _accept = accept|default('.csv,.json') %}
|
||||||
|
{% set _file_hint = file_hint|default('CSV, JSON') %}
|
||||||
|
{% set _submit = submit_label|default(title) %}
|
||||||
|
{% set _input_name = input_name|default('import_file') %}
|
||||||
|
|
||||||
|
<div id="{{ prefix }}ImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-upload text-primary mr-2"></i>{{ title }}
|
||||||
|
</h3>
|
||||||
|
<button onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ action }}" enctype="multipart/form-data" id="{{ prefix }}ImportForm">
|
||||||
|
{{ csrf_field() }}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
{% if extra_fields is defined and extra_fields %}
|
||||||
|
{{ extra_fields|raw }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||||
|
<div id="{{ prefix }}Dropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<input type="file" name="{{ _input_name }}" accept="{{ _accept }}" required id="{{ prefix }}FileInput"
|
||||||
|
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||||
|
<div id="{{ prefix }}DropzoneContent">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||||
|
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||||
|
</span>
|
||||||
|
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">{{ _file_hint }} · Max {{ max_upload_size() }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="{{ prefix }}DropzoneFile" class="hidden">
|
||||||
|
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="{{ prefix }}FileName"></p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500" id="{{ prefix }}FileSize"></p>
|
||||||
|
<button type="button" id="{{ prefix }}FileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||||
|
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if format_html is defined and format_html %}
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format
|
||||||
|
</p>
|
||||||
|
{{ format_html|raw }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="{{ prefix }}ImportBtn"
|
||||||
|
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-upload mr-1.5"></i>{{ _submit }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var pfx = '{{ prefix }}';
|
||||||
|
var dropzone = document.getElementById(pfx + 'Dropzone');
|
||||||
|
if (!dropzone) return;
|
||||||
|
var fileInput = document.getElementById(pfx + 'FileInput');
|
||||||
|
var content = document.getElementById(pfx + 'DropzoneContent');
|
||||||
|
var fileInfo = document.getElementById(pfx + 'DropzoneFile');
|
||||||
|
var fileName = document.getElementById(pfx + 'FileName');
|
||||||
|
var fileSize = document.getElementById(pfx + 'FileSize');
|
||||||
|
var removeBtn = document.getElementById(pfx + 'FileRemove');
|
||||||
|
var form = document.getElementById(pfx + 'ImportForm');
|
||||||
|
var submitBtn = document.getElementById(pfx + 'ImportBtn');
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFile(file) {
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
fileSize.textContent = formatSize(file.size);
|
||||||
|
content.classList.add('hidden');
|
||||||
|
fileInfo.classList.remove('hidden');
|
||||||
|
dropzone.classList.remove('border-gray-300');
|
||||||
|
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDropzone() {
|
||||||
|
fileInput.value = '';
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
fileInfo.classList.add('hidden');
|
||||||
|
dropzone.classList.add('border-gray-300');
|
||||||
|
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
if (this.files.length) showFile(this.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
removeBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
resetDropzone();
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function(evt) {
|
||||||
|
dropzone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||||
|
dropzone.classList.remove('border-gray-300');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(function(evt) {
|
||||||
|
dropzone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||||
|
dropzone.classList.add('border-gray-300');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var files = e.dataTransfer.files;
|
||||||
|
if (files.length) {
|
||||||
|
fileInput.files = files;
|
||||||
|
showFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var modal = document.getElementById(pfx + 'ImportModal');
|
||||||
|
if (modal && !modal.classList.contains('hidden')) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
182
app/Views/partials/transfer-modal.twig
Normal file
182
app/Views/partials/transfer-modal.twig
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{# Shared transfer modal component.
|
||||||
|
Include once per page, then call:
|
||||||
|
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>example.com</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: 123 } // or for arrays: { 'tag_ids[]': [1,2,3] }
|
||||||
|
submitText: 'Transfer', // optional, defaults to 'Transfer'
|
||||||
|
users: [...], // user objects with id, username, full_name/email
|
||||||
|
csrfToken: '...'
|
||||||
|
});
|
||||||
|
#}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const _esc = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
function _buildSearchableSelect(container, hiddenInput, users) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'relative';
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="transfer-picker-selected hidden flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary transition-colors">
|
||||||
|
<span class="transfer-picker-selected-text truncate"></span>
|
||||||
|
<button type="button" class="transfer-picker-clear ml-2 text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 flex-shrink-0" title="Clear selection">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="transfer-picker-search-wrap">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" class="transfer-picker-search w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search users..." autocomplete="off">
|
||||||
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div class="transfer-picker-list mt-1 max-h-48 overflow-y-auto border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 shadow-lg"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(wrapper);
|
||||||
|
|
||||||
|
const searchInput = wrapper.querySelector('.transfer-picker-search');
|
||||||
|
const searchWrap = wrapper.querySelector('.transfer-picker-search-wrap');
|
||||||
|
const listEl = wrapper.querySelector('.transfer-picker-list');
|
||||||
|
const selectedEl = wrapper.querySelector('.transfer-picker-selected');
|
||||||
|
const selectedText = wrapper.querySelector('.transfer-picker-selected-text');
|
||||||
|
const clearBtn = wrapper.querySelector('.transfer-picker-clear');
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
const query = (filter || '').toLowerCase();
|
||||||
|
const filtered = users.filter(u => {
|
||||||
|
const uname = (u.username || '').toLowerCase();
|
||||||
|
const fname = (u.full_name || u.email || '').toLowerCase();
|
||||||
|
return uname.includes(query) || fname.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="px-3 py-2.5 text-sm text-gray-400 dark:text-slate-500 italic">No users found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = filtered.map(u =>
|
||||||
|
`<div class="transfer-picker-item px-3 py-2.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-900 dark:text-white flex items-center justify-between transition-colors" data-user-id="${u.id}">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">${_esc(u.username)}</span>
|
||||||
|
<span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(u.full_name || u.email || 'No name')})</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-xs text-gray-300 dark:text-slate-600"></i>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.transfer-picker-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const userId = item.dataset.userId;
|
||||||
|
const user = users.find(u => String(u.id) === userId);
|
||||||
|
if (!user) return;
|
||||||
|
hiddenInput.value = userId;
|
||||||
|
selectedText.innerHTML = `<i class="fas fa-user mr-2 text-primary"></i><span class="font-medium">${_esc(user.username)}</span> <span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(user.full_name || user.email || 'No name')})</span>`;
|
||||||
|
selectedEl.classList.remove('hidden');
|
||||||
|
searchWrap.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList('');
|
||||||
|
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hiddenInput.value = '';
|
||||||
|
selectedEl.classList.add('hidden');
|
||||||
|
searchWrap.classList.remove('hidden');
|
||||||
|
searchInput.value = '';
|
||||||
|
renderList('');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedEl.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.transfer-picker-clear')) return;
|
||||||
|
selectedEl.classList.add('hidden');
|
||||||
|
searchWrap.classList.remove('hidden');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openTransferModal = function(opts) {
|
||||||
|
const users = opts.users || [];
|
||||||
|
if (users.length === 0) {
|
||||||
|
alert('No users available for transfer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldsHtml = '';
|
||||||
|
if (opts.fields) {
|
||||||
|
Object.entries(opts.fields).forEach(([name, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => {
|
||||||
|
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(v)}">`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(value)}">`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<form method="POST" action="${_esc(opts.action)}" onsubmit="return !!this.querySelector('input[name=target_user_id]').value">
|
||||||
|
<input type="hidden" name="csrf_token" value="${_esc(opts.csrfToken)}">
|
||||||
|
${fieldsHtml}
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-exchange-alt text-primary mr-2"></i>${opts.title || 'Transfer'}
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="transfer-modal-cancel text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
${opts.description ? `<p class="text-sm text-gray-500 dark:text-slate-400">${opts.description}</p>` : ''}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Transfer to User</label>
|
||||||
|
<input type="hidden" name="target_user_id" value="">
|
||||||
|
<div class="user-picker-mount"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" class="transfer-modal-cancel px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-exchange-alt mr-1.5"></i>${_esc(opts.submitText || 'Transfer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const mount = modal.querySelector('.user-picker-mount');
|
||||||
|
const hiddenInput = modal.querySelector('input[name="target_user_id"]');
|
||||||
|
_buildSearchableSelect(mount, hiddenInput, users);
|
||||||
|
|
||||||
|
modal.querySelectorAll('.transfer-modal-cancel').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => modal.remove());
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function handler(e) {
|
||||||
|
if (e.key === 'Escape' && document.body.contains(modal)) {
|
||||||
|
modal.remove();
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||||
onclick="return confirm('Are you sure you want to remove your avatar?')">
|
onclick="return confirmClick(event, 'Are you sure you want to remove your avatar?', { title: 'Remove Avatar', icon: 'fa-user-circle text-red-500' })">
|
||||||
<i class="fas fa-trash mr-2"></i>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
{% if twoFactorStatus.backup_codes_count < 3 %}
|
{% if twoFactorStatus.backup_codes_count < 3 %}
|
||||||
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirm('Generate new backup codes? Your current codes will stop working.')">
|
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirmSubmit(event, 'Generate new backup codes? Your current codes will stop working.', { title: 'Regenerate Codes', icon: 'fa-key text-blue-500', confirmText: 'Generate', confirmClass: 'bg-blue-600 hover:bg-blue-700' })">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
<i class="fas fa-refresh mr-2"></i>
|
<i class="fas fa-refresh mr-2"></i>
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
||||||
</div>
|
</div>
|
||||||
{% if sessions|default([])|length > 1 %}
|
{% if sessions|default([])|length > 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 confirmSubmit(event, 'Logout all other sessions?', { title: 'Logout Sessions', icon: 'fa-sign-out-alt text-red-500' })" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ 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>
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
|
|
||||||
<!-- Delete Button (only for non-current sessions) -->
|
<!-- Delete Button (only for non-current sessions) -->
|
||||||
{% if not isCurrent %}
|
{% if not isCurrent %}
|
||||||
<form method="POST" action="/profile/logout-session/{{ 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/{{ session.id }}" onsubmit="return confirmSubmit(event, 'Terminate this session? That device will be logged out immediately.', { title: 'Terminate Session', icon: 'fa-sign-out-alt text-red-500' })" class="ml-3">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
||||||
<i class="fas fa-times text-sm"></i>
|
<i class="fas fa-times text-sm"></i>
|
||||||
@@ -712,12 +712,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (confirm('Are you absolutely sure you want to delete your account?\n\nThis action is PERMANENT and cannot be undone!')) {
|
var ok = await confirmAction({ message: 'Are you absolutely sure you want to delete your account? This action is PERMANENT and cannot be undone!', title: 'Delete Account', icon: 'fa-skull-crossbones text-red-600' });
|
||||||
if (confirm('FINAL WARNING: This will permanently delete all your data.\n\nClick OK to proceed.')) {
|
if (!ok) return;
|
||||||
document.getElementById('deleteAccountForm').submit();
|
var ok2 = await confirmAction({ message: 'FINAL WARNING: This will permanently delete all your data. Click Confirm to proceed.', title: 'Final Confirmation', icon: 'fa-exclamation-circle text-red-600' });
|
||||||
}
|
if (ok2) document.getElementById('deleteAccountForm').submit();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDisable2FAModal() {
|
function showDisable2FAModal() {
|
||||||
|
|||||||
@@ -93,25 +93,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="app_timezone" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||||
Timezone
|
Timezone
|
||||||
</label>
|
</label>
|
||||||
<select id="app_timezone" name="app_timezone" required
|
<input type="hidden" id="app_timezone" name="app_timezone" value="{{ appSettings.app_timezone }}" required>
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
<div id="tz-picker" class="relative">
|
||||||
{% for tz, label in popularTimezones %}
|
<div id="tz-selected" class="flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary">
|
||||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
<span id="tz-selected-text">
|
||||||
{{ label }}
|
<i class="fas fa-globe mr-2 text-primary"></i>{{ popularTimezones[appSettings.app_timezone] is defined ? popularTimezones[appSettings.app_timezone] : appSettings.app_timezone|default('UTC') }}
|
||||||
</option>
|
</span>
|
||||||
{% endfor %}
|
<i class="fas fa-chevron-down text-xs text-gray-400 dark:text-slate-500"></i>
|
||||||
<option disabled>──────────</option>
|
</div>
|
||||||
{% for tz in allTimezones %}
|
<div id="tz-dropdown" class="hidden absolute z-20 left-0 right-0 mt-1 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 shadow-lg">
|
||||||
{% if tz not in popularTimezones|keys %}
|
<div class="p-2 border-b border-gray-200 dark:border-slate-600">
|
||||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
<div class="relative">
|
||||||
{{ tz }}
|
<input type="text" id="tz-search" class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search timezones..." autocomplete="off">
|
||||||
</option>
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
<div id="tz-list" class="max-h-64 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Application timezone for dates and times</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Application timezone for dates and times</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -970,7 +972,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 confirmSubmit(event, 'Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
||||||
{{ csrf_field() }}
|
{{ 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>
|
||||||
@@ -1168,7 +1170,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
||||||
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
|
onsubmit="return confirmSubmit(event, 'Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||||
{{ csrf_field() }}
|
{{ 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-undo mr-2"></i>
|
<i class="fas fa-undo mr-2"></i>
|
||||||
@@ -1449,13 +1451,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (footerEl) {
|
if (footerEl) {
|
||||||
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 dark:bg-blue-500/20 border-blue-200 dark:border-blue-500/20' : 'bg-amber-100 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/20');
|
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 dark:bg-blue-500/20 border-blue-200 dark:border-blue-500/20' : 'bg-amber-100 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/20');
|
||||||
if (isRelease) {
|
if (isRelease) {
|
||||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\')">' +
|
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\', { title: \'Update\', icon: \'fa-download text-blue-500\', confirmText: \'Update\', confirmClass: \'bg-blue-600 hover:bg-blue-700\' })">' +
|
||||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||||
'<input type="hidden" name="update_type" value="release">' +
|
'<input type="hidden" name="update_type" value="release">' +
|
||||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
||||||
'</form>';
|
'</form>';
|
||||||
} else {
|
} else {
|
||||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
|
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\', { title: \'Apply Hotfix\', icon: \'fa-download text-amber-500\', confirmText: \'Apply Hotfix\', confirmClass: \'bg-amber-600 hover:bg-amber-700\' })">' +
|
||||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||||
'<input type="hidden" name="update_type" value="hotfix">' +
|
'<input type="hidden" name="update_type" value="hotfix">' +
|
||||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
||||||
@@ -1591,5 +1593,99 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
updateCaptchaUI();
|
updateCaptchaUI();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const popularTimezones = {{ popularTimezones|json_encode|raw }};
|
||||||
|
const allTimezones = {{ allTimezones|json_encode|raw }};
|
||||||
|
|
||||||
|
const hiddenInput = document.getElementById('app_timezone');
|
||||||
|
const selectedEl = document.getElementById('tz-selected');
|
||||||
|
const selectedText = document.getElementById('tz-selected-text');
|
||||||
|
const dropdown = document.getElementById('tz-dropdown');
|
||||||
|
const searchInput = document.getElementById('tz-search');
|
||||||
|
const listEl = document.getElementById('tz-list');
|
||||||
|
|
||||||
|
const popularKeys = Object.keys(popularTimezones);
|
||||||
|
const otherTimezones = allTimezones.filter(tz => !popularKeys.includes(tz));
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
const query = (filter || '').toLowerCase();
|
||||||
|
|
||||||
|
const matchPopular = popularKeys.filter(tz => {
|
||||||
|
const label = (popularTimezones[tz] || '').toLowerCase();
|
||||||
|
return tz.toLowerCase().includes(query) || label.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchOther = otherTimezones.filter(tz => tz.toLowerCase().includes(query));
|
||||||
|
|
||||||
|
if (matchPopular.length === 0 && matchOther.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400 dark:text-slate-500 italic">No timezones found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (matchPopular.length > 0) {
|
||||||
|
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800">Popular</div>';
|
||||||
|
html += matchPopular.map(tz =>
|
||||||
|
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||||
|
<span>${esc(popularTimezones[tz])}</span>
|
||||||
|
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchOther.length > 0) {
|
||||||
|
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800 border-t border-gray-200 dark:border-slate-600">All Timezones</div>';
|
||||||
|
const capped = matchOther.slice(0, 50);
|
||||||
|
html += capped.map(tz =>
|
||||||
|
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||||
|
<span>${esc(tz)}</span>
|
||||||
|
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
if (matchOther.length > 50) {
|
||||||
|
html += '<div class="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic">Type to narrow down ' + (matchOther.length - 50) + ' more...</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.tz-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const tz = item.dataset.tz;
|
||||||
|
hiddenInput.value = tz;
|
||||||
|
const label = popularTimezones[tz] || tz;
|
||||||
|
selectedText.innerHTML = '<i class="fas fa-globe mr-2 text-primary"></i>' + esc(label);
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
searchInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedEl.addEventListener('click', () => {
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
if (!dropdown.classList.contains('hidden')) {
|
||||||
|
renderList('');
|
||||||
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!document.getElementById('tz-picker').contains(e.target)) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Import Button #}
|
{# Import Button #}
|
||||||
<button onclick="document.getElementById('importModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
<button onclick="document.getElementById('tagImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
<i class="fas fa-upload mr-2"></i>
|
<i class="fas fa-upload mr-2"></i>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if auth.isAdmin %}
|
{% if auth.isAdmin and tag.user_id is not null %}
|
||||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
class="text-blue-600 hover:text-blue-800" title="View">
|
class="text-blue-600 hover:text-blue-800" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if auth.isAdmin %}
|
{% if auth.isAdmin and tag.user_id is not null %}
|
||||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
@@ -455,63 +455,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Import Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'tag',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import Tags',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/tags/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import Tags
|
} %}
|
||||||
</h3>
|
|
||||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="tagDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="tagDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tagFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tagFileSize"></p>
|
|
||||||
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('importModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleSelectAll(checkbox) {
|
function toggleSelectAll(checkbox) {
|
||||||
@@ -561,13 +510,12 @@ function getSelectedIds() {
|
|||||||
return [...new Set(ids)];
|
return [...new Set(ids)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDeleteTags() {
|
async function bulkDeleteTags() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
if (!confirm(`Delete ${ids.length} tag(s)? This will remove them from all domains.`)) {
|
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' tag(s)? This will remove them from all domains.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -592,46 +540,15 @@ function bulkDeleteTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transferTag(tagId, tagName) {
|
function transferTag(tagId, tagName) {
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
if (users.length === 0) {
|
openTransferModal({
|
||||||
alert('No users available for transfer');
|
title: 'Transfer Tag',
|
||||||
return;
|
description: 'Transfer tag <strong>' + esc(tagName) + '</strong> to another user.',
|
||||||
}
|
action: '/tags/transfer',
|
||||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
fields: { tag_id: tagId },
|
||||||
const userOptions = users.map(user =>
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
|
csrfToken: '{{ csrf_token() }}'
|
||||||
).join('');
|
});
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tag</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/tags/transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="tag_id" value="${tagId}">
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -648,49 +565,15 @@ function bulkTransferTags() {
|
|||||||
alert('Please select tags to transfer');
|
alert('Please select tags to transfer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer Tags',
|
||||||
if (users.length === 0) {
|
description: 'Transfer ' + ids.length + ' selected tag(s) to another user.',
|
||||||
alert('No users available for transfer');
|
action: '/tags/bulk-transfer',
|
||||||
return;
|
fields: { 'tag_ids[]': ids },
|
||||||
}
|
submitText: 'Transfer All',
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
const userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tags</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/tags/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
@@ -716,8 +599,9 @@ function closeEditModal() {
|
|||||||
document.getElementById('editModal').classList.add('hidden');
|
document.getElementById('editModal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTag(id, name) {
|
async function deleteTag(id, name) {
|
||||||
if (confirm(`Are you sure you want to delete the tag "${name}"? This will remove it from all domains.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete the tag "' + name + '"? This will remove it from all domains.' });
|
||||||
|
if (ok) {
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/tags/delete';
|
form.action = '/tags/delete';
|
||||||
@@ -758,9 +642,9 @@ document.getElementById('editModal').addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('importModal').addEventListener('click', function(e) {
|
document.getElementById('tagImportModal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
document.getElementById('importModal').classList.add('hidden');
|
document.getElementById('tagImportModal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -771,81 +655,6 @@ document.addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('tagDropzone');
|
|
||||||
const fileInput = document.getElementById('tagFileInput');
|
|
||||||
const content = document.getElementById('tagDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('tagDropzoneFile');
|
|
||||||
const fileName = document.getElementById('tagFileName');
|
|
||||||
const fileSize = document.getElementById('tagFileSize');
|
|
||||||
const removeBtn = document.getElementById('tagFileRemove');
|
|
||||||
const form = document.getElementById('tagImportForm');
|
|
||||||
const submitBtn = document.getElementById('tagImportBtn');
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -210,16 +210,22 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end space-x-2">
|
<div class="flex items-center justify-end space-x-2">
|
||||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ 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 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +279,12 @@
|
|||||||
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn flex-1 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded text-center text-sm hover:bg-indigo-100 dark:hover:bg-indigo-500/20 transition-colors"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt mr-1"></i> Transfer
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -348,4 +360,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<script>
|
||||||
|
function transferDomain(domainId, domainName) {
|
||||||
|
var esc = function(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); };
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: domainId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.domain-transfer-btn');
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -348,10 +348,10 @@
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<i class="fas fa-power-off"></i>
|
<i class="fas fa-power-off"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
<i class="fas fa-eye mr-1"></i> View
|
<i class="fas fa-eye mr-1"></i> View
|
||||||
</a>
|
</a>
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirm('Refresh TLD data?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirmClick(event, 'Refresh TLD data?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -584,63 +584,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Import TLD Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="tldImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'tld',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import TLDs',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/tld-registry/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import TLDs
|
} %}
|
||||||
</h3>
|
|
||||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="tldDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="tldDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tldFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tldFileSize"></p>
|
|
||||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||||
@@ -691,14 +640,15 @@ function clearSelection() {
|
|||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmBulkDelete() {
|
async function confirmBulkDelete() {
|
||||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||||
if (checkboxes.length === 0) {
|
if (checkboxes.length === 0) {
|
||||||
alert('Please select TLDs to delete');
|
alert('Please select TLDs to delete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + checkboxes.length + ' selected TLD(s)? This action cannot be undone.' });
|
||||||
|
if (ok) {
|
||||||
const form = document.getElementById('bulk-delete-form');
|
const form = document.getElementById('bulk-delete-form');
|
||||||
checkboxes.forEach(checkbox => {
|
checkboxes.forEach(checkbox => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -759,84 +709,6 @@ document.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('tldDropzone');
|
|
||||||
const fileInput = document.getElementById('tldFileInput');
|
|
||||||
const content = document.getElementById('tldDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('tldDropzoneFile');
|
|
||||||
const fileName = document.getElementById('tldFileName');
|
|
||||||
const fileSize = document.getElementById('tldFileSize');
|
|
||||||
const removeBtn = document.getElementById('tldFileRemove');
|
|
||||||
const form = document.getElementById('tldImportForm');
|
|
||||||
const submitBtn = document.getElementById('tldImportBtn');
|
|
||||||
|
|
||||||
if (!dropzone) return;
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" 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]" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" 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]" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
Refresh
|
Refresh
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<i class="fas fa-power-off mr-1.5"></i>
|
<i class="fas fa-power-off mr-1.5"></i>
|
||||||
Toggle
|
Toggle
|
||||||
</a>
|
</a>
|
||||||
@@ -215,13 +215,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
||||||
<i class="fas fa-sync-alt text-sm"></i>
|
<i class="fas fa-sync-alt text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
||||||
<i class="fas fa-power-off text-sm"></i>
|
<i class="fas fa-power-off text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ function getSelectedUserIds() {
|
|||||||
return Array.from(checkboxes).map(cb => cb.value);
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkToggleStatus(action) {
|
async function bulkToggleStatus(action) {
|
||||||
const userIds = getSelectedUserIds();
|
const userIds = getSelectedUserIds();
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
@@ -405,9 +405,8 @@ function bulkToggleStatus(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to ' + actionText + ' ' + userIds.length + ' user(s)?', title: actionText.charAt(0).toUpperCase() + actionText.slice(1) + ' Users', icon: action === 'active' ? 'fa-user-check text-green-500' : 'fa-user-slash text-orange-500' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -450,10 +449,9 @@ function toggleUserStatus(userId) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUser(userId) {
|
async function deleteUser(userId) {
|
||||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this user? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -469,7 +467,7 @@ function deleteUser(userId) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDeleteUsers() {
|
async function bulkDeleteUsers() {
|
||||||
const userIds = getSelectedUserIds();
|
const userIds = getSelectedUserIds();
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
@@ -477,9 +475,8 @@ function bulkDeleteUsers() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + userIds.length + ' user(s)? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
|
|||||||
@@ -48,12 +48,12 @@
|
|||||||
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
{% if isActive %}
|
{% if isActive %}
|
||||||
<button type="submit" onclick="return confirm('Deactivate this user?')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Deactivate this user?', { title: 'Deactivate', icon: 'fa-user-slash text-orange-500', confirmClass: 'bg-orange-600 hover:bg-orange-700' })" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-user-slash mr-2"></i>
|
<i class="fas fa-user-slash mr-2"></i>
|
||||||
Deactivate
|
Deactivate
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="submit" onclick="return confirm('Activate this user?')" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Activate this user?', { title: 'Activate', icon: 'fa-user-check text-green-500', confirmClass: 'bg-green-600 hover:bg-green-700' })" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-user-check mr-2"></i>
|
<i class="fas fa-user-check mr-2"></i>
|
||||||
Activate
|
Activate
|
||||||
</button>
|
</button>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" onclick="return confirm('Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-trash mr-2"></i>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -529,8 +529,15 @@
|
|||||||
{{ domain.statusText }}
|
{{ domain.statusText }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-slate-400">
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
{{ domain.group_name|default('—') }}
|
{% if domain.group_name %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||||
|
<i class="fas fa-bell mr-1"></i>
|
||||||
|
{{ domain.group_name }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">No Group</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -4,20 +4,18 @@
|
|||||||
/**
|
/**
|
||||||
* DNS Record Monitoring Cron Job
|
* DNS Record Monitoring Cron Job
|
||||||
*
|
*
|
||||||
* Checks DNS records for all active domains and sends notifications
|
* Re-checks existing DNS records for all active domains and sends notifications
|
||||||
* when changes are detected (new records, removed records, changed records).
|
* when changes are detected (new records, removed records, changed records).
|
||||||
|
* No discovery (brute force / crt.sh) — use discover_dns.php for that.
|
||||||
*
|
*
|
||||||
* Also handles crt.sh subdomain fetching internally via self-invocation
|
* Also serves as the crt.sh subprocess entry point (--crtsh) for
|
||||||
* with a hard timeout (no separate script needed).
|
* DnsService::fetchCrtshSubdomains() used by discover_dns.php.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* php cron/check_dns.php — run the full DNS check
|
* php cron/check_dns.php — re-check existing records
|
||||||
* php cron/check_dns.php --crtsh <domain> [max] — (internal) crt.sh subprocess
|
* php cron/check_dns.php --crtsh <domain> [max] — (internal) crt.sh subprocess
|
||||||
*
|
*
|
||||||
* Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php
|
* Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php
|
||||||
*
|
|
||||||
* NOTE: Requires a `crtsh_last_fetched` column on the domains table:
|
|
||||||
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
@@ -33,6 +31,7 @@ use App\Models\User;
|
|||||||
use App\Services\DnsService;
|
use App\Services\DnsService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\Logger;
|
use App\Services\Logger;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
use Core\Database;
|
use Core\Database;
|
||||||
|
|
||||||
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
||||||
@@ -57,15 +56,6 @@ if (php_sapi_name() !== 'cli') {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** crt.sh subprocess hard kill (seconds). In practice crt.sh 503s in <60s, but HTTP timeout is 900s as insurance. */
|
|
||||||
const CRTSH_TIMEOUT_SECONDS = 1800;
|
|
||||||
|
|
||||||
/** Max unique subdomains from crt.sh per domain (0 = no limit) */
|
|
||||||
const CRTSH_MAX_SUBDOMAINS = 100;
|
|
||||||
|
|
||||||
/** How often to re-fetch crt.sh per domain (hours). New certs appear gradually. */
|
|
||||||
const CRTSH_REFRESH_HOURS = 24;
|
|
||||||
|
|
||||||
/** Microseconds to sleep between domains */
|
/** Microseconds to sleep between domains */
|
||||||
const INTER_DOMAIN_DELAY_US = 500000;
|
const INTER_DOMAIN_DELAY_US = 500000;
|
||||||
|
|
||||||
@@ -91,6 +81,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
$startTime = microtime(true);
|
$startTime = microtime(true);
|
||||||
|
|
||||||
logMessage("=== Starting DNS check cron job ===");
|
logMessage("=== Starting DNS check cron job ===");
|
||||||
@@ -124,8 +115,6 @@ $stats = [
|
|||||||
'in_app_notifications' => 0,
|
'in_app_notifications' => 0,
|
||||||
'errors' => 0,
|
'errors' => 0,
|
||||||
'skipped_unresolved' => 0,
|
'skipped_unresolved' => 0,
|
||||||
'crtsh_skipped' => 0,
|
|
||||||
'crtsh_fetched' => 0,
|
|
||||||
'domains_with_changes' => [],
|
'domains_with_changes' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -138,7 +127,7 @@ foreach ($domains as $domain) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Quick existence check — skip if domain doesn't resolve at all
|
// Quick existence check — skip if domain doesn't resolve at all
|
||||||
if (!domainResolves($domainName)) {
|
if (!CronHelper::hostnameResolves($domainName)) {
|
||||||
logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping");
|
logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping");
|
||||||
logTimeSince($domainStartTime);
|
logTimeSince($domainStartTime);
|
||||||
$stats['skipped_unresolved']++;
|
$stats['skipped_unresolved']++;
|
||||||
@@ -148,38 +137,9 @@ foreach ($domains as $domain) {
|
|||||||
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
||||||
$isFirstScan = empty($previousRecords);
|
$isFirstScan = empty($previousRecords);
|
||||||
|
|
||||||
// Gather subdomain candidates: known hosts from DB
|
// Re-check only known hosts — no discovery (brute force / crt.sh)
|
||||||
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
||||||
|
$newRecords = $dnsService->refreshExisting($domainName, $existingHosts);
|
||||||
// Decide whether to call crt.sh or use cached hosts
|
|
||||||
$ctSubs = [];
|
|
||||||
|
|
||||||
if (shouldFetchCrtsh($domain, $existingHosts)) {
|
|
||||||
logMessage(" 🔍 crt.sh: fetching subdomains...");
|
|
||||||
|
|
||||||
[$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName);
|
|
||||||
|
|
||||||
logMessage(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
|
||||||
$stats['crtsh_fetched']++;
|
|
||||||
|
|
||||||
// Update timestamp if server responded (200 OK).
|
|
||||||
// Empty [] is valid (no CT entries) — still counts as a successful fetch.
|
|
||||||
// Only skip update if all attempts 503'd / timed out.
|
|
||||||
if ($crtshOk) {
|
|
||||||
$domainModel->update($domain['id'], [
|
|
||||||
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in "
|
|
||||||
. crtshHoursUntilRefresh($domain) . "h)");
|
|
||||||
$stats['crtsh_skipped']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
|
||||||
|
|
||||||
// Fetch fresh DNS records
|
|
||||||
$newRecords = $dnsService->lookup($domainName, $extraSubs);
|
|
||||||
$totalRecords = array_sum(array_map('count', $newRecords));
|
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||||
|
|
||||||
if ($totalRecords === 0) {
|
if ($totalRecords === 0) {
|
||||||
@@ -262,55 +222,13 @@ exit(0);
|
|||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// Crt.sh smart caching
|
// Crt.sh subprocess entry point (invoked by DnsService::fetchCrtshSubdomains)
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should we fetch crt.sh for this domain right now?
|
|
||||||
*
|
|
||||||
* Skip if we already have enough known hosts and fetched recently.
|
|
||||||
* Always fetch on first scan or if we have very few known hosts.
|
|
||||||
*
|
|
||||||
* NOTE: Requires a `crtsh_last_fetched` DATETIME column on the domains table.
|
|
||||||
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
|
||||||
*/
|
|
||||||
function shouldFetchCrtsh(array $domain, array $existingHosts): bool
|
|
||||||
{
|
|
||||||
// Always fetch if we've never successfully fetched before
|
|
||||||
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
|
||||||
if (empty($lastFetched)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect the refresh interval — even if domain has few hosts,
|
|
||||||
// crt.sh already answered (maybe with [] or few results). Don't hammer it.
|
|
||||||
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
|
||||||
return $hoursSince >= CRTSH_REFRESH_HOURS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hours remaining until next crt.sh refresh (for log messages).
|
|
||||||
*/
|
|
||||||
function crtshHoursUntilRefresh(array $domain): string
|
|
||||||
{
|
|
||||||
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
|
||||||
if (empty($lastFetched)) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
|
||||||
$remaining = max(0, CRTSH_REFRESH_HOURS - $hoursSince);
|
|
||||||
return sprintf('%.1f', $remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Crt.sh subprocess (self-invocation with hard timeout)
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal crt.sh subprocess entry point.
|
* Internal crt.sh subprocess entry point.
|
||||||
* Called when this script is invoked with: --crtsh <domain> [max_subdomains]
|
* Called when this script is invoked with: --crtsh <domain> [max_subdomains]
|
||||||
* Outputs a JSON array of subdomains to stdout.
|
* Outputs JSON to stdout. Uses DnsService for HTTP fetch and parsing.
|
||||||
*
|
*
|
||||||
* Wildcard query ?q=%.domain.com with 5 retry attempts.
|
* Wildcard query ?q=%.domain.com with 5 retry attempts.
|
||||||
* All HTTP response details are written to stderr for real-time debugging.
|
* All HTTP response details are written to stderr for real-time debugging.
|
||||||
@@ -329,6 +247,7 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
$retryDelay = 10;
|
$retryDelay = 10;
|
||||||
$httpTimeout = 900;
|
$httpTimeout = 900;
|
||||||
|
|
||||||
|
$dnsService = new DnsService();
|
||||||
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -338,13 +257,12 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n");
|
||||||
|
|
||||||
$response = fetchCrtshWithDebug($url, $httpTimeout);
|
$response = $dnsService->fetchCrtshUrl($url, $httpTimeout, true);
|
||||||
|
|
||||||
// HTTP 200 — server answered, don't retry regardless of content
|
|
||||||
if ($response['status'] === 200) {
|
if ($response['status'] === 200) {
|
||||||
$gotHttp200 = true;
|
$gotHttp200 = true;
|
||||||
if (!empty($response['data'])) {
|
if (!empty($response['data'])) {
|
||||||
$result = extractSubdomains($response['data'], $domain);
|
$result = $dnsService->extractCrtshSubdomains($response['data'], $domain);
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n");
|
||||||
} else {
|
} else {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
||||||
@@ -352,7 +270,6 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-200 (503, timeout, connection error) — retry
|
|
||||||
if ($attempt < $maxAttempts) {
|
if ($attempt < $maxAttempts) {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
||||||
sleep($retryDelay);
|
sleep($retryDelay);
|
||||||
@@ -361,7 +278,6 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply cap
|
|
||||||
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
||||||
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
||||||
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
||||||
@@ -376,253 +292,10 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a crt.sh URL with full debug output to stderr.
|
|
||||||
* Dumps HTTP response headers + body preview immediately so you see
|
|
||||||
* exactly what the server returned — like watching curl in real-time.
|
|
||||||
*
|
|
||||||
* @param string $url Full crt.sh URL
|
|
||||||
* @param int $timeout HTTP timeout in seconds
|
|
||||||
* @return array{status: int, body_length: int, data: array, time: float}
|
|
||||||
*/
|
|
||||||
function fetchCrtshWithDebug(string $url, int $timeout = 900): array
|
|
||||||
{
|
|
||||||
$ctx = stream_context_create([
|
|
||||||
'http' => [
|
|
||||||
'timeout' => $timeout,
|
|
||||||
'ignore_errors' => true,
|
|
||||||
'header' => implode("\r\n", [
|
|
||||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
'Accept: application/json, text/plain, */*',
|
|
||||||
'Accept-Language: en-US,en;q=0.9',
|
|
||||||
'Connection: keep-alive',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$start = microtime(true);
|
|
||||||
$http_response_header = null;
|
|
||||||
$body = @file_get_contents($url, false, $ctx);
|
|
||||||
$elapsed = microtime(true) - $start;
|
|
||||||
|
|
||||||
// ── Dump full response to stderr ──────────────────────────────────
|
|
||||||
fwrite(STDERR, "--- response ---\n");
|
|
||||||
fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n");
|
|
||||||
|
|
||||||
if (isset($http_response_header) && is_array($http_response_header)) {
|
|
||||||
foreach ($http_response_header as $h) {
|
|
||||||
fwrite(STDERR, "$h\n");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fwrite(STDERR, "(no response headers — connection failed or timeout)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
$bodyLen = is_string($body) ? strlen($body) : 0;
|
|
||||||
fwrite(STDERR, "Body: $bodyLen bytes\n");
|
|
||||||
|
|
||||||
if (is_string($body) && $bodyLen > 0) {
|
|
||||||
// Show first 2000 chars of body so you can see errors, JSON start, etc.
|
|
||||||
$preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body;
|
|
||||||
fwrite(STDERR, $preview . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite(STDERR, "--- end response ---\n");
|
|
||||||
|
|
||||||
// ── Parse status and JSON ─────────────────────────────────────────
|
|
||||||
$status = 0;
|
|
||||||
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
|
||||||
$status = (int) $m[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [];
|
|
||||||
if ($status === 200 && is_string($body) && $bodyLen > 2) {
|
|
||||||
$decoded = json_decode($body, true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
$data = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => $status,
|
|
||||||
'body_length' => $bodyLen,
|
|
||||||
'data' => $data,
|
|
||||||
'time' => $elapsed,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract unique subdomain names from raw crt.sh JSON response.
|
|
||||||
*
|
|
||||||
* Each entry has a `name_value` field that may contain multiple newline-separated
|
|
||||||
* names, including wildcards. We strip wildcards, filter to our target domain,
|
|
||||||
* and return only the subdomain prefixes (e.g. "www", "mail", "api").
|
|
||||||
*
|
|
||||||
* @param array $crtshData Decoded JSON array from crt.sh
|
|
||||||
* @param string $domain The base domain (e.g. "example.com")
|
|
||||||
* @return string[] Unique subdomain prefixes
|
|
||||||
*/
|
|
||||||
function extractSubdomains(array $crtshData, string $domain): array
|
|
||||||
{
|
|
||||||
$domainLower = strtolower($domain);
|
|
||||||
$suffix = '.' . $domainLower;
|
|
||||||
$suffixLen = strlen($suffix);
|
|
||||||
$subs = [];
|
|
||||||
|
|
||||||
foreach ($crtshData as $entry) {
|
|
||||||
if (empty($entry['name_value'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (explode("\n", $entry['name_value']) as $name) {
|
|
||||||
$name = strtolower(trim($name));
|
|
||||||
|
|
||||||
// Strip wildcard prefix
|
|
||||||
if (strpos($name, '*.') === 0) {
|
|
||||||
$name = substr($name, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip the apex domain itself
|
|
||||||
if ($name === $domainLower) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be a subdomain of our domain
|
|
||||||
if (substr($name, -$suffixLen) !== $suffix) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the subdomain part (everything before .domain.tld)
|
|
||||||
$sub = substr($name, 0, strlen($name) - $suffixLen);
|
|
||||||
if (!empty($sub)) {
|
|
||||||
$subs[$sub] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($subs);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Subprocess management (main process side)
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn a subprocess of this script in --crtsh mode with a hard timeout.
|
|
||||||
* Relays stderr from the subprocess to logMessage in REAL-TIME so you see
|
|
||||||
* every HTTP response, retry, and status as it happens.
|
|
||||||
*
|
|
||||||
* @return array{0: string[], 1: bool} [subdomains, ok (true if server responded 200)]
|
|
||||||
*/
|
|
||||||
function fetchCrtshWithTimeout(string $domainName): array
|
|
||||||
{
|
|
||||||
$phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
|
|
||||||
$cmd = [$phpBin, __FILE__, '--crtsh', $domainName];
|
|
||||||
|
|
||||||
if (CRTSH_MAX_SUBDOMAINS > 0) {
|
|
||||||
$cmd[] = (string) CRTSH_MAX_SUBDOMAINS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$proc = proc_open($cmd, [
|
|
||||||
0 => ['pipe', 'r'],
|
|
||||||
1 => ['pipe', 'w'],
|
|
||||||
2 => ['pipe', 'w'],
|
|
||||||
], $pipes, __DIR__ . '/..');
|
|
||||||
|
|
||||||
if (!is_resource($proc)) {
|
|
||||||
return [[], false];
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($pipes[0]);
|
|
||||||
stream_set_blocking($pipes[1], false);
|
|
||||||
stream_set_blocking($pipes[2], false);
|
|
||||||
|
|
||||||
$start = time();
|
|
||||||
$stdout = '';
|
|
||||||
$stderrBuffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
$status = proc_get_status($proc);
|
|
||||||
|
|
||||||
if (!$status['running']) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$elapsed = time() - $start;
|
|
||||||
|
|
||||||
// Hard timeout — kill the subprocess
|
|
||||||
if ($elapsed >= CRTSH_TIMEOUT_SECONDS) {
|
|
||||||
$stdout .= drainStream($pipes[1]);
|
|
||||||
$stderrBuffer .= drainStream($pipes[2]);
|
|
||||||
flushStderrLines($stderrBuffer);
|
|
||||||
proc_terminate($proc, 9);
|
|
||||||
proc_close($proc);
|
|
||||||
logMessage(" ✗ crt.sh killed after {$elapsed}s (hard timeout)");
|
|
||||||
return [[], false];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read available data from pipes
|
|
||||||
$readable = [$pipes[1], $pipes[2]];
|
|
||||||
$w = $e = null;
|
|
||||||
if (@stream_select($readable, $w, $e, 0, 200000) > 0) {
|
|
||||||
foreach ($readable as $stream) {
|
|
||||||
$chunk = stream_get_contents($stream);
|
|
||||||
if ($stream === $pipes[1]) {
|
|
||||||
$stdout .= $chunk;
|
|
||||||
} else {
|
|
||||||
$stderrBuffer .= $chunk;
|
|
||||||
// Flush complete lines to terminal immediately
|
|
||||||
flushStderrLines($stderrBuffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain remaining output
|
|
||||||
$stdout .= stream_get_contents($pipes[1]);
|
|
||||||
$stderrBuffer .= stream_get_contents($pipes[2]);
|
|
||||||
flushStderrLines($stderrBuffer);
|
|
||||||
fclose($pipes[1]);
|
|
||||||
fclose($pipes[2]);
|
|
||||||
proc_close($proc);
|
|
||||||
|
|
||||||
$decoded = json_decode($stdout, true);
|
|
||||||
$ok = is_array($decoded) && !empty($decoded['ok']);
|
|
||||||
$subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : [];
|
|
||||||
return [$subs, $ok];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush complete lines from stderr buffer to logMessage in real-time.
|
|
||||||
* Keeps any incomplete trailing line in the buffer for next call.
|
|
||||||
*/
|
|
||||||
function flushStderrLines(string &$buffer): void
|
|
||||||
{
|
|
||||||
while (($pos = strpos($buffer, "\n")) !== false) {
|
|
||||||
$line = trim(substr($buffer, 0, $pos));
|
|
||||||
$buffer = substr($buffer, $pos + 1);
|
|
||||||
if ($line !== '') {
|
|
||||||
logMessage(" ↳ $line");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// DNS helpers
|
// DNS helpers
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a domain resolves at all (SOA, A, or AAAA).
|
|
||||||
*/
|
|
||||||
function domainResolves(string $domain): bool
|
|
||||||
{
|
|
||||||
return @checkdnsrr($domain, 'SOA')
|
|
||||||
|| @checkdnsrr($domain, 'A')
|
|
||||||
|| @checkdnsrr($domain, 'AAAA');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
||||||
*/
|
*/
|
||||||
@@ -785,73 +458,29 @@ function sendInAppNotifications(
|
|||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// Logging / formatting helpers
|
// Logging helpers (thin wrappers around CronHelper)
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function logMessage(string $message): void
|
function logMessage(string $message): void
|
||||||
{
|
{
|
||||||
global $logFile;
|
global $cron;
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$cron->log($message);
|
||||||
$line = "[$timestamp] $message\n";
|
|
||||||
file_put_contents($logFile, $line, FILE_APPEND);
|
|
||||||
echo $line;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logTimeSince(float $since): void
|
function logTimeSince(float $since): void
|
||||||
{
|
{
|
||||||
logMessage(" ⏱ " . formatDuration(microtime(true) - $since));
|
global $cron;
|
||||||
}
|
$cron->logTimeSince($since);
|
||||||
|
|
||||||
function formatDuration(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.1fs", $seconds);
|
|
||||||
}
|
|
||||||
$m = (int) floor($seconds / 60);
|
|
||||||
$s = $seconds - $m * 60;
|
|
||||||
return $m . 'm ' . sprintf("%.1fs", $s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.2f seconds", $seconds);
|
|
||||||
}
|
|
||||||
if ($seconds < 3600) {
|
|
||||||
$m = (int) floor($seconds / 60);
|
|
||||||
$s = $seconds - $m * 60;
|
|
||||||
return sprintf("%d minute%s %.2f seconds", $m, $m !== 1 ? 's' : '', $s);
|
|
||||||
}
|
|
||||||
$h = (int) floor($seconds / 3600);
|
|
||||||
$m = (int) floor(($seconds - $h * 3600) / 60);
|
|
||||||
$s = $seconds - $h * 3600 - $m * 60;
|
|
||||||
return sprintf("%d hour%s %d minute%s %.2f seconds",
|
|
||||||
$h, $h !== 1 ? 's' : '', $m, $m !== 1 ? 's' : '', $s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain remaining data from a non-blocking stream and close it.
|
|
||||||
*/
|
|
||||||
function drainStream($stream): string
|
|
||||||
{
|
|
||||||
if (!is_resource($stream)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
$data = stream_get_contents($stream);
|
|
||||||
fclose($stream);
|
|
||||||
return $data ?: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printSummary(array $stats, float $startTime): void
|
function printSummary(array $stats, float $startTime): void
|
||||||
{
|
{
|
||||||
$elapsed = formatElapsedTime(microtime(true) - $startTime);
|
$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime);
|
||||||
|
|
||||||
logMessage("\n=== DNS cron job completed ===");
|
logMessage("\n=== DNS cron job completed ===");
|
||||||
logMessage("Domains checked: {$stats['checked']}");
|
logMessage("Domains checked: {$stats['checked']}");
|
||||||
logMessage("Skipped (by status): {$stats['skipped_by_status']}");
|
logMessage("Skipped (by status): {$stats['skipped_by_status']}");
|
||||||
logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}");
|
logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}");
|
||||||
logMessage("Crt.sh fetched: {$stats['crtsh_fetched']}");
|
|
||||||
logMessage("Crt.sh skipped (cached): {$stats['crtsh_skipped']}");
|
|
||||||
logMessage("Changes detected: {$stats['changes_detected']}");
|
logMessage("Changes detected: {$stats['changes_detected']}");
|
||||||
logMessage("Records added: {$stats['records_added']}");
|
logMessage("Records added: {$stats['records_added']}");
|
||||||
logMessage("Records removed: {$stats['records_removed']}");
|
logMessage("Records removed: {$stats['records_removed']}");
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Models\User;
|
|||||||
use App\Services\WhoisService;
|
use App\Services\WhoisService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\UpdateService;
|
use App\Services\UpdateService;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
use Core\Database;
|
use Core\Database;
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
@@ -56,27 +57,11 @@ try {
|
|||||||
|
|
||||||
// Log file
|
// Log file
|
||||||
$logFile = __DIR__ . '/../logs/cron.log';
|
$logFile = __DIR__ . '/../logs/cron.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
|
|
||||||
function logMessage(string $message) {
|
function logMessage(string $message): void {
|
||||||
global $logFile;
|
global $cron;
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$cron->log($message);
|
||||||
file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND);
|
|
||||||
echo "[$timestamp] $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(float $seconds): string {
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.2f seconds", $seconds);
|
|
||||||
} elseif ($seconds < 3600) {
|
|
||||||
$minutes = floor($seconds / 60);
|
|
||||||
$remainingSeconds = $seconds - ($minutes * 60);
|
|
||||||
return sprintf("%d minute%s %.2f seconds", $minutes, $minutes != 1 ? 's' : '', $remainingSeconds);
|
|
||||||
} else {
|
|
||||||
$hours = floor($seconds / 3600);
|
|
||||||
$remainingMinutes = floor(($seconds - ($hours * 3600)) / 60);
|
|
||||||
$remainingSeconds = $seconds - ($hours * 3600) - ($remainingMinutes * 60);
|
|
||||||
return sprintf("%d hour%s %d minute%s %.2f seconds", $hours, $hours != 1 ? 's' : '', $remainingMinutes, $remainingMinutes != 1 ? 's' : '', $remainingSeconds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record start time
|
// Record start time
|
||||||
@@ -806,7 +791,7 @@ $settingModel->updateLastCheckRun();
|
|||||||
// Calculate elapsed time
|
// Calculate elapsed time
|
||||||
$endTime = microtime(true);
|
$endTime = microtime(true);
|
||||||
$elapsedTime = $endTime - $startTime;
|
$elapsedTime = $endTime - $startTime;
|
||||||
$formattedTime = formatElapsedTime($elapsedTime);
|
$formattedTime = CronHelper::formatElapsedTime($elapsedTime);
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
logMessage("\n=== Cron job completed ===");
|
logMessage("\n=== Cron job completed ===");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Models\User;
|
|||||||
use App\Services\Logger;
|
use App\Services\Logger;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\SslService;
|
use App\Services\SslService;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
use Core\Database;
|
use Core\Database;
|
||||||
|
|
||||||
if (php_sapi_name() !== 'cli') {
|
if (php_sapi_name() !== 'cli') {
|
||||||
@@ -56,6 +57,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$logFile = __DIR__ . '/../logs/ssl_cron.log';
|
$logFile = __DIR__ . '/../logs/ssl_cron.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
$startTime = microtime(true);
|
$startTime = microtime(true);
|
||||||
|
|
||||||
logMessage("=== Starting SSL check cron job ===");
|
logMessage("=== Starting SSL check cron job ===");
|
||||||
@@ -132,7 +134,7 @@ foreach ($domains as $domain) {
|
|||||||
$port = (int)($target['port'] ?? 443);
|
$port = (int)($target['port'] ?? 443);
|
||||||
$endpointLabel = $sslService->formatTargetLabel($hostname, $port);
|
$endpointLabel = $sslService->formatTargetLabel($hostname, $port);
|
||||||
|
|
||||||
if (!hostnameResolves($hostname)) {
|
if (!CronHelper::hostnameResolves($hostname)) {
|
||||||
logMessage(" {$endpointLabel}: skipped (hostname does not resolve)");
|
logMessage(" {$endpointLabel}: skipped (hostname does not resolve)");
|
||||||
$stats['skipped_unresolved']++;
|
$stats['skipped_unresolved']++;
|
||||||
continue;
|
continue;
|
||||||
@@ -231,7 +233,7 @@ logMessage("Issue endpoints: {$stats['issues_detected']}");
|
|||||||
logMessage("External notifications: {$stats['notifications_sent']}");
|
logMessage("External notifications: {$stats['notifications_sent']}");
|
||||||
logMessage("In-app notifications: {$stats['in_app_notifications']}");
|
logMessage("In-app notifications: {$stats['in_app_notifications']}");
|
||||||
logMessage("Errors: {$stats['errors']}");
|
logMessage("Errors: {$stats['errors']}");
|
||||||
logMessage("Execution time: " . formatElapsedTime(microtime(true) - $startTime));
|
logMessage("Execution time: " . CronHelper::formatElapsedTime(microtime(true) - $startTime));
|
||||||
logMessage("============================\n");
|
logMessage("============================\n");
|
||||||
|
|
||||||
exit(0);
|
exit(0);
|
||||||
@@ -350,57 +352,12 @@ function sendInAppSslNotifications(
|
|||||||
|
|
||||||
function logMessage(string $message): void
|
function logMessage(string $message): void
|
||||||
{
|
{
|
||||||
global $logFile;
|
global $cron;
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$cron->log($message);
|
||||||
$line = "[{$timestamp}] {$message}\n";
|
|
||||||
file_put_contents($logFile, $line, FILE_APPEND);
|
|
||||||
echo $line;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logTimeSince(float $since): void
|
function logTimeSince(float $since): void
|
||||||
{
|
{
|
||||||
logMessage(" -> " . formatDuration(microtime(true) - $since));
|
global $cron;
|
||||||
}
|
$cron->logTimeSince($since, ' -> ');
|
||||||
|
|
||||||
function hostnameResolves(string $hostname): bool
|
|
||||||
{
|
|
||||||
return @checkdnsrr($hostname, 'SOA')
|
|
||||||
|| @checkdnsrr($hostname, 'A')
|
|
||||||
|| @checkdnsrr($hostname, 'AAAA');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf('%.1fs', $seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$minutes = (int) floor($seconds / 60);
|
|
||||||
$remaining = $seconds - ($minutes * 60);
|
|
||||||
return $minutes . 'm ' . sprintf('%.1fs', $remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf('%.2f seconds', $seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($seconds < 3600) {
|
|
||||||
$minutes = (int) floor($seconds / 60);
|
|
||||||
$remaining = $seconds - ($minutes * 60);
|
|
||||||
return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
$hours = (int) floor($seconds / 3600);
|
|
||||||
$minutes = (int) floor(($seconds - ($hours * 3600)) / 60);
|
|
||||||
$remaining = $seconds - ($hours * 3600) - ($minutes * 60);
|
|
||||||
return sprintf(
|
|
||||||
'%d hour%s %d minute%s %.2f seconds',
|
|
||||||
$hours,
|
|
||||||
$hours !== 1 ? 's' : '',
|
|
||||||
$minutes,
|
|
||||||
$minutes !== 1 ? 's' : '',
|
|
||||||
$remaining
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
225
cron/discover_dns.php
Normal file
225
cron/discover_dns.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS Discovery Script
|
||||||
|
*
|
||||||
|
* Performs DNS subdomain discovery via brute force wordlist, crt.sh Certificate
|
||||||
|
* Transparency logs, and wildcard detection. Separate from check_dns.php which
|
||||||
|
* only re-checks existing records.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php cron/discover_dns.php — deep scan all domains
|
||||||
|
* php cron/discover_dns.php --domain example.com — deep scan single domain
|
||||||
|
* php cron/discover_dns.php --domain example.com --quick — quick scan single domain
|
||||||
|
*
|
||||||
|
* Crontab (optional, weekly): 0 3 * * 0 /usr/bin/php /path/to/project/cron/discover_dns.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\DnsRecord;
|
||||||
|
use App\Services\DnsService;
|
||||||
|
use App\Services\Logger;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
|
use Core\Database;
|
||||||
|
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
|
$dotenv->load();
|
||||||
|
new Database();
|
||||||
|
|
||||||
|
$domainModel = new Domain();
|
||||||
|
$dnsModel = new DnsRecord();
|
||||||
|
$dnsService = new DnsService();
|
||||||
|
$logger = new Logger('dns-discover');
|
||||||
|
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
try {
|
||||||
|
$appSettings = $settingModel->getAppSettings();
|
||||||
|
date_default_timezone_set($appSettings['app_timezone']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = __DIR__ . '/../logs/dns_discover.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
|
|
||||||
|
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$targetDomain = null;
|
||||||
|
$quickMode = false;
|
||||||
|
|
||||||
|
for ($i = 1; $i < count($argv); $i++) {
|
||||||
|
if ($argv[$i] === '--domain' && isset($argv[$i + 1])) {
|
||||||
|
$targetDomain = $argv[++$i];
|
||||||
|
} elseif ($argv[$i] === '--quick') {
|
||||||
|
$quickMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolve domains to scan ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($targetDomain) {
|
||||||
|
$domainRow = null;
|
||||||
|
foreach ($domainModel->all() as $d) {
|
||||||
|
if (strcasecmp($d['domain_name'], $targetDomain) === 0) {
|
||||||
|
$domainRow = $d;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$domainRow) {
|
||||||
|
$cron->log("Domain not found in database: $targetDomain");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$domains = [$domainRow];
|
||||||
|
} else {
|
||||||
|
$checkableStatuses = ['active', 'expiring_soon'];
|
||||||
|
$allDnsEnabled = array_values(array_filter(
|
||||||
|
$domainModel->where('is_active', 1),
|
||||||
|
static fn($d): bool => ($d['dns_monitoring_enabled'] ?? 1) == 1
|
||||||
|
));
|
||||||
|
$domains = array_values(array_filter($allDnsEnabled, static function ($d) use ($checkableStatuses): bool {
|
||||||
|
$status = strtolower($d['status'] ?? '');
|
||||||
|
return in_array($status, $checkableStatuses, true);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
$modeLabel = $quickMode ? 'Quick Scan' : 'Deep Scan';
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$cron->log("=== DNS Discovery ({$modeLabel}) — " . count($domains) . " domain(s) ===");
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'scanned' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'added' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'removed' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── crt.sh settings (deep scan only) ──────────────────────────────────────
|
||||||
|
|
||||||
|
const CRTSH_TIMEOUT_SECONDS = 1800;
|
||||||
|
const CRTSH_MAX_SUBDOMAINS = 100;
|
||||||
|
|
||||||
|
// ─── Scan loop ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
$domainName = $domain['domain_name'];
|
||||||
|
$domStart = microtime(true);
|
||||||
|
$cron->log("Discovering: $domainName ($modeLabel)");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!CronHelper::hostnameResolves($domainName)) {
|
||||||
|
$cron->log(" ⏭ Domain does not resolve, skipping");
|
||||||
|
$cron->logTimeSince($domStart);
|
||||||
|
$stats['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quickMode) {
|
||||||
|
$newRecords = $dnsService->quickScan($domainName);
|
||||||
|
} else {
|
||||||
|
// Deep scan: gather crt.sh subdomains + existing hosts, then full lookup
|
||||||
|
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
||||||
|
$ctSubs = [];
|
||||||
|
|
||||||
|
$cron->log(" 🔍 crt.sh: fetching subdomains...");
|
||||||
|
[$ctSubs, $crtshOk] = $dnsService->fetchCrtshSubdomains(
|
||||||
|
$domainName,
|
||||||
|
CRTSH_MAX_SUBDOMAINS,
|
||||||
|
CRTSH_TIMEOUT_SECONDS,
|
||||||
|
fn(string $line) => $cron->log(" ↳ $line")
|
||||||
|
);
|
||||||
|
$cron->log(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
||||||
|
|
||||||
|
if ($crtshOk) {
|
||||||
|
$domainModel->update($domain['id'], [
|
||||||
|
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
||||||
|
$newRecords = $dnsService->lookup($domainName, $extraSubs, fn(string $msg) => $cron->log(" 🔎 $msg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||||
|
if ($totalRecords === 0) {
|
||||||
|
$cron->log(" ⚠ No DNS records found");
|
||||||
|
$stats['errors']++;
|
||||||
|
$cron->logTimeSince($domStart);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich IP details
|
||||||
|
$ips = [];
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
foreach ($newRecords[$type] ?? [] as $r) {
|
||||||
|
if (!empty($r['value'])) {
|
||||||
|
$ips[] = $r['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($ips)) {
|
||||||
|
$ipDetails = $dnsService->lookupIpDetails($ips);
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
foreach ($newRecords[$type] as &$rec) {
|
||||||
|
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
||||||
|
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords);
|
||||||
|
$domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
|
$stats['scanned']++;
|
||||||
|
$stats['added'] += $saveStats['added'];
|
||||||
|
$stats['updated'] += $saveStats['updated'];
|
||||||
|
$stats['removed'] += $saveStats['removed'];
|
||||||
|
|
||||||
|
$cron->log(" ✓ $totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})");
|
||||||
|
|
||||||
|
$logger->info("DNS discovery completed", [
|
||||||
|
'domain' => $domainName,
|
||||||
|
'mode' => $quickMode ? 'quick' : 'deep',
|
||||||
|
'records' => $totalRecords,
|
||||||
|
'added' => $saveStats['added'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cron->logTimeSince($domStart);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$cron->log(" ✗ Error: " . $e->getMessage());
|
||||||
|
$cron->logTimeSince($domStart);
|
||||||
|
$logger->error("DNS discovery failed", [
|
||||||
|
'domain' => $domainName,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$stats['errors']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime);
|
||||||
|
$cron->log("\n=== DNS Discovery completed ===");
|
||||||
|
$cron->log("Domains scanned: {$stats['scanned']}");
|
||||||
|
$cron->log("Skipped: {$stats['skipped']}");
|
||||||
|
$cron->log("Records added: {$stats['added']}");
|
||||||
|
$cron->log("Records updated: {$stats['updated']}");
|
||||||
|
$cron->log("Records removed: {$stats['removed']}");
|
||||||
|
$cron->log("Errors: {$stats['errors']}");
|
||||||
|
$cron->log("Execution time: $elapsed");
|
||||||
|
$cron->log("==========================\n");
|
||||||
|
|
||||||
|
exit(0);
|
||||||
@@ -360,6 +360,7 @@ CREATE TABLE IF NOT EXISTS dns_records (
|
|||||||
ttl INT NULL,
|
ttl INT NULL,
|
||||||
priority INT NULL COMMENT 'MX priority',
|
priority INT NULL COMMENT 'MX priority',
|
||||||
is_cloudflare BOOLEAN DEFAULT FALSE,
|
is_cloudflare BOOLEAN DEFAULT FALSE,
|
||||||
|
source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered',
|
||||||
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
||||||
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
7
database/migrations/029_add_dns_record_source.sql
Normal file
7
database/migrations/029_add_dns_record_source.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add source tracking to DNS records (discovered vs manual vs imported)
|
||||||
|
ALTER TABLE dns_records
|
||||||
|
ADD COLUMN source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered'
|
||||||
|
AFTER is_cloudflare;
|
||||||
|
|
||||||
|
INSERT INTO migrations (migration) VALUES ('029_add_dns_record_source.sql')
|
||||||
|
ON DUPLICATE KEY UPDATE migration=migration;
|
||||||
@@ -86,6 +86,11 @@ $router->post('/domains/{id}/update', [DomainController::class, 'update']);
|
|||||||
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
|
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
|
||||||
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
||||||
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
|
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
|
||||||
|
$router->post('/domains/{id}/discover-dns', [DomainController::class, 'discoverDns']);
|
||||||
|
$router->post('/domains/{id}/dns-records', [DomainController::class, 'addDnsRecord']);
|
||||||
|
$router->post('/domains/{id}/dns-records/bulk-delete', [DomainController::class, 'bulkDeleteDnsRecords']);
|
||||||
|
$router->post('/domains/{id}/dns-records/{recordId}/delete', [DomainController::class, 'deleteDnsRecord']);
|
||||||
|
$router->post('/domains/{id}/dns-import', [DomainController::class, 'importDnsZone']);
|
||||||
$router->post('/domains/{id}/ssl/add', [DomainController::class, 'addSslHost']);
|
$router->post('/domains/{id}/ssl/add', [DomainController::class, 'addSslHost']);
|
||||||
$router->post('/domains/{id}/ssl/refresh-all', [DomainController::class, 'refreshAllSsl']);
|
$router->post('/domains/{id}/ssl/refresh-all', [DomainController::class, 'refreshAllSsl']);
|
||||||
$router->post('/domains/{id}/ssl/bulk-refresh', [DomainController::class, 'bulkRefreshSsl']);
|
$router->post('/domains/{id}/ssl/bulk-refresh', [DomainController::class, 'bulkRefreshSsl']);
|
||||||
|
|||||||
Reference in New Issue
Block a user