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:
90
app/Helpers/CronHelper.php
Normal file
90
app/Helpers/CronHelper.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
/**
|
||||
* Shared utilities for cron scripts (logging, formatting, DNS checks).
|
||||
*
|
||||
* Replaces the standalone functions that were duplicated across
|
||||
* check_dns.php, check_ssl.php, and check_domains.php.
|
||||
*/
|
||||
class CronHelper
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
public function __construct(string $logFile)
|
||||
{
|
||||
$this->logFile = $logFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a timestamped message to the log file and echo it.
|
||||
*/
|
||||
public function log(string $message): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$line = "[{$timestamp}] {$message}\n";
|
||||
file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||
echo $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log elapsed time since a given microtime start.
|
||||
*/
|
||||
public function logTimeSince(float $since, string $prefix = ' ⏱ '): void
|
||||
{
|
||||
$this->log($prefix . self::formatDuration(microtime(true) - $since));
|
||||
}
|
||||
|
||||
/**
|
||||
* Short human-readable duration: "3.2s" or "2m 14.1s".
|
||||
*/
|
||||
public static function formatDuration(float $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return sprintf('%.1fs', $seconds);
|
||||
}
|
||||
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
$remaining = $seconds - ($minutes * 60);
|
||||
return $minutes . 'm ' . sprintf('%.1fs', $remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbose elapsed time: "3.25 seconds", "2 minutes 14.25 seconds", etc.
|
||||
*/
|
||||
public static function formatElapsedTime(float $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return sprintf('%.2f seconds', $seconds);
|
||||
}
|
||||
|
||||
if ($seconds < 3600) {
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
$remaining = $seconds - ($minutes * 60);
|
||||
return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining);
|
||||
}
|
||||
|
||||
$hours = (int) floor($seconds / 3600);
|
||||
$minutes = (int) floor(($seconds - ($hours * 3600)) / 60);
|
||||
$remaining = $seconds - ($hours * 3600) - ($minutes * 60);
|
||||
return sprintf(
|
||||
'%d hour%s %d minute%s %.2f seconds',
|
||||
$hours,
|
||||
$hours !== 1 ? 's' : '',
|
||||
$minutes,
|
||||
$minutes !== 1 ? 's' : '',
|
||||
$remaining
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a hostname resolves at all (SOA, A, or AAAA).
|
||||
*/
|
||||
public static function hostnameResolves(string $hostname): bool
|
||||
{
|
||||
return @checkdnsrr($hostname, 'SOA')
|
||||
|| @checkdnsrr($hostname, 'A')
|
||||
|| @checkdnsrr($hostname, 'AAAA');
|
||||
}
|
||||
}
|
||||
@@ -89,16 +89,17 @@ class DomainHelper
|
||||
|
||||
/**
|
||||
* Get CSS class for expiry date styling
|
||||
* Includes dark: variants for visibility on dark theme
|
||||
*/
|
||||
private static function getExpiryClass(?int $daysLeft): string
|
||||
{
|
||||
if ($daysLeft === null) return '';
|
||||
|
||||
if ($daysLeft < 0) return 'text-red-600 font-semibold';
|
||||
if ($daysLeft <= 30) return 'text-orange-600 font-semibold';
|
||||
if ($daysLeft <= 90) return 'text-yellow-600';
|
||||
if ($daysLeft < 0) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
if ($daysLeft <= 30) return 'text-orange-600 dark:text-orange-400 font-semibold';
|
||||
if ($daysLeft <= 90) return 'text-yellow-600 dark:text-yellow-400';
|
||||
|
||||
return '';
|
||||
return 'text-gray-600 dark:text-slate-400';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -507,9 +507,14 @@ class EmailHelper
|
||||
|
||||
/**
|
||||
* Get email subject based on data
|
||||
* Uses explicit subject when provided (DNS, SSL, etc.), otherwise domain expiration logic
|
||||
*/
|
||||
public static function getEmailSubject(array $data): string
|
||||
{
|
||||
if (!empty($data['subject'])) {
|
||||
return $data['subject'];
|
||||
}
|
||||
|
||||
if (isset($data['domain'])) {
|
||||
$daysLeft = $data['days_left'] ?? null;
|
||||
if ($daysLeft === null) {
|
||||
|
||||
@@ -17,17 +17,90 @@ class InputValidator
|
||||
*/
|
||||
public static function validateDomain(string $domain): bool
|
||||
{
|
||||
// Check length (max 253 characters per RFC 1035)
|
||||
if (strlen($domain) > 253 || strlen($domain) < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
// Allows: example.com, sub.example.com, example.co.uk
|
||||
// Pattern: alphanumeric with hyphens, dots between labels, valid TLD
|
||||
return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user