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:
Hosteroid
2026-03-10 22:54:28 +02:00
parent 5365af00fd
commit a265a58456
46 changed files with 3130 additions and 1494 deletions

View File

@@ -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.
*/ */

View File

@@ -59,8 +59,9 @@ 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 {
$pdo = \Core\Database::getConnection(); $pdo = \Core\Database::getConnection();
@@ -204,9 +205,10 @@ 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',
]; ];
} }
// If no migrations executed and no data - fresh install (use consolidated) // If no migrations executed and no data - fresh install (use consolidated)
if (empty($executed)) { if (empty($executed)) {
return $freshInstallMigration; return $freshInstallMigration;
@@ -429,8 +431,9 @@ 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");
foreach ($allIndividualMigrations as $migration) { foreach ($allIndividualMigrations as $migration) {
try { try {
@@ -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';

View File

@@ -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');
} }

View File

@@ -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');
} }
} }

View 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');
}
}

View File

@@ -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';
} }
/** /**

View File

@@ -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) {

View File

@@ -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
* *

View File

@@ -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;
}
} }

View File

@@ -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
*/ */

View File

@@ -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;

View File

@@ -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()
] ]

View File

@@ -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';

View File

@@ -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']}"
]
]
]; ];
} }

View File

@@ -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')

View File

@@ -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, 'domain' => $domain,
'total_records' => $totalRecords, '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,
'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];
if ($maxSubdomains > 0) {
$cmd[] = (string) $maxSubdomains;
}
$projectRoot = dirname(__DIR__, 2);
$proc = proc_open($cmd, [
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];
}
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;
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);
}
$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', [
'domain' => $domain,
'subdomains_found' => count($subs),
'server_ok' => $ok,
]);
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([ $ctx = stream_context_create([
'http' => [ 'http' => [
'timeout' => 30, 'timeout' => $timeout,
'ignore_errors' => true, 'ignore_errors' => true,
'header' => "User-Agent: DomainMonitor/1.0\r\n", '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',
'ssl' => [ 'Accept: application/json, text/plain, */*',
'verify_peer' => false, 'Accept-Language: en-US,en;q=0.9',
'verify_peer_name' => false, 'Connection: keep-alive',
]),
], ],
]); ]);
$json = @file_get_contents($url, false, $ctx); $start = microtime(true);
if ($json === false) { $http_response_header = null;
$this->logger->warning('crt.sh request failed', ['domain' => $domain]); $body = @file_get_contents($url, false, $ctx);
return []; $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");
} }
$entries = @json_decode($json, true); $status = 0;
if (!is_array($entries)) { if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
return []; $status = (int) $m[0];
} }
$subdomains = []; $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); $domainLower = strtolower($domain);
$suffix = '.' . $domainLower;
$suffixLen = strlen($suffix);
$subs = [];
foreach ($entries as $entry) { foreach ($crtshData as $entry) {
$name = $entry['name_value'] ?? ''; if (empty($entry['name_value'])) {
foreach (explode("\n", $name) as $n) { continue;
$n = strtolower(trim($n)); }
$n = ltrim($n, '*.');
if (empty($n)) continue;
if ($n === $domainLower) continue; foreach (explode("\n", $entry['name_value']) as $name) {
$name = strtolower(trim($name));
if (str_ends_with($n, '.' . $domainLower)) { if (strpos($name, '*.') === 0) {
$sub = str_replace('.' . $domainLower, '', $n); $name = substr($name, 2);
if ($sub !== '' && !isset($subdomains[$sub])) { }
$subdomains[$sub] = true;
} if ($name === $domainLower) {
continue;
}
if (substr($name, -$suffixLen) !== $suffix) {
continue;
}
$sub = substr($name, 0, strlen($name) - $suffixLen);
if (!empty($sub)) {
$subs[$sub] = true;
} }
} }
} }
$result = array_keys($subdomains); return array_keys($subs);
$this->logger->info('crt.sh discovery completed', [ }
'domain' => $domain,
'subdomains_found' => count($result),
]);
return $result; /**
* 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 ?: '';
} }
// ======================================================================== // ========================================================================

View File

@@ -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">

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 %}

View File

@@ -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">
{{ csrf_field()|raw }} <form method="POST" action="/domains/{{ domain.id }}/discover-dns" class="inline">
<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"> {{ csrf_field()|raw }}
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i> <input type="hidden" name="mode" value="quick">
<span class="btn-label">Refresh DNS</span> <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">
<i class="fas fa-bolt mr-1.5" style="font-size: 10px;"></i>Quick Scan
</button>
</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>
</form> <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 %}
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)"> <div class="flex items-center gap-2 flex-wrap">
{{ csrf_field()|raw }} {# Refresh DNS (re-check existing only) #}
<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"> <form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i> {{ csrf_field()|raw }}
<span class="btn-label">Refresh DNS</span> <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>
<span class="btn-label">Refresh DNS</span>
</button>
</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> </button>
</form>
{# 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">
{% for record in dnsRecords['TXT'] %} <table class="min-w-full">
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600"> <thead class="bg-gray-50 dark:bg-slate-900">
<div class="flex items-start"> <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'] %}
{% 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' %}
{% endfor %} <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 %}
</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>

View File

@@ -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`);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 &middot; 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 %}

View File

@@ -372,6 +372,8 @@
{% endif %} {% endif %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
{% include 'partials/confirm-modal.twig' %}
</body> </body>
</html> </html>

View File

@@ -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 %}

View 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>

View 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 }} &middot; 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>

View 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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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>

View File

@@ -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() {

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 %}

View File

@@ -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 &middot; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 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 %}

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); };
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 %}

View File

@@ -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 &middot; 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 %}

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 %}

View File

@@ -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,39 +137,10 @@ 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 $totalRecords = array_sum(array_map('count', $newRecords));
$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));
if ($totalRecords === 0) { if ($totalRecords === 0) {
logMessage(" ⚠ No DNS records found for $domainName"); logMessage(" ⚠ No DNS records found for $domainName");
@@ -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']}");

View File

@@ -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 ===");

View File

@@ -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
View 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);

View File

@@ -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,

View 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;

View File

@@ -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']);