Compare commits
10 Commits
8559e903b9
...
a0328d69f6
| 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/),
|
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).
|
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
|
## [1.1.4] - 2026-03-02
|
||||||
|
|
||||||
### Added
|
### 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] 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)
|
- [x] Import domains from CSV/JSON (completed - v1.1.3, TLD Registry - v1.1.4)
|
||||||
- [ ] Domain transfer tracking
|
- [ ] Domain transfer tracking
|
||||||
- [ ] DNS record monitoring
|
- [x] DNS record monitoring (completed - v1.1.5)
|
||||||
- [ ] SSL certificate monitoring
|
- [x] SSL certificate monitoring (completed - v1.1.5)
|
||||||
- [ ] Downtime monitoring
|
- [ ] Downtime monitoring
|
||||||
- [x] 2FA for login (completed - v1.1.0)
|
- [x] 2FA for login (completed - v1.1.0)
|
||||||
- [ ] Mobile app
|
- [ ] Mobile app
|
||||||
@@ -491,6 +527,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Version History
|
## 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)
|
### 1.1.4 (2026-03-02)
|
||||||
- **TLD Registry Import & Export** - CSV/JSON export/import for TLD entries with WHOIS, RDAP, registry URL data
|
- **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)
|
- **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['email'] = $user['email'];
|
||||||
$_SESSION['role'] = $user['role'];
|
$_SESSION['role'] = $user['role'];
|
||||||
$_SESSION['2fa_required'] = true;
|
$_SESSION['2fa_required'] = true;
|
||||||
|
$_SESSION['pending_remember'] = $remember;
|
||||||
|
|
||||||
// Clear any existing session messages before redirecting to 2FA
|
// Clear any existing session messages before redirecting to 2FA
|
||||||
unset($_SESSION['error']);
|
unset($_SESSION['error']);
|
||||||
@@ -172,6 +173,9 @@ class AuthController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regenerate session ID to prevent session fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
// Login successful - create session
|
// Login successful - create session
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['username'] = $user['username'];
|
$_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
|
* Create remember me token linked to current session
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,8 @@ class InstallerController extends Controller
|
|||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -202,6 +204,8 @@ class InstallerController extends Controller
|
|||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.sql',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +315,9 @@ class InstallerController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/install');
|
||||||
|
|
||||||
// Block re-installation if already installed
|
// Block re-installation if already installed
|
||||||
if ($this->isInstalled()) {
|
if ($this->isInstalled()) {
|
||||||
$_SESSION['error'] = 'System is already installed. Use the update function instead.';
|
$_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';
|
$file = __DIR__ . '/../../database/migrations/000_initial_schema_v1.1.0.sql';
|
||||||
$sql = file_get_contents($file);
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
// Replace admin credentials
|
// Replace admin credentials (use PDO::quote to prevent SQL injection)
|
||||||
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
|
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
|
||||||
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql);
|
$sql = str_replace("'{{ADMIN_PASSWORD_HASH}}'", $pdo->quote($passwordHash), $sql);
|
||||||
$sql = str_replace('{{ADMIN_USERNAME}}', $adminUsername, $sql);
|
$sql = str_replace("'{{ADMIN_USERNAME}}'", $pdo->quote($adminUsername), $sql);
|
||||||
$sql = str_replace('{{ADMIN_EMAIL}}', $adminEmail, $sql);
|
$sql = str_replace("'{{ADMIN_EMAIL}}'", $pdo->quote($adminEmail), $sql);
|
||||||
|
|
||||||
// Execute the entire consolidated schema at once
|
// Execute the entire consolidated schema at once
|
||||||
// This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS
|
// This is safe because MySQL can handle multiple statements with CREATE TABLE IF NOT EXISTS
|
||||||
@@ -426,6 +433,8 @@ class InstallerController extends Controller
|
|||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
'027_add_dns_monitoring.sql',
|
'027_add_dns_monitoring.sql',
|
||||||
|
'028_add_ssl_monitoring.sql',
|
||||||
|
'029_add_dns_record_source.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
@@ -579,6 +588,9 @@ class InstallerController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
$this->verifyCsrf('/install/update');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = \Core\Database::getConnection();
|
$pdo = \Core\Database::getConnection();
|
||||||
$migrations = $this->getPendingMigrations();
|
$migrations = $this->getPendingMigrations();
|
||||||
@@ -661,7 +673,9 @@ class InstallerController extends Controller
|
|||||||
|
|
||||||
// Fallback: detect "to" version from which migrations were run
|
// Fallback: detect "to" version from which migrations were run
|
||||||
if ($toVersion === $fromVersion) {
|
if ($toVersion === $fromVersion) {
|
||||||
if (in_array('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';
|
$toVersion = '1.1.4';
|
||||||
} elseif (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
|
} elseif (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
|
||||||
$toVersion = '1.1.3';
|
$toVersion = '1.1.3';
|
||||||
|
|||||||
@@ -687,6 +687,13 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
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 {
|
try {
|
||||||
$this->channelModel->delete($id);
|
$this->channelModel->delete($id);
|
||||||
$_SESSION['success'] = 'Channel deleted successfully';
|
$_SESSION['success'] = 'Channel deleted successfully';
|
||||||
@@ -714,6 +721,13 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
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 {
|
try {
|
||||||
$this->channelModel->toggleActive($id);
|
$this->channelModel->toggleActive($id);
|
||||||
$_SESSION['success'] = 'Channel status updated';
|
$_SESSION['success'] = 'Channel status updated';
|
||||||
@@ -1065,16 +1079,35 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Transfer group
|
|
||||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
// Also transfer all domains in this group
|
|
||||||
$domainModel = new \App\Models\Domain();
|
$domainModel = new \App\Models\Domain();
|
||||||
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
$domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Notification group transferred', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'group_name' => $group['name'],
|
||||||
|
'from_user_id' => $group['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domains_transferred' => $domainsTransferred,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}";
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
$logger->error('Notification group transfer failed', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
$_SESSION['error'] = 'Failed to transfer group. Please try again.';
|
$_SESSION['error'] = 'Failed to transfer group. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,25 +1146,51 @@ class NotificationGroupController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$domainModel = new \App\Models\Domain();
|
||||||
|
$tagModel = new \App\Models\Tag();
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
$transferred = 0;
|
$transferred = 0;
|
||||||
foreach ($groupIds as $groupId) {
|
foreach ($groupIds as $groupId) {
|
||||||
$groupId = (int)$groupId;
|
$groupId = (int)$groupId;
|
||||||
if ($groupId > 0) {
|
if ($groupId > 0) {
|
||||||
try {
|
try {
|
||||||
// Transfer group
|
$group = $this->groupModel->find($groupId);
|
||||||
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
$this->groupModel->update($groupId, ['user_id' => $targetUserId]);
|
||||||
|
|
||||||
// Also transfer all domains in this group
|
$domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
||||||
$domainModel = new \App\Models\Domain();
|
$tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId);
|
||||||
$domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]);
|
|
||||||
|
$logger->info('Notification group transferred (bulk)', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'group_name' => $group['name'] ?? 'unknown',
|
||||||
|
'from_user_id' => $group['user_id'] ?? null,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domains_transferred' => $domainsTransferred,
|
||||||
|
'tags_removed' => $tagsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$transferred++;
|
$transferred++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Continue with other groups
|
$logger->error('Notification group transfer failed (bulk)', [
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger->info('Bulk notification group transfer completed', [
|
||||||
|
'transferred' => $transferred,
|
||||||
|
'total_requested' => count($groupIds),
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}";
|
||||||
$this->redirect('/groups');
|
$this->redirect('/groups');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,11 +570,19 @@ class TagController extends Controller
|
|||||||
'showing_to' => min($offset + $perPage, $total)
|
'showing_to' => min($offset + $perPage, $total)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
if (\Core\Auth::isAdmin()) {
|
||||||
|
$userModel = new \App\Models\User();
|
||||||
|
$currentUserId = \Core\Auth::id();
|
||||||
|
$users = array_values(array_filter($userModel->all(), fn($u) => (int)$u['id'] !== $currentUserId));
|
||||||
|
}
|
||||||
|
|
||||||
$this->view('tags/view', [
|
$this->view('tags/view', [
|
||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
'domains' => $paginatedDomains,
|
'domains' => $paginatedDomains,
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'pagination' => $pagination
|
'pagination' => $pagination,
|
||||||
|
'users' => $users,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +778,12 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($tag['user_id'] === null) {
|
||||||
|
$_SESSION['error'] = 'Global tags cannot be transferred';
|
||||||
|
$this->redirect('/tags');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$userModel = new \App\Models\User();
|
$userModel = new \App\Models\User();
|
||||||
$targetUser = $userModel->find($targetUserId);
|
$targetUser = $userModel->find($targetUserId);
|
||||||
if (!$targetUser) {
|
if (!$targetUser) {
|
||||||
@@ -778,9 +792,28 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||||
|
$domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Tag transferred', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'tag_name' => $tag['name'],
|
||||||
|
'from_user_id' => $tag['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domain_associations_removed' => $domainsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
|
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
|
||||||
} else {
|
} else {
|
||||||
|
$logger->error('Tag transfer failed', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'error' => 'Model update returned false',
|
||||||
|
]);
|
||||||
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
|
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,18 +851,50 @@ class TagController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logger = new \App\Services\Logger('transfer');
|
||||||
|
|
||||||
$transferred = 0;
|
$transferred = 0;
|
||||||
|
$skippedGlobal = 0;
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$tagId = (int)$tagId;
|
$tagId = (int)$tagId;
|
||||||
if ($tagId > 0) {
|
if ($tagId > 0) {
|
||||||
$tag = $this->tagModel->find($tagId);
|
$tag = $this->tagModel->find($tagId);
|
||||||
|
if ($tag && $tag['user_id'] === null) {
|
||||||
|
$skippedGlobal++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
|
||||||
|
$domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId);
|
||||||
|
|
||||||
|
$logger->info('Tag transferred (bulk)', [
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'tag_name' => $tag['name'],
|
||||||
|
'from_user_id' => $tag['user_id'],
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'domain_associations_removed' => $domainsRemoved,
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
$transferred++;
|
$transferred++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['success'] = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
|
$logger->info('Bulk tag transfer completed', [
|
||||||
|
'transferred' => $transferred,
|
||||||
|
'skipped_global' => $skippedGlobal,
|
||||||
|
'total_requested' => count($tagIds),
|
||||||
|
'to_user_id' => $targetUserId,
|
||||||
|
'to_username' => $targetUser['username'],
|
||||||
|
'admin_user_id' => \Core\Auth::id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
|
||||||
|
if ($skippedGlobal > 0) {
|
||||||
|
$msg .= " ($skippedGlobal global tag(s) skipped)";
|
||||||
|
}
|
||||||
|
$_SESSION['success'] = $msg;
|
||||||
$this->redirect('/tags');
|
$this->redirect('/tags');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,13 @@ class TwoFactorController extends Controller
|
|||||||
$this->twoFactorService->recordAttempt($userId, $ipAddress, $verified);
|
$this->twoFactorService->recordAttempt($userId, $ipAddress, $verified);
|
||||||
|
|
||||||
if ($verified) {
|
if ($verified) {
|
||||||
|
// Regenerate session ID to prevent session fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
// Clear 2FA requirement and complete login
|
// Clear 2FA requirement and complete login
|
||||||
|
$pendingRemember = !empty($_SESSION['pending_remember']);
|
||||||
unset($_SESSION['2fa_required']);
|
unset($_SESSION['2fa_required']);
|
||||||
|
unset($_SESSION['pending_remember']);
|
||||||
|
|
||||||
// Determine which method was used
|
// Determine which method was used
|
||||||
$method = 'unknown';
|
$method = 'unknown';
|
||||||
@@ -296,6 +301,12 @@ class TwoFactorController extends Controller
|
|||||||
'method' => $method
|
'method' => $method
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Handle remember me (carried over from login form)
|
||||||
|
if ($pendingRemember) {
|
||||||
|
$authController = new \App\Controllers\AuthController();
|
||||||
|
$authController->createRememberTokenPublic($userId);
|
||||||
|
}
|
||||||
|
|
||||||
// Update last login timestamp
|
// Update last login timestamp
|
||||||
$this->userModel->updateLastLogin($userId);
|
$this->userModel->updateLastLogin($userId);
|
||||||
|
|
||||||
@@ -335,6 +346,8 @@ class TwoFactorController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->verifyCsrf('/2fa/verify');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if user is in 2FA verification state
|
// Check if user is in 2FA verification state
|
||||||
if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) {
|
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
|
* Get CSS class for expiry date styling
|
||||||
|
* Includes dark: variants for visibility on dark theme
|
||||||
*/
|
*/
|
||||||
private static function getExpiryClass(?int $daysLeft): string
|
private static function getExpiryClass(?int $daysLeft): string
|
||||||
{
|
{
|
||||||
if ($daysLeft === null) return '';
|
if ($daysLeft === null) return '';
|
||||||
|
|
||||||
if ($daysLeft < 0) return 'text-red-600 font-semibold';
|
if ($daysLeft < 0) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
if ($daysLeft <= 30) return 'text-orange-600 font-semibold';
|
if ($daysLeft <= 30) return 'text-orange-600 dark:text-orange-400 font-semibold';
|
||||||
if ($daysLeft <= 90) return 'text-yellow-600';
|
if ($daysLeft <= 90) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
|
||||||
return '';
|
return 'text-gray-600 dark:text-slate-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -507,9 +507,14 @@ class EmailHelper
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get email subject based on data
|
* Get email subject based on data
|
||||||
|
* Uses explicit subject when provided (DNS, SSL, etc.), otherwise domain expiration logic
|
||||||
*/
|
*/
|
||||||
public static function getEmailSubject(array $data): string
|
public static function getEmailSubject(array $data): string
|
||||||
{
|
{
|
||||||
|
if (!empty($data['subject'])) {
|
||||||
|
return $data['subject'];
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$daysLeft = $data['days_left'] ?? null;
|
$daysLeft = $data['days_left'] ?? null;
|
||||||
if ($daysLeft === null) {
|
if ($daysLeft === null) {
|
||||||
|
|||||||
@@ -17,17 +17,90 @@ class InputValidator
|
|||||||
*/
|
*/
|
||||||
public static function validateDomain(string $domain): bool
|
public static function validateDomain(string $domain): bool
|
||||||
{
|
{
|
||||||
// Check length (max 253 characters per RFC 1035)
|
|
||||||
if (strlen($domain) > 253 || strlen($domain) < 3) {
|
if (strlen($domain) > 253 || strlen($domain) < 3) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain format
|
|
||||||
// Allows: example.com, sub.example.com, example.co.uk
|
|
||||||
// Pattern: alphanumeric with hyphens, dots between labels, valid TLD
|
|
||||||
return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain);
|
return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize raw domain input — strips protocol, www prefix, trailing dots/slashes, paths.
|
||||||
|
*
|
||||||
|
* @return string Cleaned, lowercased domain (may still be invalid)
|
||||||
|
*/
|
||||||
|
public static function sanitizeDomainInput(string $input): string
|
||||||
|
{
|
||||||
|
$input = strtolower(trim($input));
|
||||||
|
|
||||||
|
$input = preg_replace('#^https?://#', '', $input);
|
||||||
|
$input = preg_replace('#/.*$#', '', $input);
|
||||||
|
$input = rtrim($input, '.');
|
||||||
|
$input = trim($input);
|
||||||
|
|
||||||
|
if (str_starts_with($input, 'www.')) {
|
||||||
|
$stripped = substr($input, 4);
|
||||||
|
if (substr_count($stripped, '.') >= 1) {
|
||||||
|
$input = $stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a domain is a registrable root domain (not a subdomain).
|
||||||
|
* Uses the tld_registry table to identify multi-level TLDs like .co.uk.
|
||||||
|
*
|
||||||
|
* @return array{valid: bool, domain: string, error: string|null}
|
||||||
|
*/
|
||||||
|
public static function validateRootDomain(string $domain): array
|
||||||
|
{
|
||||||
|
$domain = self::sanitizeDomainInput($domain);
|
||||||
|
|
||||||
|
if (empty($domain)) {
|
||||||
|
return ['valid' => false, 'domain' => '', 'error' => 'Domain name is required'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::validateDomain($domain)) {
|
||||||
|
return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain format: $domain"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('.', $domain);
|
||||||
|
if (count($parts) < 2) {
|
||||||
|
return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain: $domain"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tldModel = new \App\Models\TldRegistry();
|
||||||
|
|
||||||
|
$matchedTld = null;
|
||||||
|
for ($i = 1; $i < count($parts); $i++) {
|
||||||
|
$candidate = '.' . implode('.', array_slice($parts, $i));
|
||||||
|
$tld = $tldModel->findByTld($candidate);
|
||||||
|
if ($tld) {
|
||||||
|
$matchedTld = $candidate;
|
||||||
|
$labelCount = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matchedTld) {
|
||||||
|
$matchedTld = '.' . $parts[count($parts) - 1];
|
||||||
|
$labelCount = count($parts) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($labelCount !== 1) {
|
||||||
|
$rootDomain = $parts[$labelCount - 1] . $matchedTld;
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'domain' => $domain,
|
||||||
|
'error' => "\"$domain\" looks like a subdomain. Did you mean the root domain \"$rootDomain\"?"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => true, 'domain' => $domain, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate text field length
|
* Validate text field length
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ class DnsRecord extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a snapshot of DNS records for a domain.
|
* Save a snapshot of DNS records for a domain.
|
||||||
* Updates existing records, inserts new ones, removes stale ones.
|
* Updates existing records, inserts new ones.
|
||||||
|
* Only auto-removes stale records whose source is 'discovered' — manual and imported records are preserved.
|
||||||
|
*
|
||||||
* @return array{added: int, updated: int, removed: int}
|
* @return array{added: int, updated: int, removed: int}
|
||||||
*/
|
*/
|
||||||
public function saveSnapshot(int $domainId, array $groupedRecords): array
|
public function saveSnapshot(int $domainId, array $groupedRecords, string $source = 'discovered'): array
|
||||||
{
|
{
|
||||||
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
@@ -108,27 +110,26 @@ class DnsRecord extends Model
|
|||||||
$stats['updated']++;
|
$stats['updated']++;
|
||||||
} else {
|
} else {
|
||||||
$stmt = $this->db->prepare(
|
$stmt = $this->db->prepare(
|
||||||
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at)
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $source, $now, $now, $now, $now]);
|
||||||
$seenIds[] = (int)$this->db->lastInsertId();
|
$seenIds[] = (int)$this->db->lastInsertId();
|
||||||
$stats['added']++;
|
$stats['added']++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove records that no longer exist
|
// Only auto-remove stale discovered records — manual/imported records are never auto-deleted
|
||||||
if (!empty($seenIds)) {
|
if (!empty($seenIds)) {
|
||||||
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
||||||
$deleteStmt = $this->db->prepare(
|
$deleteStmt = $this->db->prepare(
|
||||||
"DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})"
|
"DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered' AND id NOT IN ({$placeholders})"
|
||||||
);
|
);
|
||||||
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
||||||
$stats['removed'] = $deleteStmt->rowCount();
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
} else {
|
} else {
|
||||||
// No records found at all — remove everything
|
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered'");
|
||||||
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
|
||||||
$deleteStmt->execute([$domainId]);
|
$deleteStmt->execute([$domainId]);
|
||||||
$stats['removed'] = $deleteStmt->rowCount();
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
}
|
}
|
||||||
@@ -213,4 +214,82 @@ class DnsRecord extends Model
|
|||||||
|
|
||||||
return $grouped;
|
return $grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single DNS record belonging to a domain.
|
||||||
|
*/
|
||||||
|
public function deleteRecord(int $id, int $domainId): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE id = ? AND domain_id = ?");
|
||||||
|
$stmt->execute([$id, $domainId]);
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk delete DNS records belonging to a domain.
|
||||||
|
*
|
||||||
|
* @return int Number of records deleted
|
||||||
|
*/
|
||||||
|
public function bulkDeleteRecords(array $ids, int $domainId): int
|
||||||
|
{
|
||||||
|
if (empty($ids)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"DELETE FROM dns_records WHERE domain_id = ? AND id IN ({$placeholders})"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge([$domainId], $ids));
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single manually-created DNS record.
|
||||||
|
*/
|
||||||
|
public function addManualRecord(int $domainId, string $type, string $host, string $value, ?int $ttl = null, ?int $priority = null): int
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, 'manual', ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $now, $now, $now, $now]);
|
||||||
|
return (int)$this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-insert records from a zone file import.
|
||||||
|
*
|
||||||
|
* @return int Number of records imported
|
||||||
|
*/
|
||||||
|
public function addImportedRecords(int $domainId, array $groupedRecords): int
|
||||||
|
{
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($groupedRecords as $type => $records) {
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$host = $record['host'] ?? '@';
|
||||||
|
$value = $record['value'] ?? '';
|
||||||
|
$ttl = $record['ttl'] ?? null;
|
||||||
|
$priority = $record['priority'] ?? null;
|
||||||
|
$isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0;
|
||||||
|
$rawData = isset($record['raw']) ? json_encode($record['raw']) : null;
|
||||||
|
|
||||||
|
$existing = $this->findExisting($domainId, $type, $host, $value, $priority);
|
||||||
|
if ($existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ class ErrorLog extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
$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';
|
$sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
$query = "
|
$query = "
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class Setting extends Model
|
|||||||
*/
|
*/
|
||||||
public function getAppVersion(): string
|
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
|
* Get available colors for tags
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ class DiscordChannel implements NotificationChannelInterface
|
|||||||
|
|
||||||
private function createEmbed(string $message, array $data): array
|
private function createEmbed(string $message, array $data): array
|
||||||
{
|
{
|
||||||
|
$title = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||||
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
$color = $this->getColorByDaysLeft($data['days_left'] ?? null);
|
||||||
|
|
||||||
$embed = [
|
$embed = [
|
||||||
'title' => '🔔 Domain Expiration Alert',
|
'title' => $title,
|
||||||
'description' => $message,
|
'description' => $message,
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
'timestamp' => date('c'),
|
'timestamp' => date('c'),
|
||||||
@@ -65,23 +66,22 @@ class DiscordChannel implements NotificationChannelInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$embed['fields'] = [
|
$fields = [
|
||||||
[
|
['name' => 'Domain', 'value' => $data['domain'], 'inline' => true]
|
||||||
'name' => 'Domain',
|
|
||||||
'value' => $data['domain'],
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Days Left',
|
|
||||||
'value' => (string) ($data['days_left'] ?? 'N/A'),
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Expiration Date',
|
|
||||||
'value' => $data['expiration_date'] ?? 'N/A',
|
|
||||||
'inline' => true
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only add expiration fields for domain expiration alerts
|
||||||
|
if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) {
|
||||||
|
$fields[] = ['name' => 'Days Left', 'value' => (string) ($data['days_left'] ?? 'N/A'), 'inline' => true];
|
||||||
|
$fields[] = ['name' => 'Expiration Date', 'value' => $data['expiration_date'] ?? 'N/A', 'inline' => true];
|
||||||
|
} elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) {
|
||||||
|
$fields[] = ['name' => 'Hostname', 'value' => $data['hostname'], 'inline' => true];
|
||||||
|
}
|
||||||
|
if (isset($data['new_status'])) {
|
||||||
|
$fields[] = ['name' => 'Status', 'value' => $data['new_status'], 'inline' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$embed['fields'] = $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $embed;
|
return $embed;
|
||||||
|
|||||||
@@ -28,37 +28,36 @@ class MattermostChannel implements NotificationChannelInterface
|
|||||||
'text' => $message
|
'text' => $message
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add attachments for richer formatting if domain data is available
|
// Add green-bar attachment only for expiration/SSL/status alerts, not for DNS change (plain text only)
|
||||||
if (isset($data['domain'])) {
|
$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);
|
$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'] = [
|
$payload['attachments'] = [
|
||||||
[
|
[
|
||||||
'color' => $color,
|
'color' => $color,
|
||||||
'title' => '🔔 Domain Expiration Alert',
|
'title' => $title,
|
||||||
'text' => $message,
|
'fields' => $fields,
|
||||||
'fields' => [
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Domain',
|
|
||||||
'value' => $data['domain']
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Days Left',
|
|
||||||
'value' => $data['days_left'] ?? 'N/A'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Expiration Date',
|
|
||||||
'value' => $data['expiration_date'] ?? 'N/A'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'short' => true,
|
|
||||||
'title' => 'Registrar',
|
|
||||||
'value' => $data['registrar'] ?? 'N/A'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'footer' => 'Domain Monitor',
|
'footer' => 'Domain Monitor',
|
||||||
'ts' => time()
|
'ts' => time()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ class PushoverChannel implements NotificationChannelInterface
|
|||||||
'priority' => $priority,
|
'priority' => $priority,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optional: Add title
|
// Optional: Add title - use subject when provided (DNS, SSL, etc.)
|
||||||
if (isset($data['domain'])) {
|
if (!empty($data['subject'])) {
|
||||||
|
$payload['title'] = $data['subject'];
|
||||||
|
} elseif (isset($data['domain'])) {
|
||||||
$payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain'];
|
$payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain'];
|
||||||
} else {
|
} else {
|
||||||
$payload['title'] = '🔔 Domain Monitor Notification';
|
$payload['title'] = '🔔 Domain Monitor Notification';
|
||||||
|
|||||||
@@ -53,12 +53,14 @@ class SlackChannel implements NotificationChannelInterface
|
|||||||
|
|
||||||
private function createBlocks(string $message, array $data): array
|
private function createBlocks(string $message, array $data): array
|
||||||
{
|
{
|
||||||
|
$headerText = $data['subject'] ?? '🔔 Domain Monitor Alert';
|
||||||
|
|
||||||
$blocks = [
|
$blocks = [
|
||||||
[
|
[
|
||||||
'type' => 'header',
|
'type' => 'header',
|
||||||
'text' => [
|
'text' => [
|
||||||
'type' => 'plain_text',
|
'type' => 'plain_text',
|
||||||
'text' => '🔔 Domain Expiration Alert'
|
'text' => $headerText
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -71,26 +73,25 @@ class SlackChannel implements NotificationChannelInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
|
$fields = [
|
||||||
|
['type' => 'mrkdwn', 'text' => "*Domain:*\n{$data['domain']}"]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add expiration fields for domain expiration alerts
|
||||||
|
if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Days Left:*\n" . ($data['days_left'] ?? 'N/A')];
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Expiration:*\n" . ($data['expiration_date'] ?? 'N/A')];
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Registrar:*\n" . ($data['registrar'] ?? 'N/A')];
|
||||||
|
} elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Hostname:*\n{$data['hostname']}"];
|
||||||
|
}
|
||||||
|
if (isset($data['new_status'])) {
|
||||||
|
$fields[] = ['type' => 'mrkdwn', 'text' => "*Status:*\n{$data['new_status']}"];
|
||||||
|
}
|
||||||
|
|
||||||
$blocks[] = [
|
$blocks[] = [
|
||||||
'type' => 'section',
|
'type' => 'section',
|
||||||
'fields' => [
|
'fields' => $fields
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Domain:*\n{$data['domain']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Days Left:*\n{$data['days_left']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Expiration:*\n{$data['expiration_date']}"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'type' => 'mrkdwn',
|
|
||||||
'text' => "*Registrar:*\n{$data['registrar']}"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class WebhookChannel implements NotificationChannelInterface
|
|||||||
private function buildGenericPayload(string $message, array $data): array
|
private function buildGenericPayload(string $message, array $data): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'event' => 'domain_expiration_alert',
|
'event' => 'domain_monitor_alert',
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'sent_at' => date('c')
|
'sent_at' => date('c')
|
||||||
|
|||||||
@@ -86,124 +86,173 @@ class DnsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// MAIN LOOKUP
|
// DNS SCAN METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprehensive DNS lookup for a domain.
|
* Re-check only records that already exist in the database.
|
||||||
* Scans root + common subdomains + targets extracted from NS/MX/CNAME.
|
* Queries root domain for all types + known subdomain hosts.
|
||||||
* Resolves NS/MX targets to A/AAAA IPs.
|
* No wordlist brute force, no crt.sh. Used by the cron and Refresh button.
|
||||||
*
|
|
||||||
* @param string $domain The domain to scan
|
|
||||||
* @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans)
|
|
||||||
*/
|
*/
|
||||||
public function lookup(string $domain, array $extraSubdomains = []): array
|
public function refreshExisting(string $domain, array $existingHosts = []): array
|
||||||
{
|
{
|
||||||
$this->logger->info("DNS lookup started", ['domain' => $domain]);
|
$this->logger->info("DNS refresh started", ['domain' => $domain, 'known_hosts' => count($existingHosts)]);
|
||||||
|
|
||||||
$records = [
|
[$records, $seen] = $this->queryRootDomain($domain);
|
||||||
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
|
||||||
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
|
||||||
];
|
|
||||||
$seen = []; // "TYPE:host:value" dedup keys
|
|
||||||
|
|
||||||
// Phase 1: Root domain — query each type individually
|
// Query known subdomain hosts directly (no existence probe needed)
|
||||||
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
foreach ($existingHosts as $sub) {
|
||||||
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1b: DNS_ALL fallback to catch anything we missed
|
|
||||||
$this->queryAllFallback($domain, $domain, $records, $seen);
|
|
||||||
|
|
||||||
// Phase 1c: gethostbynamel fallback for A records
|
|
||||||
if (empty($records['A'])) {
|
|
||||||
$ips = @gethostbynamel($domain);
|
|
||||||
if (is_array($ips)) {
|
|
||||||
foreach ($ips as $ip) {
|
|
||||||
$this->addIfNew('A', [
|
|
||||||
'host' => '@', 'value' => $ip, 'ttl' => 0,
|
|
||||||
'is_cloudflare' => $this->isCloudflareIp($ip),
|
|
||||||
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
|
|
||||||
], $records, $seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV
|
|
||||||
$candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains);
|
|
||||||
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
|
|
||||||
foreach ($records[$type] as $rec) {
|
|
||||||
$target = rtrim($rec['value'] ?? '', '.');
|
|
||||||
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
|
||||||
$sub = str_replace('.' . $domain, '', strtolower($target));
|
|
||||||
if ($sub && !in_array($sub, $candidates)) {
|
|
||||||
$candidates[] = $sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$candidates = array_unique($candidates);
|
|
||||||
|
|
||||||
// Phase 3: Probe subdomains — fast checkdnsrr existence test first
|
|
||||||
$discovered = [];
|
|
||||||
foreach ($candidates as $sub) {
|
|
||||||
$fqdn = "{$sub}.{$domain}";
|
|
||||||
if ($this->subdomainExists($fqdn)) {
|
|
||||||
$discovered[] = $sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Deep scan discovered subdomains (A, AAAA, CNAME, TXT)
|
|
||||||
foreach ($discovered as $sub) {
|
|
||||||
$fqdn = "{$sub}.{$domain}";
|
$fqdn = "{$sub}.{$domain}";
|
||||||
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
// TXT only for known useful subdomains
|
$this->queryAndCollect($fqdn, DNS_MX, 'MX', $domain, $records, $seen);
|
||||||
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4b: Special TXT subdomains (always query even if not "discovered")
|
|
||||||
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
||||||
$fqdn = "{$sub}.{$domain}";
|
$fqdn = "{$sub}.{$domain}";
|
||||||
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Resolve MX targets that are under this domain — add their A/AAAA records
|
$this->resolveMxTargets($domain, $records, $seen);
|
||||||
foreach ($records['MX'] as $mxRec) {
|
$this->resolveNsIps($records);
|
||||||
$target = rtrim($mxRec['value'] ?? '', '.');
|
$this->sortRecords($records);
|
||||||
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
|
||||||
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
|
|
||||||
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 6: Resolve NS server IPs — store in raw data for display
|
|
||||||
foreach ($records['NS'] as &$nsRec) {
|
|
||||||
$nsHost = rtrim($nsRec['value'] ?? '', '.');
|
|
||||||
if ($nsHost) {
|
|
||||||
$nsIps = $this->resolveHostIps($nsHost);
|
|
||||||
$nsRec['raw']['_ns_ips'] = $nsIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($nsRec);
|
|
||||||
|
|
||||||
// Sort A/AAAA: root first, then alphabetical
|
|
||||||
foreach (['A', 'AAAA'] as $type) {
|
|
||||||
usort($records[$type], function ($a, $b) {
|
|
||||||
if ($a['host'] === '@') return -1;
|
|
||||||
if ($b['host'] === '@') return 1;
|
|
||||||
return strcmp($a['host'], $b['host']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalRecords = array_sum(array_map('count', $records));
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
$this->logger->info("DNS lookup completed", [
|
$this->logger->info("DNS refresh completed", [
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'total_records' => $totalRecords,
|
'total_records' => $totalRecords,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard DNS lookup: root domain + resolve targets + special TXT.
|
||||||
|
* No subdomain brute force, no crt.sh. Like running nslookup/dig.
|
||||||
|
* Used by Discover > Quick Scan.
|
||||||
|
*/
|
||||||
|
public function quickScan(string $domain): array
|
||||||
|
{
|
||||||
|
$this->logger->info("DNS quick scan started", ['domain' => $domain]);
|
||||||
|
|
||||||
|
[$records, $seen] = $this->queryRootDomain($domain);
|
||||||
|
|
||||||
|
// Add subdomains found as NS/MX/CNAME/SRV targets
|
||||||
|
$targetSubs = $this->extractTargetSubdomains($domain, $records);
|
||||||
|
foreach ($targetSubs as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
|
$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),
|
'subdomains_discovered' => count($discovered),
|
||||||
|
'wildcard_detected' => $wildcardDetected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $records;
|
return $records;
|
||||||
@@ -314,70 +363,577 @@ class DnsService
|
|||||||
return $ips;
|
return $ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SHARED SCAN HELPERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query root domain for all record types + DNS_ALL fallback + gethostbynamel fallback.
|
||||||
|
*
|
||||||
|
* @return array{0: array, 1: array} [$records, $seen]
|
||||||
|
*/
|
||||||
|
private function queryRootDomain(string $domain): array
|
||||||
|
{
|
||||||
|
$records = [
|
||||||
|
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
||||||
|
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
||||||
|
];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
||||||
|
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->queryAllFallback($domain, $domain, $records, $seen);
|
||||||
|
|
||||||
|
if (empty($records['A'])) {
|
||||||
|
$ips = @gethostbynamel($domain);
|
||||||
|
if (is_array($ips)) {
|
||||||
|
foreach ($ips as $ip) {
|
||||||
|
$this->addIfNew('A', [
|
||||||
|
'host' => '@', 'value' => $ip, 'ttl' => 0,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIp($ip),
|
||||||
|
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
|
||||||
|
], $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$records, $seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract subdomain labels found as NS/MX/CNAME/SRV targets under the given domain.
|
||||||
|
*/
|
||||||
|
private function extractTargetSubdomains(string $domain, array $records): array
|
||||||
|
{
|
||||||
|
$subs = [];
|
||||||
|
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
|
||||||
|
foreach ($records[$type] as $rec) {
|
||||||
|
$target = rtrim($rec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$sub = str_replace('.' . $domain, '', strtolower($target));
|
||||||
|
if ($sub && !in_array($sub, $subs)) {
|
||||||
|
$subs[] = $sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $subs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve MX targets that are under the domain — add their A/AAAA records.
|
||||||
|
*/
|
||||||
|
private function resolveMxTargets(string $domain, array &$records, array &$seen): void
|
||||||
|
{
|
||||||
|
foreach ($records['MX'] as $mxRec) {
|
||||||
|
$target = rtrim($mxRec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve NS server hostnames to their A/AAAA IPs (stored in raw data for display).
|
||||||
|
*/
|
||||||
|
private function resolveNsIps(array &$records): void
|
||||||
|
{
|
||||||
|
foreach ($records['NS'] as &$nsRec) {
|
||||||
|
$nsHost = rtrim($nsRec['value'] ?? '', '.');
|
||||||
|
if ($nsHost) {
|
||||||
|
$nsIps = $this->resolveHostIps($nsHost);
|
||||||
|
$nsRec['raw']['_ns_ips'] = $nsIps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($nsRec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort 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)
|
// CERTIFICATE TRANSPARENCY (crt.sh)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover subdomains via crt.sh Certificate Transparency logs.
|
* Discover subdomains via crt.sh Certificate Transparency logs.
|
||||||
* Returns an array of subdomain labels (e.g. ['www', 'mail', 'api']).
|
*
|
||||||
* Slow/unreliable — use only in cron, not on manual refresh.
|
* Spawns check_dns.php --crtsh as a subprocess with a hard timeout to
|
||||||
|
* protect against crt.sh hangs. The subprocess handles HTTP retries and
|
||||||
|
* streams debug output to stderr, relayed via the $onStderrLine callback.
|
||||||
|
*
|
||||||
|
* @param string $domain The domain to scan
|
||||||
|
* @param int $maxSubdomains Cap on returned subdomains (0 = no limit)
|
||||||
|
* @param int $timeoutSeconds Hard kill timeout for the subprocess
|
||||||
|
* @param callable|null $onStderrLine fn(string $line) for real-time stderr relay
|
||||||
|
* @return array{0: string[], 1: bool} [subdomains, serverResponded]
|
||||||
*/
|
*/
|
||||||
public function crtshSubdomains(string $domain): array
|
public function fetchCrtshSubdomains(
|
||||||
{
|
string $domain,
|
||||||
$url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json';
|
int $maxSubdomains = 100,
|
||||||
|
int $timeoutSeconds = 1800,
|
||||||
|
?callable $onStderrLine = null
|
||||||
|
): array {
|
||||||
|
$phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
|
||||||
|
$scriptPath = dirname(__DIR__, 2) . '/cron/check_dns.php';
|
||||||
|
$cmd = [$phpBin, $scriptPath, '--crtsh', $domain];
|
||||||
|
|
||||||
|
if ($maxSubdomains > 0) {
|
||||||
|
$cmd[] = (string) $maxSubdomains;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__, 2);
|
||||||
|
$proc = proc_open($cmd, [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
], $pipes, $projectRoot);
|
||||||
|
|
||||||
|
if (!is_resource($proc)) {
|
||||||
|
$this->logger->error('Failed to spawn crt.sh subprocess', ['domain' => $domain]);
|
||||||
|
return [[], false];
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($pipes[0]);
|
||||||
|
stream_set_blocking($pipes[1], false);
|
||||||
|
stream_set_blocking($pipes[2], false);
|
||||||
|
|
||||||
|
$start = time();
|
||||||
|
$stdout = '';
|
||||||
|
$stderrBuffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$status = proc_get_status($proc);
|
||||||
|
|
||||||
|
if (!$status['running']) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = time() - $start;
|
||||||
|
|
||||||
|
if ($elapsed >= $timeoutSeconds) {
|
||||||
|
$stdout .= self::drainStream($pipes[1]);
|
||||||
|
$stderrBuffer .= self::drainStream($pipes[2]);
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
|
proc_terminate($proc, 9);
|
||||||
|
proc_close($proc);
|
||||||
|
$this->logger->warning("crt.sh subprocess killed after {$elapsed}s", ['domain' => $domain]);
|
||||||
|
return [[], false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$readable = [$pipes[1], $pipes[2]];
|
||||||
|
$w = $e = null;
|
||||||
|
if (@stream_select($readable, $w, $e, 0, 200000) > 0) {
|
||||||
|
foreach ($readable as $stream) {
|
||||||
|
$chunk = stream_get_contents($stream);
|
||||||
|
if ($stream === $pipes[1]) {
|
||||||
|
$stdout .= $chunk;
|
||||||
|
} else {
|
||||||
|
$stderrBuffer .= $chunk;
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stdout .= stream_get_contents($pipes[1]);
|
||||||
|
$stderrBuffer .= stream_get_contents($pipes[2]);
|
||||||
|
$this->flushCrtshStderrLines($stderrBuffer, $onStderrLine);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
proc_close($proc);
|
||||||
|
|
||||||
|
$decoded = json_decode($stdout, true);
|
||||||
|
$ok = is_array($decoded) && !empty($decoded['ok']);
|
||||||
|
$subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : [];
|
||||||
|
|
||||||
|
$this->logger->info('crt.sh discovery completed', [
|
||||||
|
'domain' => $domain,
|
||||||
|
'subdomains_found' => count($subs),
|
||||||
|
'server_ok' => $ok,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$subs, $ok];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a crt.sh URL with optional debug output to stderr.
|
||||||
|
*
|
||||||
|
* Called from the crt.sh subprocess where stderr is relayed to the parent
|
||||||
|
* in real-time. Pass $debug = true in subprocess context.
|
||||||
|
*
|
||||||
|
* @return array{status: int, body_length: int, data: array, time: float}
|
||||||
|
*/
|
||||||
|
public function fetchCrtshUrl(string $url, int $timeout = 900, bool $debug = false): array
|
||||||
|
{
|
||||||
$ctx = stream_context_create([
|
$ctx = stream_context_create([
|
||||||
'http' => [
|
'http' => [
|
||||||
'timeout' => 30,
|
'timeout' => $timeout,
|
||||||
'ignore_errors' => true,
|
'ignore_errors' => true,
|
||||||
'header' => "User-Agent: DomainMonitor/1.0\r\n",
|
'header' => implode("\r\n", [
|
||||||
],
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'ssl' => [
|
'Accept: application/json, text/plain, */*',
|
||||||
'verify_peer' => false,
|
'Accept-Language: en-US,en;q=0.9',
|
||||||
'verify_peer_name' => false,
|
'Connection: keep-alive',
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$json = @file_get_contents($url, false, $ctx);
|
$start = microtime(true);
|
||||||
if ($json === false) {
|
$http_response_header = null;
|
||||||
$this->logger->warning('crt.sh request failed', ['domain' => $domain]);
|
$body = @file_get_contents($url, false, $ctx);
|
||||||
return [];
|
$elapsed = microtime(true) - $start;
|
||||||
|
|
||||||
|
$bodyLen = is_string($body) ? strlen($body) : 0;
|
||||||
|
|
||||||
|
if ($debug) {
|
||||||
|
fwrite(STDERR, "--- response ---\n");
|
||||||
|
fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n");
|
||||||
|
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
foreach ($http_response_header as $h) {
|
||||||
|
fwrite(STDERR, "$h\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "(no response headers — connection failed or timeout)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "Body: $bodyLen bytes\n");
|
||||||
|
|
||||||
|
if (is_string($body) && $bodyLen > 0) {
|
||||||
|
$preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body;
|
||||||
|
fwrite(STDERR, $preview . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "--- end response ---\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
$entries = @json_decode($json, true);
|
$status = 0;
|
||||||
if (!is_array($entries)) {
|
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
||||||
return [];
|
$status = (int) $m[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$subdomains = [];
|
$data = [];
|
||||||
|
if ($status === 200 && is_string($body) && $bodyLen > 2) {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$data = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'body_length' => $bodyLen,
|
||||||
|
'data' => $data,
|
||||||
|
'time' => $elapsed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract unique subdomain prefixes from raw crt.sh JSON response.
|
||||||
|
*
|
||||||
|
* Each entry has a name_value field that may contain multiple newline-separated
|
||||||
|
* names, including wildcards. Returns only the subdomain prefixes
|
||||||
|
* (e.g. "www", "mail", "api").
|
||||||
|
*
|
||||||
|
* @param array $crtshData Decoded JSON array from crt.sh
|
||||||
|
* @param string $domain The base domain (e.g. "example.com")
|
||||||
|
* @return string[] Unique subdomain prefixes
|
||||||
|
*/
|
||||||
|
public function extractCrtshSubdomains(array $crtshData, string $domain): array
|
||||||
|
{
|
||||||
$domainLower = strtolower($domain);
|
$domainLower = strtolower($domain);
|
||||||
|
$suffix = '.' . $domainLower;
|
||||||
|
$suffixLen = strlen($suffix);
|
||||||
|
$subs = [];
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
foreach ($crtshData as $entry) {
|
||||||
$name = $entry['name_value'] ?? '';
|
if (empty($entry['name_value'])) {
|
||||||
foreach (explode("\n", $name) as $n) {
|
continue;
|
||||||
$n = strtolower(trim($n));
|
}
|
||||||
$n = ltrim($n, '*.');
|
|
||||||
if (empty($n)) continue;
|
|
||||||
|
|
||||||
if ($n === $domainLower) continue;
|
foreach (explode("\n", $entry['name_value']) as $name) {
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
if (str_ends_with($n, '.' . $domainLower)) {
|
if (strpos($name, '*.') === 0) {
|
||||||
$sub = str_replace('.' . $domainLower, '', $n);
|
$name = substr($name, 2);
|
||||||
if ($sub !== '' && !isset($subdomains[$sub])) {
|
}
|
||||||
$subdomains[$sub] = true;
|
|
||||||
}
|
if ($name === $domainLower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (substr($name, -$suffixLen) !== $suffix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = substr($name, 0, strlen($name) - $suffixLen);
|
||||||
|
if (!empty($sub)) {
|
||||||
|
$subs[$sub] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = array_keys($subdomains);
|
return array_keys($subs);
|
||||||
$this->logger->info('crt.sh discovery completed', [
|
}
|
||||||
'domain' => $domain,
|
|
||||||
'subdomains_found' => count($result),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $result;
|
/**
|
||||||
|
* Flush complete stderr lines from buffer via callback.
|
||||||
|
*/
|
||||||
|
private function flushCrtshStderrLines(string &$buffer, ?callable $onLine): void
|
||||||
|
{
|
||||||
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||||
|
$line = trim(substr($buffer, 0, $pos));
|
||||||
|
$buffer = substr($buffer, $pos + 1);
|
||||||
|
if ($line !== '' && $onLine) {
|
||||||
|
$onLine($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain remaining data from a non-blocking stream and close it.
|
||||||
|
*/
|
||||||
|
private static function drainStream($stream): string
|
||||||
|
{
|
||||||
|
if (!is_resource($stream)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$data = stream_get_contents($stream);
|
||||||
|
fclose($stream);
|
||||||
|
return $data ?: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -713,6 +713,151 @@ class NotificationService
|
|||||||
return $results;
|
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)
|
* 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>
|
<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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{% for tag in availableTags %}
|
{% 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">
|
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>
|
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
<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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{% for tag in availableTags %}
|
{% 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">
|
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>
|
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
<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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{% for tag in availableTags %}
|
{% 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">
|
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>
|
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
@@ -161,6 +161,16 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
@@ -195,7 +205,7 @@
|
|||||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain permanently?')" class="m-0">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
||||||
|
|||||||
@@ -375,10 +375,16 @@
|
|||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
|
<form method="POST" action="/domains/{{ domain.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this domain?')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
|
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -398,7 +404,18 @@
|
|||||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
|
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
|
||||||
<a href="/domains/{{ domain.id }}" class="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
<a href="/domains/{{ domain.id }}" class="flex-1 text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -566,13 +583,12 @@ function bulkRefresh() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' domain(s)? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -641,16 +657,15 @@ function bulkAddTag(tagName) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkRemoveAllTags() {
|
async function bulkRemoveAllTags() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
alert('Please select at least one domain');
|
alert('Please select at least one domain');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
|
var ok = await confirmAction({ message: 'Remove all tags from ' + ids.length + ' domain(s)?', title: 'Remove Tags', icon: 'fa-tags text-orange-500' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -680,51 +695,15 @@ function bulkTransfer() {
|
|||||||
alert('Please select at least one domain');
|
alert('Please select at least one domain');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer ' + ids.length + ' Domain(s)',
|
||||||
if (users.length === 0) {
|
description: 'Select the user to transfer the selected domains to.',
|
||||||
alert('No users available for transfer');
|
action: '/domains/bulk-transfer',
|
||||||
return;
|
fields: { 'domain_ids[]': ids },
|
||||||
}
|
submitText: 'Transfer Domains',
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
let userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || user.email})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
|
||||||
<div class="mt-3">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Transfer ${ids.length} Domain(s)</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Select the user to transfer the selected domains to:</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/domains/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${ids.map(id => `<input type="hidden" name="domain_ids[]" value="${id}">`).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="target_user_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User:</label>
|
|
||||||
<select name="target_user_id" id="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select a user...</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded-md hover:bg-gray-400 dark:hover:bg-slate-500">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
|
|
||||||
Transfer Domains
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
||||||
@@ -977,5 +956,26 @@ document.addEventListener('click', function(e) {
|
|||||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function transferDomain(domainId, domainName) {
|
||||||
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: domainId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('.domain-transfer-btn');
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -23,22 +23,32 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||||
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Click "Refresh DNS" to fetch the current DNS records for this domain.</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Run a quick scan, import a zone file, or add records manually.</p>
|
||||||
{% if domain %}
|
{% if domain %}
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
<div class="flex items-center justify-center gap-2 flex-wrap">
|
||||||
{{ csrf_field()|raw }}
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns" class="inline">
|
||||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
{{ csrf_field()|raw }}
|
||||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
<input type="hidden" name="mode" value="quick">
|
||||||
<span class="btn-label">Refresh DNS</span>
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-bolt mr-1.5" style="font-size: 10px;"></i>Quick Scan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<!-- Action Bar -->
|
<!-- Action Bar -->
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||||
<i class="far fa-clock mr-1"></i>
|
<i class="far fa-clock mr-1"></i>
|
||||||
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||||
@@ -51,13 +61,63 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if domain and dnsMonitoringEnabled %}
|
{% if domain and dnsMonitoringEnabled %}
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{{ csrf_field()|raw }}
|
{# Refresh DNS (re-check existing only) #}
|
||||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
{{ csrf_field()|raw }}
|
||||||
<span class="btn-label">Refresh DNS</span>
|
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
<span class="btn-label">Refresh DNS</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Discover DNS dropdown #}
|
||||||
|
<div class="relative" id="discoverDropdown">
|
||||||
|
<button type="button" onclick="document.getElementById('discoverMenu').classList.toggle('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-search mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Discover DNS
|
||||||
|
<i class="fas fa-caret-down ml-1.5" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
<div id="discoverMenu" class="hidden absolute right-0 mt-1 w-56 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg shadow-lg z-30">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="mode" value="quick">
|
||||||
|
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-t-lg transition-colors">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-bolt text-amber-500 mr-1.5"></i>Quick Scan</div>
|
||||||
|
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Root domain + NS/MX targets</p>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="mode" value="deep">
|
||||||
|
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-b-lg border-t border-gray-100 dark:border-slate-700 transition-colors">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-microscope text-purple-500 mr-1.5"></i>Deep Scan</div>
|
||||||
|
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Brute force + crt.sh (background)</p>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Add Record #}
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
{# Import Zone #}
|
||||||
|
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Bulk delete (shown when records are selected) #}
|
||||||
|
<button type="button" id="dnsBulkDeleteBtn" class="hidden inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||||
|
onclick="submitDnsBulkDelete()">
|
||||||
|
<i class="fas fa-trash-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Delete Selected (<span id="dnsSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,23 +191,31 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="A"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['A'] %}
|
{% for record in dnsRecords['A'] %}
|
||||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
{% if record.host == '@' %}
|
{% if record.host == '@' %}
|
||||||
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ record.host }}
|
{{ record.host }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs">
|
<td class="px-4 py-2 text-xs">
|
||||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
@@ -177,6 +245,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -199,23 +275,31 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="AAAA"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['AAAA'] %}
|
{% for record in dnsRecords['AAAA'] %}
|
||||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
{% if record.host == '@' %}
|
{% if record.host == '@' %}
|
||||||
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ record.host }}
|
{{ record.host }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs">
|
<td class="px-4 py-2 text-xs">
|
||||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
@@ -245,6 +329,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -267,17 +359,32 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CNAME"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['CNAME'] %}
|
{% for record in dnsRecords['CNAME'] %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -300,19 +407,43 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="MX"></th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">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">Priority</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['MX'] %}
|
{% for record in dnsRecords['MX'] %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 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">
|
<td class="px-4 py-2">
|
||||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
|
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -331,10 +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>
|
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="overflow-x-auto">
|
||||||
{% for record in dnsRecords['TXT'] %}
|
<table class="min-w-full">
|
||||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<div class="flex items-start">
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="TXT"></th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">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 %}
|
{% set val = record.value|lower %}
|
||||||
{% if val starts with 'v=spf1' %}
|
{% if val starts with 'v=spf1' %}
|
||||||
{% set txtType = 'SPF' %}
|
{% set txtType = 'SPF' %}
|
||||||
@@ -351,11 +492,37 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% set txtType = 'TXT' %}
|
{% set txtType = 'TXT' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded mr-2 flex-shrink-0">{{ txtType }}</span>
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
</div>
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
</div>
|
{% if record.host == '@' %}
|
||||||
{% endfor %}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -374,11 +541,13 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="NS"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
@@ -386,10 +555,15 @@
|
|||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||||
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||||
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
||||||
{{ nsIps.ipv4|join(', ') }}
|
{{ nsIps.ipv4|join(', ') }}
|
||||||
@@ -401,6 +575,14 @@
|
|||||||
{% else %}-{% endif %}
|
{% else %}-{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -423,24 +605,39 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="SRV"></th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['SRV'] %}
|
{% for record in dnsRecords['SRV'] %}
|
||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||||
|
{% elseif record.source|default('discovered') == 'imported' %}
|
||||||
|
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||||
|
{% endif %}</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -463,22 +660,46 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CAA"></th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">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">Tag</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
||||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
<th class="w-8 px-2 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
{% for record in dnsRecords['CAA'] %}
|
{% for record in dnsRecords['CAA'] %}
|
||||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||||
|
<td class="px-4 py-2 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">
|
<td class="px-4 py-2">
|
||||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
||||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
<td class="w-8 px-2 py-2">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||||
|
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -491,6 +712,88 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== Add Record Modal ===== #}
|
||||||
|
{% if domain %}
|
||||||
|
<div id="addDnsRecordModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-plus text-primary mr-2"></i>Add DNS Record
|
||||||
|
</h3>
|
||||||
|
<button onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/dns-records" id="addDnsRecordForm">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Type</label>
|
||||||
|
<select name="record_type" id="dnsRecordType" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<option value="A">A (IPv4)</option>
|
||||||
|
<option value="AAAA">AAAA (IPv6)</option>
|
||||||
|
<option value="CNAME">CNAME (Alias)</option>
|
||||||
|
<option value="MX">MX (Mail)</option>
|
||||||
|
<option value="TXT">TXT (Text)</option>
|
||||||
|
<option value="NS">NS (Nameserver)</option>
|
||||||
|
<option value="SRV">SRV (Service)</option>
|
||||||
|
<option value="CAA">CAA (CA Auth)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Host</label>
|
||||||
|
<input type="text" name="host" id="dnsRecordHost" value="@" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="@ or subdomain">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5" id="dnsValueLabel">Value</label>
|
||||||
|
<input type="text" name="value" id="dnsRecordValue" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="IPv4 address (e.g. 1.2.3.4)">
|
||||||
|
<p class="mt-1 text-xs text-gray-400 dark:text-slate-500" id="dnsValueHint">Enter the IPv4 address this record points to.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">TTL <span class="text-gray-400 font-normal">(seconds)</span></label>
|
||||||
|
<input type="number" name="ttl" id="dnsRecordTtl" min="0" value="3600" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="3600">
|
||||||
|
</div>
|
||||||
|
<div id="dnsPriorityWrap">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Priority</label>
|
||||||
|
<input type="number" name="priority" id="dnsRecordPriority" min="0" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-plus mr-1.5"></i>Add Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ===== Import Zone Modal (shared partial) ===== #}
|
||||||
|
{% include 'partials/import-modal.twig' with {
|
||||||
|
prefix: 'dnsZone',
|
||||||
|
title: 'Import DNS Zone File',
|
||||||
|
action: '/domains/' ~ domain.id ~ '/dns-import',
|
||||||
|
accept: '.txt,.zone,.db,.bind',
|
||||||
|
file_hint: 'BIND zone file (.txt, .zone)',
|
||||||
|
input_name: 'zone_file',
|
||||||
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">Standard BIND zone file format:</p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5 font-mono">@ IN A 1.2.3.4</p><p class="text-xs text-gray-600 dark:text-slate-400 font-mono">www IN CNAME example.com.</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Duplicate records will be skipped. Imported records are tagged as "imported".</p>',
|
||||||
|
submit_label: 'Import Zone'
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{# ===== Bulk Delete Form (hidden, submitted via JS) ===== #}
|
||||||
|
<form id="dnsBulkDeleteForm" method="POST" action="/domains/{{ domain.id }}/dns-records/bulk-delete" class="hidden">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function handleDnsRefresh(form) {
|
function handleDnsRefresh(form) {
|
||||||
var btn = form.querySelector('.dns-refresh-btn');
|
var btn = form.querySelector('.dns-refresh-btn');
|
||||||
@@ -504,4 +807,137 @@ function handleDnsRefresh(form) {
|
|||||||
if (label) label.textContent = 'Scanning DNS...';
|
if (label) label.textContent = 'Scanning DNS...';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Close discover dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var dropdown = document.getElementById('discoverDropdown');
|
||||||
|
var menu = document.getElementById('discoverMenu');
|
||||||
|
if (dropdown && menu && !dropdown.contains(e.target)) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox select-all
|
||||||
|
document.querySelectorAll('.dns-select-all').forEach(function(selectAll) {
|
||||||
|
selectAll.addEventListener('change', function() {
|
||||||
|
var table = this.closest('table');
|
||||||
|
table.querySelectorAll('.dns-record-cb').forEach(function(cb) {
|
||||||
|
cb.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
updateBulkDeleteBtn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual checkbox change
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('dns-record-cb')) {
|
||||||
|
updateBulkDeleteBtn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var modals = ['addDnsRecordModal', 'dnsZoneImportModal'];
|
||||||
|
modals.forEach(function(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el && !el.classList.contains('hidden')) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var menu = document.getElementById('discoverMenu');
|
||||||
|
if (menu) menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function updateBulkDeleteBtn() {
|
||||||
|
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||||
|
var btn = document.getElementById('dnsBulkDeleteBtn');
|
||||||
|
var count = document.getElementById('dnsSelectedCount');
|
||||||
|
if (btn) {
|
||||||
|
if (checked.length > 0) {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
if (count) count.textContent = checked.length;
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDnsBulkDelete() {
|
||||||
|
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
var ok = await confirmAction({ message: 'Delete ' + checked.length + ' selected DNS record(s)?' });
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
var form = document.getElementById('dnsBulkDeleteForm');
|
||||||
|
// Remove any previous hidden inputs
|
||||||
|
form.querySelectorAll('input[name="record_ids[]"]').forEach(function(el) { el.remove(); });
|
||||||
|
checked.forEach(function(cb) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'record_ids[]';
|
||||||
|
input.value = cb.value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var typeSelect = document.getElementById('dnsRecordType');
|
||||||
|
if (!typeSelect) return;
|
||||||
|
|
||||||
|
var valueInput = document.getElementById('dnsRecordValue');
|
||||||
|
var valueLabel = document.getElementById('dnsValueLabel');
|
||||||
|
var valueHint = document.getElementById('dnsValueHint');
|
||||||
|
var hostInput = document.getElementById('dnsRecordHost');
|
||||||
|
var prioWrap = document.getElementById('dnsPriorityWrap');
|
||||||
|
var prioInput = document.getElementById('dnsRecordPriority');
|
||||||
|
|
||||||
|
var typeMeta = {
|
||||||
|
A: { label: 'IPv4 Address', placeholder: '1.2.3.4', hint: 'Enter the IPv4 address this record points to.', priority: false },
|
||||||
|
AAAA: { label: 'IPv6 Address', placeholder: '2001:db8::1', hint: 'Enter the full IPv6 address.', priority: false },
|
||||||
|
CNAME: { label: 'Target Hostname', placeholder: 'example.com', hint: 'The canonical hostname this alias resolves to (no trailing dot needed).', priority: false, host: 'subdomain' },
|
||||||
|
MX: { label: 'Mail Server', placeholder: 'mail.example.com', hint: 'Hostname of the mail server. Set priority (lower = higher preference).', priority: true, prioDefault: '10' },
|
||||||
|
TXT: { label: 'Text Value', placeholder: 'v=spf1 include:_spf.google.com ~all', hint: 'SPF, DKIM, verification tokens, or any text value.', priority: false },
|
||||||
|
NS: { label: 'Nameserver', placeholder: 'ns1.example.com', hint: 'Hostname of the authoritative nameserver.', priority: false },
|
||||||
|
SRV: { label: 'Target', placeholder: 'sipserver.example.com', hint: 'Target hostname. Host should be _service._proto format. Set priority & use value format: weight port target.', priority: true, prioDefault: '0', host: '_sip._tcp' },
|
||||||
|
CAA: { label: 'Value', placeholder: '0 issue "letsencrypt.org"', hint: 'Format: flags tag value (e.g. 0 issue "letsencrypt.org").', priority: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateFieldsForType() {
|
||||||
|
var t = typeSelect.value;
|
||||||
|
var meta = typeMeta[t] || typeMeta['A'];
|
||||||
|
|
||||||
|
valueLabel.textContent = meta.label;
|
||||||
|
valueInput.placeholder = meta.placeholder;
|
||||||
|
valueHint.textContent = meta.hint;
|
||||||
|
|
||||||
|
if (meta.priority) {
|
||||||
|
prioWrap.classList.remove('hidden');
|
||||||
|
if (prioInput && !prioInput.value) prioInput.placeholder = meta.prioDefault || '10';
|
||||||
|
} else {
|
||||||
|
prioWrap.classList.add('hidden');
|
||||||
|
if (prioInput) prioInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.host) {
|
||||||
|
hostInput.placeholder = meta.host;
|
||||||
|
} else {
|
||||||
|
hostInput.placeholder = '@ or subdomain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', updateFieldsForType);
|
||||||
|
updateFieldsForType();
|
||||||
|
|
||||||
|
document.getElementById('addDnsRecordForm').addEventListener('submit', function() {
|
||||||
|
var ttlInput = document.getElementById('dnsRecordTtl');
|
||||||
|
if (ttlInput && (!ttlInput.value || ttlInput.value.trim() === '')) {
|
||||||
|
ttlInput.value = '3600';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
{% if domain.registrar_url is not empty %}
|
{% if domain.registrar_url is not empty %}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-gray-500 dark:text-slate-400">Registrar URL:</span>
|
<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>
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
Visit
|
Visit
|
||||||
</a>
|
</a>
|
||||||
@@ -191,9 +191,15 @@
|
|||||||
<i class="fas fa-lock text-indigo-500 dark:text-indigo-400 mr-2" style="font-size: 10px;"></i>
|
<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>
|
<span class="text-xs text-gray-700 dark:text-slate-300">SSL</span>
|
||||||
</div>
|
</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">
|
{% if domain.ssl_monitoring_enabled|default(0) %}
|
||||||
<i class="fas fa-minus-circle mr-1" style="font-size: 9px;"></i>Coming Soon
|
<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>
|
</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>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<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">
|
<div class="space-y-3">
|
||||||
<!-- Cert 1 (root) -->
|
{% if not sslMonitoringEnabled %}
|
||||||
<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="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
<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-start">
|
||||||
<div class="flex items-center justify-between">
|
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">SSL monitoring is disabled</h3>
|
||||||
<div>
|
<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>
|
||||||
<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>
|
<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">
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
<i class="fas fa-edit mr-1"></i>Enable SSL monitoring in Edit
|
||||||
</div>
|
</a>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{% else %}
|
||||||
<div class="space-y-3">
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
|
||||||
<div>
|
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
<div>
|
||||||
<div class="mt-1.5 space-y-1.5">
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">SSL certificate monitoring</h3>
|
||||||
<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>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||||
<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>
|
Track the default root certificate and any monitored HTTPS endpoints, including custom ports.
|
||||||
<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>
|
</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">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>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
<div class="flex flex-wrap gap-2">
|
||||||
<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>
|
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||||
<div class="flex gap-2">
|
{{ csrf_field()|raw }}
|
||||||
<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>
|
<input type="hidden" name="hostname" value="@">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cert 2 (mail subdomain) -->
|
{% if sslStats.total > 0 %}
|
||||||
<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="grid grid-cols-2 xl:grid-cols-5 gap-3">
|
||||||
<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="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">
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||||
<div class="flex items-center gap-3">
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-1">{{ sslStats.total }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||||
<div class="space-y-3">
|
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-1">{{ sslStats.valid }}</p>
|
||||||
<div>
|
</div>
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
<div class="mt-1.5 space-y-1.5">
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring</p>
|
||||||
<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>
|
<p class="text-lg font-semibold text-amber-600 dark:text-amber-400 mt-1">{{ sslStats.expiring }}</p>
|
||||||
<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>
|
||||||
<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 class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
</div>
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expired</p>
|
||||||
</div>
|
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.expired }}</p>
|
||||||
<div>
|
</div>
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
<div class="mt-1.5">
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||||
<p class="text-xs text-gray-900 dark:text-white font-medium">DigiCert Inc.</p>
|
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.invalid }}</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||||
<div class="space-y-3">
|
<span>
|
||||||
<div>
|
<i class="far fa-clock mr-1"></i>
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
Last checked: {{ domain.ssl_last_checked ? domain.ssl_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||||
<div class="mt-1.5 space-y-1">
|
</span>
|
||||||
<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>
|
||||||
<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 class="flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||||
</div>
|
<div class="relative flex-1 max-w-md">
|
||||||
</div>
|
<input
|
||||||
<div>
|
type="text"
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
id="ssl-search"
|
||||||
<div class="mt-1.5 space-y-1.5">
|
placeholder="Search monitored endpoints..."
|
||||||
<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>
|
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"
|
||||||
<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>
|
<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>
|
||||||
</div>
|
<div class="flex flex-wrap gap-2">
|
||||||
</div>
|
<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">
|
||||||
</div>
|
<option value="all">All certificates</option>
|
||||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
<option value="valid">Valid only</option>
|
||||||
<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>
|
<option value="expiring">Expiring soon</option>
|
||||||
<div class="flex gap-2">
|
<option value="expired">Expired</option>
|
||||||
<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>
|
<option value="invalid">Invalid</option>
|
||||||
<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>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cert 3 (api - expired) -->
|
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-refresh" id="ssl-bulk-refresh-form" class="hidden">
|
||||||
<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">
|
{{ csrf_field()|raw }}
|
||||||
<div class="px-4 py-2 bg-red-50 dark:bg-red-500/10 border-b border-red-200 dark:border-red-800">
|
<input type="hidden" name="certificate_ids" id="ssl-bulk-refresh-ids">
|
||||||
<div class="flex items-center justify-between">
|
</form>
|
||||||
<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()">
|
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-delete" id="ssl-bulk-delete-form" class="hidden">
|
||||||
<i class="fas fa-lock text-red-600 dark:text-red-400 mr-2" style="font-size: 14px;"></i>
|
{{ csrf_field()|raw }}
|
||||||
<div>
|
<input type="hidden" name="certificate_ids" id="ssl-bulk-delete-ids">
|
||||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">api.example.com</h3>
|
</form>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
|
||||||
|
<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>
|
||||||
</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>
|
</div>
|
||||||
<div class="p-4">
|
{% endfor %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
</div>
|
||||||
<div class="space-y-3">
|
{% else %}
|
||||||
<div>
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
<i class="fas fa-lock text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||||
<div class="mt-1.5 space-y-1.5">
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No SSL certificates monitored yet</h3>
|
||||||
<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>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">
|
||||||
<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>
|
Start with the root domain on port 443, or add specific hosts and custom HTTPS ports you want to monitor.
|
||||||
<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>
|
</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>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
|
||||||
<div class="mt-1.5">
|
<button type="button" onclick="closeAddSslEndpointModal()"
|
||||||
<p class="text-xs text-gray-900 dark:text-white font-medium">Self-Signed</p>
|
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">
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">⚠️ Not Trusted</p>
|
Cancel
|
||||||
</div>
|
</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>
|
||||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
</form>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</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>
|
<script>
|
||||||
|
function getSelectedSSLIds() {
|
||||||
|
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map((checkbox) => checkbox.value);
|
||||||
|
}
|
||||||
|
|
||||||
function updateSSLBulkActions() {
|
function updateSSLBulkActions() {
|
||||||
const checkboxes = document.querySelectorAll('.ssl-checkbox:checked');
|
const selectedIds = getSelectedSSLIds();
|
||||||
const bulkActions = document.getElementById('ssl-bulk-actions');
|
const bulkActions = document.getElementById('ssl-bulk-actions');
|
||||||
const selectedCount = document.getElementById('ssl-selected-count');
|
const selectedCount = document.getElementById('ssl-selected-count');
|
||||||
if (checkboxes.length > 0) {
|
|
||||||
|
if (!bulkActions || !selectedCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIds.length > 0) {
|
||||||
bulkActions.classList.remove('hidden');
|
bulkActions.classList.remove('hidden');
|
||||||
selectedCount.textContent = `${checkboxes.length} certificate(s) selected`;
|
selectedCount.textContent = `${selectedIds.length} endpoint(s) selected`;
|
||||||
} else {
|
} else {
|
||||||
bulkActions.classList.add('hidden');
|
bulkActions.classList.add('hidden');
|
||||||
|
selectedCount.textContent = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSSLSelection() {
|
function clearSSLSelection() {
|
||||||
document.querySelectorAll('.ssl-checkbox').forEach(cb => cb.checked = false);
|
document.querySelectorAll('.ssl-checkbox').forEach((checkbox) => {
|
||||||
|
checkbox.checked = false;
|
||||||
|
});
|
||||||
updateSSLBulkActions();
|
updateSSLBulkActions();
|
||||||
}
|
}
|
||||||
function getSelectedSSLIds() {
|
|
||||||
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map(cb => cb.value);
|
async function submitBulkSslAction(action) {
|
||||||
}
|
const selectedIds = getSelectedSSLIds();
|
||||||
function bulkCheckSSL() {
|
if (selectedIds.length === 0) {
|
||||||
const ids = getSelectedSSLIds();
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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...');
|
function applySslFilters() {
|
||||||
}
|
const searchInput = document.getElementById('ssl-search');
|
||||||
document.getElementById('ssl-search')?.addEventListener('input', function(e) {
|
const filterSelect = document.getElementById('ssl-filter');
|
||||||
const searchTerm = e.target.value.toLowerCase();
|
const noResults = document.getElementById('ssl-no-results');
|
||||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
const items = document.querySelectorAll('.ssl-cert-item');
|
||||||
const text = item.textContent.toLowerCase();
|
|
||||||
item.style.display = text.includes(searchTerm) ? '' : 'none';
|
if (!searchInput || !filterSelect || items.length === 0) {
|
||||||
});
|
return;
|
||||||
});
|
}
|
||||||
document.getElementById('ssl-filter')?.addEventListener('change', function(e) {
|
|
||||||
const filter = e.target.value;
|
const searchTerm = searchInput.value.trim().toLowerCase();
|
||||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
const filter = filterSelect.value;
|
||||||
if (filter === 'all') {
|
let visibleCount = 0;
|
||||||
item.style.display = '';
|
|
||||||
} else {
|
items.forEach((item) => {
|
||||||
const status = item.dataset.status;
|
const haystack = item.dataset.search || '';
|
||||||
item.style.display = status === filter ? '' : 'none';
|
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>
|
</script>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
<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 %}
|
{% 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>
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
Visit Registrar
|
Visit Registrar
|
||||||
</a>
|
</a>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
||||||
{% if domain.abuse_email %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="text-gray-400 dark:text-slate-500">-</span>
|
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<i class="fas fa-edit mr-1.5"></i>
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||||
{{ csrf_field()|raw }}
|
{{ csrf_field()|raw }}
|
||||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
<i class="fas fa-trash mr-1.5"></i>
|
<i class="fas fa-trash mr-1.5"></i>
|
||||||
@@ -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">
|
<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>
|
<i class="fas fa-lock mr-1.5" style="font-size: 10px;"></i>
|
||||||
SSL
|
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>
|
{% if not (domain.ssl_monitoring_enabled|default(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">1</span>
|
<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>
|
||||||
<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">
|
<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>
|
<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>
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
<i class="fas fa-trash mr-1.5"></i>
|
<i class="fas fa-trash mr-1.5"></i>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
{% if domain.registrar_url is not empty %}
|
{% if domain.registrar_url is not empty %}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
<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>
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
Visit
|
Visit
|
||||||
</a>
|
</a>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
{% if domain.abuse_email is not empty %}
|
{% if domain.abuse_email is not empty %}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
<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 }}
|
{{ domain.abuse_email }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -465,8 +465,9 @@ function submitResolution() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteError() {
|
async function deleteError() {
|
||||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/{{ error.error_id }}/delete';
|
form.action = '/errors/{{ error.error_id }}/delete';
|
||||||
|
|||||||
@@ -412,8 +412,9 @@ function submitResolution() {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteError(errorId) {
|
async function deleteError(errorId) {
|
||||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/' + errorId + '/delete';
|
form.action = '/errors/' + errorId + '/delete';
|
||||||
@@ -457,10 +458,11 @@ function clearSelection() {
|
|||||||
updateBulkActions();
|
updateBulkActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const errorIds = getSelectedErrorIds();
|
const errorIds = getSelectedErrorIds();
|
||||||
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
||||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) return;
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + errorIds.length + ' error(s) and all their occurrences? This action cannot be undone.' });
|
||||||
|
if (!ok) return;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/errors/bulk-delete';
|
form.action = '/errors/bulk-delete';
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
|
class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
|
||||||
onclick="return confirm('Delete this channel?')">
|
onclick="return confirmClick(event, 'Delete this channel?')">
|
||||||
<i class="fas fa-trash mr-1"></i>
|
<i class="fas fa-trash mr-1"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete"
|
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete"
|
||||||
aria-label="Delete group {{ group.name }}">
|
aria-label="Delete group {{ group.name }}">
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||||
<i class="fas fa-cog mr-1"></i> Manage
|
<i class="fas fa-cog mr-1"></i> Manage
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
||||||
<i class="fas fa-trash mr-1"></i> Delete
|
<i class="fas fa-trash mr-1"></i> Delete
|
||||||
@@ -252,7 +252,7 @@ function getSelectedGroupIds() {
|
|||||||
return Array.from(checkboxes).map(cb => cb.value);
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDelete() {
|
async function bulkDelete() {
|
||||||
const groupIds = getSelectedGroupIds();
|
const groupIds = getSelectedGroupIds();
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
@@ -260,9 +260,8 @@ function bulkDelete() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + groupIds.length + ' group(s)? Domains will be unassigned from these groups.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -285,49 +284,15 @@ function bulkDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transferGroup(groupId, groupName) {
|
function transferGroup(groupId, groupName) {
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
openTransferModal({
|
||||||
if (users.length === 0) {
|
title: 'Transfer Group',
|
||||||
alert('No users available for transfer');
|
description: 'Transfer group <strong>' + esc(groupName) + '</strong> to another user.',
|
||||||
return;
|
action: '/groups/transfer',
|
||||||
}
|
fields: { group_id: groupId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
const userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Group</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer group "${groupName}" to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/groups/transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="group_id" value="${groupId}">
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkTransfer() {
|
function bulkTransfer() {
|
||||||
@@ -336,52 +301,15 @@ function bulkTransfer() {
|
|||||||
alert('Please select groups to transfer');
|
alert('Please select groups to transfer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer Groups',
|
||||||
|
description: 'Transfer ' + groupIds.length + ' selected group(s) to another user.',
|
||||||
if (users.length === 0) {
|
action: '/groups/bulk-transfer',
|
||||||
alert('No users available for transfer');
|
fields: { 'group_ids[]': groupIds },
|
||||||
return;
|
submitText: 'Transfer All',
|
||||||
}
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
const userOptions = users.map(user =>
|
});
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Groups</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/groups/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${groupIds.map(id =>
|
|
||||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
|
||||||
).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -398,140 +326,11 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{# Import Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'group',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import Notification Groups',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/groups/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
} %}
|
||||||
</h3>
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="groupDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="groupDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="groupFileSize"></p>
|
|
||||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('groupDropzone');
|
|
||||||
const fileInput = document.getElementById('groupFileInput');
|
|
||||||
const content = document.getElementById('groupDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
|
||||||
const fileName = document.getElementById('groupFileName');
|
|
||||||
const fileSize = document.getElementById('groupFileSize');
|
|
||||||
const removeBtn = document.getElementById('groupFileRemove');
|
|
||||||
const form = document.getElementById('groupImportForm');
|
|
||||||
const submitBtn = document.getElementById('groupImportBtn');
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" action="/install/update" class="space-y-3">
|
<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">
|
<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>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Run Update Now
|
Run Update Now
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" action="/install/run" class="space-y-5">
|
<form method="POST" action="/install/run" class="space-y-5">
|
||||||
|
{{ csrf_field() }}
|
||||||
<div class="border-t border-gray-200 pt-6">
|
<div class="border-t border-gray-200 pt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -373,5 +373,7 @@
|
|||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
|
{% include 'partials/confirm-modal.twig' %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
<i class="fas fa-check text-xs"></i>
|
<i class="fas fa-check text-xs"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirm('Delete this notification?')">
|
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this notification?')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
||||||
<i class="fas fa-times text-xs"></i>
|
<i class="fas fa-times text-xs"></i>
|
||||||
@@ -407,16 +407,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function markAllAsRead() {
|
async function markAllAsRead() {
|
||||||
if (confirm('Mark all notifications as read?')) {
|
var ok = await confirmAction({ message: 'Mark all notifications as read?', title: 'Mark All Read', icon: 'fa-check-double text-primary', confirmText: 'Mark Read', confirmClass: 'bg-primary hover:bg-primary-dark' });
|
||||||
window.location.href = '/notifications/mark-all-read';
|
if (ok) window.location.href = '/notifications/mark-all-read';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
async function clearAll() {
|
||||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
var ok = await confirmAction({ message: 'Clear all notifications? This action cannot be undone.' });
|
||||||
document.getElementById('clearAllForm').submit();
|
if (ok) document.getElementById('clearAllForm').submit();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
122
app/Views/partials/confirm-modal.twig
Normal file
122
app/Views/partials/confirm-modal.twig
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{# Global confirmation modal — replaces native confirm() dialogs.
|
||||||
|
Included once in layout/base.twig. Provides:
|
||||||
|
|
||||||
|
confirmAction({ message, title, confirmText, cancelText, confirmClass, icon })
|
||||||
|
Returns a Promise<boolean>.
|
||||||
|
|
||||||
|
confirmSubmit(event, message, opts)
|
||||||
|
For onsubmit="return confirmSubmit(event, 'Delete?')"
|
||||||
|
|
||||||
|
confirmClick(event, message, opts)
|
||||||
|
For onclick="return confirmClick(event, 'Are you sure?')"
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" id="confirmModalTitle">
|
||||||
|
<i id="confirmModalIcon" class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||||
|
<span id="confirmModalTitleText">Confirm</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" id="confirmModalClose"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-slate-400" id="confirmModalMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" id="confirmModalCancel"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" id="confirmModalConfirm"
|
||||||
|
class="px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var modal = document.getElementById('confirmModal');
|
||||||
|
var titleText = document.getElementById('confirmModalTitleText');
|
||||||
|
var icon = document.getElementById('confirmModalIcon');
|
||||||
|
var message = document.getElementById('confirmModalMessage');
|
||||||
|
var confirmBtn = document.getElementById('confirmModalConfirm');
|
||||||
|
var cancelBtn = document.getElementById('confirmModalCancel');
|
||||||
|
var closeBtn = document.getElementById('confirmModalClose');
|
||||||
|
|
||||||
|
var _resolve = null;
|
||||||
|
|
||||||
|
function close(result) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
if (_resolve) { _resolve(result); _resolve = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', function() { close(true); });
|
||||||
|
cancelBtn.addEventListener('click', function() { close(false); });
|
||||||
|
closeBtn.addEventListener('click', function() { close(false); });
|
||||||
|
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) close(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||||
|
close(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.confirmAction = function(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
titleText.textContent = opts.title || 'Confirm';
|
||||||
|
message.innerHTML = opts.message || 'Are you sure?';
|
||||||
|
|
||||||
|
var iconClass = opts.icon || 'fa-exclamation-triangle text-red-500';
|
||||||
|
icon.className = 'fas ' + iconClass + ' mr-2';
|
||||||
|
|
||||||
|
confirmBtn.textContent = opts.confirmText || 'Confirm';
|
||||||
|
cancelBtn.textContent = opts.cancelText || 'Cancel';
|
||||||
|
|
||||||
|
var btnClass = opts.confirmClass || 'bg-red-600 hover:bg-red-700';
|
||||||
|
confirmBtn.className = 'px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ' + btnClass;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
confirmBtn.focus();
|
||||||
|
|
||||||
|
return new Promise(function(resolve) { _resolve = resolve; });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.confirmSubmit = function(e, msg, opts) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = e.target.closest('form') || e.target;
|
||||||
|
opts = opts || {};
|
||||||
|
opts.message = opts.message || msg;
|
||||||
|
confirmAction(opts).then(function(ok) {
|
||||||
|
if (ok) form.submit();
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.confirmClick = function(e, msg, opts) {
|
||||||
|
e.preventDefault();
|
||||||
|
var el = e.currentTarget || e.target.closest('a') || e.target;
|
||||||
|
opts = opts || {};
|
||||||
|
opts.message = opts.message || msg;
|
||||||
|
confirmAction(opts).then(function(ok) {
|
||||||
|
if (ok) {
|
||||||
|
if (el.tagName === 'A' && el.href) {
|
||||||
|
window.location.href = el.href;
|
||||||
|
} else if (el.form) {
|
||||||
|
el.form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
175
app/Views/partials/import-modal.twig
Normal file
175
app/Views/partials/import-modal.twig
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{#
|
||||||
|
# Shared import modal with drag & drop file upload.
|
||||||
|
#
|
||||||
|
# Parameters:
|
||||||
|
# prefix - Unique prefix for element IDs (e.g. 'tag', 'group', 'tld', 'dnsZone')
|
||||||
|
# title - Modal title (e.g. 'Import Tags')
|
||||||
|
# action - Form POST action URL
|
||||||
|
# accept - File input accept attribute (default: '.csv,.json')
|
||||||
|
# file_hint - Accepted file types hint (default: 'CSV, JSON')
|
||||||
|
# format_html - Raw HTML for the "Expected Format" info block
|
||||||
|
# submit_label - Submit button text (default: title)
|
||||||
|
# input_name - File input name attribute (default: 'import_file')
|
||||||
|
# extra_fields - Optional raw HTML for extra form fields (textarea, etc.)
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set _accept = accept|default('.csv,.json') %}
|
||||||
|
{% set _file_hint = file_hint|default('CSV, JSON') %}
|
||||||
|
{% set _submit = submit_label|default(title) %}
|
||||||
|
{% set _input_name = input_name|default('import_file') %}
|
||||||
|
|
||||||
|
<div id="{{ prefix }}ImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-upload text-primary mr-2"></i>{{ title }}
|
||||||
|
</h3>
|
||||||
|
<button onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||||
|
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ action }}" enctype="multipart/form-data" id="{{ prefix }}ImportForm">
|
||||||
|
{{ csrf_field() }}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
{% if extra_fields is defined and extra_fields %}
|
||||||
|
{{ extra_fields|raw }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||||
|
<div id="{{ prefix }}Dropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<input type="file" name="{{ _input_name }}" accept="{{ _accept }}" required id="{{ prefix }}FileInput"
|
||||||
|
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||||
|
<div id="{{ prefix }}DropzoneContent">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||||
|
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||||
|
</span>
|
||||||
|
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">{{ _file_hint }} · Max {{ max_upload_size() }}</p>
|
||||||
|
</div>
|
||||||
|
<div id="{{ prefix }}DropzoneFile" class="hidden">
|
||||||
|
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="{{ prefix }}FileName"></p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500" id="{{ prefix }}FileSize"></p>
|
||||||
|
<button type="button" id="{{ prefix }}FileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||||
|
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if format_html is defined and format_html %}
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format
|
||||||
|
</p>
|
||||||
|
{{ format_html|raw }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="{{ prefix }}ImportBtn"
|
||||||
|
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-upload mr-1.5"></i>{{ _submit }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var pfx = '{{ prefix }}';
|
||||||
|
var dropzone = document.getElementById(pfx + 'Dropzone');
|
||||||
|
if (!dropzone) return;
|
||||||
|
var fileInput = document.getElementById(pfx + 'FileInput');
|
||||||
|
var content = document.getElementById(pfx + 'DropzoneContent');
|
||||||
|
var fileInfo = document.getElementById(pfx + 'DropzoneFile');
|
||||||
|
var fileName = document.getElementById(pfx + 'FileName');
|
||||||
|
var fileSize = document.getElementById(pfx + 'FileSize');
|
||||||
|
var removeBtn = document.getElementById(pfx + 'FileRemove');
|
||||||
|
var form = document.getElementById(pfx + 'ImportForm');
|
||||||
|
var submitBtn = document.getElementById(pfx + 'ImportBtn');
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFile(file) {
|
||||||
|
fileName.textContent = file.name;
|
||||||
|
fileSize.textContent = formatSize(file.size);
|
||||||
|
content.classList.add('hidden');
|
||||||
|
fileInfo.classList.remove('hidden');
|
||||||
|
dropzone.classList.remove('border-gray-300');
|
||||||
|
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDropzone() {
|
||||||
|
fileInput.value = '';
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
fileInfo.classList.add('hidden');
|
||||||
|
dropzone.classList.add('border-gray-300');
|
||||||
|
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
if (this.files.length) showFile(this.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
removeBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
resetDropzone();
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function(evt) {
|
||||||
|
dropzone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||||
|
dropzone.classList.remove('border-gray-300');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(function(evt) {
|
||||||
|
dropzone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||||
|
dropzone.classList.add('border-gray-300');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzone.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var files = e.dataTransfer.files;
|
||||||
|
if (files.length) {
|
||||||
|
fileInput.files = files;
|
||||||
|
showFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function() {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var modal = document.getElementById(pfx + 'ImportModal');
|
||||||
|
if (modal && !modal.classList.contains('hidden')) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
182
app/Views/partials/transfer-modal.twig
Normal file
182
app/Views/partials/transfer-modal.twig
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{# Shared transfer modal component.
|
||||||
|
Include once per page, then call:
|
||||||
|
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>example.com</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: 123 } // or for arrays: { 'tag_ids[]': [1,2,3] }
|
||||||
|
submitText: 'Transfer', // optional, defaults to 'Transfer'
|
||||||
|
users: [...], // user objects with id, username, full_name/email
|
||||||
|
csrfToken: '...'
|
||||||
|
});
|
||||||
|
#}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const _esc = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
function _buildSearchableSelect(container, hiddenInput, users) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'relative';
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<div class="transfer-picker-selected hidden flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary transition-colors">
|
||||||
|
<span class="transfer-picker-selected-text truncate"></span>
|
||||||
|
<button type="button" class="transfer-picker-clear ml-2 text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 flex-shrink-0" title="Clear selection">
|
||||||
|
<i class="fas fa-times text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="transfer-picker-search-wrap">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" class="transfer-picker-search w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search users..." autocomplete="off">
|
||||||
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div class="transfer-picker-list mt-1 max-h-48 overflow-y-auto border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 shadow-lg"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(wrapper);
|
||||||
|
|
||||||
|
const searchInput = wrapper.querySelector('.transfer-picker-search');
|
||||||
|
const searchWrap = wrapper.querySelector('.transfer-picker-search-wrap');
|
||||||
|
const listEl = wrapper.querySelector('.transfer-picker-list');
|
||||||
|
const selectedEl = wrapper.querySelector('.transfer-picker-selected');
|
||||||
|
const selectedText = wrapper.querySelector('.transfer-picker-selected-text');
|
||||||
|
const clearBtn = wrapper.querySelector('.transfer-picker-clear');
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
const query = (filter || '').toLowerCase();
|
||||||
|
const filtered = users.filter(u => {
|
||||||
|
const uname = (u.username || '').toLowerCase();
|
||||||
|
const fname = (u.full_name || u.email || '').toLowerCase();
|
||||||
|
return uname.includes(query) || fname.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="px-3 py-2.5 text-sm text-gray-400 dark:text-slate-500 italic">No users found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = filtered.map(u =>
|
||||||
|
`<div class="transfer-picker-item px-3 py-2.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-900 dark:text-white flex items-center justify-between transition-colors" data-user-id="${u.id}">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">${_esc(u.username)}</span>
|
||||||
|
<span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(u.full_name || u.email || 'No name')})</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-xs text-gray-300 dark:text-slate-600"></i>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.transfer-picker-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const userId = item.dataset.userId;
|
||||||
|
const user = users.find(u => String(u.id) === userId);
|
||||||
|
if (!user) return;
|
||||||
|
hiddenInput.value = userId;
|
||||||
|
selectedText.innerHTML = `<i class="fas fa-user mr-2 text-primary"></i><span class="font-medium">${_esc(user.username)}</span> <span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(user.full_name || user.email || 'No name')})</span>`;
|
||||||
|
selectedEl.classList.remove('hidden');
|
||||||
|
searchWrap.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList('');
|
||||||
|
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hiddenInput.value = '';
|
||||||
|
selectedEl.classList.add('hidden');
|
||||||
|
searchWrap.classList.remove('hidden');
|
||||||
|
searchInput.value = '';
|
||||||
|
renderList('');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedEl.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.transfer-picker-clear')) return;
|
||||||
|
selectedEl.classList.add('hidden');
|
||||||
|
searchWrap.classList.remove('hidden');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openTransferModal = function(opts) {
|
||||||
|
const users = opts.users || [];
|
||||||
|
if (users.length === 0) {
|
||||||
|
alert('No users available for transfer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldsHtml = '';
|
||||||
|
if (opts.fields) {
|
||||||
|
Object.entries(opts.fields).forEach(([name, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => {
|
||||||
|
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(v)}">`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(value)}">`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<form method="POST" action="${_esc(opts.action)}" onsubmit="return !!this.querySelector('input[name=target_user_id]').value">
|
||||||
|
<input type="hidden" name="csrf_token" value="${_esc(opts.csrfToken)}">
|
||||||
|
${fieldsHtml}
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<i class="fas fa-exchange-alt text-primary mr-2"></i>${opts.title || 'Transfer'}
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="transfer-modal-cancel text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
${opts.description ? `<p class="text-sm text-gray-500 dark:text-slate-400">${opts.description}</p>` : ''}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Transfer to User</label>
|
||||||
|
<input type="hidden" name="target_user_id" value="">
|
||||||
|
<div class="user-picker-mount"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||||
|
<button type="button" class="transfer-modal-cancel px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||||
|
<i class="fas fa-exchange-alt mr-1.5"></i>${_esc(opts.submitText || 'Transfer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const mount = modal.querySelector('.user-picker-mount');
|
||||||
|
const hiddenInput = modal.querySelector('input[name="target_user_id"]');
|
||||||
|
_buildSearchableSelect(mount, hiddenInput, users);
|
||||||
|
|
||||||
|
modal.querySelectorAll('.transfer-modal-cancel').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => modal.remove());
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function handler(e) {
|
||||||
|
if (e.key === 'Escape' && document.body.contains(modal)) {
|
||||||
|
modal.remove();
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||||
onclick="return confirm('Are you sure you want to remove your avatar?')">
|
onclick="return confirmClick(event, 'Are you sure you want to remove your avatar?', { title: 'Remove Avatar', icon: 'fa-user-circle text-red-500' })">
|
||||||
<i class="fas fa-trash mr-2"></i>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
{% if twoFactorStatus.backup_codes_count < 3 %}
|
{% if twoFactorStatus.backup_codes_count < 3 %}
|
||||||
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirm('Generate new backup codes? Your current codes will stop working.')">
|
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirmSubmit(event, 'Generate new backup codes? Your current codes will stop working.', { title: 'Regenerate Codes', icon: 'fa-key text-blue-500', confirmText: 'Generate', confirmClass: 'bg-blue-600 hover:bg-blue-700' })">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
<i class="fas fa-refresh mr-2"></i>
|
<i class="fas fa-refresh mr-2"></i>
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
||||||
</div>
|
</div>
|
||||||
{% if sessions|default([])|length > 1 %}
|
{% if sessions|default([])|length > 1 %}
|
||||||
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
|
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirmSubmit(event, 'Logout all other sessions?', { title: 'Logout Sessions', icon: 'fa-sign-out-alt text-red-500' })" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
|
|
||||||
<!-- Delete Button (only for non-current sessions) -->
|
<!-- Delete Button (only for non-current sessions) -->
|
||||||
{% if not isCurrent %}
|
{% if not isCurrent %}
|
||||||
<form method="POST" action="/profile/logout-session/{{ session.id }}" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
<form method="POST" action="/profile/logout-session/{{ session.id }}" onsubmit="return confirmSubmit(event, 'Terminate this session? That device will be logged out immediately.', { title: 'Terminate Session', icon: 'fa-sign-out-alt text-red-500' })" class="ml-3">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
||||||
<i class="fas fa-times text-sm"></i>
|
<i class="fas fa-times text-sm"></i>
|
||||||
@@ -712,12 +712,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (confirm('Are you absolutely sure you want to delete your account?\n\nThis action is PERMANENT and cannot be undone!')) {
|
var ok = await confirmAction({ message: 'Are you absolutely sure you want to delete your account? This action is PERMANENT and cannot be undone!', title: 'Delete Account', icon: 'fa-skull-crossbones text-red-600' });
|
||||||
if (confirm('FINAL WARNING: This will permanently delete all your data.\n\nClick OK to proceed.')) {
|
if (!ok) return;
|
||||||
document.getElementById('deleteAccountForm').submit();
|
var ok2 = await confirmAction({ message: 'FINAL WARNING: This will permanently delete all your data. Click Confirm to proceed.', title: 'Final Confirmation', icon: 'fa-exclamation-circle text-red-600' });
|
||||||
}
|
if (ok2) document.getElementById('deleteAccountForm').submit();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDisable2FAModal() {
|
function showDisable2FAModal() {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
||||||
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
||||||
{% set lastDnsCheckRun = settings.last_dns_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 currentVer = appSettings.app_version|default('0') %}
|
||||||
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
||||||
|
|
||||||
@@ -91,25 +93,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="app_timezone" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||||
Timezone
|
Timezone
|
||||||
</label>
|
</label>
|
||||||
<select id="app_timezone" name="app_timezone" required
|
<input type="hidden" id="app_timezone" name="app_timezone" value="{{ appSettings.app_timezone }}" required>
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
<div id="tz-picker" class="relative">
|
||||||
{% for tz, label in popularTimezones %}
|
<div id="tz-selected" class="flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary">
|
||||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
<span id="tz-selected-text">
|
||||||
{{ label }}
|
<i class="fas fa-globe mr-2 text-primary"></i>{{ popularTimezones[appSettings.app_timezone] is defined ? popularTimezones[appSettings.app_timezone] : appSettings.app_timezone|default('UTC') }}
|
||||||
</option>
|
</span>
|
||||||
{% endfor %}
|
<i class="fas fa-chevron-down text-xs text-gray-400 dark:text-slate-500"></i>
|
||||||
<option disabled>──────────</option>
|
</div>
|
||||||
{% for tz in allTimezones %}
|
<div id="tz-dropdown" class="hidden absolute z-20 left-0 right-0 mt-1 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 shadow-lg">
|
||||||
{% if tz not in popularTimezones|keys %}
|
<div class="p-2 border-b border-gray-200 dark:border-slate-600">
|
||||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
<div class="relative">
|
||||||
{{ tz }}
|
<input type="text" id="tz-search" class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search timezones..." autocomplete="off">
|
||||||
</option>
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
<div id="tz-list" class="max-h-64 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Application timezone for dates and times</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Application timezone for dates and times</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -792,6 +796,10 @@
|
|||||||
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
|
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
|
||||||
<code>php cron/check_dns.php</code>
|
<code>php cron/check_dns.php</code>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -811,7 +819,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">DNS check (every 6 hours)</p>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -824,7 +838,7 @@
|
|||||||
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
|
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
|
||||||
Last Cronjob Run
|
Last Cronjob Run
|
||||||
</h4>
|
</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 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>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
|
||||||
@@ -869,6 +883,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,6 +924,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
|
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
|
||||||
</div>
|
</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 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>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
||||||
@@ -934,7 +972,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/clear-logs" onsubmit="return confirm('Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
<form method="POST" action="/settings/clear-logs" onsubmit="return confirmSubmit(event, 'Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-trash-alt mr-2"></i>
|
<i class="fas fa-trash-alt mr-2"></i>
|
||||||
@@ -1132,7 +1170,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
||||||
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
|
onsubmit="return confirmSubmit(event, 'Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
<i class="fas fa-undo mr-2"></i>
|
<i class="fas fa-undo mr-2"></i>
|
||||||
@@ -1413,13 +1451,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (footerEl) {
|
if (footerEl) {
|
||||||
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 dark:bg-blue-500/20 border-blue-200 dark:border-blue-500/20' : 'bg-amber-100 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/20');
|
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 dark:bg-blue-500/20 border-blue-200 dark:border-blue-500/20' : 'bg-amber-100 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/20');
|
||||||
if (isRelease) {
|
if (isRelease) {
|
||||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\')">' +
|
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\', { title: \'Update\', icon: \'fa-download text-blue-500\', confirmText: \'Update\', confirmClass: \'bg-blue-600 hover:bg-blue-700\' })">' +
|
||||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||||
'<input type="hidden" name="update_type" value="release">' +
|
'<input type="hidden" name="update_type" value="release">' +
|
||||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
||||||
'</form>';
|
'</form>';
|
||||||
} else {
|
} else {
|
||||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
|
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\', { title: \'Apply Hotfix\', icon: \'fa-download text-amber-500\', confirmText: \'Apply Hotfix\', confirmClass: \'bg-amber-600 hover:bg-amber-700\' })">' +
|
||||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||||
'<input type="hidden" name="update_type" value="hotfix">' +
|
'<input type="hidden" name="update_type" value="hotfix">' +
|
||||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
||||||
@@ -1555,5 +1593,99 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
updateCaptchaUI();
|
updateCaptchaUI();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const popularTimezones = {{ popularTimezones|json_encode|raw }};
|
||||||
|
const allTimezones = {{ allTimezones|json_encode|raw }};
|
||||||
|
|
||||||
|
const hiddenInput = document.getElementById('app_timezone');
|
||||||
|
const selectedEl = document.getElementById('tz-selected');
|
||||||
|
const selectedText = document.getElementById('tz-selected-text');
|
||||||
|
const dropdown = document.getElementById('tz-dropdown');
|
||||||
|
const searchInput = document.getElementById('tz-search');
|
||||||
|
const listEl = document.getElementById('tz-list');
|
||||||
|
|
||||||
|
const popularKeys = Object.keys(popularTimezones);
|
||||||
|
const otherTimezones = allTimezones.filter(tz => !popularKeys.includes(tz));
|
||||||
|
|
||||||
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
const query = (filter || '').toLowerCase();
|
||||||
|
|
||||||
|
const matchPopular = popularKeys.filter(tz => {
|
||||||
|
const label = (popularTimezones[tz] || '').toLowerCase();
|
||||||
|
return tz.toLowerCase().includes(query) || label.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchOther = otherTimezones.filter(tz => tz.toLowerCase().includes(query));
|
||||||
|
|
||||||
|
if (matchPopular.length === 0 && matchOther.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400 dark:text-slate-500 italic">No timezones found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (matchPopular.length > 0) {
|
||||||
|
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800">Popular</div>';
|
||||||
|
html += matchPopular.map(tz =>
|
||||||
|
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||||
|
<span>${esc(popularTimezones[tz])}</span>
|
||||||
|
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchOther.length > 0) {
|
||||||
|
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800 border-t border-gray-200 dark:border-slate-600">All Timezones</div>';
|
||||||
|
const capped = matchOther.slice(0, 50);
|
||||||
|
html += capped.map(tz =>
|
||||||
|
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||||
|
<span>${esc(tz)}</span>
|
||||||
|
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
if (matchOther.length > 50) {
|
||||||
|
html += '<div class="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic">Type to narrow down ' + (matchOther.length - 50) + ' more...</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.tz-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const tz = item.dataset.tz;
|
||||||
|
hiddenInput.value = tz;
|
||||||
|
const label = popularTimezones[tz] || tz;
|
||||||
|
selectedText.innerHTML = '<i class="fas fa-globe mr-2 text-primary"></i>' + esc(label);
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
searchInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedEl.addEventListener('click', () => {
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
if (!dropdown.classList.contains('hidden')) {
|
||||||
|
renderList('');
|
||||||
|
setTimeout(() => searchInput.focus(), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!document.getElementById('tz-picker').contains(e.target)) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Import Button #}
|
{# Import Button #}
|
||||||
<button onclick="document.getElementById('importModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
<button onclick="document.getElementById('tagImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
<i class="fas fa-upload mr-2"></i>
|
<i class="fas fa-upload mr-2"></i>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if auth.isAdmin %}
|
{% if auth.isAdmin and tag.user_id is not null %}
|
||||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
class="text-blue-600 hover:text-blue-800" title="View">
|
class="text-blue-600 hover:text-blue-800" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if auth.isAdmin %}
|
{% if auth.isAdmin and tag.user_id is not null %}
|
||||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||||
<i class="fas fa-exchange-alt"></i>
|
<i class="fas fa-exchange-alt"></i>
|
||||||
@@ -455,63 +455,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Import Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'tag',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import Tags',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/tags/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import Tags
|
} %}
|
||||||
</h3>
|
|
||||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="tagDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="tagDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tagFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tagFileSize"></p>
|
|
||||||
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('importModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleSelectAll(checkbox) {
|
function toggleSelectAll(checkbox) {
|
||||||
@@ -561,13 +510,12 @@ function getSelectedIds() {
|
|||||||
return [...new Set(ids)];
|
return [...new Set(ids)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDeleteTags() {
|
async function bulkDeleteTags() {
|
||||||
const ids = getSelectedIds();
|
const ids = getSelectedIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
if (!confirm(`Delete ${ids.length} tag(s)? This will remove them from all domains.`)) {
|
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' tag(s)? This will remove them from all domains.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -592,46 +540,15 @@ function bulkDeleteTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transferTag(tagId, tagName) {
|
function transferTag(tagId, tagName) {
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
if (users.length === 0) {
|
openTransferModal({
|
||||||
alert('No users available for transfer');
|
title: 'Transfer Tag',
|
||||||
return;
|
description: 'Transfer tag <strong>' + esc(tagName) + '</strong> to another user.',
|
||||||
}
|
action: '/tags/transfer',
|
||||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
fields: { tag_id: tagId },
|
||||||
const userOptions = users.map(user =>
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
|
csrfToken: '{{ csrf_token() }}'
|
||||||
).join('');
|
});
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tag</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/tags/transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="tag_id" value="${tagId}">
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -648,49 +565,15 @@ function bulkTransferTags() {
|
|||||||
alert('Please select tags to transfer');
|
alert('Please select tags to transfer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openTransferModal({
|
||||||
const users = {{ users|default([])|json_encode|raw }};
|
title: 'Transfer Tags',
|
||||||
if (users.length === 0) {
|
description: 'Transfer ' + ids.length + ' selected tag(s) to another user.',
|
||||||
alert('No users available for transfer');
|
action: '/tags/bulk-transfer',
|
||||||
return;
|
fields: { 'tag_ids[]': ids },
|
||||||
}
|
submitText: 'Transfer All',
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
const userOptions = users.map(user =>
|
csrfToken: '{{ csrf_token() }}'
|
||||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
});
|
||||||
).join('');
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tags</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/tags/bulk-transfer">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
|
||||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
|
||||||
<option value="">Select User</option>
|
|
||||||
${userOptions}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
|
||||||
Transfer All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
@@ -716,8 +599,9 @@ function closeEditModal() {
|
|||||||
document.getElementById('editModal').classList.add('hidden');
|
document.getElementById('editModal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTag(id, name) {
|
async function deleteTag(id, name) {
|
||||||
if (confirm(`Are you sure you want to delete the tag "${name}"? This will remove it from all domains.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete the tag "' + name + '"? This will remove it from all domains.' });
|
||||||
|
if (ok) {
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/tags/delete';
|
form.action = '/tags/delete';
|
||||||
@@ -758,9 +642,9 @@ document.getElementById('editModal').addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('importModal').addEventListener('click', function(e) {
|
document.getElementById('tagImportModal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
document.getElementById('importModal').classList.add('hidden');
|
document.getElementById('tagImportModal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -771,81 +655,6 @@ document.addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('tagDropzone');
|
|
||||||
const fileInput = document.getElementById('tagFileInput');
|
|
||||||
const content = document.getElementById('tagDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('tagDropzoneFile');
|
|
||||||
const fileName = document.getElementById('tagFileName');
|
|
||||||
const fileSize = document.getElementById('tagFileSize');
|
|
||||||
const removeBtn = document.getElementById('tagFileRemove');
|
|
||||||
const form = document.getElementById('tagImportForm');
|
|
||||||
const submitBtn = document.getElementById('tagImportBtn');
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -210,16 +210,22 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div class="flex items-center justify-end space-x-2">
|
<div class="flex items-center justify-end space-x-2">
|
||||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +279,12 @@
|
|||||||
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<button type="button" class="domain-transfer-btn flex-1 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded text-center text-sm hover:bg-indigo-100 dark:hover:bg-indigo-500/20 transition-colors"
|
||||||
|
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||||
|
<i class="fas fa-exchange-alt mr-1"></i> Transfer
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -348,4 +360,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if auth.isAdmin %}
|
||||||
|
<script>
|
||||||
|
function transferDomain(domainId, domainName) {
|
||||||
|
var esc = function(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); };
|
||||||
|
openTransferModal({
|
||||||
|
title: 'Transfer Domain',
|
||||||
|
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||||
|
action: '/domains/transfer',
|
||||||
|
fields: { domain_id: domainId },
|
||||||
|
users: {{ users|default([])|json_encode|raw }},
|
||||||
|
csrfToken: '{{ csrf_token() }}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.domain-transfer-btn');
|
||||||
|
if (btn) {
|
||||||
|
e.preventDefault();
|
||||||
|
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% include 'partials/transfer-modal.twig' %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@
|
|||||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div>
|
||||||
{% if tld.registry_url %}
|
{% if tld.registry_url %}
|
||||||
<div class="text-sm text-gray-500 dark:text-slate-400">
|
<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>
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
Registry
|
Registry
|
||||||
</a>
|
</a>
|
||||||
@@ -348,10 +348,10 @@
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<i class="fas fa-power-off"></i>
|
<i class="fas fa-power-off"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -375,7 +375,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3>
|
||||||
{% if tld.registry_url %}
|
{% 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>
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
Registry
|
Registry
|
||||||
</a>
|
</a>
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
<i class="fas fa-eye mr-1"></i> View
|
<i class="fas fa-eye mr-1"></i> View
|
||||||
</a>
|
</a>
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirm('Refresh TLD data?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirmClick(event, 'Refresh TLD data?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -584,63 +584,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Import TLD Modal #}
|
{% include 'partials/import-modal.twig' with {
|
||||||
<div id="tldImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
prefix: 'tld',
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
title: 'Import TLDs',
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
action: '/tld-registry/import',
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>'
|
||||||
<i class="fas fa-upload text-primary mr-2"></i>Import TLDs
|
} %}
|
||||||
</h3>
|
|
||||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
|
||||||
{{ csrf_field() }}
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
{# Drag & Drop Zone #}
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
|
||||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
|
||||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
|
||||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
|
||||||
<div id="tldDropzoneContent">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
|
||||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
|
||||||
</span>
|
|
||||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
|
||||||
</div>
|
|
||||||
<div id="tldDropzoneFile" class="hidden">
|
|
||||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tldFileName"></p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tldFileSize"></p>
|
|
||||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
|
||||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
|
||||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||||
@@ -691,14 +640,15 @@ function clearSelection() {
|
|||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmBulkDelete() {
|
async function confirmBulkDelete() {
|
||||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||||
if (checkboxes.length === 0) {
|
if (checkboxes.length === 0) {
|
||||||
alert('Please select TLDs to delete');
|
alert('Please select TLDs to delete');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + checkboxes.length + ' selected TLD(s)? This action cannot be undone.' });
|
||||||
|
if (ok) {
|
||||||
const form = document.getElementById('bulk-delete-form');
|
const form = document.getElementById('bulk-delete-form');
|
||||||
checkboxes.forEach(checkbox => {
|
checkboxes.forEach(checkbox => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -759,84 +709,6 @@ document.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(function() {
|
|
||||||
const dropzone = document.getElementById('tldDropzone');
|
|
||||||
const fileInput = document.getElementById('tldFileInput');
|
|
||||||
const content = document.getElementById('tldDropzoneContent');
|
|
||||||
const fileInfo = document.getElementById('tldDropzoneFile');
|
|
||||||
const fileName = document.getElementById('tldFileName');
|
|
||||||
const fileSize = document.getElementById('tldFileSize');
|
|
||||||
const removeBtn = document.getElementById('tldFileRemove');
|
|
||||||
const form = document.getElementById('tldImportForm');
|
|
||||||
const submitBtn = document.getElementById('tldImportBtn');
|
|
||||||
|
|
||||||
if (!dropzone) return;
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return bytes + ' B';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFile(file) {
|
|
||||||
fileName.textContent = file.name;
|
|
||||||
fileSize.textContent = formatSize(file.size);
|
|
||||||
content.classList.add('hidden');
|
|
||||||
fileInfo.classList.remove('hidden');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDropzone() {
|
|
||||||
fileInput.value = '';
|
|
||||||
content.classList.remove('hidden');
|
|
||||||
fileInfo.classList.add('hidden');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (this.files.length) showFile(this.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
removeBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
resetDropzone();
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.remove('border-gray-300');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(evt => {
|
|
||||||
dropzone.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!fileInput.files.length) {
|
|
||||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
|
||||||
dropzone.classList.add('border-gray-300');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length) {
|
|
||||||
fileInput.files = files;
|
|
||||||
showFile(files[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', function() {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
Refresh
|
Refresh
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<i class="fas fa-power-off mr-1.5"></i>
|
<i class="fas fa-power-off mr-1.5"></i>
|
||||||
Toggle
|
Toggle
|
||||||
</a>
|
</a>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
{% if tld.registry_url %}
|
{% if tld.registry_url %}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registry URL</label>
|
<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>
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
Visit Registry
|
Visit Registry
|
||||||
</a>
|
</a>
|
||||||
@@ -215,13 +215,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
{% if session.role is defined and session.role == 'admin' %}
|
{% if session.role is defined and session.role == 'admin' %}
|
||||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
|
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||||
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
||||||
<i class="fas fa-sync-alt text-sm"></i>
|
<i class="fas fa-sync-alt text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
|
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||||
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
||||||
<i class="fas fa-power-off text-sm"></i>
|
<i class="fas fa-power-off text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if tld.registry_url %}
|
{% 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">
|
<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>
|
<i class="fas fa-external-link-alt text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ function getSelectedUserIds() {
|
|||||||
return Array.from(checkboxes).map(cb => cb.value);
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkToggleStatus(action) {
|
async function bulkToggleStatus(action) {
|
||||||
const userIds = getSelectedUserIds();
|
const userIds = getSelectedUserIds();
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
@@ -405,9 +405,8 @@ function bulkToggleStatus(action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to ' + actionText + ' ' + userIds.length + ' user(s)?', title: actionText.charAt(0).toUpperCase() + actionText.slice(1) + ' Users', icon: action === 'active' ? 'fa-user-check text-green-500' : 'fa-user-slash text-orange-500' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -450,10 +449,9 @@ function toggleUserStatus(userId) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUser(userId) {
|
async function deleteUser(userId) {
|
||||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this user? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
@@ -469,7 +467,7 @@ function deleteUser(userId) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bulkDeleteUsers() {
|
async function bulkDeleteUsers() {
|
||||||
const userIds = getSelectedUserIds();
|
const userIds = getSelectedUserIds();
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
@@ -477,9 +475,8 @@ function bulkDeleteUsers() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + userIds.length + ' user(s)? This action cannot be undone.' });
|
||||||
return;
|
if (!ok) return;
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
|
|||||||
@@ -48,12 +48,12 @@
|
|||||||
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
{% if isActive %}
|
{% if isActive %}
|
||||||
<button type="submit" onclick="return confirm('Deactivate this user?')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Deactivate this user?', { title: 'Deactivate', icon: 'fa-user-slash text-orange-500', confirmClass: 'bg-orange-600 hover:bg-orange-700' })" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-user-slash mr-2"></i>
|
<i class="fas fa-user-slash mr-2"></i>
|
||||||
Deactivate
|
Deactivate
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="submit" onclick="return confirm('Activate this user?')" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Activate this user?', { title: 'Activate', icon: 'fa-user-check text-green-500', confirmClass: 'bg-green-600 hover:bg-green-700' })" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-user-check mr-2"></i>
|
<i class="fas fa-user-check mr-2"></i>
|
||||||
Activate
|
Activate
|
||||||
</button>
|
</button>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" onclick="return confirm('Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
<button type="submit" onclick="return confirmClick(event, 'Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||||
<i class="fas fa-trash mr-2"></i>
|
<i class="fas fa-trash mr-2"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -529,8 +529,15 @@
|
|||||||
{{ domain.statusText }}
|
{{ domain.statusText }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-slate-400">
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
{{ domain.group_name|default('—') }}
|
{% if domain.group_name %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||||
|
<i class="fas fa-bell mr-1"></i>
|
||||||
|
{{ domain.group_name }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">No Group</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ class SessionConfig
|
|||||||
*/
|
*/
|
||||||
public static function start(): void
|
public static function start(): void
|
||||||
{
|
{
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
// Validate session exists in database (for database-backed sessions)
|
// Validate session exists in database (for database-backed sessions)
|
||||||
|
|||||||
@@ -239,6 +239,26 @@ class TwigService
|
|||||||
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
|
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) {
|
$this->twig->addFilter(new TwigFilter('from_json', function ($value) {
|
||||||
if ($value === null || $value === '') {
|
if ($value === null || $value === '') {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -4,20 +4,18 @@
|
|||||||
/**
|
/**
|
||||||
* DNS Record Monitoring Cron Job
|
* DNS Record Monitoring Cron Job
|
||||||
*
|
*
|
||||||
* Checks DNS records for all active domains and sends notifications
|
* Re-checks existing DNS records for all active domains and sends notifications
|
||||||
* when changes are detected (new records, removed records, changed records).
|
* when changes are detected (new records, removed records, changed records).
|
||||||
|
* No discovery (brute force / crt.sh) — use discover_dns.php for that.
|
||||||
*
|
*
|
||||||
* Also handles crt.sh subdomain fetching internally via self-invocation
|
* Also serves as the crt.sh subprocess entry point (--crtsh) for
|
||||||
* with a hard timeout (no separate script needed).
|
* DnsService::fetchCrtshSubdomains() used by discover_dns.php.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* php cron/check_dns.php — run the full DNS check
|
* php cron/check_dns.php — re-check existing records
|
||||||
* php cron/check_dns.php --crtsh <domain> [max] — (internal) crt.sh subprocess
|
* php cron/check_dns.php --crtsh <domain> [max] — (internal) crt.sh subprocess
|
||||||
*
|
*
|
||||||
* Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php
|
* Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php
|
||||||
*
|
|
||||||
* NOTE: Requires a `crtsh_last_fetched` column on the domains table:
|
|
||||||
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
@@ -33,6 +31,7 @@ use App\Models\User;
|
|||||||
use App\Services\DnsService;
|
use App\Services\DnsService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\Logger;
|
use App\Services\Logger;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
use Core\Database;
|
use Core\Database;
|
||||||
|
|
||||||
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
||||||
@@ -57,15 +56,6 @@ if (php_sapi_name() !== 'cli') {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** crt.sh subprocess hard kill (seconds). In practice crt.sh 503s in <60s, but HTTP timeout is 900s as insurance. */
|
|
||||||
const CRTSH_TIMEOUT_SECONDS = 1800;
|
|
||||||
|
|
||||||
/** Max unique subdomains from crt.sh per domain (0 = no limit) */
|
|
||||||
const CRTSH_MAX_SUBDOMAINS = 100;
|
|
||||||
|
|
||||||
/** How often to re-fetch crt.sh per domain (hours). New certs appear gradually. */
|
|
||||||
const CRTSH_REFRESH_HOURS = 24;
|
|
||||||
|
|
||||||
/** Microseconds to sleep between domains */
|
/** Microseconds to sleep between domains */
|
||||||
const INTER_DOMAIN_DELAY_US = 500000;
|
const INTER_DOMAIN_DELAY_US = 500000;
|
||||||
|
|
||||||
@@ -91,16 +81,32 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
$startTime = microtime(true);
|
$startTime = microtime(true);
|
||||||
|
|
||||||
logMessage("=== Starting DNS check cron job ===");
|
logMessage("=== Starting DNS check cron job ===");
|
||||||
|
|
||||||
$domains = $domainModel->where('is_active', 1);
|
// Only check domains that are registered and in use (active or expiring_soon).
|
||||||
$domains = array_values(array_filter($domains, fn($d) => ($d['dns_monitoring_enabled'] ?? 1) == 1));
|
// Skip available, expired, error, redemption_period, pending_delete — they typically have no DNS.
|
||||||
logMessage("Found " . count($domains) . " domain(s) with DNS monitoring enabled");
|
$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 = [
|
$stats = [
|
||||||
'checked' => 0,
|
'checked' => 0,
|
||||||
|
'skipped_by_status' => $skippedByStatus,
|
||||||
'changes_detected' => 0,
|
'changes_detected' => 0,
|
||||||
'records_added' => 0,
|
'records_added' => 0,
|
||||||
'records_removed' => 0,
|
'records_removed' => 0,
|
||||||
@@ -109,8 +115,6 @@ $stats = [
|
|||||||
'in_app_notifications' => 0,
|
'in_app_notifications' => 0,
|
||||||
'errors' => 0,
|
'errors' => 0,
|
||||||
'skipped_unresolved' => 0,
|
'skipped_unresolved' => 0,
|
||||||
'crtsh_skipped' => 0,
|
|
||||||
'crtsh_fetched' => 0,
|
|
||||||
'domains_with_changes' => [],
|
'domains_with_changes' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -123,7 +127,7 @@ foreach ($domains as $domain) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Quick existence check — skip if domain doesn't resolve at all
|
// Quick existence check — skip if domain doesn't resolve at all
|
||||||
if (!domainResolves($domainName)) {
|
if (!CronHelper::hostnameResolves($domainName)) {
|
||||||
logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping");
|
logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping");
|
||||||
logTimeSince($domainStartTime);
|
logTimeSince($domainStartTime);
|
||||||
$stats['skipped_unresolved']++;
|
$stats['skipped_unresolved']++;
|
||||||
@@ -133,39 +137,10 @@ foreach ($domains as $domain) {
|
|||||||
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
||||||
$isFirstScan = empty($previousRecords);
|
$isFirstScan = empty($previousRecords);
|
||||||
|
|
||||||
// Gather subdomain candidates: known hosts from DB
|
// Re-check only known hosts — no discovery (brute force / crt.sh)
|
||||||
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
||||||
|
$newRecords = $dnsService->refreshExisting($domainName, $existingHosts);
|
||||||
// Decide whether to call crt.sh or use cached hosts
|
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||||
$ctSubs = [];
|
|
||||||
|
|
||||||
if (shouldFetchCrtsh($domain, $existingHosts)) {
|
|
||||||
logMessage(" 🔍 crt.sh: fetching subdomains...");
|
|
||||||
|
|
||||||
[$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName);
|
|
||||||
|
|
||||||
logMessage(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
|
||||||
$stats['crtsh_fetched']++;
|
|
||||||
|
|
||||||
// Update timestamp if server responded (200 OK).
|
|
||||||
// Empty [] is valid (no CT entries) — still counts as a successful fetch.
|
|
||||||
// Only skip update if all attempts 503'd / timed out.
|
|
||||||
if ($crtshOk) {
|
|
||||||
$domainModel->update($domain['id'], [
|
|
||||||
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in "
|
|
||||||
. crtshHoursUntilRefresh($domain) . "h)");
|
|
||||||
$stats['crtsh_skipped']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
|
||||||
|
|
||||||
// Fetch fresh DNS records
|
|
||||||
$newRecords = $dnsService->lookup($domainName, $extraSubs);
|
|
||||||
$totalRecords = array_sum(array_map('count', $newRecords));
|
|
||||||
|
|
||||||
if ($totalRecords === 0) {
|
if ($totalRecords === 0) {
|
||||||
logMessage(" ⚠ No DNS records found for $domainName");
|
logMessage(" ⚠ No DNS records found for $domainName");
|
||||||
@@ -247,55 +222,13 @@ exit(0);
|
|||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// Crt.sh smart caching
|
// Crt.sh subprocess entry point (invoked by DnsService::fetchCrtshSubdomains)
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should we fetch crt.sh for this domain right now?
|
|
||||||
*
|
|
||||||
* Skip if we already have enough known hosts and fetched recently.
|
|
||||||
* Always fetch on first scan or if we have very few known hosts.
|
|
||||||
*
|
|
||||||
* NOTE: Requires a `crtsh_last_fetched` DATETIME column on the domains table.
|
|
||||||
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
|
||||||
*/
|
|
||||||
function shouldFetchCrtsh(array $domain, array $existingHosts): bool
|
|
||||||
{
|
|
||||||
// Always fetch if we've never successfully fetched before
|
|
||||||
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
|
||||||
if (empty($lastFetched)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect the refresh interval — even if domain has few hosts,
|
|
||||||
// crt.sh already answered (maybe with [] or few results). Don't hammer it.
|
|
||||||
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
|
||||||
return $hoursSince >= CRTSH_REFRESH_HOURS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hours remaining until next crt.sh refresh (for log messages).
|
|
||||||
*/
|
|
||||||
function crtshHoursUntilRefresh(array $domain): string
|
|
||||||
{
|
|
||||||
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
|
||||||
if (empty($lastFetched)) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
|
||||||
$remaining = max(0, CRTSH_REFRESH_HOURS - $hoursSince);
|
|
||||||
return sprintf('%.1f', $remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Crt.sh subprocess (self-invocation with hard timeout)
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal crt.sh subprocess entry point.
|
* Internal crt.sh subprocess entry point.
|
||||||
* Called when this script is invoked with: --crtsh <domain> [max_subdomains]
|
* Called when this script is invoked with: --crtsh <domain> [max_subdomains]
|
||||||
* Outputs a JSON array of subdomains to stdout.
|
* Outputs JSON to stdout. Uses DnsService for HTTP fetch and parsing.
|
||||||
*
|
*
|
||||||
* Wildcard query ?q=%.domain.com with 5 retry attempts.
|
* Wildcard query ?q=%.domain.com with 5 retry attempts.
|
||||||
* All HTTP response details are written to stderr for real-time debugging.
|
* All HTTP response details are written to stderr for real-time debugging.
|
||||||
@@ -314,6 +247,7 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
$retryDelay = 10;
|
$retryDelay = 10;
|
||||||
$httpTimeout = 900;
|
$httpTimeout = 900;
|
||||||
|
|
||||||
|
$dnsService = new DnsService();
|
||||||
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -323,13 +257,12 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n");
|
||||||
|
|
||||||
$response = fetchCrtshWithDebug($url, $httpTimeout);
|
$response = $dnsService->fetchCrtshUrl($url, $httpTimeout, true);
|
||||||
|
|
||||||
// HTTP 200 — server answered, don't retry regardless of content
|
|
||||||
if ($response['status'] === 200) {
|
if ($response['status'] === 200) {
|
||||||
$gotHttp200 = true;
|
$gotHttp200 = true;
|
||||||
if (!empty($response['data'])) {
|
if (!empty($response['data'])) {
|
||||||
$result = extractSubdomains($response['data'], $domain);
|
$result = $dnsService->extractCrtshSubdomains($response['data'], $domain);
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n");
|
||||||
} else {
|
} else {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
||||||
@@ -337,7 +270,6 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-200 (503, timeout, connection error) — retry
|
|
||||||
if ($attempt < $maxAttempts) {
|
if ($attempt < $maxAttempts) {
|
||||||
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
||||||
sleep($retryDelay);
|
sleep($retryDelay);
|
||||||
@@ -346,7 +278,6 @@ function runCrtshSubprocess(array $argv): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply cap
|
|
||||||
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
||||||
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
||||||
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
||||||
@@ -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
|
// DNS helpers
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a domain resolves at all (SOA, A, or AAAA).
|
|
||||||
*/
|
|
||||||
function domainResolves(string $domain): bool
|
|
||||||
{
|
|
||||||
return @checkdnsrr($domain, 'SOA')
|
|
||||||
|| @checkdnsrr($domain, 'A')
|
|
||||||
|| @checkdnsrr($domain, 'AAAA');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
||||||
*/
|
*/
|
||||||
@@ -770,72 +458,29 @@ function sendInAppNotifications(
|
|||||||
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
// Logging / formatting helpers
|
// Logging helpers (thin wrappers around CronHelper)
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function logMessage(string $message): void
|
function logMessage(string $message): void
|
||||||
{
|
{
|
||||||
global $logFile;
|
global $cron;
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$cron->log($message);
|
||||||
$line = "[$timestamp] $message\n";
|
|
||||||
file_put_contents($logFile, $line, FILE_APPEND);
|
|
||||||
echo $line;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logTimeSince(float $since): void
|
function logTimeSince(float $since): void
|
||||||
{
|
{
|
||||||
logMessage(" ⏱ " . formatDuration(microtime(true) - $since));
|
global $cron;
|
||||||
}
|
$cron->logTimeSince($since);
|
||||||
|
|
||||||
function formatDuration(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.1fs", $seconds);
|
|
||||||
}
|
|
||||||
$m = (int) floor($seconds / 60);
|
|
||||||
$s = $seconds - $m * 60;
|
|
||||||
return $m . 'm ' . sprintf("%.1fs", $s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(float $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.2f seconds", $seconds);
|
|
||||||
}
|
|
||||||
if ($seconds < 3600) {
|
|
||||||
$m = (int) floor($seconds / 60);
|
|
||||||
$s = $seconds - $m * 60;
|
|
||||||
return sprintf("%d minute%s %.2f seconds", $m, $m !== 1 ? 's' : '', $s);
|
|
||||||
}
|
|
||||||
$h = (int) floor($seconds / 3600);
|
|
||||||
$m = (int) floor(($seconds - $h * 3600) / 60);
|
|
||||||
$s = $seconds - $h * 3600 - $m * 60;
|
|
||||||
return sprintf("%d hour%s %d minute%s %.2f seconds",
|
|
||||||
$h, $h !== 1 ? 's' : '', $m, $m !== 1 ? 's' : '', $s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain remaining data from a non-blocking stream and close it.
|
|
||||||
*/
|
|
||||||
function drainStream($stream): string
|
|
||||||
{
|
|
||||||
if (!is_resource($stream)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
$data = stream_get_contents($stream);
|
|
||||||
fclose($stream);
|
|
||||||
return $data ?: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printSummary(array $stats, float $startTime): void
|
function printSummary(array $stats, float $startTime): void
|
||||||
{
|
{
|
||||||
$elapsed = formatElapsedTime(microtime(true) - $startTime);
|
$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime);
|
||||||
|
|
||||||
logMessage("\n=== DNS cron job completed ===");
|
logMessage("\n=== DNS cron job completed ===");
|
||||||
logMessage("Domains checked: {$stats['checked']}");
|
logMessage("Domains checked: {$stats['checked']}");
|
||||||
|
logMessage("Skipped (by status): {$stats['skipped_by_status']}");
|
||||||
logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}");
|
logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}");
|
||||||
logMessage("Crt.sh fetched: {$stats['crtsh_fetched']}");
|
|
||||||
logMessage("Crt.sh skipped (cached): {$stats['crtsh_skipped']}");
|
|
||||||
logMessage("Changes detected: {$stats['changes_detected']}");
|
logMessage("Changes detected: {$stats['changes_detected']}");
|
||||||
logMessage("Records added: {$stats['records_added']}");
|
logMessage("Records added: {$stats['records_added']}");
|
||||||
logMessage("Records removed: {$stats['records_removed']}");
|
logMessage("Records removed: {$stats['records_removed']}");
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Models\User;
|
|||||||
use App\Services\WhoisService;
|
use App\Services\WhoisService;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\UpdateService;
|
use App\Services\UpdateService;
|
||||||
|
use App\Helpers\CronHelper;
|
||||||
use Core\Database;
|
use Core\Database;
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
@@ -56,27 +57,11 @@ try {
|
|||||||
|
|
||||||
// Log file
|
// Log file
|
||||||
$logFile = __DIR__ . '/../logs/cron.log';
|
$logFile = __DIR__ . '/../logs/cron.log';
|
||||||
|
$cron = new CronHelper($logFile);
|
||||||
|
|
||||||
function logMessage(string $message) {
|
function logMessage(string $message): void {
|
||||||
global $logFile;
|
global $cron;
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$cron->log($message);
|
||||||
file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND);
|
|
||||||
echo "[$timestamp] $message\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(float $seconds): string {
|
|
||||||
if ($seconds < 60) {
|
|
||||||
return sprintf("%.2f seconds", $seconds);
|
|
||||||
} elseif ($seconds < 3600) {
|
|
||||||
$minutes = floor($seconds / 60);
|
|
||||||
$remainingSeconds = $seconds - ($minutes * 60);
|
|
||||||
return sprintf("%d minute%s %.2f seconds", $minutes, $minutes != 1 ? 's' : '', $remainingSeconds);
|
|
||||||
} else {
|
|
||||||
$hours = floor($seconds / 3600);
|
|
||||||
$remainingMinutes = floor(($seconds - ($hours * 3600)) / 60);
|
|
||||||
$remainingSeconds = $seconds - ($hours * 3600) - ($remainingMinutes * 60);
|
|
||||||
return sprintf("%d hour%s %d minute%s %.2f seconds", $hours, $hours != 1 ? 's' : '', $remainingMinutes, $remainingMinutes != 1 ? 's' : '', $remainingSeconds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record start time
|
// Record start time
|
||||||
@@ -806,7 +791,7 @@ $settingModel->updateLastCheckRun();
|
|||||||
// Calculate elapsed time
|
// Calculate elapsed time
|
||||||
$endTime = microtime(true);
|
$endTime = microtime(true);
|
||||||
$elapsedTime = $endTime - $startTime;
|
$elapsedTime = $endTime - $startTime;
|
||||||
$formattedTime = formatElapsedTime($elapsedTime);
|
$formattedTime = CronHelper::formatElapsedTime($elapsedTime);
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
logMessage("\n=== Cron job completed ===");
|
logMessage("\n=== Cron job completed ===");
|
||||||
|
|||||||
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),
|
abuse_email VARCHAR(255),
|
||||||
last_checked TIMESTAMP NULL,
|
last_checked TIMESTAMP NULL,
|
||||||
dns_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',
|
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
|
||||||
whois_data JSON,
|
whois_data JSON,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
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,
|
user_id INT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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,
|
ttl INT NULL,
|
||||||
priority INT NULL COMMENT 'MX priority',
|
priority INT NULL COMMENT 'MX priority',
|
||||||
is_cloudflare BOOLEAN DEFAULT FALSE,
|
is_cloudflare BOOLEAN DEFAULT FALSE,
|
||||||
|
source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered',
|
||||||
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
||||||
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -368,6 +373,38 @@ CREATE TABLE IF NOT EXISTS dns_records (
|
|||||||
INDEX idx_last_seen (last_seen_at)
|
INDEX idx_last_seen (last_seen_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
-- SYSTEM SETTINGS
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
@@ -389,7 +426,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
|||||||
('app_name', 'Domain Monitor', 'string', 'Application name'),
|
('app_name', 'Domain Monitor', 'string', 'Application name'),
|
||||||
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
|
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
|
||||||
('app_timezone', 'UTC', 'string', 'Application timezone'),
|
('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
|
-- Email settings
|
||||||
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),
|
('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'),
|
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
|
||||||
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran'),
|
('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 system settings
|
||||||
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
|
('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)')
|
('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
|
- `021_add_avatar_field.sql` - User avatar field
|
||||||
- `022_add_pushover_channel_type.sql` - Pushover notification channel support
|
- `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
|
- `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`
|
**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/verify', [TwoFactorController::class, 'verify']);
|
||||||
$router->post('/2fa/send-email-code', [TwoFactorController::class, 'sendEmailCode']);
|
$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
|
// Protected routes - require authentication
|
||||||
Auth::require();
|
Auth::require();
|
||||||
|
|
||||||
|
// Debug route (admin-only)
|
||||||
|
$router->get('/debug/whois', [DebugController::class, 'whois']);
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
$router->get('/', [DashboardController::class, 'index']);
|
$router->get('/', [DashboardController::class, 'index']);
|
||||||
$router->get('/dashboard', [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}/update-notes', [DomainController::class, 'updateNotes']);
|
||||||
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
||||||
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
|
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
|
||||||
|
$router->post('/domains/{id}/discover-dns', [DomainController::class, 'discoverDns']);
|
||||||
|
$router->post('/domains/{id}/dns-records', [DomainController::class, 'addDnsRecord']);
|
||||||
|
$router->post('/domains/{id}/dns-records/bulk-delete', [DomainController::class, 'bulkDeleteDnsRecords']);
|
||||||
|
$router->post('/domains/{id}/dns-records/{recordId}/delete', [DomainController::class, 'deleteDnsRecord']);
|
||||||
|
$router->post('/domains/{id}/dns-import', [DomainController::class, 'importDnsZone']);
|
||||||
|
$router->post('/domains/{id}/ssl/add', [DomainController::class, 'addSslHost']);
|
||||||
|
$router->post('/domains/{id}/ssl/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}/refresh-all', [DomainController::class, 'refreshAll']);
|
||||||
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user