Compare commits
10 Commits
8559e903b9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a0328d69f6 | |||
|
|
6145e45059 | ||
|
|
3e173644c7 | ||
|
|
62a034c0db | ||
|
|
27f036eee1 | ||
|
|
e3006738a9 | ||
|
|
36abf58838 | ||
|
|
a265a58456 | ||
|
|
5365af00fd | ||
|
|
5916daa293 |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.github
|
||||
vendor/
|
||||
.env
|
||||
.env.*
|
||||
logs/*.log
|
||||
logs/*.txt
|
||||
cache/*
|
||||
!cache/.gitkeep
|
||||
domain-monitor-docker/
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
*.tmp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
node_modules/
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Copy to .env and fill in real values before running docker compose up
|
||||
|
||||
DB_DATABASE=domain_monitor
|
||||
DB_USERNAME=domain_monitor
|
||||
DB_PASSWORD=changeme
|
||||
DB_ROOT_PASSWORD=rootchangeme
|
||||
|
||||
# Timezone (e.g. Europe/Bucharest)
|
||||
TZ=UTC
|
||||
|
||||
# Set to 'development' to show detailed error pages
|
||||
APP_ENV=production
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -5,6 +5,42 @@ All notable changes to Domain Monitor will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.5] - 2026-03-08
|
||||
|
||||
### Added
|
||||
- **Twig Templating** - All PHP views migrated to Twig; `twig/twig` added as dependency
|
||||
- **Twig-Only Rendering** - Removed legacy PHP view fallbacks; ErrorHandler renders via Twig with safe escaped HTML fallback on failure
|
||||
- **DNS Monitoring** - Track DNS record changes with DnsService (lookup, crt.sh discovery, Cloudflare detection, IP enrichment), DnsRecord model for snapshots and diffs, per-domain `dns_monitoring_enabled` toggle, manual and scheduled refresh via `cron/check_dns.php`
|
||||
- **SSL Certificate Monitoring** - Track TLS certificates with SslService and SslCertificate model; validity, expiry, issuer, SAN list; per-domain `ssl_monitoring_enabled`; add/refresh/delete endpoints for root and custom hostnames/ports; `cron/check_ssl.php` for scheduled checks
|
||||
- **Domain View Tabs** - New tabbed domain view: Overview, DNS, Billing, Notifications, SSL, WHOIS
|
||||
- **Domain View Template Setting** - Choose `detailed` (tabbed) or `legacy` view per installation
|
||||
- **Cron Staleness Warnings** - Settings shows warnings when domain/DNS/SSL cron runs are overdue
|
||||
- **Timezone on Installer Routes** - App timezone now applied even on `/install` and `/install/update` when installed, so upgrade notifications use correct timezone
|
||||
|
||||
### Changed
|
||||
- **2FA Flows** - Twig templates for setup, verify, backup-codes; TwoFactorService silences deprecated QR code warnings
|
||||
- **Settings Page** - Timezone lists, notification preset selection, cron path display, cached update state, rollback availability
|
||||
- **Avatar and 2FA Data in Controllers** - ProfileController and UserController pass avatar and two-factor info to Twig views
|
||||
- **EmailHelper** - Safer subject handling
|
||||
- **TldRegistry** - Search improvements
|
||||
- **Domain Sorting** - Uses effective status (e.g. `expiring_soon`) for ordering
|
||||
- **Discord Channel** - Null-safe field handling
|
||||
|
||||
### Technical
|
||||
- **Core** - `Core\TwigService` for Twig rendering; Controller and Router always use Twig
|
||||
- **Models** - `DnsRecord`, `SslCertificate`
|
||||
- **Services** - `DnsService` (DNS lookup, crt.sh, Cloudflare detection), `SslService` (certificate fetch and parsing)
|
||||
- **DomainController** - `performWhoisRefresh`/`performDnsRefresh`, `refreshWhois`, `refreshDns`, `refreshAll`; SSL endpoints: `addSslHost`, `refreshAllSsl`, `bulkRefreshSsl`, `bulkDeleteSsl`, `refreshSsl`, `deleteSsl`
|
||||
- **NotificationService** - Notifications when DNS or SSL monitoring is toggled
|
||||
- **domains table** - `dns_last_checked`, `dns_monitoring_enabled`, `crtsh_last_fetched`, `ssl_last_checked`, `ssl_monitoring_enabled`
|
||||
- **settings** - `domain_view_template`, `dns_check_interval_hours`, `last_dns_check_run`, `ssl_check_interval_hours`, `last_ssl_check_run`
|
||||
|
||||
### Migrations
|
||||
- `027_add_dns_monitoring.sql` - dns_records table, domain columns, DNS cron settings
|
||||
- `028_add_ssl_monitoring.sql` - ssl_certificates table, domain columns, SSL cron settings, app version 1.1.5
|
||||
|
||||
---
|
||||
|
||||
## [1.1.4] - 2026-03-02
|
||||
|
||||
### Added
|
||||
@@ -471,8 +507,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- [x] Export functionality (CSV, JSON) (completed - v1.1.3, TLD Registry - v1.1.4)
|
||||
- [x] Import domains from CSV/JSON (completed - v1.1.3, TLD Registry - v1.1.4)
|
||||
- [ ] Domain transfer tracking
|
||||
- [ ] DNS record monitoring
|
||||
- [ ] SSL certificate monitoring
|
||||
- [x] DNS record monitoring (completed - v1.1.5)
|
||||
- [x] SSL certificate monitoring (completed - v1.1.5)
|
||||
- [ ] Downtime monitoring
|
||||
- [x] 2FA for login (completed - v1.1.0)
|
||||
- [ ] Mobile app
|
||||
@@ -491,6 +527,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Version History
|
||||
|
||||
### 1.1.5 (2026-03-08)
|
||||
- **Twig Templating** - All PHP views migrated to Twig; Twig-only rendering with safe error fallback
|
||||
- **DNS Monitoring** - DnsService, DnsRecord model, crt.sh discovery, Cloudflare detection, per-domain toggle, `cron/check_dns.php`
|
||||
- **SSL Certificate Monitoring** - SslService, SslCertificate model, add/refresh/delete endpoints, `cron/check_ssl.php`
|
||||
- **Domain View Tabs** - Overview, DNS, Billing, Notifications, SSL, WHOIS; `domain_view_template` setting (detailed/legacy)
|
||||
- **Cron Staleness Warnings** - Settings shows overdue warnings for domain/DNS/SSL cron runs
|
||||
- **Timezone on Installer** - App timezone applied on `/install` and `/install/update` when installed
|
||||
- **2FA/Settings** - Twig templates for 2FA, timezone lists, notification presets, cron path in Settings
|
||||
- Migrations: `027_add_dns_monitoring.sql`, `028_add_ssl_monitoring.sql`
|
||||
|
||||
### 1.1.4 (2026-03-02)
|
||||
- **TLD Registry Import & Export** - CSV/JSON export/import for TLD entries with WHOIS, RDAP, registry URL data
|
||||
- **Manual TLD Creation** - Modal form to add custom TLDs with multi-level support (.co.uk, .co.za, .com.au)
|
||||
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
FROM php:apache
|
||||
|
||||
# Build-time deps for PHP extensions
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libicu-dev libzip-dev libpng-dev libjpeg62-turbo-dev libfreetype6-dev \
|
||||
libxml2-dev libcurl4-openssl-dev libonig-dev pkg-config unzip git tzdata \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
pdo pdo_mysql mysqli intl zip gd bcmath mbstring curl xml \
|
||||
&& a2enmod rewrite headers \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Composer from official image
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Configure Apache to listen on port 7876
|
||||
RUN sed -i 's/Listen 80/Listen 7876/' /etc/apache2/ports.conf \
|
||||
&& sed -i 's/<VirtualHost \*:80>/<VirtualHost *:7876>/' /etc/apache2/sites-available/000-default.conf
|
||||
|
||||
# Set Apache DocumentRoot to /var/www/html/public
|
||||
RUN sed -ri -e 's!/var/www/html!/var/www/html/public!g' /etc/apache2/sites-available/000-default.conf
|
||||
|
||||
# Apache request timeout
|
||||
RUN echo "Timeout 300" > /etc/apache2/conf-available/timeouts.conf && a2enconf timeouts
|
||||
|
||||
# PHP config
|
||||
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy application source
|
||||
COPY . .
|
||||
|
||||
# Install Composer dependencies
|
||||
RUN composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader
|
||||
|
||||
# Base permissions: code owned root:www-data, writable dirs owned www-data
|
||||
RUN chown -R root:www-data /var/www/html \
|
||||
&& find /var/www/html -type d -exec chmod 755 {} \; \
|
||||
&& find /var/www/html -type f -exec chmod 644 {} \; \
|
||||
&& mkdir -p logs cache public/assets/uploads/avatars \
|
||||
&& chown -R www-data:www-data logs cache public/assets/uploads \
|
||||
&& chmod -R 775 logs cache public/assets/uploads \
|
||||
&& chmod 775 /var/www/html
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 7876
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -156,6 +156,7 @@ class AuthController extends Controller
|
||||
$_SESSION['email'] = $user['email'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
$_SESSION['2fa_required'] = true;
|
||||
$_SESSION['pending_remember'] = $remember;
|
||||
|
||||
// Clear any existing session messages before redirecting to 2FA
|
||||
unset($_SESSION['error']);
|
||||
@@ -172,6 +173,9 @@ class AuthController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Regenerate session ID to prevent session fixation
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Login successful - create session
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
@@ -706,6 +710,14 @@ class AuthController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper for creating remember token (used by TwoFactorController after 2FA)
|
||||
*/
|
||||
public function createRememberTokenPublic(int $userId): void
|
||||
{
|
||||
$this->createRememberToken($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create remember me token linked to current session
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,8 +58,10 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_monitoring.sql',
|
||||
'028_add_ssl_monitoring.sql',
|
||||
'029_add_dns_record_source.sql',
|
||||
];
|
||||
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
|
||||
@@ -202,9 +204,11 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_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 (empty($executed)) {
|
||||
return $freshInstallMigration;
|
||||
@@ -311,6 +315,9 @@ class InstallerController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
$this->verifyCsrf('/install');
|
||||
|
||||
// Block re-installation if already installed
|
||||
if ($this->isInstalled()) {
|
||||
$_SESSION['error'] = 'System is already installed. Use the update function instead.';
|
||||
@@ -360,11 +367,11 @@ class InstallerController extends Controller
|
||||
$file = __DIR__ . '/../../database/migrations/000_initial_schema_v1.1.0.sql';
|
||||
$sql = file_get_contents($file);
|
||||
|
||||
// Replace admin credentials
|
||||
// Replace admin credentials (use PDO::quote to prevent SQL injection)
|
||||
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
|
||||
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql);
|
||||
$sql = str_replace('{{ADMIN_USERNAME}}', $adminUsername, $sql);
|
||||
$sql = str_replace('{{ADMIN_EMAIL}}', $adminEmail, $sql);
|
||||
$sql = str_replace("'{{ADMIN_PASSWORD_HASH}}'", $pdo->quote($passwordHash), $sql);
|
||||
$sql = str_replace("'{{ADMIN_USERNAME}}'", $pdo->quote($adminUsername), $sql);
|
||||
$sql = str_replace("'{{ADMIN_EMAIL}}'", $pdo->quote($adminEmail), $sql);
|
||||
|
||||
// Execute the entire consolidated schema at once
|
||||
// This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS
|
||||
@@ -426,8 +433,10 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_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");
|
||||
foreach ($allIndividualMigrations as $migration) {
|
||||
try {
|
||||
@@ -578,6 +587,9 @@ class InstallerController extends Controller
|
||||
$this->redirect('/install/update');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
$this->verifyCsrf('/install/update');
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
@@ -661,7 +673,9 @@ class InstallerController extends Controller
|
||||
|
||||
// Fallback: detect "to" version from which migrations were run
|
||||
if ($toVersion === $fromVersion) {
|
||||
if (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
||||
if (in_array('029_add_dns_record_source.sql', $executed)) {
|
||||
$toVersion = '1.1.5';
|
||||
} elseif (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
||||
$toVersion = '1.1.4';
|
||||
} elseif (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
|
||||
$toVersion = '1.1.3';
|
||||
|
||||
@@ -687,6 +687,13 @@ class NotificationGroupController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = $this->channelModel->find($id);
|
||||
if (!$channel || (int)$channel['notification_group_id'] !== (int)$groupId) {
|
||||
$_SESSION['error'] = 'Channel not found';
|
||||
$this->redirect("/groups/$groupId/edit");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->channelModel->delete($id);
|
||||
$_SESSION['success'] = 'Channel deleted successfully';
|
||||
@@ -714,6 +721,13 @@ class NotificationGroupController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = $this->channelModel->find($id);
|
||||
if (!$channel || (int)$channel['notification_group_id'] !== (int)$groupId) {
|
||||
$_SESSION['error'] = 'Channel not found';
|
||||
$this->redirect("/groups/$groupId/edit");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->channelModel->toggleActive($id);
|
||||
$_SESSION['success'] = 'Channel status updated';
|
||||
@@ -1065,16 +1079,35 @@ class NotificationGroupController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = new \App\Services\Logger('transfer');
|
||||
|
||||
try {
|
||||
// Transfer group
|
||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||
|
||||
// Also transfer all domains in this group
|
||||
$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']}";
|
||||
} 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.';
|
||||
}
|
||||
|
||||
@@ -1113,25 +1146,51 @@ class NotificationGroupController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$domainModel = new \App\Models\Domain();
|
||||
$tagModel = new \App\Models\Tag();
|
||||
$logger = new \App\Services\Logger('transfer');
|
||||
|
||||
$transferred = 0;
|
||||
foreach ($groupIds as $groupId) {
|
||||
$groupId = (int)$groupId;
|
||||
if ($groupId > 0) {
|
||||
try {
|
||||
// Transfer group
|
||||
$group = $this->groupModel->find($groupId);
|
||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||
|
||||
// Also transfer all domains in this group
|
||||
$domainModel = new \App\Models\Domain();
|
||||
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||
$domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $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++;
|
||||
} 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']}";
|
||||
$this->redirect('/groups');
|
||||
}
|
||||
|
||||
@@ -570,11 +570,19 @@ class TagController extends Controller
|
||||
'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', [
|
||||
'tag' => $tag,
|
||||
'domains' => $paginatedDomains,
|
||||
'filters' => $filters,
|
||||
'pagination' => $pagination
|
||||
'pagination' => $pagination,
|
||||
'users' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -770,6 +778,12 @@ class TagController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tag['user_id'] === null) {
|
||||
$_SESSION['error'] = 'Global tags cannot be transferred';
|
||||
$this->redirect('/tags');
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new \App\Models\User();
|
||||
$targetUser = $userModel->find($targetUserId);
|
||||
if (!$targetUser) {
|
||||
@@ -778,9 +792,28 @@ class TagController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = new \App\Services\Logger('transfer');
|
||||
|
||||
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']}";
|
||||
} 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.';
|
||||
}
|
||||
|
||||
@@ -818,18 +851,50 @@ class TagController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = new \App\Services\Logger('transfer');
|
||||
|
||||
$transferred = 0;
|
||||
$skippedGlobal = 0;
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tagId = (int)$tagId;
|
||||
if ($tagId > 0) {
|
||||
$tag = $this->tagModel->find($tagId);
|
||||
if ($tag && $tag['user_id'] === null) {
|
||||
$skippedGlobal++;
|
||||
continue;
|
||||
}
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$_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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,8 +275,13 @@ class TwoFactorController extends Controller
|
||||
$this->twoFactorService->recordAttempt($userId, $ipAddress, $verified);
|
||||
|
||||
if ($verified) {
|
||||
// Regenerate session ID to prevent session fixation
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Clear 2FA requirement and complete login
|
||||
$pendingRemember = !empty($_SESSION['pending_remember']);
|
||||
unset($_SESSION['2fa_required']);
|
||||
unset($_SESSION['pending_remember']);
|
||||
|
||||
// Determine which method was used
|
||||
$method = 'unknown';
|
||||
@@ -296,6 +301,12 @@ class TwoFactorController extends Controller
|
||||
'method' => $method
|
||||
]);
|
||||
|
||||
// Handle remember me (carried over from login form)
|
||||
if ($pendingRemember) {
|
||||
$authController = new \App\Controllers\AuthController();
|
||||
$authController->createRememberTokenPublic($userId);
|
||||
}
|
||||
|
||||
// Update last login timestamp
|
||||
$this->userModel->updateLastLogin($userId);
|
||||
|
||||
@@ -335,6 +346,8 @@ class TwoFactorController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/2fa/verify');
|
||||
|
||||
try {
|
||||
// Check if user is in 2FA verification state
|
||||
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {
|
||||
|
||||
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
|
||||
*
|
||||
|
||||
@@ -80,10 +80,12 @@ class DnsRecord extends Model
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
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];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
@@ -108,27 +110,26 @@ class DnsRecord extends Model
|
||||
$stats['updated']++;
|
||||
} else {
|
||||
$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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$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();
|
||||
$stats['added']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove records that no longer exist
|
||||
// Only auto-remove stale discovered records — manual/imported records are never auto-deleted
|
||||
if (!empty($seenIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
||||
$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));
|
||||
$stats['removed'] = $deleteStmt->rowCount();
|
||||
} else {
|
||||
// No records found at all — remove everything
|
||||
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
||||
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered'");
|
||||
$deleteStmt->execute([$domainId]);
|
||||
$stats['removed'] = $deleteStmt->rowCount();
|
||||
}
|
||||
@@ -213,4 +214,82 @@ class DnsRecord extends Model
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,9 @@ class ErrorLog extends Model
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sortColumn = $filters['sort'];
|
||||
|
||||
$allowedSort = ['error_id', 'error_type', 'error_message', 'is_resolved', 'occurred_at', 'last_occurred_at', 'occurrences'];
|
||||
$sortColumn = in_array($filters['sort'], $allowedSort, true) ? $filters['sort'] : 'last_occurred_at';
|
||||
$sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
$query = "
|
||||
|
||||
@@ -122,7 +122,7 @@ class Setting extends Model
|
||||
*/
|
||||
public function getAppVersion(): string
|
||||
{
|
||||
return $this->getValue('app_version', '1.1.4');
|
||||
return $this->getValue('app_version', '1.1.5');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
166
app/Models/SslCertificate.php
Normal file
166
app/Models/SslCertificate.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Core\Model;
|
||||
|
||||
class SslCertificate extends Model
|
||||
{
|
||||
protected static string $table = 'ssl_certificates';
|
||||
|
||||
/**
|
||||
* Get all monitored SSL certificates for a domain.
|
||||
*/
|
||||
public function getByDomain(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC, port ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count monitored SSL certificates for a domain.
|
||||
*/
|
||||
public function countByDomain(int $domainId): int
|
||||
{
|
||||
$stmt = $this->db->prepare("SELECT COUNT(*) FROM ssl_certificates WHERE domain_id = ?");
|
||||
$stmt->execute([$domainId]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct monitored hostnames for a domain.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getDistinctHosts(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT DISTINCT hostname FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return array_column($stmt->fetchAll(), 'hostname');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct monitored SSL targets for a domain.
|
||||
*
|
||||
* @return array<int,array{hostname:string,port:int}>
|
||||
*/
|
||||
public function getDistinctTargets(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT DISTINCT hostname, port FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC, port ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
|
||||
return array_map(
|
||||
static fn(array $row): array => [
|
||||
'hostname' => strtolower($row['hostname']),
|
||||
'port' => (int)$row['port'],
|
||||
],
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a monitored SSL certificate by domain and host.
|
||||
*/
|
||||
public function findByDomainAndHost(int $domainId, string $hostname, int $port = 443): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? AND hostname = ? AND port = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, strtolower($hostname), $port]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a monitored SSL certificate by domain and id.
|
||||
*/
|
||||
public function findByDomainAndId(int $domainId, int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? AND id = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, $id]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the latest SSL snapshot for a monitored host.
|
||||
* Creates the row if it does not exist.
|
||||
*/
|
||||
public function saveSnapshot(int $domainId, string $hostname, array $snapshot, int $port = 443): int
|
||||
{
|
||||
$hostname = strtolower($hostname);
|
||||
$existing = $this->findByDomainAndHost($domainId, $hostname, $port);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$data = [
|
||||
'domain_id' => $domainId,
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'status' => $snapshot['status'] ?? 'invalid',
|
||||
'is_trusted' => !empty($snapshot['is_trusted']) ? 1 : 0,
|
||||
'is_self_signed' => !empty($snapshot['is_self_signed']) ? 1 : 0,
|
||||
'valid_from' => $snapshot['valid_from'] ?? null,
|
||||
'valid_to' => $snapshot['valid_to'] ?? null,
|
||||
'days_remaining' => $snapshot['days_remaining'] ?? null,
|
||||
'issuer_name' => $snapshot['issuer_name'] ?? null,
|
||||
'subject_name' => $snapshot['subject_name'] ?? null,
|
||||
'serial_number' => $snapshot['serial_number'] ?? null,
|
||||
'signature_algorithm' => $snapshot['signature_algorithm'] ?? null,
|
||||
'key_bits' => $snapshot['key_bits'] ?? null,
|
||||
'key_type' => $snapshot['key_type'] ?? null,
|
||||
'certificate_version' => $snapshot['certificate_version'] ?? null,
|
||||
'san_list' => isset($snapshot['san_list']) ? json_encode($snapshot['san_list']) : null,
|
||||
'last_checked' => $snapshot['last_checked'] ?? $now,
|
||||
'last_error' => $snapshot['last_error'] ?? null,
|
||||
'raw_data' => isset($snapshot['raw_data']) ? json_encode($snapshot['raw_data']) : null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$update = $data;
|
||||
unset($update['domain_id'], $update['hostname'], $update['port']);
|
||||
$this->update($existing['id'], $update);
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
|
||||
$data['created_at'] = $now;
|
||||
return $this->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitored SSL certificate by domain and id.
|
||||
*/
|
||||
public function deleteByDomainAndId(int $domainId, int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM ssl_certificates WHERE domain_id = ? AND id = ?");
|
||||
return $stmt->execute([$domainId, $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple monitored SSL certificates by domain and ids.
|
||||
* @return int Number of deleted rows.
|
||||
*/
|
||||
public function deleteByDomainAndIds(int $domainId, array $ids): int
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->prepare(
|
||||
"DELETE FROM ssl_certificates WHERE domain_id = ? AND id IN ({$placeholders})"
|
||||
);
|
||||
$stmt->execute(array_merge([$domainId], $ids));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -52,10 +52,11 @@ class DiscordChannel implements NotificationChannelInterface
|
||||
|
||||
private function createEmbed(string $message, array $data): array
|
||||
{
|
||||
$title = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
||||
|
||||
$embed = [
|
||||
'title' => '🔔 Domain Expiration Alert',
|
||||
'title' => $title,
|
||||
'description' => $message,
|
||||
'color' => $color,
|
||||
'timestamp' => date('c'),
|
||||
@@ -65,23 +66,22 @@ class DiscordChannel implements NotificationChannelInterface
|
||||
];
|
||||
|
||||
if (isset($data['domain'])) {
|
||||
$embed['fields'] = [
|
||||
[
|
||||
'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
|
||||
]
|
||||
$fields = [
|
||||
['name' => 'Domain', 'value' => $data['domain'], '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;
|
||||
|
||||
@@ -28,37 +28,36 @@ class MattermostChannel implements NotificationChannelInterface
|
||||
'text' => $message
|
||||
];
|
||||
|
||||
// Add attachments for richer formatting if domain data is available
|
||||
if (isset($data['domain'])) {
|
||||
// Add green-bar attachment only for expiration/SSL/status alerts, not for DNS change (plain text only)
|
||||
$isRichAlert = isset($data['domain'])
|
||||
&& (array_key_exists('days_left', $data)
|
||||
|| array_key_exists('expiration_date', $data)
|
||||
|| isset($data['new_status'])
|
||||
|| (isset($data['hostname']) && $data['hostname'] !== $data['domain']));
|
||||
if ($isRichAlert) {
|
||||
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
||||
|
||||
$title = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||
|
||||
$fields = [
|
||||
['short' => true, 'title' => 'Domain', 'value' => $data['domain']]
|
||||
];
|
||||
|
||||
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'] = [
|
||||
[
|
||||
'color' => $color,
|
||||
'title' => '🔔 Domain Expiration Alert',
|
||||
'text' => $message,
|
||||
'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'
|
||||
]
|
||||
],
|
||||
'title' => $title,
|
||||
'fields' => $fields,
|
||||
'footer' => 'Domain Monitor',
|
||||
'ts' => time()
|
||||
]
|
||||
|
||||
@@ -40,8 +40,10 @@ class PushoverChannel implements NotificationChannelInterface
|
||||
'priority' => $priority,
|
||||
];
|
||||
|
||||
// Optional: Add title
|
||||
if (isset($data['domain'])) {
|
||||
// Optional: Add title - use subject when provided (DNS, SSL, etc.)
|
||||
if (!empty($data['subject'])) {
|
||||
$payload['title'] = $data['subject'];
|
||||
} elseif (isset($data['domain'])) {
|
||||
$payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain'];
|
||||
} else {
|
||||
$payload['title'] = '🔔 Domain Monitor Notification';
|
||||
|
||||
@@ -53,12 +53,14 @@ class SlackChannel implements NotificationChannelInterface
|
||||
|
||||
private function createBlocks(string $message, array $data): array
|
||||
{
|
||||
$headerText = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||
|
||||
$blocks = [
|
||||
[
|
||||
'type' => 'header',
|
||||
'text' => [
|
||||
'type' => 'plain_text',
|
||||
'text' => '🔔 Domain Expiration Alert'
|
||||
'text' => $headerText
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -71,26 +73,25 @@ class SlackChannel implements NotificationChannelInterface
|
||||
];
|
||||
|
||||
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[] = [
|
||||
'type' => 'section',
|
||||
'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']}"
|
||||
]
|
||||
]
|
||||
'fields' => $fields
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ class WebhookChannel implements NotificationChannelInterface
|
||||
private function buildGenericPayload(string $message, array $data): array
|
||||
{
|
||||
return [
|
||||
'event' => 'domain_expiration_alert',
|
||||
'event' => 'domain_monitor_alert',
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'sent_at' => date('c')
|
||||
|
||||
@@ -86,124 +86,173 @@ class DnsService
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MAIN LOOKUP
|
||||
// DNS SCAN METHODS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Comprehensive DNS lookup for a domain.
|
||||
* Scans root + common subdomains + targets extracted from NS/MX/CNAME.
|
||||
* Resolves NS/MX targets to A/AAAA IPs.
|
||||
*
|
||||
* @param string $domain The domain to scan
|
||||
* @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans)
|
||||
* Re-check only records that already exist in the database.
|
||||
* Queries root domain for all types + known subdomain hosts.
|
||||
* No wordlist brute force, no crt.sh. Used by the cron and Refresh button.
|
||||
*/
|
||||
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 = [
|
||||
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
||||
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
||||
];
|
||||
$seen = []; // "TYPE:host:value" dedup keys
|
||||
[$records, $seen] = $this->queryRootDomain($domain);
|
||||
|
||||
// Phase 1: Root domain — query each type individually
|
||||
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
||||
$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) {
|
||||
// Query known subdomain hosts directly (no existence probe needed)
|
||||
foreach ($existingHosts 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);
|
||||
// TXT only for known useful subdomains
|
||||
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_MX, 'MX', $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) {
|
||||
$fqdn = "{$sub}.{$domain}";
|
||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||
}
|
||||
|
||||
// Phase 5: Resolve MX targets that are under this domain — add their A/AAAA records
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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']);
|
||||
});
|
||||
}
|
||||
$this->resolveMxTargets($domain, $records, $seen);
|
||||
$this->resolveNsIps($records);
|
||||
$this->sortRecords($records);
|
||||
|
||||
$totalRecords = array_sum(array_map('count', $records));
|
||||
$this->logger->info("DNS lookup completed", [
|
||||
$this->logger->info("DNS refresh completed", [
|
||||
'domain' => $domain,
|
||||
'total_records' => $totalRecords,
|
||||
]);
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard DNS lookup: root domain + resolve targets + special TXT.
|
||||
* No subdomain brute force, no crt.sh. Like running nslookup/dig.
|
||||
* Used by Discover > Quick Scan.
|
||||
*/
|
||||
public function quickScan(string $domain): array
|
||||
{
|
||||
$this->logger->info("DNS quick scan started", ['domain' => $domain]);
|
||||
|
||||
[$records, $seen] = $this->queryRootDomain($domain);
|
||||
|
||||
// Add subdomains found as NS/MX/CNAME/SRV targets
|
||||
$targetSubs = $this->extractTargetSubdomains($domain, $records);
|
||||
foreach ($targetSubs as $sub) {
|
||||
$fqdn = "{$sub}.{$domain}";
|
||||
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||
$this->queryAndCollect($fqdn, DNS_MX, 'MX', $domain, $records, $seen);
|
||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $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
|
||||
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);
|
||||
$this->queryAndCollect($fqdn, DNS_MX, 'MX', $domain, $records, $seen);
|
||||
$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),
|
||||
'wildcard_detected' => $wildcardDetected,
|
||||
]);
|
||||
|
||||
return $records;
|
||||
@@ -314,70 +363,577 @@ class DnsService
|
||||
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 records: root (@) first, then alphabetical by host.
|
||||
*/
|
||||
private function sortRecords(array &$records): void
|
||||
{
|
||||
foreach (['A', 'AAAA', 'MX', 'TXT', 'CAA'] as $type) {
|
||||
if (empty($records[$type])) {
|
||||
continue;
|
||||
}
|
||||
usort($records[$type], function ($a, $b) {
|
||||
if ($a['host'] === '@' && $b['host'] !== '@') return -1;
|
||||
if ($b['host'] === '@' && $a['host'] !== '@') return 1;
|
||||
$hostCmp = strcmp($a['host'], $b['host']);
|
||||
if ($hostCmp !== 0) return $hostCmp;
|
||||
return ($a['priority'] ?? 0) <=> ($b['priority'] ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 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)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json';
|
||||
public function fetchCrtshSubdomains(
|
||||
string $domain,
|
||||
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([
|
||||
'http' => [
|
||||
'timeout' => 30,
|
||||
'timeout' => $timeout,
|
||||
'ignore_errors' => true,
|
||||
'header' => "User-Agent: DomainMonitor/1.0\r\n",
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'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',
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$json = @file_get_contents($url, false, $ctx);
|
||||
if ($json === false) {
|
||||
$this->logger->warning('crt.sh request failed', ['domain' => $domain]);
|
||||
return [];
|
||||
$start = microtime(true);
|
||||
$http_response_header = null;
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
$elapsed = microtime(true) - $start;
|
||||
|
||||
$bodyLen = is_string($body) ? strlen($body) : 0;
|
||||
|
||||
if ($debug) {
|
||||
fwrite(STDERR, "--- response ---\n");
|
||||
fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n");
|
||||
|
||||
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||
foreach ($http_response_header as $h) {
|
||||
fwrite(STDERR, "$h\n");
|
||||
}
|
||||
} else {
|
||||
fwrite(STDERR, "(no response headers — connection failed or timeout)\n");
|
||||
}
|
||||
|
||||
fwrite(STDERR, "Body: $bodyLen bytes\n");
|
||||
|
||||
if (is_string($body) && $bodyLen > 0) {
|
||||
$preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body;
|
||||
fwrite(STDERR, $preview . "\n");
|
||||
}
|
||||
|
||||
fwrite(STDERR, "--- end response ---\n");
|
||||
}
|
||||
|
||||
$entries = @json_decode($json, true);
|
||||
if (!is_array($entries)) {
|
||||
return [];
|
||||
$status = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
||||
$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);
|
||||
$suffix = '.' . $domainLower;
|
||||
$suffixLen = strlen($suffix);
|
||||
$subs = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$name = $entry['name_value'] ?? '';
|
||||
foreach (explode("\n", $name) as $n) {
|
||||
$n = strtolower(trim($n));
|
||||
$n = ltrim($n, '*.');
|
||||
if (empty($n)) continue;
|
||||
foreach ($crtshData as $entry) {
|
||||
if (empty($entry['name_value'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($n === $domainLower) continue;
|
||||
foreach (explode("\n", $entry['name_value']) as $name) {
|
||||
$name = strtolower(trim($name));
|
||||
|
||||
if (str_ends_with($n, '.' . $domainLower)) {
|
||||
$sub = str_replace('.' . $domainLower, '', $n);
|
||||
if ($sub !== '' && !isset($subdomains[$sub])) {
|
||||
$subdomains[$sub] = true;
|
||||
}
|
||||
if (strpos($name, '*.') === 0) {
|
||||
$name = substr($name, 2);
|
||||
}
|
||||
|
||||
if ($name === $domainLower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (substr($name, -$suffixLen) !== $suffix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sub = substr($name, 0, strlen($name) - $suffixLen);
|
||||
if (!empty($sub)) {
|
||||
$subs[$sub] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_keys($subdomains);
|
||||
$this->logger->info('crt.sh discovery completed', [
|
||||
'domain' => $domain,
|
||||
'subdomains_found' => count($result),
|
||||
]);
|
||||
return array_keys($subs);
|
||||
}
|
||||
|
||||
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 ?: '';
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -713,6 +713,151 @@ class NotificationService
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSL monitoring notification (in-app / bell icon).
|
||||
*/
|
||||
public function notifySslStatusChange(
|
||||
int $userId,
|
||||
string $domainName,
|
||||
string $hostname,
|
||||
int $domainId,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null
|
||||
): void {
|
||||
$notificationModel = new \App\Models\Notification();
|
||||
$notificationModel->createNotification(
|
||||
$userId,
|
||||
'ssl_status_change',
|
||||
$this->getSslNotificationTitle($newStatus),
|
||||
$this->formatSslStatusSummary($domainName, $hostname, $newStatus, $oldStatus),
|
||||
$domainId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SSL status alert to external channels.
|
||||
*/
|
||||
public function sendSslStatusAlert(
|
||||
array $domain,
|
||||
array $notificationChannels,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null,
|
||||
?string $validTo = null,
|
||||
?string $error = null
|
||||
): array {
|
||||
$message = $this->formatSslStatusMessage($domain, $hostname, $newStatus, $oldStatus, $validTo, $error);
|
||||
$results = [];
|
||||
|
||||
foreach ($notificationChannels as $channel) {
|
||||
$config = json_decode($channel['channel_config'], true);
|
||||
$success = $this->send(
|
||||
$channel['channel_type'],
|
||||
$config,
|
||||
$message,
|
||||
[
|
||||
'subject' => $this->getSslNotificationTitle($newStatus) . ': ' . $hostname,
|
||||
'domain' => $domain['domain_name'],
|
||||
'domain_id' => $domain['id'],
|
||||
'hostname' => $hostname,
|
||||
'new_status' => $newStatus,
|
||||
'old_status' => $oldStatus,
|
||||
'valid_to' => $validTo,
|
||||
'error' => $error,
|
||||
]
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'channel' => $channel['channel_type'],
|
||||
'success' => $success,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSL status label for human-readable messages.
|
||||
*/
|
||||
public static function getSslStatusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'valid' => 'Valid',
|
||||
'expiring' => 'Expiring Soon',
|
||||
'expired' => 'Expired',
|
||||
'invalid' => 'Invalid',
|
||||
default => ucfirst(str_replace('_', ' ', $status)),
|
||||
};
|
||||
}
|
||||
|
||||
private function getSslNotificationTitle(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'valid' => 'SSL Certificate Recovered',
|
||||
'expiring' => 'SSL Certificate Expiring Soon',
|
||||
'expired' => 'SSL Certificate Expired',
|
||||
'invalid' => 'SSL Certificate Check Failed',
|
||||
default => 'SSL Status Changed',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatSslStatusSummary(
|
||||
string $domainName,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null
|
||||
): string {
|
||||
$hostText = $hostname === $domainName ? $domainName : "{$hostname} ({$domainName})";
|
||||
$newLabel = self::getSslStatusLabel($newStatus);
|
||||
|
||||
if ($oldStatus !== null) {
|
||||
$oldLabel = self::getSslStatusLabel($oldStatus);
|
||||
return "{$hostText} - SSL status changed from {$oldLabel} to {$newLabel}";
|
||||
}
|
||||
|
||||
return "{$hostText} - SSL status is {$newLabel}";
|
||||
}
|
||||
|
||||
private function formatSslStatusMessage(
|
||||
array $domain,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null,
|
||||
?string $validTo = null,
|
||||
?string $error = null
|
||||
): string {
|
||||
$domainName = $domain['domain_name'];
|
||||
$validToText = $validTo ? date('F j, Y H:i', strtotime($validTo)) : 'Unknown';
|
||||
$oldLabel = $oldStatus !== null ? self::getSslStatusLabel($oldStatus) : null;
|
||||
|
||||
return match ($newStatus) {
|
||||
'valid' => "✅ SSL RECOVERED: {$hostname} is now using a valid certificate.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Valid until: {$validToText}",
|
||||
|
||||
'expiring' => "⚠️ SSL EXPIRING SOON: {$hostname} is approaching certificate expiration.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Valid until: {$validToText}",
|
||||
|
||||
'expired' => "🚨 SSL EXPIRED: {$hostname} has an expired certificate.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Expired on: {$validToText}",
|
||||
|
||||
'invalid' => "❌ SSL INVALID: {$hostname} failed certificate validation.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
($error ? "Error: {$error}\n" : ''),
|
||||
|
||||
default => "ℹ️ SSL STATUS CHANGE: {$hostname} changed SSL status.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Current status: " . self::getSslStatusLabel($newStatus) . "\n" .
|
||||
"Domain: {$domainName}"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old read notifications (cleanup)
|
||||
*/
|
||||
|
||||
390
app/Services/SslService.php
Normal file
390
app/Services/SslService.php
Normal file
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Helpers\InputValidator;
|
||||
|
||||
class SslService
|
||||
{
|
||||
private const DEFAULT_PORT = 443;
|
||||
private const CONNECT_TIMEOUT = 15;
|
||||
private const EXPIRING_SOON_DAYS = 30;
|
||||
|
||||
private Logger $logger;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logger = new Logger('ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-supplied SSL host into a monitored hostname for the domain.
|
||||
*/
|
||||
public function normalizeHostname(string $input, string $baseDomain): ?string
|
||||
{
|
||||
$target = $this->parseMonitorTarget($input, $baseDomain);
|
||||
return $target['hostname'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user-supplied SSL monitoring target into hostname + port.
|
||||
*
|
||||
* @return array{hostname:string,port:int}|null
|
||||
*/
|
||||
public function parseMonitorTarget(string $input, string $baseDomain): ?array
|
||||
{
|
||||
$baseDomain = strtolower(trim($baseDomain));
|
||||
$input = strtolower(trim($input));
|
||||
$port = self::DEFAULT_PORT;
|
||||
|
||||
if ($input === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($input, '://') || preg_match('/[\/\\\\\s?#]/', $input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$colonPos = strrpos($input, ':');
|
||||
if ($colonPos !== false) {
|
||||
$portText = substr($input, $colonPos + 1);
|
||||
if ($portText === '' || !ctype_digit($portText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$port = (int)$portText;
|
||||
if ($port < 1 || $port > 65535) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = substr($input, 0, $colonPos);
|
||||
if ($input === '') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$hostname = $this->normalizeMonitorHostname($input, $baseDomain);
|
||||
if ($hostname === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse certificate details for a hostname.
|
||||
*
|
||||
* @return array{status:string,is_trusted:bool,is_self_signed:bool,valid_from:?string,valid_to:?string,days_remaining:?int,issuer_name:?string,subject_name:?string,serial_number:?string,signature_algorithm:?string,key_bits:?int,key_type:?string,certificate_version:?string,san_list:array,last_checked:string,last_error:?string,raw_data:array}
|
||||
*/
|
||||
public function fetchCertificateSnapshot(string $hostname, int $port = self::DEFAULT_PORT): array
|
||||
{
|
||||
$hostname = strtolower(trim($hostname));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$primary = $this->connect($hostname, $port, true);
|
||||
$verified = $primary['success'];
|
||||
$connection = $primary;
|
||||
|
||||
if (!$primary['success']) {
|
||||
$fallback = $this->connect($hostname, $port, false);
|
||||
if ($fallback['success']) {
|
||||
$connection = $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($connection['certificate'])) {
|
||||
$error = $primary['error'] ?: ($connection['error'] ?? 'Could not retrieve certificate');
|
||||
|
||||
$this->logger->warning('SSL certificate fetch failed', [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'invalid',
|
||||
'is_trusted' => false,
|
||||
'is_self_signed' => false,
|
||||
'valid_from' => null,
|
||||
'valid_to' => null,
|
||||
'days_remaining' => null,
|
||||
'issuer_name' => null,
|
||||
'subject_name' => null,
|
||||
'serial_number' => null,
|
||||
'signature_algorithm' => null,
|
||||
'key_bits' => null,
|
||||
'key_type' => null,
|
||||
'certificate_version' => null,
|
||||
'san_list' => [],
|
||||
'last_checked' => $now,
|
||||
'last_error' => $error,
|
||||
'raw_data' => [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'verified_attempt_error' => $primary['error'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$parsed = @openssl_x509_parse($connection['certificate']);
|
||||
if (!is_array($parsed)) {
|
||||
return [
|
||||
'status' => 'invalid',
|
||||
'is_trusted' => false,
|
||||
'is_self_signed' => false,
|
||||
'valid_from' => null,
|
||||
'valid_to' => null,
|
||||
'days_remaining' => null,
|
||||
'issuer_name' => null,
|
||||
'subject_name' => null,
|
||||
'serial_number' => null,
|
||||
'signature_algorithm' => null,
|
||||
'key_bits' => null,
|
||||
'key_type' => null,
|
||||
'certificate_version' => null,
|
||||
'san_list' => [],
|
||||
'last_checked' => $now,
|
||||
'last_error' => 'Could not parse certificate',
|
||||
'raw_data' => [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'verified_attempt_error' => $primary['error'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$publicKeyDetails = $this->getPublicKeyDetails($connection['certificate']);
|
||||
$validFromTs = isset($parsed['validFrom_time_t']) ? (int)$parsed['validFrom_time_t'] : null;
|
||||
$validToTs = isset($parsed['validTo_time_t']) ? (int)$parsed['validTo_time_t'] : null;
|
||||
$daysRemaining = $validToTs !== null ? (int)floor(($validToTs - time()) / 86400) : null;
|
||||
$subjectName = $this->formatDistinguishedName($parsed['subject'] ?? []);
|
||||
$issuerName = $this->formatDistinguishedName($parsed['issuer'] ?? []);
|
||||
$isSelfSigned = $subjectName !== '' && $subjectName === $issuerName;
|
||||
$sanList = $this->extractSanList($parsed);
|
||||
$status = $this->determineStatus($verified, $daysRemaining);
|
||||
$error = $primary['error'] ?? null;
|
||||
|
||||
$snapshot = [
|
||||
'status' => $status,
|
||||
'is_trusted' => $verified,
|
||||
'is_self_signed' => $isSelfSigned,
|
||||
'valid_from' => $validFromTs ? date('Y-m-d H:i:s', $validFromTs) : null,
|
||||
'valid_to' => $validToTs ? date('Y-m-d H:i:s', $validToTs) : null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'issuer_name' => $issuerName ?: null,
|
||||
'subject_name' => $subjectName ?: null,
|
||||
'serial_number' => $parsed['serialNumberHex'] ?? ($parsed['serialNumber'] ?? null),
|
||||
'signature_algorithm' => $parsed['signatureTypeLN'] ?? ($parsed['signatureTypeSN'] ?? null),
|
||||
'key_bits' => $publicKeyDetails['bits'],
|
||||
'key_type' => $publicKeyDetails['type'],
|
||||
'certificate_version' => isset($parsed['version']) ? 'v' . ((int)$parsed['version'] + 1) : null,
|
||||
'san_list' => $sanList,
|
||||
'last_checked' => $now,
|
||||
'last_error' => $status === 'valid' || $status === 'expiring' || $status === 'expired' ? null : $error,
|
||||
'raw_data' => [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'subject' => $parsed['subject'] ?? [],
|
||||
'issuer' => $parsed['issuer'] ?? [],
|
||||
'extensions' => $parsed['extensions'] ?? [],
|
||||
'verified_attempt_error' => $primary['error'] ?? null,
|
||||
'san_list' => $sanList,
|
||||
],
|
||||
];
|
||||
|
||||
$this->logger->info('SSL certificate fetched', [
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'status' => $snapshot['status'],
|
||||
'trusted' => $snapshot['is_trusted'],
|
||||
'days_remaining' => $snapshot['days_remaining'],
|
||||
]);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a monitored target for display and notifications.
|
||||
*/
|
||||
public function formatTargetLabel(string $hostname, int $port = self::DEFAULT_PORT): string
|
||||
{
|
||||
$hostname = strtolower(trim($hostname));
|
||||
return $port === self::DEFAULT_PORT ? $hostname : $hostname . ':' . $port;
|
||||
}
|
||||
|
||||
private function determineStatus(bool $verified, ?int $daysRemaining): string
|
||||
{
|
||||
if ($daysRemaining !== null && $daysRemaining < 0) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if (!$verified) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if ($daysRemaining !== null && $daysRemaining <= self::EXPIRING_SOON_DAYS) {
|
||||
return 'expiring';
|
||||
}
|
||||
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
private function connect(string $hostname, int $port, bool $verifyPeer): array
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'capture_peer_cert' => true,
|
||||
'capture_peer_cert_chain' => true,
|
||||
'SNI_enabled' => true,
|
||||
'peer_name' => $hostname,
|
||||
'verify_peer' => $verifyPeer,
|
||||
'verify_peer_name' => $verifyPeer,
|
||||
'allow_self_signed' => !$verifyPeer,
|
||||
'disable_compression' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$warning = null;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message) use (&$warning): bool {
|
||||
$warning = $message;
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
$socket = @stream_socket_client(
|
||||
"ssl://{$hostname}:{$port}",
|
||||
$errno,
|
||||
$errstr,
|
||||
self::CONNECT_TIMEOUT,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
if (!$socket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $warning ?: $errstr ?: ('Connection failed (' . $errno . ')'),
|
||||
'certificate' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($socket);
|
||||
fclose($socket);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error' => $warning,
|
||||
'certificate' => $params['options']['ssl']['peer_certificate'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function getPublicKeyDetails($certificate): array
|
||||
{
|
||||
$publicKey = @openssl_pkey_get_public($certificate);
|
||||
if ($publicKey === false) {
|
||||
return ['bits' => null, 'type' => null];
|
||||
}
|
||||
|
||||
$details = @openssl_pkey_get_details($publicKey) ?: [];
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
@openssl_free_key($publicKey);
|
||||
}
|
||||
|
||||
return [
|
||||
'bits' => isset($details['bits']) ? (int)$details['bits'] : null,
|
||||
'type' => $this->mapKeyType($details['type'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapKeyType(?int $type): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
OPENSSL_KEYTYPE_RSA => 'RSA',
|
||||
OPENSSL_KEYTYPE_DSA => 'DSA',
|
||||
OPENSSL_KEYTYPE_DH => 'DH',
|
||||
OPENSSL_KEYTYPE_EC => 'EC',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function extractSanList(array $parsed): array
|
||||
{
|
||||
$sanText = $parsed['extensions']['subjectAltName'] ?? '';
|
||||
if ($sanText === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (explode(',', $sanText) as $entry) {
|
||||
$entry = trim($entry);
|
||||
if (str_starts_with($entry, 'DNS:')) {
|
||||
$result[] = substr($entry, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($result)));
|
||||
}
|
||||
|
||||
private function formatDistinguishedName(array $parts): string
|
||||
{
|
||||
if (!empty($parts['CN'])) {
|
||||
return (string)$parts['CN'];
|
||||
}
|
||||
|
||||
foreach (['O', 'OU', 'emailAddress'] as $field) {
|
||||
if (!empty($parts[$field])) {
|
||||
return (string)$parts[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($parts as $key => $value) {
|
||||
if (is_scalar($value) && $value !== '') {
|
||||
$values[] = $key . '=' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $values);
|
||||
}
|
||||
|
||||
private function normalizeMonitorHostname(string $input, string $baseDomain): ?string
|
||||
{
|
||||
if ($input === '' || $input === '@') {
|
||||
return $baseDomain;
|
||||
}
|
||||
|
||||
$input = rtrim($input, '.');
|
||||
|
||||
if ($input === $baseDomain) {
|
||||
return $baseDomain;
|
||||
}
|
||||
|
||||
if (InputValidator::validateDomain($input) && str_ends_with($input, '.' . $baseDomain)) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
if (!$this->isValidRelativeHost($input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = $input . '.' . $baseDomain;
|
||||
return InputValidator::validateDomain($candidate) ? $candidate : null;
|
||||
}
|
||||
|
||||
private function isValidRelativeHost(string $host): bool
|
||||
{
|
||||
return (bool)preg_match(
|
||||
'/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/i',
|
||||
$host
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
<button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
{{ tag.name }}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
<button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
{{ tag.name }}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
<button type="button" onclick="addTag('{{ tag.name|e('js') }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
{{ tag.name }}
|
||||
@@ -152,15 +152,25 @@
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
|
||||
<input type="checkbox"
|
||||
name="dns_monitoring_enabled"
|
||||
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
|
||||
<input type="checkbox"
|
||||
name="dns_monitoring_enabled"
|
||||
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
|
||||
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable DNS Monitoring</span>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, DNS records will be checked for changes and you'll receive alerts</p>
|
||||
</div>
|
||||
</label>
|
||||
<label id="ssl-monitoring" class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
|
||||
<input type="checkbox"
|
||||
name="ssl_monitoring_enabled"
|
||||
{{ domain.ssl_monitoring_enabled|default(0) ? 'checked' : '' }}
|
||||
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable SSL Monitoring</span>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, the root certificate and any monitored SSL endpoints will be checked automatically</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
@@ -195,7 +205,7 @@
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
||||
</button>
|
||||
</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() }}
|
||||
<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">
|
||||
|
||||
@@ -375,10 +375,16 @@
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</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">
|
||||
<i class="fas fa-edit"></i>
|
||||
</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() }}
|
||||
<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>
|
||||
@@ -398,7 +404,18 @@
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<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()">
|
||||
<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>
|
||||
{% endfor %}
|
||||
@@ -566,13 +583,12 @@ function bulkRefresh() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' domain(s)? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -641,16 +657,15 @@ function bulkAddTag(tagName) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkRemoveAllTags() {
|
||||
async function bulkRemoveAllTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
alert('Please select at least one domain');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Remove all tags from ' + ids.length + ' domain(s)?', title: 'Remove Tags', icon: 'fa-tags text-orange-500' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -680,51 +695,15 @@ function bulkTransfer() {
|
||||
alert('Please select at least one domain');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
let userOptions = users.map(user =>
|
||||
`<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);
|
||||
openTransferModal({
|
||||
title: 'Transfer ' + ids.length + ' Domain(s)',
|
||||
description: 'Select the user to transfer the selected domains to.',
|
||||
action: '/domains/bulk-transfer',
|
||||
fields: { 'domain_ids[]': ids },
|
||||
submitText: 'Transfer Domains',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
||||
@@ -977,5 +956,26 @@ document.addEventListener('click', function(e) {
|
||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function transferDomain(domainId, domainName) {
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: domainId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.domain-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,22 +23,32 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||
<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>
|
||||
<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 %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
<span class="btn-label">Refresh DNS</span>
|
||||
<div class="flex items-center justify-center gap-2 flex-wrap">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/discover-dns" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="mode" value="quick">
|
||||
<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>
|
||||
</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 %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex items-center gap-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 flex-wrap">
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||
<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' }}
|
||||
@@ -51,13 +61,63 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if domain and dnsMonitoringEnabled %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
<span class="btn-label">Refresh DNS</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# Refresh DNS (re-check existing only) #}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<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>
|
||||
</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 %}
|
||||
</div>
|
||||
|
||||
@@ -131,23 +191,31 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="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">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">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="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['A'] %}
|
||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||
<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">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% 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 class="px-4 py-2 text-xs">
|
||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||
@@ -177,6 +245,14 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
@@ -199,23 +275,31 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="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">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">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="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['AAAA'] %}
|
||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||
<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">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% 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 class="px-4 py-2 text-xs">
|
||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||
@@ -245,6 +329,14 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
@@ -267,17 +359,32 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="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">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="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['CNAME'] %}
|
||||
<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 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>
|
||||
@@ -300,19 +407,43 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="MX"></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">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">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['MX'] %}
|
||||
<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">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-green-600 dark:text-green-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% 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 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>
|
||||
</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="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>
|
||||
@@ -331,10 +462,20 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
{% for record in dnsRecords['TXT'] %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
|
||||
<div class="flex items-start">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="TXT"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</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 %}
|
||||
{% if val starts with 'v=spf1' %}
|
||||
{% set txtType = 'SPF' %}
|
||||
@@ -351,11 +492,37 @@
|
||||
{% else %}
|
||||
{% set txtType = 'TXT' %}
|
||||
{% 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>
|
||||
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<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">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-purple-600 dark:text-purple-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% 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 class="px-4 py-2">
|
||||
<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>
|
||||
</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>
|
||||
{% endif %}
|
||||
@@ -374,11 +541,13 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="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">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">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="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
@@ -386,10 +555,15 @@
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
||||
<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">
|
||||
<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 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">
|
||||
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
||||
{{ nsIps.ipv4|join(', ') }}
|
||||
@@ -401,6 +575,14 @@
|
||||
{% else %}-{% endif %}
|
||||
</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>
|
||||
@@ -423,24 +605,39 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="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">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">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">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['SRV'] %}
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||
<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 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">{{ 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="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>
|
||||
@@ -463,22 +660,46 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CAA"></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">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">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="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['CAA'] %}
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||
<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">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-orange-600 dark:text-orange-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% 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 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>
|
||||
</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">{{ 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>
|
||||
@@ -491,6 +712,88 @@
|
||||
{% 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>
|
||||
function handleDnsRefresh(form) {
|
||||
var btn = form.querySelector('.dns-refresh-btn');
|
||||
@@ -504,4 +807,137 @@ function handleDnsRefresh(form) {
|
||||
if (label) label.textContent = 'Scanning DNS...';
|
||||
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>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% if domain.registrar_url is not empty %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-slate-400">Registrar URL:</span>
|
||||
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit
|
||||
</a>
|
||||
@@ -191,9 +191,15 @@
|
||||
<i class="fas fa-lock text-indigo-500 dark:text-indigo-400 mr-2" style="font-size: 10px;"></i>
|
||||
<span class="text-xs text-gray-700 dark:text-slate-300">SSL</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-minus-circle mr-1" style="font-size: 9px;"></i>Coming Soon
|
||||
{% if domain.ssl_monitoring_enabled|default(0) %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -1,379 +1,508 @@
|
||||
<!-- SSL TAB CONTENT -->
|
||||
{% set sslStats = sslStats|default({
|
||||
total: 0,
|
||||
valid: 0,
|
||||
expiring: 0,
|
||||
expired: 0,
|
||||
invalid: 0,
|
||||
issues: 0
|
||||
}) %}
|
||||
{% set sslCertificates = sslCertificates|default([]) %}
|
||||
{% set sslMonitoringEnabled = domain.ssl_monitoring_enabled|default(0) %}
|
||||
{% set rootCertificate = sslCertificates|filter(cert => cert.is_root)|first %}
|
||||
|
||||
<!-- Preview Banner -->
|
||||
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
|
||||
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">SSL certificate monitoring is coming soon. This is a design preview with sample data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Actions Bar -->
|
||||
<div class="mb-3 flex flex-wrap gap-3 justify-between items-center">
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input type="text" id="ssl-search" placeholder="Search certificates..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<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>
|
||||
<div class="flex gap-2">
|
||||
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
|
||||
<option value="all">All Certificates</option>
|
||||
<option value="valid">Valid Only</option>
|
||||
<option value="expiring">Expiring Soon</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
<button onclick="checkAllCertificates()" class="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">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Subdomain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default) -->
|
||||
<div id="ssl-bulk-actions" class="hidden mb-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
|
||||
<button type="button" onclick="bulkCheckSSL()" class="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: 9px;"></i>
|
||||
Check Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkDeleteSSL()" class="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">
|
||||
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 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-times mr-1.5" style="font-size: 9px;"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">3</p>
|
||||
</div>
|
||||
<i class="fas fa-lock text-gray-400 dark:text-slate-500" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-0.5">2</p>
|
||||
</div>
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring Soon</p>
|
||||
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">1</p>
|
||||
</div>
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 dark:text-orange-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-0.5">1</p>
|
||||
</div>
|
||||
<i class="fas fa-times-circle text-red-500 dark:text-red-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-3 flex justify-between items-center">
|
||||
<div class="text-xs text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">1</span> to <span class="font-semibold text-gray-900 dark:text-white">3</span> of <span class="font-semibold text-gray-900 dark:text-white">3</span> certificate(s)
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select class="px-2 py-1 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded text-xs">
|
||||
<option>10</option>
|
||||
<option selected>25</option>
|
||||
<option>50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Certificates List -->
|
||||
<div class="space-y-3">
|
||||
<!-- Cert 1 (root) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="1" data-status="valid">
|
||||
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">example.com <span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span></h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||
Valid & Trusted
|
||||
</span>
|
||||
{% if not sslMonitoringEnabled %}
|
||||
<div class="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">SSL monitoring is disabled</h3>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the SSL cron. Enable it in Edit to monitor the root certificate and tracked SSL endpoints.</p>
|
||||
<a href="/domains/{{ domain.id }}/edit#ssl-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
|
||||
<i class="fas fa-edit mr-1"></i>Enable SSL monitoring in Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Oct 05, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jan 08, 2026</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">65 days</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">Let's Encrypt Authority X3</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">www.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
|
||||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">SSL certificate monitoring</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
Track the default root certificate and any monitored HTTPS endpoints, including custom ports.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" class="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">
|
||||
<i class="fas fa-shield-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
{{ rootCertificate ? 'Check Root SSL (443)' : 'Start Root Monitoring (443)' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/refresh-all" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="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">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="openAddSslEndpointModal()" class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cert 2 (mail subdomain) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="2" data-status="valid">
|
||||
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="2" onchange="updateSSLBulkActions()">
|
||||
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">mail.example.com</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||
Valid & Trusted
|
||||
</span>
|
||||
</div>
|
||||
{% if sslStats.total > 0 %}
|
||||
<div class="grid grid-cols-2 xl:grid-cols-5 gap-3">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-1">{{ sslStats.total }}</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Aug 01, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jul 28, 2026</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">270 days</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">DigiCert Inc.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">mail.example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">smtp.example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">imap.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
<button 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>Remove</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-1">{{ sslStats.valid }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring</p>
|
||||
<p class="text-lg font-semibold text-amber-600 dark:text-amber-400 mt-1">{{ sslStats.expiring }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expired</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.expired }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.invalid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||
<span>
|
||||
<i class="far fa-clock mr-1"></i>
|
||||
Last checked: {{ domain.ssl_last_checked ? domain.ssl_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
id="ssl-search"
|
||||
placeholder="Search monitored endpoints..."
|
||||
class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"
|
||||
>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
|
||||
<option value="all">All certificates</option>
|
||||
<option value="valid">Valid only</option>
|
||||
<option value="expiring">Expiring soon</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ssl-bulk-actions" class="hidden bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" onclick="submitBulkSslAction('refresh')" class="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: 9px;"></i>
|
||||
Check Selected
|
||||
</button>
|
||||
<button type="button" onclick="submitBulkSslAction('delete')" class="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">
|
||||
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 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-times mr-1.5" style="font-size: 9px;"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cert 3 (api - expired) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-red-200 dark:border-red-800 overflow-hidden ssl-cert-item" data-cert-id="3" data-status="expired">
|
||||
<div class="px-4 py-2 bg-red-50 dark:bg-red-500/10 border-b border-red-200 dark:border-red-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="3" onchange="updateSSLBulkActions()">
|
||||
<i class="fas fa-lock text-red-600 dark:text-red-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">api.example.com</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-refresh" id="ssl-bulk-refresh-form" class="hidden">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="certificate_ids" id="ssl-bulk-refresh-ids">
|
||||
</form>
|
||||
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-delete" id="ssl-bulk-delete-form" class="hidden">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="certificate_ids" id="ssl-bulk-delete-ids">
|
||||
</form>
|
||||
|
||||
<div id="ssl-no-results" class="hidden bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6 text-center">
|
||||
<i class="fas fa-search text-gray-300 dark:text-slate-600 mb-2" style="font-size: 28px;"></i>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">No certificates match the current filter</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Try a different search term or status filter.</p>
|
||||
</div>
|
||||
|
||||
<div id="ssl-list" class="space-y-3">
|
||||
{% for certificate in sslCertificates %}
|
||||
{% set validityText = 'Unknown' %}
|
||||
{% if certificate.days_remaining is not null %}
|
||||
{% if certificate.days_remaining < 0 %}
|
||||
{% set validityText = (certificate.days_remaining|abs) ~ ' days ago' %}
|
||||
{% else %}
|
||||
{% set validityText = certificate.days_remaining ~ ' days' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div
|
||||
class="ssl-cert-item bg-white dark:bg-slate-800 rounded-lg border-2 {{ certificate.card_border_class }} overflow-hidden"
|
||||
data-status="{{ certificate.status }}"
|
||||
data-search="{{ (certificate.display_target ~ ' ' ~ certificate.hostname ~ ' ' ~ (certificate.issuer_name|default('')) ~ ' ' ~ (certificate.issuer_organization|default('')) ~ ' ' ~ (certificate.subject_name|default('')) ~ ' ' ~ (certificate.subject_organization|default('')))|lower }}"
|
||||
>
|
||||
<div class="px-4 py-2 border-b {{ certificate.header_class }}">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{% if certificate.can_delete %}
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ certificate.id }}" onchange="updateSSLBulkActions()">
|
||||
{% endif %}
|
||||
<i class="fas fa-lock {{ certificate.accent_class }}" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ certificate.display_target }}
|
||||
{% if certificate.is_root %}
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{{ certificate.is_trusted ? 'Trusted certificate' : 'Certificate issue detected' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded border {{ certificate.status_badge_class }}">
|
||||
<i class="fas {{ certificate.status_icon }} mr-1" style="font-size: 9px;"></i>
|
||||
{{ certificate.status_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Issued</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{{ certificate.valid_from ? certificate.valid_from|date('M d, Y H:i') : 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Expires</span>
|
||||
<span class="text-xs font-semibold {{ certificate.accent_class }}">
|
||||
{{ certificate.valid_to ? certificate.valid_to|date('M d, Y H:i') : 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Time left</span>
|
||||
<span class="text-xs font-semibold {{ certificate.accent_class }}">
|
||||
{% if certificate.days_remaining is not null and certificate.days_remaining < 0 %}
|
||||
Expired {{ validityText }}
|
||||
{% else %}
|
||||
{{ validityText }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.issuer_name|default('Unknown issuer') }}</p>
|
||||
{% if certificate.issuer_organization and certificate.issuer_organization != certificate.issuer_name %}
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Organization</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.issuer_organization }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{% if certificate.is_self_signed %}
|
||||
Self-signed
|
||||
{% elseif certificate.is_trusted %}
|
||||
Trusted
|
||||
{% else %}
|
||||
Not trusted
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if certificate.last_error %}
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error details</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-400">{{ certificate.last_error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1 max-h-28 overflow-auto pr-1">
|
||||
{% if certificate.san_list is not empty %}
|
||||
{% for san in certificate.san_list %}
|
||||
<div class="flex items-center text-xs">
|
||||
<i class="fas fa-check {{ certificate.accent_class }} mr-1.5" style="font-size: 9px;"></i>
|
||||
<span class="text-gray-900 dark:text-white font-mono break-all">{{ san }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">No SAN entries recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Subject</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.subject_name|default('Unknown') }}</span>
|
||||
</div>
|
||||
{% if certificate.subject_organization and certificate.subject_organization != certificate.subject_name %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Subject org</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.subject_organization }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Signature</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.signature_algorithm|default('Unknown') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Key</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{{ certificate.key_type|default('Unknown') }}{% if certificate.key_bits %} {{ certificate.key_bits }} bits{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Version</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.certificate_version|default('Unknown') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="far fa-clock mr-1"></i>
|
||||
Last checked: {{ certificate.last_checked ? certificate.last_checked|date('M d, Y H:i') : 'Never' }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if certificate.is_root and not certificate.can_delete %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
|
||||
Check Now
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/refresh" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
|
||||
Check Now
|
||||
</button>
|
||||
</form>
|
||||
<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 }}
|
||||
<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>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded border border-red-200 dark:border-red-800">
|
||||
<i class="fas fa-times-circle mr-1" style="font-size: 9px;"></i>
|
||||
EXPIRED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Sep 26, 2024</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-red-700 dark:text-red-400">Sep 30, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-red-600 dark:text-red-400">30 days (expired)</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<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-lock 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 SSL certificates monitored yet</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">
|
||||
Start with the root domain on port 443, or add specific hosts and custom HTTPS ports you want to monitor.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" 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-shield-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check Root SSL (443)
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/refresh-all" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 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-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="openAddSslEndpointModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Add SSL Endpoint Modal #}
|
||||
<div id="addSslEndpointModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full" onclick="event.stopPropagation()">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add">
|
||||
{{ csrf_field()|raw }}
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Add SSL Endpoint</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label for="ssl-endpoint-hostname" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Endpoint</label>
|
||||
<input type="text" id="ssl-endpoint-hostname" name="hostname" 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-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="mail, mail:8443, or mail.{{ domain.domain_name }}:8443">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
Use <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">@</code> for root, <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">@:8443</code> for root on custom port, or <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">mail</code>, <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">mail:8443</code>, or full hostname under {{ domain.domain_name }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">Self-Signed</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">⚠️ Not Trusted</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
|
||||
<button type="button" onclick="closeAddSslEndpointModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary-dark">
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error Details</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-400">Certificate has expired</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">api.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 11:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
<button 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>Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">Page <span class="font-semibold text-gray-900 dark:text-white">1</span> of <span class="font-semibold text-gray-900 dark:text-white">1</span></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-left"></i></button>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-left"></i> Previous</button>
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">1</span>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed">Next <i class="fas fa-angle-right"></i></button>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getSelectedSSLIds() {
|
||||
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map((checkbox) => checkbox.value);
|
||||
}
|
||||
|
||||
function updateSSLBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.ssl-checkbox:checked');
|
||||
const selectedIds = getSelectedSSLIds();
|
||||
const bulkActions = document.getElementById('ssl-bulk-actions');
|
||||
const selectedCount = document.getElementById('ssl-selected-count');
|
||||
if (checkboxes.length > 0) {
|
||||
|
||||
if (!bulkActions || !selectedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = `${checkboxes.length} certificate(s) selected`;
|
||||
selectedCount.textContent = `${selectedIds.length} endpoint(s) selected`;
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
selectedCount.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearSSLSelection() {
|
||||
document.querySelectorAll('.ssl-checkbox').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.ssl-checkbox').forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
updateSSLBulkActions();
|
||||
}
|
||||
function getSelectedSSLIds() {
|
||||
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map(cb => cb.value);
|
||||
}
|
||||
function bulkCheckSSL() {
|
||||
const ids = getSelectedSSLIds();
|
||||
console.log('Checking SSL certificates:', ids);
|
||||
}
|
||||
function bulkDeleteSSL() {
|
||||
const ids = getSelectedSSLIds();
|
||||
if (confirm(`Delete ${ids.length} certificate(s)? This action cannot be undone.`)) {
|
||||
console.log('Deleting SSL certificates:', ids);
|
||||
|
||||
async function submitBulkSslAction(action) {
|
||||
const selectedIds = getSelectedSSLIds();
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
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 form = document.getElementById(`ssl-bulk-${action}-form`);
|
||||
|
||||
if (!input || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = selectedIds.join(',');
|
||||
form.submit();
|
||||
}
|
||||
function checkAllCertificates() {
|
||||
console.log('Checking all certificates...');
|
||||
}
|
||||
document.getElementById('ssl-search')?.addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
item.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
document.getElementById('ssl-filter')?.addEventListener('change', function(e) {
|
||||
const filter = e.target.value;
|
||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||
if (filter === 'all') {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
const status = item.dataset.status;
|
||||
item.style.display = status === filter ? '' : 'none';
|
||||
|
||||
function applySslFilters() {
|
||||
const searchInput = document.getElementById('ssl-search');
|
||||
const filterSelect = document.getElementById('ssl-filter');
|
||||
const noResults = document.getElementById('ssl-no-results');
|
||||
const items = document.querySelectorAll('.ssl-cert-item');
|
||||
|
||||
if (!searchInput || !filterSelect || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerm = searchInput.value.trim().toLowerCase();
|
||||
const filter = filterSelect.value;
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach((item) => {
|
||||
const haystack = item.dataset.search || '';
|
||||
const status = item.dataset.status || '';
|
||||
const matchesSearch = searchTerm === '' || haystack.includes(searchTerm);
|
||||
const matchesFilter = filter === 'all' || status === filter;
|
||||
const visible = matchesSearch && matchesFilter;
|
||||
|
||||
item.style.display = visible ? '' : 'none';
|
||||
if (visible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (noResults) {
|
||||
noResults.classList.toggle('hidden', visibleCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('ssl-search')?.addEventListener('input', applySslFilters);
|
||||
document.getElementById('ssl-filter')?.addEventListener('change', applySslFilters);
|
||||
|
||||
function openAddSslEndpointModal() {
|
||||
document.getElementById('addSslEndpointModal')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddSslEndpointModal() {
|
||||
const modal = document.getElementById('addSslEndpointModal');
|
||||
modal?.classList.add('hidden');
|
||||
modal?.querySelector('form')?.reset();
|
||||
}
|
||||
|
||||
document.getElementById('addSslEndpointModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAddSslEndpointModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
||||
{% if domain.registrar_url is defined and domain.registrar_url %}
|
||||
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit Registrar
|
||||
</a>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
||||
{% if domain.abuse_email %}
|
||||
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a>
|
||||
<a href="{{ domain.abuse_email|safe_mailto }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</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 }}
|
||||
<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>
|
||||
@@ -120,8 +120,19 @@
|
||||
<button onclick="switchTab('ssl')" id="tab-ssl" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||
<i class="fas fa-lock mr-1.5" style="font-size: 10px;"></i>
|
||||
SSL
|
||||
<span class="ml-1.5 px-1.5 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">2</span>
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">1</span>
|
||||
{% if not (domain.ssl_monitoring_enabled|default(0)) %}
|
||||
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="SSL monitoring disabled"></i>
|
||||
{% else %}
|
||||
{% if sslStats.total|default(0) > 0 %}
|
||||
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ sslStats.total }}</span>
|
||||
{% endif %}
|
||||
{% if sslStats.expiring|default(0) > 0 %}
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400 text-xs font-semibold rounded">{{ sslStats.expiring }}</span>
|
||||
{% endif %}
|
||||
{% if sslStats.issues|default(0) > 0 %}
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">{{ sslStats.issues }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</button>
|
||||
<button onclick="switchTab('dns')" id="tab-dns" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||
<i class="fas fa-network-wired mr-1.5" style="font-size: 10px;"></i>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</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() }}
|
||||
<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>
|
||||
@@ -98,7 +98,7 @@
|
||||
{% if domain.registrar_url is not empty %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
||||
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<a href="{{ domain.registrar_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit
|
||||
</a>
|
||||
@@ -107,7 +107,7 @@
|
||||
{% if domain.abuse_email is not empty %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
||||
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<a href="{{ domain.abuse_email|safe_mailto }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
{{ domain.abuse_email }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -465,8 +465,9 @@ function submitResolution() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError() {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
async function deleteError() {
|
||||
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');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/{{ error.error_id }}/delete';
|
||||
|
||||
@@ -412,8 +412,9 @@ function submitResolution() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError(errorId) {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
async function deleteError(errorId) {
|
||||
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');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/delete';
|
||||
@@ -457,10 +458,11 @@ function clearSelection() {
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const errorIds = getSelectedErrorIds();
|
||||
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');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/bulk-delete';
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
{{ csrf_field() }}
|
||||
<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"
|
||||
onclick="return confirm('Delete this channel?')">
|
||||
onclick="return confirmClick(event, 'Delete this channel?')">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
{% 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() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete"
|
||||
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">
|
||||
<i class="fas fa-cog mr-1"></i> Manage
|
||||
</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() }}">
|
||||
<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
|
||||
@@ -252,7 +252,7 @@ function getSelectedGroupIds() {
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
@@ -260,9 +260,8 @@ function bulkDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + groupIds.length + ' group(s)? Domains will be unassigned from these groups.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -285,49 +284,15 @@ function bulkDelete() {
|
||||
}
|
||||
|
||||
function transferGroup(groupId, groupName) {
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
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 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);
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Group',
|
||||
description: 'Transfer group <strong>' + esc(groupName) + '</strong> to another user.',
|
||||
action: '/groups/transfer',
|
||||
fields: { group_id: groupId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
function bulkTransfer() {
|
||||
@@ -336,52 +301,15 @@ function bulkTransfer() {
|
||||
alert('Please select groups to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
openTransferModal({
|
||||
title: 'Transfer Groups',
|
||||
description: 'Transfer ' + groupIds.length + ' selected group(s) to another user.',
|
||||
action: '/groups/bulk-transfer',
|
||||
fields: { 'group_ids[]': groupIds },
|
||||
submitText: 'Transfer All',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
@@ -398,140 +326,11 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Import Modal #}
|
||||
<div id="groupImportModal" 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>Import Notification Groups
|
||||
</h3>
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="groupDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="groupDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="groupFileSize"></p>
|
||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const dropzone = document.getElementById('groupDropzone');
|
||||
const fileInput = document.getElementById('groupFileInput');
|
||||
const content = document.getElementById('groupDropzoneContent');
|
||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
||||
const fileName = document.getElementById('groupFileName');
|
||||
const fileSize = document.getElementById('groupFileSize');
|
||||
const removeBtn = document.getElementById('groupFileRemove');
|
||||
const form = document.getElementById('groupImportForm');
|
||||
const submitBtn = document.getElementById('groupImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'group',
|
||||
title: 'Import Notification Groups',
|
||||
action: '/groups/import',
|
||||
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>'
|
||||
} %}
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/install/update" class="space-y-3">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Run Update Now
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/install/run" class="space-y-5">
|
||||
{{ csrf_field() }}
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
|
||||
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
{% include 'partials/confirm-modal.twig' %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</a>
|
||||
{% 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() }}
|
||||
<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>
|
||||
@@ -407,16 +407,14 @@
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function markAllAsRead() {
|
||||
if (confirm('Mark all notifications as read?')) {
|
||||
window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
async function markAllAsRead() {
|
||||
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' });
|
||||
if (ok) window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
||||
document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
async function clearAll() {
|
||||
var ok = await confirmAction({ message: 'Clear all notifications? This action cannot be undone.' });
|
||||
if (ok) document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
122
app/Views/partials/confirm-modal.twig
Normal file
122
app/Views/partials/confirm-modal.twig
Normal file
@@ -0,0 +1,122 @@
|
||||
{# Global confirmation modal — replaces native confirm() dialogs.
|
||||
Included once in layout/base.twig. Provides:
|
||||
|
||||
confirmAction({ message, title, confirmText, cancelText, confirmClass, icon })
|
||||
Returns a Promise<boolean>.
|
||||
|
||||
confirmSubmit(event, message, opts)
|
||||
For onsubmit="return confirmSubmit(event, 'Delete?')"
|
||||
|
||||
confirmClick(event, message, opts)
|
||||
For onclick="return confirmClick(event, 'Are you sure?')"
|
||||
#}
|
||||
|
||||
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" id="confirmModalTitle">
|
||||
<i id="confirmModalIcon" class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||
<span id="confirmModalTitleText">Confirm</span>
|
||||
</h3>
|
||||
<button type="button" id="confirmModalClose"
|
||||
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400" id="confirmModalMessage"></p>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" id="confirmModalCancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" id="confirmModalConfirm"
|
||||
class="px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('confirmModal');
|
||||
var titleText = document.getElementById('confirmModalTitleText');
|
||||
var icon = document.getElementById('confirmModalIcon');
|
||||
var message = document.getElementById('confirmModalMessage');
|
||||
var confirmBtn = document.getElementById('confirmModalConfirm');
|
||||
var cancelBtn = document.getElementById('confirmModalCancel');
|
||||
var closeBtn = document.getElementById('confirmModalClose');
|
||||
|
||||
var _resolve = null;
|
||||
|
||||
function close(result) {
|
||||
modal.classList.add('hidden');
|
||||
if (_resolve) { _resolve(result); _resolve = null; }
|
||||
}
|
||||
|
||||
confirmBtn.addEventListener('click', function() { close(true); });
|
||||
cancelBtn.addEventListener('click', function() { close(false); });
|
||||
closeBtn.addEventListener('click', function() { close(false); });
|
||||
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) close(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
close(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.confirmAction = function(opts) {
|
||||
opts = opts || {};
|
||||
|
||||
titleText.textContent = opts.title || 'Confirm';
|
||||
message.innerHTML = opts.message || 'Are you sure?';
|
||||
|
||||
var iconClass = opts.icon || 'fa-exclamation-triangle text-red-500';
|
||||
icon.className = 'fas ' + iconClass + ' mr-2';
|
||||
|
||||
confirmBtn.textContent = opts.confirmText || 'Confirm';
|
||||
cancelBtn.textContent = opts.cancelText || 'Cancel';
|
||||
|
||||
var btnClass = opts.confirmClass || 'bg-red-600 hover:bg-red-700';
|
||||
confirmBtn.className = 'px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ' + btnClass;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
confirmBtn.focus();
|
||||
|
||||
return new Promise(function(resolve) { _resolve = resolve; });
|
||||
};
|
||||
|
||||
window.confirmSubmit = function(e, msg, opts) {
|
||||
e.preventDefault();
|
||||
var form = e.target.closest('form') || e.target;
|
||||
opts = opts || {};
|
||||
opts.message = opts.message || msg;
|
||||
confirmAction(opts).then(function(ok) {
|
||||
if (ok) form.submit();
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
window.confirmClick = function(e, msg, opts) {
|
||||
e.preventDefault();
|
||||
var el = e.currentTarget || e.target.closest('a') || e.target;
|
||||
opts = opts || {};
|
||||
opts.message = opts.message || msg;
|
||||
confirmAction(opts).then(function(ok) {
|
||||
if (ok) {
|
||||
if (el.tagName === 'A' && el.href) {
|
||||
window.location.href = el.href;
|
||||
} else if (el.form) {
|
||||
el.form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
175
app/Views/partials/import-modal.twig
Normal file
175
app/Views/partials/import-modal.twig
Normal file
@@ -0,0 +1,175 @@
|
||||
{#
|
||||
# Shared import modal with drag & drop file upload.
|
||||
#
|
||||
# Parameters:
|
||||
# prefix - Unique prefix for element IDs (e.g. 'tag', 'group', 'tld', 'dnsZone')
|
||||
# title - Modal title (e.g. 'Import Tags')
|
||||
# action - Form POST action URL
|
||||
# accept - File input accept attribute (default: '.csv,.json')
|
||||
# file_hint - Accepted file types hint (default: 'CSV, JSON')
|
||||
# format_html - Raw HTML for the "Expected Format" info block
|
||||
# submit_label - Submit button text (default: title)
|
||||
# input_name - File input name attribute (default: 'import_file')
|
||||
# extra_fields - Optional raw HTML for extra form fields (textarea, etc.)
|
||||
#}
|
||||
|
||||
{% set _accept = accept|default('.csv,.json') %}
|
||||
{% set _file_hint = file_hint|default('CSV, JSON') %}
|
||||
{% set _submit = submit_label|default(title) %}
|
||||
{% set _input_name = input_name|default('import_file') %}
|
||||
|
||||
<div id="{{ prefix }}ImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>{{ title }}
|
||||
</h3>
|
||||
<button onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="{{ action }}" enctype="multipart/form-data" id="{{ prefix }}ImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{% if extra_fields is defined and extra_fields %}
|
||||
{{ extra_fields|raw }}
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="{{ prefix }}Dropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="{{ _input_name }}" accept="{{ _accept }}" required id="{{ prefix }}FileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="{{ prefix }}DropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">{{ _file_hint }} · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="{{ prefix }}DropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="{{ prefix }}FileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="{{ prefix }}FileSize"></p>
|
||||
<button type="button" id="{{ prefix }}FileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if format_html is defined and format_html %}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format
|
||||
</p>
|
||||
{{ format_html|raw }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="{{ prefix }}ImportBtn"
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>{{ _submit }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var pfx = '{{ prefix }}';
|
||||
var dropzone = document.getElementById(pfx + 'Dropzone');
|
||||
if (!dropzone) return;
|
||||
var fileInput = document.getElementById(pfx + 'FileInput');
|
||||
var content = document.getElementById(pfx + 'DropzoneContent');
|
||||
var fileInfo = document.getElementById(pfx + 'DropzoneFile');
|
||||
var fileName = document.getElementById(pfx + 'FileName');
|
||||
var fileSize = document.getElementById(pfx + 'FileSize');
|
||||
var removeBtn = document.getElementById(pfx + 'FileRemove');
|
||||
var form = document.getElementById(pfx + 'ImportForm');
|
||||
var submitBtn = document.getElementById(pfx + 'ImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(function(evt) {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(function(evt) {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
var files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modal = document.getElementById(pfx + 'ImportModal');
|
||||
if (modal && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
182
app/Views/partials/transfer-modal.twig
Normal file
182
app/Views/partials/transfer-modal.twig
Normal file
@@ -0,0 +1,182 @@
|
||||
{# Shared transfer modal component.
|
||||
Include once per page, then call:
|
||||
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>example.com</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: 123 } // or for arrays: { 'tag_ids[]': [1,2,3] }
|
||||
submitText: 'Transfer', // optional, defaults to 'Transfer'
|
||||
users: [...], // user objects with id, username, full_name/email
|
||||
csrfToken: '...'
|
||||
});
|
||||
#}
|
||||
<script>
|
||||
(function() {
|
||||
const _esc = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
function _buildSearchableSelect(container, hiddenInput, users) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative';
|
||||
wrapper.innerHTML = `
|
||||
<div class="transfer-picker-selected hidden flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary transition-colors">
|
||||
<span class="transfer-picker-selected-text truncate"></span>
|
||||
<button type="button" class="transfer-picker-clear ml-2 text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 flex-shrink-0" title="Clear selection">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="transfer-picker-search-wrap">
|
||||
<div class="relative">
|
||||
<input type="text" class="transfer-picker-search w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search users..." autocomplete="off">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="transfer-picker-list mt-1 max-h-48 overflow-y-auto border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 shadow-lg"></div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(wrapper);
|
||||
|
||||
const searchInput = wrapper.querySelector('.transfer-picker-search');
|
||||
const searchWrap = wrapper.querySelector('.transfer-picker-search-wrap');
|
||||
const listEl = wrapper.querySelector('.transfer-picker-list');
|
||||
const selectedEl = wrapper.querySelector('.transfer-picker-selected');
|
||||
const selectedText = wrapper.querySelector('.transfer-picker-selected-text');
|
||||
const clearBtn = wrapper.querySelector('.transfer-picker-clear');
|
||||
|
||||
function renderList(filter) {
|
||||
const query = (filter || '').toLowerCase();
|
||||
const filtered = users.filter(u => {
|
||||
const uname = (u.username || '').toLowerCase();
|
||||
const fname = (u.full_name || u.email || '').toLowerCase();
|
||||
return uname.includes(query) || fname.includes(query);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="px-3 py-2.5 text-sm text-gray-400 dark:text-slate-500 italic">No users found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(u =>
|
||||
`<div class="transfer-picker-item px-3 py-2.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-900 dark:text-white flex items-center justify-between transition-colors" data-user-id="${u.id}">
|
||||
<div>
|
||||
<span class="font-medium">${_esc(u.username)}</span>
|
||||
<span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(u.full_name || u.email || 'No name')})</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-xs text-gray-300 dark:text-slate-600"></i>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
listEl.querySelectorAll('.transfer-picker-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const userId = item.dataset.userId;
|
||||
const user = users.find(u => String(u.id) === userId);
|
||||
if (!user) return;
|
||||
hiddenInput.value = userId;
|
||||
selectedText.innerHTML = `<i class="fas fa-user mr-2 text-primary"></i><span class="font-medium">${_esc(user.username)}</span> <span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(user.full_name || user.email || 'No name')})</span>`;
|
||||
selectedEl.classList.remove('hidden');
|
||||
searchWrap.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderList('');
|
||||
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hiddenInput.value = '';
|
||||
selectedEl.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
searchInput.value = '';
|
||||
renderList('');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
selectedEl.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.transfer-picker-clear')) return;
|
||||
selectedEl.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
|
||||
window.openTransferModal = function(opts) {
|
||||
const users = opts.users || [];
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
let fieldsHtml = '';
|
||||
if (opts.fields) {
|
||||
Object.entries(opts.fields).forEach(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(v)}">`;
|
||||
});
|
||||
} else {
|
||||
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(value)}">`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<form method="POST" action="${_esc(opts.action)}" onsubmit="return !!this.querySelector('input[name=target_user_id]').value">
|
||||
<input type="hidden" name="csrf_token" value="${_esc(opts.csrfToken)}">
|
||||
${fieldsHtml}
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-exchange-alt text-primary mr-2"></i>${opts.title || 'Transfer'}
|
||||
</h3>
|
||||
<button type="button" class="transfer-modal-cancel text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
${opts.description ? `<p class="text-sm text-gray-500 dark:text-slate-400">${opts.description}</p>` : ''}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Transfer to User</label>
|
||||
<input type="hidden" name="target_user_id" value="">
|
||||
<div class="user-picker-mount"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" class="transfer-modal-cancel px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-exchange-alt mr-1.5"></i>${_esc(opts.submitText || 'Transfer')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const mount = modal.querySelector('.user-picker-mount');
|
||||
const hiddenInput = modal.querySelector('input[name="target_user_id"]');
|
||||
_buildSearchableSelect(mount, hiddenInput, users);
|
||||
|
||||
modal.querySelectorAll('.transfer-modal-cancel').forEach(btn => {
|
||||
btn.addEventListener('click', () => modal.remove());
|
||||
});
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
});
|
||||
document.addEventListener('keydown', function handler(e) {
|
||||
if (e.key === 'Escape' && document.body.contains(modal)) {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@@ -158,7 +158,7 @@
|
||||
{{ csrf_field() }}
|
||||
<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"
|
||||
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>
|
||||
Remove
|
||||
</button>
|
||||
@@ -350,7 +350,7 @@
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-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() }}
|
||||
<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>
|
||||
@@ -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>
|
||||
</div>
|
||||
{% 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() }}
|
||||
<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>
|
||||
@@ -578,7 +578,7 @@
|
||||
|
||||
<!-- Delete Button (only for non-current sessions) -->
|
||||
{% 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() }}
|
||||
<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>
|
||||
@@ -712,12 +712,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
function confirmDelete() {
|
||||
if (confirm('Are you absolutely sure you want to delete your account?\n\nThis action is PERMANENT and cannot be undone!')) {
|
||||
if (confirm('FINAL WARNING: This will permanently delete all your data.\n\nClick OK to proceed.')) {
|
||||
document.getElementById('deleteAccountForm').submit();
|
||||
}
|
||||
}
|
||||
async function confirmDelete() {
|
||||
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 (!ok) return;
|
||||
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() {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
||||
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
||||
{% set lastDnsCheckRun = settings.last_dns_check_run|default(null) %}
|
||||
{% set currentSslCheckInterval = settings.ssl_check_interval_hours|default('12') %}
|
||||
{% set lastSslCheckRun = settings.last_ssl_check_run|default(null) %}
|
||||
{% set currentVer = appSettings.app_version|default('0') %}
|
||||
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
||||
|
||||
@@ -91,25 +93,27 @@
|
||||
</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
|
||||
</label>
|
||||
<select id="app_timezone" name="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">
|
||||
{% for tz, label in popularTimezones %}
|
||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option disabled>──────────</option>
|
||||
{% for tz in allTimezones %}
|
||||
{% if tz not in popularTimezones|keys %}
|
||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
||||
{{ tz }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" id="app_timezone" name="app_timezone" value="{{ appSettings.app_timezone }}" required>
|
||||
<div id="tz-picker" class="relative">
|
||||
<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">
|
||||
<span id="tz-selected-text">
|
||||
<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') }}
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400 dark:text-slate-500"></i>
|
||||
</div>
|
||||
<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">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-slate-600">
|
||||
<div class="relative">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -792,6 +796,10 @@
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
|
||||
<code>php cron/check_dns.php</code>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">SSL certificate check</p>
|
||||
<code>php cron/check_ssl.php</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -811,7 +819,13 @@
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">DNS check (every 6 hours)</p>
|
||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
||||
<code>0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}</code>
|
||||
<code>0 0,6,12,18 * * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">SSL check (every {{ currentSslCheckInterval }}h)</p>
|
||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
||||
<code>0 */{{ currentSslCheckInterval }} * * * php {{ cronPath|replace({'check_domains.php': 'check_ssl.php'}) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -824,7 +838,7 @@
|
||||
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
|
||||
Last Cronjob Run
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
|
||||
@@ -869,6 +883,23 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">SSL</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_ssl.php</p>
|
||||
</div>
|
||||
{% if lastSslCheckRun %}
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-gray-700 dark:text-slate-300">{{ lastSslCheckRun|date('M d, Y H:i') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Never run</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -893,6 +924,13 @@
|
||||
</div>
|
||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">SSL Cron Log</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">SSL certificate monitoring logs</p>
|
||||
</div>
|
||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/ssl_cron.log</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
||||
@@ -934,7 +972,7 @@
|
||||
</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() }}
|
||||
<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>
|
||||
@@ -1132,7 +1170,7 @@
|
||||
</div>
|
||||
|
||||
<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() }}
|
||||
<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>
|
||||
@@ -1413,13 +1451,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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');
|
||||
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) + '">' : '') +
|
||||
'<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>' +
|
||||
'</form>';
|
||||
} 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) + '">' : '') +
|
||||
'<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>' +
|
||||
@@ -1555,5 +1593,99 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCaptchaUI();
|
||||
}
|
||||
});
|
||||
|
||||
(function() {
|
||||
const popularTimezones = {{ popularTimezones|json_encode|raw }};
|
||||
const allTimezones = {{ allTimezones|json_encode|raw }};
|
||||
|
||||
const hiddenInput = document.getElementById('app_timezone');
|
||||
const selectedEl = document.getElementById('tz-selected');
|
||||
const selectedText = document.getElementById('tz-selected-text');
|
||||
const dropdown = document.getElementById('tz-dropdown');
|
||||
const searchInput = document.getElementById('tz-search');
|
||||
const listEl = document.getElementById('tz-list');
|
||||
|
||||
const popularKeys = Object.keys(popularTimezones);
|
||||
const otherTimezones = allTimezones.filter(tz => !popularKeys.includes(tz));
|
||||
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
function renderList(filter) {
|
||||
const query = (filter || '').toLowerCase();
|
||||
|
||||
const matchPopular = popularKeys.filter(tz => {
|
||||
const label = (popularTimezones[tz] || '').toLowerCase();
|
||||
return tz.toLowerCase().includes(query) || label.includes(query);
|
||||
});
|
||||
|
||||
const matchOther = otherTimezones.filter(tz => tz.toLowerCase().includes(query));
|
||||
|
||||
if (matchPopular.length === 0 && matchOther.length === 0) {
|
||||
listEl.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400 dark:text-slate-500 italic">No timezones found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (matchPopular.length > 0) {
|
||||
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800">Popular</div>';
|
||||
html += matchPopular.map(tz =>
|
||||
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||
<span>${esc(popularTimezones[tz])}</span>
|
||||
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (matchOther.length > 0) {
|
||||
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800 border-t border-gray-200 dark:border-slate-600">All Timezones</div>';
|
||||
const capped = matchOther.slice(0, 50);
|
||||
html += capped.map(tz =>
|
||||
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||
<span>${esc(tz)}</span>
|
||||
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
if (matchOther.length > 50) {
|
||||
html += '<div class="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic">Type to narrow down ' + (matchOther.length - 50) + ' more...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
listEl.innerHTML = html;
|
||||
|
||||
listEl.querySelectorAll('.tz-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const tz = item.dataset.tz;
|
||||
hiddenInput.value = tz;
|
||||
const label = popularTimezones[tz] || tz;
|
||||
selectedText.innerHTML = '<i class="fas fa-globe mr-2 text-primary"></i>' + esc(label);
|
||||
dropdown.classList.add('hidden');
|
||||
searchInput.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectedEl.addEventListener('click', () => {
|
||||
dropdown.classList.toggle('hidden');
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
renderList('');
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!document.getElementById('tz-picker').contains(e.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{# 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>
|
||||
Import
|
||||
</button>
|
||||
@@ -198,7 +198,7 @@
|
||||
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</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"
|
||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
@@ -256,7 +256,7 @@
|
||||
class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</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"
|
||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
@@ -455,63 +455,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import Modal #}
|
||||
<div id="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>Import Tags
|
||||
</h3>
|
||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tagDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="tagDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tagFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tagFileSize"></p>
|
||||
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('importModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'tag',
|
||||
title: 'Import Tags',
|
||||
action: '/tags/import',
|
||||
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>'
|
||||
} %}
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
@@ -561,13 +510,12 @@ function getSelectedIds() {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function bulkDeleteTags() {
|
||||
async function bulkDeleteTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (!confirm(`Delete ${ids.length} tag(s)? This will remove them from all domains.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' tag(s)? This will remove them from all domains.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -592,46 +540,15 @@ function bulkDeleteTags() {
|
||||
}
|
||||
|
||||
function transferTag(tagId, tagName) {
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(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 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);
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Tag',
|
||||
description: 'Transfer tag <strong>' + esc(tagName) + '</strong> to another user.',
|
||||
action: '/tags/transfer',
|
||||
fields: { tag_id: tagId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
@@ -648,49 +565,15 @@ function bulkTransferTags() {
|
||||
alert('Please select tags to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
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 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);
|
||||
openTransferModal({
|
||||
title: 'Transfer Tags',
|
||||
description: 'Transfer ' + ids.length + ' selected tag(s) to another user.',
|
||||
action: '/tags/bulk-transfer',
|
||||
fields: { 'tag_ids[]': ids },
|
||||
submitText: 'Transfer All',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
@@ -716,8 +599,9 @@ function closeEditModal() {
|
||||
document.getElementById('editModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function deleteTag(id, name) {
|
||||
if (confirm(`Are you sure you want to delete the tag "${name}"? This will remove it from all domains.`)) {
|
||||
async function deleteTag(id, name) {
|
||||
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');
|
||||
form.method = 'POST';
|
||||
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) {
|
||||
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>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -210,16 +210,22 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<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>
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
@@ -273,6 +279,12 @@
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
||||
</button>
|
||||
</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>
|
||||
{% endfor %}
|
||||
@@ -348,4 +360,28 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if auth.isAdmin %}
|
||||
<script>
|
||||
function transferDomain(domainId, domainName) {
|
||||
var esc = function(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); };
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: domainId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.domain-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div>
|
||||
{% if tld.registry_url %}
|
||||
<div class="text-sm text-gray-500 dark:text-slate-400">
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-primary hover:text-primary-dark">
|
||||
<a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
@@ -348,10 +348,10 @@
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% 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>
|
||||
</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>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -375,7 +375,7 @@
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3>
|
||||
{% if tld.registry_url %}
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-xs text-primary hover:text-primary-dark">
|
||||
<a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
@@ -421,7 +421,7 @@
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
{% 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
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -584,63 +584,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import TLD Modal #}
|
||||
<div id="tldImportModal" 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>Import TLDs
|
||||
</h3>
|
||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tldDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="tldDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tldFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tldFileSize"></p>
|
||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'tld',
|
||||
title: 'Import TLDs',
|
||||
action: '/tld-registry/import',
|
||||
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>'
|
||||
} %}
|
||||
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
@@ -691,14 +640,15 @@ function clearSelection() {
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
async function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Please select TLDs to delete');
|
||||
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');
|
||||
checkboxes.forEach(checkbox => {
|
||||
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>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{% 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>
|
||||
Refresh
|
||||
</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>
|
||||
Toggle
|
||||
</a>
|
||||
@@ -60,7 +60,7 @@
|
||||
{% if tld.registry_url %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registry URL</label>
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center">
|
||||
<a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit Registry
|
||||
</a>
|
||||
@@ -215,13 +215,13 @@
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
{% 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">
|
||||
<i class="fas fa-sync-alt text-sm"></i>
|
||||
</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>
|
||||
</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">
|
||||
<i class="fas fa-power-off text-sm"></i>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if tld.registry_url %}
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-all duration-200 group">
|
||||
<a href="{{ tld.registry_url|safe_url }}" target="_blank" rel="noopener noreferrer" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-all duration-200 group">
|
||||
<div class="w-9 h-9 bg-blue-50 dark:bg-blue-500/10 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 dark:text-blue-400 transition-colors duration-200">
|
||||
<i class="fas fa-external-link-alt text-sm"></i>
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ function getSelectedUserIds() {
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkToggleStatus(action) {
|
||||
async function bulkToggleStatus(action) {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
@@ -405,9 +405,8 @@ function bulkToggleStatus(action) {
|
||||
}
|
||||
|
||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
||||
return;
|
||||
}
|
||||
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' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -450,10 +449,9 @@ function toggleUserStatus(userId) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
async function deleteUser(userId) {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete this user? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -469,7 +467,7 @@ function deleteUser(userId) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDeleteUsers() {
|
||||
async function bulkDeleteUsers() {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
@@ -477,9 +475,8 @@ function bulkDeleteUsers() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + userIds.length + ' user(s)? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
|
||||
@@ -48,12 +48,12 @@
|
||||
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
||||
{{ csrf_field() }}
|
||||
{% 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>
|
||||
Deactivate
|
||||
</button>
|
||||
{% 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>
|
||||
Activate
|
||||
</button>
|
||||
@@ -61,7 +61,7 @@
|
||||
</form>
|
||||
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
||||
{{ 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>
|
||||
Delete
|
||||
</button>
|
||||
@@ -529,8 +529,15 @@
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-slate-400">
|
||||
{{ domain.group_name|default('—') }}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -69,6 +69,15 @@ class SessionConfig
|
||||
*/
|
||||
public static function start(): void
|
||||
{
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => !empty($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
|
||||
// Validate session exists in database (for database-backed sessions)
|
||||
|
||||
@@ -239,6 +239,26 @@ class TwigService
|
||||
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
|
||||
}));
|
||||
|
||||
$this->twig->addFilter(new TwigFilter('safe_url', function (?string $url): string {
|
||||
if ($url === null || $url === '') {
|
||||
return '#';
|
||||
}
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
return $url;
|
||||
}
|
||||
return '#';
|
||||
}));
|
||||
|
||||
$this->twig->addFilter(new TwigFilter('safe_mailto', function (?string $email): string {
|
||||
if ($email === null || $email === '') {
|
||||
return '#';
|
||||
}
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return 'mailto:' . $email;
|
||||
}
|
||||
return '#';
|
||||
}));
|
||||
|
||||
$this->twig->addFilter(new TwigFilter('from_json', function ($value) {
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
|
||||
@@ -4,20 +4,18 @@
|
||||
/**
|
||||
* 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).
|
||||
* No discovery (brute force / crt.sh) — use discover_dns.php for that.
|
||||
*
|
||||
* Also handles crt.sh subdomain fetching internally via self-invocation
|
||||
* with a hard timeout (no separate script needed).
|
||||
* Also serves as the crt.sh subprocess entry point (--crtsh) for
|
||||
* DnsService::fetchCrtshSubdomains() used by discover_dns.php.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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';
|
||||
@@ -33,6 +31,7 @@ use App\Models\User;
|
||||
use App\Services\DnsService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Logger;
|
||||
use App\Helpers\CronHelper;
|
||||
use Core\Database;
|
||||
|
||||
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
||||
@@ -57,15 +56,6 @@ if (php_sapi_name() !== 'cli') {
|
||||
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 */
|
||||
const INTER_DOMAIN_DELAY_US = 500000;
|
||||
|
||||
@@ -91,16 +81,32 @@ try {
|
||||
}
|
||||
|
||||
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
||||
$cron = new CronHelper($logFile);
|
||||
$startTime = microtime(true);
|
||||
|
||||
logMessage("=== Starting DNS check cron job ===");
|
||||
|
||||
$domains = $domainModel->where('is_active', 1);
|
||||
$domains = array_values(array_filter($domains, fn($d) => ($d['dns_monitoring_enabled'] ?? 1) == 1));
|
||||
logMessage("Found " . count($domains) . " domain(s) with DNS monitoring enabled");
|
||||
// Only check domains that are registered and in use (active or expiring_soon).
|
||||
// Skip available, expired, error, redemption_period, pending_delete — they typically have no DNS.
|
||||
$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);
|
||||
}));
|
||||
$skippedByStatus = count($allDnsEnabled) - count($domains);
|
||||
logMessage("Found " . count($domains) . " domain(s) with DNS monitoring enabled and checkable status (active/expiring_soon)");
|
||||
if ($skippedByStatus > 0) {
|
||||
logMessage("Skipped " . $skippedByStatus . " domain(s) with non-checkable status (available/expired/error/redemption_period/pending_delete)");
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'checked' => 0,
|
||||
'skipped_by_status' => $skippedByStatus,
|
||||
'changes_detected' => 0,
|
||||
'records_added' => 0,
|
||||
'records_removed' => 0,
|
||||
@@ -109,8 +115,6 @@ $stats = [
|
||||
'in_app_notifications' => 0,
|
||||
'errors' => 0,
|
||||
'skipped_unresolved' => 0,
|
||||
'crtsh_skipped' => 0,
|
||||
'crtsh_fetched' => 0,
|
||||
'domains_with_changes' => [],
|
||||
];
|
||||
|
||||
@@ -123,7 +127,7 @@ foreach ($domains as $domain) {
|
||||
|
||||
try {
|
||||
// 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");
|
||||
logTimeSince($domainStartTime);
|
||||
$stats['skipped_unresolved']++;
|
||||
@@ -133,39 +137,10 @@ foreach ($domains as $domain) {
|
||||
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
||||
$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']);
|
||||
|
||||
// Decide whether to call crt.sh or use cached hosts
|
||||
$ctSubs = [];
|
||||
|
||||
if (shouldFetchCrtsh($domain, $existingHosts)) {
|
||||
logMessage(" 🔍 crt.sh: fetching subdomains...");
|
||||
|
||||
[$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName);
|
||||
|
||||
logMessage(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
||||
$stats['crtsh_fetched']++;
|
||||
|
||||
// Update timestamp if server responded (200 OK).
|
||||
// Empty [] is valid (no CT entries) — still counts as a successful fetch.
|
||||
// Only skip update if all attempts 503'd / timed out.
|
||||
if ($crtshOk) {
|
||||
$domainModel->update($domain['id'], [
|
||||
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in "
|
||||
. crtshHoursUntilRefresh($domain) . "h)");
|
||||
$stats['crtsh_skipped']++;
|
||||
}
|
||||
|
||||
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
||||
|
||||
// Fetch fresh DNS records
|
||||
$newRecords = $dnsService->lookup($domainName, $extraSubs);
|
||||
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||
$newRecords = $dnsService->refreshExisting($domainName, $existingHosts);
|
||||
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||
|
||||
if ($totalRecords === 0) {
|
||||
logMessage(" ⚠ No DNS records found for $domainName");
|
||||
@@ -247,55 +222,13 @@ exit(0);
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Crt.sh smart caching
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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)
|
||||
// Crt.sh subprocess entry point (invoked by DnsService::fetchCrtshSubdomains)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Internal crt.sh subprocess entry point.
|
||||
* 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.
|
||||
* All HTTP response details are written to stderr for real-time debugging.
|
||||
@@ -314,6 +247,7 @@ function runCrtshSubprocess(array $argv): void
|
||||
$retryDelay = 10;
|
||||
$httpTimeout = 900;
|
||||
|
||||
$dnsService = new DnsService();
|
||||
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
||||
|
||||
try {
|
||||
@@ -323,13 +257,12 @@ function runCrtshSubprocess(array $argv): void
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
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) {
|
||||
$gotHttp200 = true;
|
||||
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");
|
||||
} else {
|
||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
||||
@@ -337,7 +270,6 @@ function runCrtshSubprocess(array $argv): void
|
||||
break;
|
||||
}
|
||||
|
||||
// Non-200 (503, timeout, connection error) — retry
|
||||
if ($attempt < $maxAttempts) {
|
||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
||||
sleep($retryDelay);
|
||||
@@ -346,7 +278,6 @@ function runCrtshSubprocess(array $argv): void
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cap
|
||||
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
||||
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
||||
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
||||
@@ -361,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
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@@ -770,72 +458,29 @@ function sendInAppNotifications(
|
||||
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Logging / formatting helpers
|
||||
// Logging helpers (thin wrappers around CronHelper)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function logMessage(string $message): void
|
||||
{
|
||||
global $logFile;
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$line = "[$timestamp] $message\n";
|
||||
file_put_contents($logFile, $line, FILE_APPEND);
|
||||
echo $line;
|
||||
global $cron;
|
||||
$cron->log($message);
|
||||
}
|
||||
|
||||
function logTimeSince(float $since): void
|
||||
{
|
||||
logMessage(" ⏱ " . formatDuration(microtime(true) - $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 ?: '';
|
||||
global $cron;
|
||||
$cron->logTimeSince($since);
|
||||
}
|
||||
|
||||
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("Domains checked: {$stats['checked']}");
|
||||
logMessage("Skipped (by status): {$stats['skipped_by_status']}");
|
||||
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("Records added: {$stats['records_added']}");
|
||||
logMessage("Records removed: {$stats['records_removed']}");
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Models\User;
|
||||
use App\Services\WhoisService;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\UpdateService;
|
||||
use App\Helpers\CronHelper;
|
||||
use Core\Database;
|
||||
|
||||
// Load environment variables
|
||||
@@ -56,27 +57,11 @@ try {
|
||||
|
||||
// Log file
|
||||
$logFile = __DIR__ . '/../logs/cron.log';
|
||||
$cron = new CronHelper($logFile);
|
||||
|
||||
function logMessage(string $message) {
|
||||
global $logFile;
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
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);
|
||||
}
|
||||
function logMessage(string $message): void {
|
||||
global $cron;
|
||||
$cron->log($message);
|
||||
}
|
||||
|
||||
// Record start time
|
||||
@@ -806,7 +791,7 @@ $settingModel->updateLastCheckRun();
|
||||
// Calculate elapsed time
|
||||
$endTime = microtime(true);
|
||||
$elapsedTime = $endTime - $startTime;
|
||||
$formattedTime = formatElapsedTime($elapsedTime);
|
||||
$formattedTime = CronHelper::formatElapsedTime($elapsedTime);
|
||||
|
||||
// Summary
|
||||
logMessage("\n=== Cron job completed ===");
|
||||
|
||||
363
cron/check_ssl.php
Normal file
363
cron/check_ssl.php
Normal file
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SSL Certificate Monitoring Cron Job
|
||||
*
|
||||
* Checks tracked SSL endpoints for active domains with SSL monitoring enabled.
|
||||
* If no root endpoint is tracked yet, the root domain falls back to port 443.
|
||||
* Sends notifications when an SSL state changes or when the first monitored
|
||||
* baseline already has an issue.
|
||||
*
|
||||
* Usage: php cron/check_ssl.php
|
||||
* Recommended schedule: run at minute 0 every 12 hours.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use App\Models\Domain;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationGroup;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\User;
|
||||
use App\Services\Logger;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\SslService;
|
||||
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();
|
||||
$sslModel = new SslCertificate();
|
||||
$channelModel = new NotificationChannel();
|
||||
$groupModel = new NotificationGroup();
|
||||
$logModel = new NotificationLog();
|
||||
$settingModel = new Setting();
|
||||
$userModel = new User();
|
||||
$sslService = new SslService();
|
||||
$notificationService = new NotificationService();
|
||||
$logger = new Logger('ssl-cron');
|
||||
|
||||
try {
|
||||
$appSettings = $settingModel->getAppSettings();
|
||||
date_default_timezone_set($appSettings['app_timezone']);
|
||||
} catch (\Exception $e) {
|
||||
date_default_timezone_set('UTC');
|
||||
}
|
||||
|
||||
$logFile = __DIR__ . '/../logs/ssl_cron.log';
|
||||
$cron = new CronHelper($logFile);
|
||||
$startTime = microtime(true);
|
||||
|
||||
logMessage("=== Starting SSL check cron job ===");
|
||||
|
||||
// Only check domains that are registered and in use (active or expiring_soon).
|
||||
// Skip available, expired, error, redemption_period, pending_delete — they typically have no DNS/SSL.
|
||||
$checkableStatuses = ['active', 'expiring_soon'];
|
||||
|
||||
$allSslEnabled = array_values(array_filter(
|
||||
$domainModel->where('is_active', 1),
|
||||
static fn(array $d): bool => ($d['ssl_monitoring_enabled'] ?? 0) == 1
|
||||
));
|
||||
$domains = array_values(array_filter($allSslEnabled, static function (array $domain) use ($checkableStatuses): bool {
|
||||
$status = strtolower($domain['status'] ?? '');
|
||||
return in_array($status, $checkableStatuses, true);
|
||||
}));
|
||||
$skippedByStatus = count($allSslEnabled) - count($domains);
|
||||
logMessage("Found " . count($domains) . " domain(s) with SSL monitoring enabled and checkable status (active/expiring_soon)");
|
||||
if ($skippedByStatus > 0) {
|
||||
logMessage("Skipped " . $skippedByStatus . " domain(s) with non-checkable status (available/expired/error/redemption_period/pending_delete)");
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'checked_domains' => 0,
|
||||
'checked_hosts' => 0,
|
||||
'skipped_by_status' => $skippedByStatus,
|
||||
'skipped_unresolved' => 0,
|
||||
'issues_detected' => 0,
|
||||
'notifications_sent' => 0,
|
||||
'in_app_notifications' => 0,
|
||||
'errors' => 0,
|
||||
'status_changes' => 0,
|
||||
];
|
||||
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$domainName = strtolower($domain['domain_name']);
|
||||
$domainStart = microtime(true);
|
||||
logMessage("Checking SSL: {$domainName}");
|
||||
|
||||
try {
|
||||
$targets = $sslModel->getDistinctTargets($domain['id']);
|
||||
$hasTrackedRootTarget = false;
|
||||
|
||||
foreach ($targets as $target) {
|
||||
if ($target['hostname'] === $domainName) {
|
||||
$hasTrackedRootTarget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasTrackedRootTarget) {
|
||||
$targets[] = [
|
||||
'hostname' => $domainName,
|
||||
'port' => 443,
|
||||
];
|
||||
}
|
||||
|
||||
usort($targets, static function (array $a, array $b): int {
|
||||
$hostnameCompare = strcasecmp($a['hostname'], $b['hostname']);
|
||||
if ($hostnameCompare !== 0) {
|
||||
return $hostnameCompare;
|
||||
}
|
||||
|
||||
return ((int)$a['port']) <=> ((int)$b['port']);
|
||||
});
|
||||
|
||||
$domainIssues = 0;
|
||||
$domainStatusChanges = 0;
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$hostname = $target['hostname'];
|
||||
$port = (int)($target['port'] ?? 443);
|
||||
$endpointLabel = $sslService->formatTargetLabel($hostname, $port);
|
||||
|
||||
if (!CronHelper::hostnameResolves($hostname)) {
|
||||
logMessage(" {$endpointLabel}: skipped (hostname does not resolve)");
|
||||
$stats['skipped_unresolved']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $sslModel->findByDomainAndHost($domain['id'], $hostname, $port);
|
||||
$previousStatus = $existing['status'] ?? null;
|
||||
|
||||
$snapshot = $sslService->fetchCertificateSnapshot($hostname, $port);
|
||||
$sslModel->saveSnapshot($domain['id'], $hostname, $snapshot, $port);
|
||||
$stats['checked_hosts']++;
|
||||
|
||||
$status = $snapshot['status'];
|
||||
$isIssue = in_array($status, ['expiring', 'expired', 'invalid'], true);
|
||||
if ($isIssue) {
|
||||
$domainIssues++;
|
||||
$stats['issues_detected']++;
|
||||
}
|
||||
|
||||
$statusChanged = $previousStatus !== null && $previousStatus !== $status;
|
||||
$firstIssueBaseline = $previousStatus === null && $isIssue;
|
||||
|
||||
logMessage(
|
||||
" {$endpointLabel}: {$status}" .
|
||||
($snapshot['valid_to'] ? " (valid_to: {$snapshot['valid_to']})" : '') .
|
||||
($snapshot['last_error'] ? " (error: {$snapshot['last_error']})" : '')
|
||||
);
|
||||
|
||||
if (!$statusChanged && !$firstIssueBaseline) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$domainStatusChanges++;
|
||||
$stats['status_changes']++;
|
||||
|
||||
sendExternalSslNotifications(
|
||||
$domain,
|
||||
$endpointLabel,
|
||||
$status,
|
||||
$previousStatus,
|
||||
$snapshot,
|
||||
$channelModel,
|
||||
$logModel,
|
||||
$notificationService,
|
||||
$stats
|
||||
);
|
||||
|
||||
sendInAppSslNotifications(
|
||||
$domain,
|
||||
$endpointLabel,
|
||||
$status,
|
||||
$previousStatus,
|
||||
$isolationMode,
|
||||
$userModel,
|
||||
$groupModel,
|
||||
$notificationService,
|
||||
$stats
|
||||
);
|
||||
}
|
||||
|
||||
$domainModel->update($domain['id'], ['ssl_last_checked' => date('Y-m-d H:i:s')]);
|
||||
$stats['checked_domains']++;
|
||||
|
||||
if ($domainStatusChanges === 0) {
|
||||
logMessage(" -> No SSL status changes detected");
|
||||
} else {
|
||||
logMessage(" -> {$domainStatusChanges} SSL status change(s) detected");
|
||||
}
|
||||
|
||||
if ($domainIssues > 0) {
|
||||
logMessage(" -> {$domainIssues} issue host(s) currently detected");
|
||||
}
|
||||
|
||||
logTimeSince($domainStart);
|
||||
usleep(250000);
|
||||
} catch (\Exception $e) {
|
||||
logMessage(" x Error: " . $e->getMessage());
|
||||
logTimeSince($domainStart);
|
||||
$logger->error('SSL check failed', [
|
||||
'domain' => $domainName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
$settingModel->setValue('last_ssl_check_run', date('Y-m-d H:i:s'));
|
||||
|
||||
logMessage("\n=== SSL cron job completed ===");
|
||||
logMessage("Domains checked: {$stats['checked_domains']}");
|
||||
logMessage("Domains skipped: {$stats['skipped_by_status']} (non-checkable status)");
|
||||
logMessage("Endpoints skipped: {$stats['skipped_unresolved']} (hostname does not resolve)");
|
||||
logMessage("Endpoints checked: {$stats['checked_hosts']}");
|
||||
logMessage("Status changes: {$stats['status_changes']}");
|
||||
logMessage("Issue endpoints: {$stats['issues_detected']}");
|
||||
logMessage("External notifications: {$stats['notifications_sent']}");
|
||||
logMessage("In-app notifications: {$stats['in_app_notifications']}");
|
||||
logMessage("Errors: {$stats['errors']}");
|
||||
logMessage("Execution time: " . CronHelper::formatElapsedTime(microtime(true) - $startTime));
|
||||
logMessage("============================\n");
|
||||
|
||||
exit(0);
|
||||
|
||||
function sendExternalSslNotifications(
|
||||
array $domain,
|
||||
string $hostname,
|
||||
string $status,
|
||||
?string $previousStatus,
|
||||
array $snapshot,
|
||||
NotificationChannel $channelModel,
|
||||
NotificationLog $logModel,
|
||||
NotificationService $notificationService,
|
||||
array &$stats
|
||||
): void {
|
||||
if (empty($domain['notification_group_id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$channels = $channelModel->getActiveByGroupId($domain['notification_group_id']);
|
||||
if (empty($channels)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logMessage(" -> Sending SSL alerts to " . count($channels) . " channel(s)");
|
||||
|
||||
$results = $notificationService->sendSslStatusAlert(
|
||||
$domain,
|
||||
$channels,
|
||||
$hostname,
|
||||
$status,
|
||||
$previousStatus,
|
||||
$snapshot['valid_to'] ?? null,
|
||||
$snapshot['last_error'] ?? null
|
||||
);
|
||||
|
||||
foreach ($results as $result) {
|
||||
$success = $result['success'];
|
||||
if ($success) {
|
||||
$stats['notifications_sent']++;
|
||||
}
|
||||
|
||||
logMessage($success
|
||||
? " + Sent to {$result['channel']}"
|
||||
: " - Failed: {$result['channel']}"
|
||||
);
|
||||
|
||||
$logModel->log(
|
||||
$domain['id'],
|
||||
'ssl_status_' . $status,
|
||||
$result['channel'],
|
||||
"SSL status for {$hostname}: {$status}",
|
||||
$success,
|
||||
$success ? null : 'Failed to send SSL status notification'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sendInAppSslNotifications(
|
||||
array $domain,
|
||||
string $hostname,
|
||||
string $status,
|
||||
?string $previousStatus,
|
||||
string $isolationMode,
|
||||
User $userModel,
|
||||
NotificationGroup $groupModel,
|
||||
NotificationService $notificationService,
|
||||
array &$stats
|
||||
): void {
|
||||
$usersToNotify = [];
|
||||
|
||||
if ($isolationMode === 'isolated') {
|
||||
$userId = $domain['user_id'] ?? null;
|
||||
|
||||
if (!$userId && !empty($domain['notification_group_id'])) {
|
||||
$group = $groupModel->find($domain['notification_group_id']);
|
||||
$userId = $group['user_id'] ?? null;
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
$usersToNotify[] = $userId;
|
||||
}
|
||||
} else {
|
||||
foreach ($userModel->where('is_active', 1) as $user) {
|
||||
$usersToNotify[] = $user['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($usersToNotify)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifiedCount = 0;
|
||||
|
||||
foreach ($usersToNotify as $userId) {
|
||||
try {
|
||||
$notificationService->notifySslStatusChange(
|
||||
$userId,
|
||||
$domain['domain_name'],
|
||||
$hostname,
|
||||
$domain['id'],
|
||||
$status,
|
||||
$previousStatus
|
||||
);
|
||||
$notifiedCount++;
|
||||
} catch (\Exception $e) {
|
||||
logMessage(" ! In-app SSL notification failed for user {$userId}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($notifiedCount > 0) {
|
||||
logMessage(" -> Notified {$notifiedCount} user(s) in-app");
|
||||
$stats['in_app_notifications'] += $notifiedCount;
|
||||
}
|
||||
}
|
||||
|
||||
function logMessage(string $message): void
|
||||
{
|
||||
global $cron;
|
||||
$cron->log($message);
|
||||
}
|
||||
|
||||
function logTimeSince(float $since): void
|
||||
{
|
||||
global $cron;
|
||||
$cron->logTimeSince($since, ' -> ');
|
||||
}
|
||||
225
cron/discover_dns.php
Normal file
225
cron/discover_dns.php
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* DNS Discovery Script
|
||||
*
|
||||
* Performs DNS subdomain discovery via brute force wordlist, crt.sh Certificate
|
||||
* Transparency logs, and wildcard detection. Separate from check_dns.php which
|
||||
* only re-checks existing records.
|
||||
*
|
||||
* Usage:
|
||||
* php cron/discover_dns.php — deep scan all domains
|
||||
* php cron/discover_dns.php --domain example.com — deep scan single domain
|
||||
* php cron/discover_dns.php --domain example.com --quick — quick scan single domain
|
||||
*
|
||||
* Crontab (optional, weekly): 0 3 * * 0 /usr/bin/php /path/to/project/cron/discover_dns.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use App\Models\Domain;
|
||||
use App\Models\DnsRecord;
|
||||
use App\Services\DnsService;
|
||||
use App\Services\Logger;
|
||||
use App\Helpers\CronHelper;
|
||||
use Core\Database;
|
||||
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
||||
$dotenv->load();
|
||||
new Database();
|
||||
|
||||
$domainModel = new Domain();
|
||||
$dnsModel = new DnsRecord();
|
||||
$dnsService = new DnsService();
|
||||
$logger = new Logger('dns-discover');
|
||||
|
||||
$settingModel = new \App\Models\Setting();
|
||||
try {
|
||||
$appSettings = $settingModel->getAppSettings();
|
||||
date_default_timezone_set($appSettings['app_timezone']);
|
||||
} catch (\Exception $e) {
|
||||
date_default_timezone_set('UTC');
|
||||
}
|
||||
|
||||
$logFile = __DIR__ . '/../logs/dns_discover.log';
|
||||
$cron = new CronHelper($logFile);
|
||||
|
||||
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
|
||||
|
||||
$targetDomain = null;
|
||||
$quickMode = false;
|
||||
|
||||
for ($i = 1; $i < count($argv); $i++) {
|
||||
if ($argv[$i] === '--domain' && isset($argv[$i + 1])) {
|
||||
$targetDomain = $argv[++$i];
|
||||
} elseif ($argv[$i] === '--quick') {
|
||||
$quickMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve domains to scan ────────────────────────────────────────────────
|
||||
|
||||
if ($targetDomain) {
|
||||
$domainRow = null;
|
||||
foreach ($domainModel->all() as $d) {
|
||||
if (strcasecmp($d['domain_name'], $targetDomain) === 0) {
|
||||
$domainRow = $d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$domainRow) {
|
||||
$cron->log("Domain not found in database: $targetDomain");
|
||||
exit(1);
|
||||
}
|
||||
$domains = [$domainRow];
|
||||
} else {
|
||||
$checkableStatuses = ['active', 'expiring_soon'];
|
||||
$allDnsEnabled = array_values(array_filter(
|
||||
$domainModel->where('is_active', 1),
|
||||
static fn($d): bool => ($d['dns_monitoring_enabled'] ?? 1) == 1
|
||||
));
|
||||
$domains = array_values(array_filter($allDnsEnabled, static function ($d) use ($checkableStatuses): bool {
|
||||
$status = strtolower($d['status'] ?? '');
|
||||
return in_array($status, $checkableStatuses, true);
|
||||
}));
|
||||
}
|
||||
|
||||
$modeLabel = $quickMode ? 'Quick Scan' : 'Deep Scan';
|
||||
$startTime = microtime(true);
|
||||
$cron->log("=== DNS Discovery ({$modeLabel}) — " . count($domains) . " domain(s) ===");
|
||||
|
||||
$stats = [
|
||||
'scanned' => 0,
|
||||
'skipped' => 0,
|
||||
'added' => 0,
|
||||
'updated' => 0,
|
||||
'removed' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
// ─── crt.sh settings (deep scan only) ──────────────────────────────────────
|
||||
|
||||
const CRTSH_TIMEOUT_SECONDS = 1800;
|
||||
const CRTSH_MAX_SUBDOMAINS = 100;
|
||||
|
||||
// ─── Scan loop ───────────────────────────────────────────────────────────────
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$domainName = $domain['domain_name'];
|
||||
$domStart = microtime(true);
|
||||
$cron->log("Discovering: $domainName ($modeLabel)");
|
||||
|
||||
try {
|
||||
if (!CronHelper::hostnameResolves($domainName)) {
|
||||
$cron->log(" ⏭ Domain does not resolve, skipping");
|
||||
$cron->logTimeSince($domStart);
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($quickMode) {
|
||||
$newRecords = $dnsService->quickScan($domainName);
|
||||
} else {
|
||||
// Deep scan: gather crt.sh subdomains + existing hosts, then full lookup
|
||||
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
||||
$ctSubs = [];
|
||||
|
||||
$cron->log(" 🔍 crt.sh: fetching subdomains...");
|
||||
[$ctSubs, $crtshOk] = $dnsService->fetchCrtshSubdomains(
|
||||
$domainName,
|
||||
CRTSH_MAX_SUBDOMAINS,
|
||||
CRTSH_TIMEOUT_SECONDS,
|
||||
fn(string $line) => $cron->log(" ↳ $line")
|
||||
);
|
||||
$cron->log(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
||||
|
||||
if ($crtshOk) {
|
||||
$domainModel->update($domain['id'], [
|
||||
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
||||
$newRecords = $dnsService->lookup($domainName, $extraSubs, fn(string $msg) => $cron->log(" 🔎 $msg"));
|
||||
}
|
||||
|
||||
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||
if ($totalRecords === 0) {
|
||||
$cron->log(" ⚠ No DNS records found");
|
||||
$stats['errors']++;
|
||||
$cron->logTimeSince($domStart);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enrich IP details
|
||||
$ips = [];
|
||||
foreach (['A', 'AAAA'] as $type) {
|
||||
foreach ($newRecords[$type] ?? [] as $r) {
|
||||
if (!empty($r['value'])) {
|
||||
$ips[] = $r['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($ips)) {
|
||||
$ipDetails = $dnsService->lookupIpDetails($ips);
|
||||
foreach (['A', 'AAAA'] as $type) {
|
||||
foreach ($newRecords[$type] as &$rec) {
|
||||
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
||||
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
}
|
||||
}
|
||||
|
||||
$saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords);
|
||||
$domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||
|
||||
$stats['scanned']++;
|
||||
$stats['added'] += $saveStats['added'];
|
||||
$stats['updated'] += $saveStats['updated'];
|
||||
$stats['removed'] += $saveStats['removed'];
|
||||
|
||||
$cron->log(" ✓ $totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})");
|
||||
|
||||
$logger->info("DNS discovery completed", [
|
||||
'domain' => $domainName,
|
||||
'mode' => $quickMode ? 'quick' : 'deep',
|
||||
'records' => $totalRecords,
|
||||
'added' => $saveStats['added'],
|
||||
]);
|
||||
|
||||
$cron->logTimeSince($domStart);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$cron->log(" ✗ Error: " . $e->getMessage());
|
||||
$cron->logTimeSince($domStart);
|
||||
$logger->error("DNS discovery failed", [
|
||||
'domain' => $domainName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime);
|
||||
$cron->log("\n=== DNS Discovery completed ===");
|
||||
$cron->log("Domains scanned: {$stats['scanned']}");
|
||||
$cron->log("Skipped: {$stats['skipped']}");
|
||||
$cron->log("Records added: {$stats['added']}");
|
||||
$cron->log("Records updated: {$stats['updated']}");
|
||||
$cron->log("Records removed: {$stats['removed']}");
|
||||
$cron->log("Errors: {$stats['errors']}");
|
||||
$cron->log("Execution time: $elapsed");
|
||||
$cron->log("==========================\n");
|
||||
|
||||
exit(0);
|
||||
@@ -142,10 +142,14 @@ CREATE TABLE IF NOT EXISTS domains (
|
||||
abuse_email VARCHAR(255),
|
||||
last_checked TIMESTAMP NULL,
|
||||
dns_last_checked TIMESTAMP NULL,
|
||||
ssl_last_checked TIMESTAMP NULL,
|
||||
crtsh_last_fetched DATETIME NULL DEFAULT NULL,
|
||||
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
|
||||
whois_data JSON,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
dns_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
ssl_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
user_id INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -356,6 +360,7 @@ CREATE TABLE IF NOT EXISTS dns_records (
|
||||
ttl INT NULL,
|
||||
priority INT NULL COMMENT 'MX priority',
|
||||
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()',
|
||||
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -368,6 +373,38 @@ CREATE TABLE IF NOT EXISTS dns_records (
|
||||
INDEX idx_last_seen (last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- SSL certificates table for tracking monitored TLS endpoints
|
||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
domain_id INT NOT NULL,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
port INT NOT NULL DEFAULT 443,
|
||||
status ENUM('valid', 'expiring', 'expired', 'invalid') NOT NULL DEFAULT 'invalid',
|
||||
is_trusted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_self_signed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
valid_from DATETIME NULL,
|
||||
valid_to DATETIME NULL,
|
||||
days_remaining INT NULL,
|
||||
issuer_name VARCHAR(255) NULL,
|
||||
subject_name VARCHAR(255) NULL,
|
||||
serial_number VARCHAR(255) NULL,
|
||||
signature_algorithm VARCHAR(100) NULL,
|
||||
key_bits INT NULL,
|
||||
key_type VARCHAR(20) NULL,
|
||||
certificate_version VARCHAR(20) NULL,
|
||||
san_list JSON NULL,
|
||||
last_checked DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
raw_data JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uniq_domain_host_port (domain_id, hostname, port),
|
||||
INDEX idx_ssl_domain_id (domain_id),
|
||||
INDEX idx_ssl_status (status),
|
||||
INDEX idx_ssl_valid_to (valid_to)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- =====================================================
|
||||
-- SYSTEM SETTINGS
|
||||
-- =====================================================
|
||||
@@ -389,7 +426,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||
('app_name', 'Domain Monitor', 'string', 'Application name'),
|
||||
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
|
||||
('app_timezone', 'UTC', 'string', 'Application timezone'),
|
||||
('app_version', '1.1.4', 'string', 'Application version number'),
|
||||
('app_version', '1.1.5', 'string', 'Application version number'),
|
||||
|
||||
-- Email settings
|
||||
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),
|
||||
@@ -431,6 +468,10 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
|
||||
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran'),
|
||||
|
||||
-- SSL monitoring settings
|
||||
('ssl_check_interval_hours', '12', 'string', 'SSL certificate check interval in hours'),
|
||||
('last_ssl_check_run', NULL, 'datetime', 'Last time SSL cron job ran'),
|
||||
|
||||
-- Update system settings
|
||||
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
|
||||
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')
|
||||
|
||||
50
database/migrations/028_add_ssl_monitoring.sql
Normal file
50
database/migrations/028_add_ssl_monitoring.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- SSL Monitoring - Add ssl_certificates table for tracking monitored TLS endpoints
|
||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
domain_id INT NOT NULL,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
port INT NOT NULL DEFAULT 443,
|
||||
status ENUM('valid', 'expiring', 'expired', 'invalid') NOT NULL DEFAULT 'invalid',
|
||||
is_trusted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_self_signed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
valid_from DATETIME NULL,
|
||||
valid_to DATETIME NULL,
|
||||
days_remaining INT NULL,
|
||||
issuer_name VARCHAR(255) NULL,
|
||||
subject_name VARCHAR(255) NULL,
|
||||
serial_number VARCHAR(255) NULL,
|
||||
signature_algorithm VARCHAR(100) NULL,
|
||||
key_bits INT NULL,
|
||||
key_type VARCHAR(20) NULL,
|
||||
certificate_version VARCHAR(20) NULL,
|
||||
san_list JSON NULL,
|
||||
last_checked DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
raw_data JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uniq_domain_host_port (domain_id, hostname, port),
|
||||
INDEX idx_ssl_domain_id (domain_id),
|
||||
INDEX idx_ssl_status (status),
|
||||
INDEX idx_ssl_valid_to (valid_to)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- SSL Monitoring - Add per-domain toggle, timestamps, and cron settings
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN ssl_last_checked TIMESTAMP NULL AFTER dns_last_checked,
|
||||
ADD COLUMN ssl_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=SSL monitoring active, 0=disabled' AFTER dns_monitoring_enabled;
|
||||
|
||||
-- Add SSL monitoring cron settings
|
||||
INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||
('ssl_check_interval_hours', '12', 'string', 'SSL certificate check interval in hours'),
|
||||
('last_ssl_check_run', NULL, 'datetime', 'Last time SSL cron job ran')
|
||||
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||
|
||||
-- Update application version to 1.1.5
|
||||
UPDATE settings
|
||||
SET setting_value = '1.1.5'
|
||||
WHERE setting_key = 'app_version';
|
||||
|
||||
INSERT INTO migrations (migration) VALUES ('028_add_ssl_monitoring.sql')
|
||||
ON DUPLICATE KEY UPDATE migration=migration;
|
||||
7
database/migrations/029_add_dns_record_source.sql
Normal file
7
database/migrations/029_add_dns_record_source.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add source tracking to DNS records (discovered vs manual vs imported)
|
||||
ALTER TABLE dns_records
|
||||
ADD COLUMN source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered'
|
||||
AFTER is_cloudflare;
|
||||
|
||||
INSERT INTO migrations (migration) VALUES ('029_add_dns_record_source.sql')
|
||||
ON DUPLICATE KEY UPDATE migration=migration;
|
||||
@@ -35,6 +35,11 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
|
||||
- `021_add_avatar_field.sql` - User avatar field
|
||||
- `022_add_pushover_channel_type.sql` - Pushover notification channel support
|
||||
- `023_update_app_version_to_1.1.1.sql` - Update version to 1.1.1
|
||||
- `024_add_status_notifications_v1.1.2.sql` - Status notification triggers
|
||||
- `025_add_update_system_v1.1.3.sql` - In-app update system
|
||||
- `026_update_app_version_v1.1.4.sql` - Update version to 1.1.4
|
||||
- `027_add_dns_monitoring.sql` - DNS monitoring tables and settings
|
||||
- `028_add_ssl_monitoring.sql` - SSL certificate monitoring table, per-domain toggles, timestamps, and cron settings
|
||||
|
||||
**Upgrade via:** Web updater at `/install/update`
|
||||
|
||||
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: domnitor-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7876:7876"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: "3306"
|
||||
DB_DATABASE: ${DB_DATABASE:-domain_monitor}
|
||||
DB_USERNAME: ${DB_USERNAME:-domain_monitor}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
TZ: ${TZ:-UTC}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: mariadb:lts
|
||||
container_name: domnitor-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_DATABASE:-domain_monitor}
|
||||
MARIADB_USER: ${DB_USERNAME:-domain_monitor}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootchangeme}
|
||||
TZ: ${TZ:-UTC}
|
||||
command:
|
||||
- "--character-set-server=utf8mb4"
|
||||
- "--collation-server=utf8mb4_unicode_ci"
|
||||
volumes:
|
||||
- dbdata:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
start_period: 15s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
44
docker/entrypoint.sh
Normal file
44
docker/entrypoint.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="/var/www/html/.env"
|
||||
|
||||
upsert_kv() {
|
||||
local key="$1" val="$2"
|
||||
if grep -qE "^${key}=" "$ENV_FILE" 2>/dev/null; then
|
||||
sed -i "s#^${key}=.*#${key}=${val}#" "$ENV_FILE"
|
||||
else
|
||||
printf "%s=%s\n" "$key" "$val" >> "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Bootstrap .env from template if not present
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f /var/www/html/env.example.txt ]; then
|
||||
cp /var/www/html/env.example.txt "$ENV_FILE"
|
||||
else
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Inject database and app config from environment variables
|
||||
upsert_kv "DB_HOST" "${DB_HOST:-db}"
|
||||
upsert_kv "DB_PORT" "${DB_PORT:-3306}"
|
||||
upsert_kv "DB_DATABASE" "${DB_DATABASE:-domain_monitor}"
|
||||
upsert_kv "DB_USERNAME" "${DB_USERNAME:-domain_monitor}"
|
||||
upsert_kv "DB_PASSWORD" "${DB_PASSWORD:-}"
|
||||
upsert_kv "APP_ENV" "${APP_ENV:-production}"
|
||||
|
||||
[ -n "${APP_ENCRYPTION_KEY:-}" ] && upsert_kv "APP_ENCRYPTION_KEY" "$APP_ENCRYPTION_KEY"
|
||||
[ -n "${SESSION_LIFETIME:-}" ] && upsert_kv "SESSION_LIFETIME" "$SESSION_LIFETIME"
|
||||
|
||||
# Ownership & permissions on runtime-writable paths
|
||||
chown www-data:www-data "$ENV_FILE"
|
||||
chmod 660 "$ENV_FILE"
|
||||
|
||||
for d in logs cache public/assets/uploads; do
|
||||
dir="/var/www/html/$d"
|
||||
[ -d "$dir" ] && chown -R www-data:www-data "$dir" && chmod -R 775 "$dir"
|
||||
done
|
||||
|
||||
exec "$@"
|
||||
19
docker/php.ini
Normal file
19
docker/php.ini
Normal file
@@ -0,0 +1,19 @@
|
||||
; ---- PHP limits ----
|
||||
max_execution_time = 0
|
||||
max_input_time = -1
|
||||
max_input_vars = 3000
|
||||
memory_limit = 512M
|
||||
post_max_size = 32M
|
||||
upload_max_filesize = 16M
|
||||
default_socket_timeout = 120
|
||||
|
||||
; Recommended defaults
|
||||
date.timezone = UTC
|
||||
display_errors = Off
|
||||
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
|
||||
|
||||
; Opcache
|
||||
opcache.enable=1
|
||||
opcache.enable_cli=1
|
||||
opcache.validate_timestamps=1
|
||||
opcache.revalidate_freq=2
|
||||
@@ -47,12 +47,12 @@ $router->get('/2fa/verify', [TwoFactorController::class, 'showVerify']);
|
||||
$router->post('/2fa/verify', [TwoFactorController::class, 'verify']);
|
||||
$router->post('/2fa/send-email-code', [TwoFactorController::class, 'sendEmailCode']);
|
||||
|
||||
// Debug route (public - remove in production!)
|
||||
$router->get('/debug/whois', [DebugController::class, 'whois']);
|
||||
|
||||
// Protected routes - require authentication
|
||||
Auth::require();
|
||||
|
||||
// Debug route (admin-only)
|
||||
$router->get('/debug/whois', [DebugController::class, 'whois']);
|
||||
|
||||
// Dashboard
|
||||
$router->get('/', [DashboardController::class, 'index']);
|
||||
$router->get('/dashboard', [DashboardController::class, 'index']);
|
||||
@@ -86,6 +86,17 @@ $router->post('/domains/{id}/update', [DomainController::class, 'update']);
|
||||
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
|
||||
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
||||
$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/refresh-all', [DomainController::class, 'refreshAllSsl']);
|
||||
$router->post('/domains/{id}/ssl/bulk-refresh', [DomainController::class, 'bulkRefreshSsl']);
|
||||
$router->post('/domains/{id}/ssl/bulk-delete', [DomainController::class, 'bulkDeleteSsl']);
|
||||
$router->post('/domains/{id}/ssl/{certificateId}/refresh', [DomainController::class, 'refreshSsl']);
|
||||
$router->post('/domains/{id}/ssl/{certificateId}/delete', [DomainController::class, 'deleteSsl']);
|
||||
$router->post('/domains/{id}/refresh-all', [DomainController::class, 'refreshAll']);
|
||||
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user