Add import/export and update system

Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
This commit is contained in:
Hosteroid
2026-02-11 17:43:23 +02:00
parent 0c759cdd1d
commit 3688c8b71b
32 changed files with 4268 additions and 350 deletions

2
.gitignore vendored
View File

@@ -46,7 +46,7 @@ Desktop.ini
*.pem *.pem
*.key *.key
*.crt *.crt
/database/backups/ /backups/
# Development # Development
/tests/coverage/ /tests/coverage/

View File

@@ -5,6 +5,56 @@ 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.3] - 2026-02-11
### Added
- **CSV/JSON Import & Export for Domains** - Export all domains with tags, groups, and notes; import from file with WHOIS auto-lookup, group matching by name, and duplicate skip
- **CSV/JSON Import & Export for Tags** - Export/import user tags with human-readable color names and descriptions
- **CSV/JSON Import & Export for Notification Groups** - Export groups with channels (sensitive data masked); import with auto-disable for masked credentials
- **In-App Update System** - Check, download, and apply updates directly from Settings (GitHub Releases & hotfix tracking)
- Two update channels: Stable (releases only) and Latest (releases + hotfixes)
- Full file and database backup before every update, with one-click rollback
- Automatic `composer install` when dependencies change (detects cPanel/shared hosting limitations)
- Commit SHA integrity verification on downloaded archives
- Update badge in top navigation bar (admin-only, configurable)
- Cron-based background update checks with admin notifications
- **Update Available Notifications** - In-app alerts for admins when a new release or hotfix is detected
- **Tag Transfer** - Admin-only transfer of individual or bulk-selected tags to another user
- **Domain Bulk Transfer** - Admin-only bulk transfer of selected domains to another user
- **Drag-and-Drop File Upload** - File import zones on Domains (bulk-add), Tags, and Groups pages with format hints and size limits
### Changed
- **Bulk Action Bars Redesigned** - Consistent inline toolbar across Domains, Tags, Groups, Users, Errors, and TLD Registry
- **Notification Click Routing** - `update_available` notifications redirect to Settings → Updates tab
- **Domains Per-Page Preference** - Remembered via cookie (persists for 1 year)
- **Installer Route Protection** - Requires admin auth for post-install routes; blocks re-installation
- **Settings Page** - New Updates tab with status card, preferences, rollback, and release notes viewer (Markdown rendered via marked.js + DOMPurify)
- **Button Color Consistency** - TLD Registry and transfer modals use `bg-primary` branding instead of mixed indigo/green
- **ErrorHandler Hardened** - Recursion guard, `JSON_PARTIAL_OUTPUT_ON_ERROR` for stack traces, `\Throwable` catch, graceful fallback to `error_log()`
### Fixed
- **Tag Delete XSS** - Fixed escaping of tag names containing quotes in delete confirmation
- **Bulk Actions Bar Toggle Bug** - Removed flex class toggling that caused display issues
### Security
- **Sensitive Data Masking in Exports** - API tokens show `****` + last 4 chars; webhook URLs show scheme + host only; masked channels imported as disabled
- **Installer Access Control** - Post-install pages (update, migration runner) require admin authentication
- **Import Validation** - File size limits (5 MB domains, 2 MB groups, 1 MB tags), extension whitelist (`.csv`, `.json`), CSRF on all import forms
### Technical
- **UpdateController** - New admin-only controller with check, apply, rollback, and preference endpoints
- **UpdateService** - GitHub API integration with release/commit tracking, file + DB backup, staged extraction, and rollback
- **LayoutHelper::getUpdateBadgeInfo()** - Cached badge state for top-nav without API calls on page load
- **ViewHelper::getMaxUploadSize()** - Returns effective PHP upload limit as human-readable string
- **NotificationGroup::findByName()** - Lookup groups by name with optional user scope
- **Setting::getUpdateSettings()** - Returns all update-related settings in one call
- **In-memory CSV building** - Uses `php://temp` streams to avoid output buffer conflicts
### Migrations
- `025_add_update_system_v1.1.3.sql` - Adds `update_channel` and `update_badge_enabled` settings, updates app version to 1.1.3
---
## [1.1.2] - 2026-02-09 ## [1.1.2] - 2026-02-09
### Added ### Added
@@ -393,8 +443,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [ ] SMS notifications (Twilio) - [ ] SMS notifications (Twilio)
- [x] Google Chat notifications (completed - v1.1.2) - [x] Google Chat notifications (completed - v1.1.2)
- [ ] WhatsApp notifications - [ ] WhatsApp notifications
- [ ] Export functionality (CSV, PDF) - [x] Export functionality (CSV, JSON) (completed - v1.1.3)
- [ ] Import domains from CSV - [x] Import domains from CSV/JSON (completed - v1.1.3)
- [ ] Domain transfer tracking - [ ] Domain transfer tracking
- [ ] DNS record monitoring - [ ] DNS record monitoring
- [ ] SSL certificate monitoring - [ ] SSL certificate monitoring
@@ -416,6 +466,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version History ## Version History
### 1.1.3 (2026-02-11)
- **CSV/JSON Import & Export** - Domains, Tags, and Notification Groups with drag-and-drop file upload
- **Sensitive Data Masking** - API tokens and webhook URLs masked in group exports; masked channels imported as disabled
- **In-App Update System** - Check, apply, and rollback updates from Settings (GitHub Releases + hotfix tracking)
- **Update Channels** - Stable (releases only) or Latest (releases + hotfixes) with configurable badge
- **File & Database Backup** - Automatic backup before every update, one-click rollback
- **Update Notifications** - In-app alerts for admins when new releases or hotfixes are detected
- **Tag Transfer** - Admin-only individual and bulk transfer of tags between users
- **Domain Bulk Transfer** - Admin-only bulk transfer of domains to another user
- **Bulk Action Bars Redesigned** - Consistent inline toolbar styling across all list pages
- **Installer Hardened** - Admin auth required post-install; re-installation blocked
- **ErrorHandler Improvements** - Recursion guard, graceful fallback logging, `\Throwable` catch
- Migration: `025_add_update_system_v1.1.3.sql`
### 1.1.2 (2026-02-09) ### 1.1.2 (2026-02-09)
- **Google Chat Webhook Support** - Selectable payload formats (Generic, Google Chat, Simple Text) - **Google Chat Webhook Support** - Selectable payload formats (Generic, Google Chat, Simple Text)
- **Domain Status Change Notifications** - Configurable alerts for available, registered, expired, redemption_period, pending_delete - **Domain Status Change Notifications** - Configurable alerts for available, registered, expired, redemption_period, pending_delete

View File

@@ -51,7 +51,13 @@ class DomainController extends Controller
$sortBy = $_GET['sort'] ?? 'domain_name'; $sortBy = $_GET['sort'] ?? 'domain_name';
$sortOrder = $_GET['order'] ?? 'asc'; $sortOrder = $_GET['order'] ?? 'asc';
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100 // Remember per_page preference via cookie
if (isset($_GET['per_page'])) {
$perPage = max(10, min(100, (int)$_GET['per_page']));
setcookie('domains_per_page', (string)$perPage, time() + 365 * 24 * 60 * 60, '/');
} else {
$perPage = max(10, min(100, (int)($_COOKIE['domains_per_page'] ?? 25)));
}
// Get expiring threshold from settings // Get expiring threshold from settings
$notificationDays = $settingModel->getNotificationDays(); $notificationDays = $settingModel->getNotificationDays();
@@ -114,6 +120,260 @@ class DomainController extends Controller
]); ]);
} }
/**
* Export domains as CSV or JSON
*/
public function export()
{
$logger = new \App\Services\Logger('export');
try {
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$format = $_GET['format'] ?? 'csv';
$logger->info("Domains export started", ['format' => $format, 'user_id' => $userId]);
if (!in_array($format, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid export format';
$this->redirect('/domains');
return;
}
// Get all domains with groups and tags
$domains = $this->domainModel->getAllWithGroups($isolationMode === 'isolated' ? $userId : null);
$exportData = [];
foreach ($domains as $domain) {
$exportData[] = [
'domain_name' => $domain['domain_name'],
'status' => $domain['status'] ?? '',
'registrar' => $domain['registrar'] ?? '',
'expiration_date' => $domain['expiration_date'] ?? '',
'tags' => $domain['tags'] ?? '',
'notification_group' => $domain['group_name'] ?? '',
'notes' => $domain['notes'] ?? ''
];
}
$date = date('Y-m-d');
$filename = "domains_export_{$date}";
// Clean any prior output buffers to prevent header conflicts
while (ob_get_level()) {
ob_end_clean();
}
if ($format === 'json') {
header('Content-Type: application/json');
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} else {
// Build CSV in memory to avoid fopen('php://output') issues
$csvContent = $this->buildCsv($exportData, ['domain_name', 'status', 'registrar', 'expiration_date', 'tags', 'notification_group', 'notes']);
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
header('Content-Length: ' . strlen($csvContent));
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo $csvContent;
}
$logger->info("Domains export completed successfully");
exit;
} catch (\Throwable $e) {
$logger->error("Domains export failed", [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
$this->redirect('/domains');
}
}
/**
* Build CSV string in memory from array data
*/
private function buildCsv(array $rows, array $headers): string
{
$handle = fopen('php://temp', 'r+');
fputcsv($handle, $headers, ',', '"', '\\');
foreach ($rows as $row) {
fputcsv($handle, array_values($row), ',', '"', '\\');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return $csv;
}
/**
* Import domains from CSV or JSON file
*/
public function import()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains/bulk-add');
return;
}
$this->verifyCsrf('/domains/bulk-add');
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['error'] = 'Please select a valid file to import';
$this->redirect('/domains/bulk-add');
return;
}
$file = $_FILES['import_file'];
// Validate file size (5MB max for domains)
if ($file['size'] > 5242880) {
$_SESSION['error'] = 'File is too large. Maximum size is 5MB';
$this->redirect('/domains/bulk-add');
return;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
$this->redirect('/domains/bulk-add');
return;
}
$content = file_get_contents($file['tmp_name']);
$domainsData = [];
if ($ext === 'json') {
$parsed = json_decode($content, true);
if (!is_array($parsed)) {
$_SESSION['error'] = 'Invalid JSON file';
$this->redirect('/domains/bulk-add');
return;
}
$domainsData = $parsed;
} else {
$lines = array_filter(explode("\n", $content));
$header = null;
foreach ($lines as $line) {
$row = str_getcsv(trim($line), ',', '"', '\\');
if (!$header) {
$header = array_map('strtolower', array_map('trim', $row));
continue;
}
$item = [];
foreach ($header as $i => $col) {
$item[$col] = $row[$i] ?? '';
}
$domainsData[] = $item;
}
}
if (empty($domainsData)) {
$_SESSION['error'] = 'No domains found in file';
$this->redirect('/domains/bulk-add');
return;
}
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$tagModel = new \App\Models\Tag();
// Form-level notification group
$formGroupId = (int)($_POST['notification_group_id'] ?? 0);
$added = 0;
$skipped = 0;
$errors = [];
$logger = new \App\Services\Logger();
foreach ($domainsData as $row) {
$domainName = strtolower(trim($row['domain_name'] ?? ''));
if (empty($domainName)) {
continue;
}
// Remove protocol/www
$domainName = preg_replace('#^https?://#', '', $domainName);
$domainName = preg_replace('#^www\.#', '', $domainName);
$domainName = rtrim($domainName, '/');
if ($this->domainModel->existsByDomain($domainName)) {
$skipped++;
continue;
}
try {
// Fetch WHOIS data
$whoisData = $this->whoisService->getDomainInfo($domainName);
if (!$whoisData) {
$errors[] = $domainName;
continue;
}
$status = $this->whoisService->getDomainStatus(
$whoisData['expiration_date'] ?? null,
$whoisData['status'] ?? [],
$whoisData
);
// Determine notification group: from file column or form fallback
$groupId = null;
$groupName = trim($row['notification_group'] ?? '');
if (!empty($groupName)) {
$groupStmt = $this->groupModel->findByName($groupName, $isolationMode === 'isolated' ? $userId : null);
if ($groupStmt) {
$groupId = $groupStmt['id'];
}
}
if (!$groupId && $formGroupId > 0) {
$groupId = $formGroupId;
}
$domainId = $this->domainModel->create([
'domain_name' => $domainName,
'registrar' => $whoisData['registrar'] ?? null,
'registrar_url' => $whoisData['registrar_url'] ?? null,
'expiration_date' => $whoisData['expiration_date'] ?? null,
'updated_date' => $whoisData['updated_date'] ?? null,
'abuse_email' => $whoisData['abuse_email'] ?? null,
'status' => $status,
'whois_data' => json_encode($whoisData),
'notes' => trim($row['notes'] ?? ''),
'last_checked' => date('Y-m-d H:i:s'),
'notification_group_id' => $groupId,
'user_id' => $isolationMode === 'isolated' ? $userId : null
]);
// Handle tags from file
$fileTags = trim($row['tags'] ?? '');
if (!empty($fileTags) && $domainId) {
$tagModel->updateDomainTags($domainId, $fileTags, $userId);
}
if ($domainId) {
$added++;
}
} catch (\Exception $e) {
$errors[] = $domainName;
$logger->error('Domain import failed', ['domain' => $domainName, 'error' => $e->getMessage()]);
}
}
$msg = "{$added} domain(s) imported successfully";
if ($skipped > 0) $msg .= ", {$skipped} skipped (already exist)";
if (!empty($errors)) $msg .= ", " . count($errors) . " failed";
$_SESSION['success'] = $msg;
$this->redirect('/domains');
}
public function create() public function create()
{ {
// Get groups based on isolation mode // Get groups based on isolation mode

View File

@@ -55,6 +55,7 @@ class InstallerController extends Controller
'022_add_pushover_channel_type.sql', '022_add_pushover_channel_type.sql',
'023_update_app_version_to_1.1.1.sql', '023_update_app_version_to_1.1.1.sql',
'024_add_status_notifications_v1.1.2.sql', '024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
]; ];
try { try {
@@ -196,6 +197,7 @@ class InstallerController extends Controller
'022_add_pushover_channel_type.sql', '022_add_pushover_channel_type.sql',
'023_update_app_version_to_1.1.1.sql', '023_update_app_version_to_1.1.1.sql',
'024_add_status_notifications_v1.1.2.sql', '024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
]; ];
} }
@@ -222,12 +224,33 @@ class InstallerController extends Controller
} }
} }
/**
* Require admin authentication (for post-install routes)
* Redirects to login if not authenticated, or home if not admin.
*/
private function requireAdmin(): void
{
if (!\Core\Auth::check()) {
$_SESSION['error'] = 'Please log in as an administrator to access this page.';
header('Location: /login');
exit;
}
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
$_SESSION['error'] = 'Access denied. Admin privileges required.';
header('Location: /');
exit;
}
}
/** /**
* Show installer welcome page * Show installer welcome page
*/ */
public function index() public function index()
{ {
if ($this->isInstalled()) { if ($this->isInstalled()) {
// System is installed — require admin for any further access
$this->requireAdmin();
// Check for pending migrations without executing them // Check for pending migrations without executing them
$pending = $this->getPendingMigrations(false); $pending = $this->getPendingMigrations(false);
if (empty($pending)) { if (empty($pending)) {
@@ -250,6 +273,13 @@ class InstallerController extends Controller
*/ */
public function checkDatabase() public function checkDatabase()
{ {
// Block access if already installed
if ($this->isInstalled()) {
$_SESSION['info'] = 'System is already installed.';
$this->redirect('/');
return;
}
try { try {
$pdo = \Core\Database::getConnection(); $pdo = \Core\Database::getConnection();
$pdo->query("SELECT 1"); $pdo->query("SELECT 1");
@@ -276,6 +306,13 @@ class InstallerController extends Controller
$this->redirect('/install'); $this->redirect('/install');
return; return;
} }
// Block re-installation if already installed
if ($this->isInstalled()) {
$_SESSION['error'] = 'System is already installed. Use the update function instead.';
$this->redirect('/');
return;
}
$adminUsername = trim($_POST['admin_username'] ?? ''); $adminUsername = trim($_POST['admin_username'] ?? '');
$adminPassword = trim($_POST['admin_password'] ?? ''); $adminPassword = trim($_POST['admin_password'] ?? '');
@@ -382,6 +419,7 @@ class InstallerController extends Controller
'022_add_pushover_channel_type.sql', '022_add_pushover_channel_type.sql',
'023_update_app_version_to_1.1.1.sql', '023_update_app_version_to_1.1.1.sql',
'024_add_status_notifications_v1.1.2.sql', '024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.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");
@@ -505,6 +543,9 @@ class InstallerController extends Controller
*/ */
public function showUpdate() public function showUpdate()
{ {
// Require admin authentication — updates are only for installed systems
$this->requireAdmin();
$pending = $this->getPendingMigrations(); $pending = $this->getPendingMigrations();
if (empty($pending)) { if (empty($pending)) {
@@ -524,6 +565,9 @@ class InstallerController extends Controller
*/ */
public function runUpdate() public function runUpdate()
{ {
// Require admin authentication
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/install/update'); $this->redirect('/install/update');
return; return;
@@ -600,10 +644,12 @@ class InstallerController extends Controller
// Determine from/to versions based on migrations // Determine from/to versions based on migrations
$fromVersion = '1.0.0'; $fromVersion = '1.0.0';
$toVersion = '1.1.2'; $toVersion = '1.1.3';
// Detect version based on which migrations were run // Detect version based on which migrations were run
if (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) { if (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
$toVersion = '1.1.3';
} elseif (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) {
$toVersion = '1.1.2'; $toVersion = '1.1.2';
} elseif (in_array('022_add_pushover_channel_type.sql', $executed)) { } elseif (in_array('022_add_pushover_channel_type.sql', $executed)) {
$toVersion = '1.1.1'; $toVersion = '1.1.1';

View File

@@ -93,7 +93,7 @@ class NotificationController extends Controller
$this->notificationModel->markAsRead($notificationId, $userId); $this->notificationModel->markAsRead($notificationId, $userId);
// If redirect=domain, go to the domain view page // Optional redirect after marking read
$redirect = $_GET['redirect'] ?? ''; $redirect = $_GET['redirect'] ?? '';
if ($redirect === 'domain') { if ($redirect === 'domain') {
$domainId = (int)($_GET['domain_id'] ?? 0); $domainId = (int)($_GET['domain_id'] ?? 0);
@@ -102,6 +102,10 @@ class NotificationController extends Controller
return; return;
} }
} }
if ($redirect === 'settings') {
$this->redirect('/settings#updates');
return;
}
// AJAX request - return JSON (check multiple detection methods) // AJAX request - return JSON (check multiple detection methods)
$isAjax = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') $isAjax = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')
@@ -225,6 +229,7 @@ class NotificationController extends Controller
'whois_failed' => 'exclamation-circle', 'whois_failed' => 'exclamation-circle',
'system_welcome' => 'hand-sparkles', 'system_welcome' => 'hand-sparkles',
'system_upgrade' => 'arrow-up', 'system_upgrade' => 'arrow-up',
'update_available' => 'cloud-download-alt',
default => 'bell' default => 'bell'
}; };
} }
@@ -247,6 +252,7 @@ class NotificationController extends Controller
'whois_failed' => 'gray', 'whois_failed' => 'gray',
'system_welcome' => 'purple', 'system_welcome' => 'purple',
'system_upgrade' => 'indigo', 'system_upgrade' => 'indigo',
'update_available' => 'blue',
default => 'gray' default => 'gray'
}; };
} }

View File

@@ -61,6 +61,332 @@ class NotificationGroupController extends Controller
]); ]);
} }
/**
* Export notification groups with channels as CSV or JSON (secrets masked)
*/
public function export()
{
$logger = new \App\Services\Logger('export');
try {
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$format = $_GET['format'] ?? 'csv';
$logger->info("Groups export started", ['format' => $format, 'user_id' => $userId]);
if (!in_array($format, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid export format';
$this->redirect('/groups');
return;
}
// Get groups
if ($isolationMode === 'isolated') {
$groups = $this->groupModel->getAllWithChannelCount($userId);
} else {
$groups = $this->groupModel->getAllWithChannelCount();
}
$exportData = [];
foreach ($groups as $group) {
$channels = $this->channelModel->getByGroupId($group['id']);
$maskedChannels = [];
foreach ($channels as $ch) {
$config = json_decode($ch['channel_config'], true) ?? [];
$maskedConfig = $this->maskChannelConfig($ch['channel_type'], $config);
$maskedChannels[] = [
'channel_type' => $ch['channel_type'],
'channel_config' => $maskedConfig,
'is_active' => (bool)$ch['is_active']
];
}
$exportData[] = [
'group_name' => $group['name'],
'group_description' => $group['description'] ?? '',
'channels' => $maskedChannels
];
}
$date = date('Y-m-d');
$filename = "notification_groups_export_{$date}";
// Clean any prior output buffers to prevent header conflicts
while (ob_get_level()) {
ob_end_clean();
}
if ($format === 'json') {
header('Content-Type: application/json');
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} else {
// Build CSV in memory — flatten groups with channels into rows
$csvRows = [];
foreach ($exportData as $group) {
if (empty($group['channels'])) {
$csvRows[] = ['group_name' => $group['group_name'], 'group_description' => $group['group_description'], 'channel_type' => '', 'channel_config' => '', 'is_active' => ''];
} else {
foreach ($group['channels'] as $ch) {
$csvRows[] = [
'group_name' => $group['group_name'],
'group_description' => $group['group_description'],
'channel_type' => $ch['channel_type'],
'channel_config' => json_encode($ch['channel_config']),
'is_active' => $ch['is_active'] ? '1' : '0'
];
}
}
}
$csvContent = $this->buildCsv($csvRows, ['group_name', 'group_description', 'channel_type', 'channel_config', 'is_active']);
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
header('Content-Length: ' . strlen($csvContent));
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo $csvContent;
}
$logger->info("Groups export completed successfully");
exit;
} catch (\Throwable $e) {
$logger->error("Groups export failed", [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
$this->redirect('/groups');
}
}
/**
* Build CSV string in memory from array data
*/
private function buildCsv(array $rows, array $headers): string
{
$handle = fopen('php://temp', 'r+');
fputcsv($handle, $headers, ',', '"', '\\');
foreach ($rows as $row) {
fputcsv($handle, array_values($row), ',', '"', '\\');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return $csv;
}
/**
* Import notification groups from CSV or JSON file
*/
public function import()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/groups');
return;
}
$this->verifyCsrf('/groups');
$validChannelTypes = ['email', 'telegram', 'discord', 'slack', 'mattermost', 'webhook', 'pushover'];
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['error'] = 'Please select a valid file to import';
$this->redirect('/groups');
return;
}
$file = $_FILES['import_file'];
if ($file['size'] > 2097152) {
$_SESSION['error'] = 'File is too large. Maximum size is 2MB';
$this->redirect('/groups');
return;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
$this->redirect('/groups');
return;
}
$content = file_get_contents($file['tmp_name']);
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$groupsCreated = 0;
$channelsCreated = 0;
$groupsSkipped = 0;
if ($ext === 'json') {
$parsed = json_decode($content, true);
if (!is_array($parsed)) {
$_SESSION['error'] = 'Invalid JSON file';
$this->redirect('/groups');
return;
}
foreach ($parsed as $groupData) {
$groupName = trim($groupData['group_name'] ?? '');
if (empty($groupName)) continue;
// Check if group already exists
$existing = $this->groupModel->findByName($groupName, $isolationMode === 'isolated' ? $userId : null);
if ($existing) {
$groupsSkipped++;
continue;
}
$groupId = $this->groupModel->create([
'name' => $groupName,
'description' => trim($groupData['group_description'] ?? ''),
'user_id' => $isolationMode === 'isolated' ? $userId : null
]);
if ($groupId && !empty($groupData['channels'])) {
foreach ($groupData['channels'] as $ch) {
$channelType = $ch['channel_type'] ?? '';
$config = $ch['channel_config'] ?? [];
if (empty($channelType) || !in_array($channelType, $validChannelTypes)) continue;
// Channels with masked secrets are created as inactive
$hasMasked = $this->configHasMaskedValues($config);
$this->channelModel->create([
'notification_group_id' => $groupId,
'channel_type' => $channelType,
'channel_config' => json_encode($config),
'is_active' => $hasMasked ? 0 : ((int)($ch['is_active'] ?? 1))
]);
$channelsCreated++;
}
}
$groupsCreated++;
}
} else {
// CSV: group rows by group_name
$lines = array_filter(explode("\n", $content));
$header = null;
$csvGroups = [];
foreach ($lines as $line) {
$row = str_getcsv(trim($line), ',', '"', '\\');
if (!$header) {
$header = array_map('strtolower', array_map('trim', $row));
continue;
}
$item = [];
foreach ($header as $i => $col) {
$item[$col] = $row[$i] ?? '';
}
$gName = trim($item['group_name'] ?? '');
if (empty($gName)) continue;
if (!isset($csvGroups[$gName])) {
$csvGroups[$gName] = [
'description' => trim($item['group_description'] ?? ''),
'channels' => []
];
}
$chType = trim($item['channel_type'] ?? '');
if (!empty($chType) && in_array($chType, $validChannelTypes)) {
$config = json_decode($item['channel_config'] ?? '{}', true) ?: [];
$csvGroups[$gName]['channels'][] = [
'channel_type' => $chType,
'channel_config' => $config,
'is_active' => $item['is_active'] ?? '1'
];
}
}
foreach ($csvGroups as $gName => $gData) {
$existing = $this->groupModel->findByName($gName, $isolationMode === 'isolated' ? $userId : null);
if ($existing) {
$groupsSkipped++;
continue;
}
$groupId = $this->groupModel->create([
'name' => $gName,
'description' => $gData['description'],
'user_id' => $isolationMode === 'isolated' ? $userId : null
]);
if ($groupId) {
foreach ($gData['channels'] as $ch) {
$config = $ch['channel_config'] ?? [];
$hasMasked = $this->configHasMaskedValues($config);
$this->channelModel->create([
'notification_group_id' => $groupId,
'channel_type' => $ch['channel_type'],
'channel_config' => json_encode($config),
'is_active' => $hasMasked ? 0 : ((int)($ch['is_active'] ?? 1))
]);
$channelsCreated++;
}
$groupsCreated++;
}
}
}
$msg = "{$groupsCreated} group(s) imported ({$channelsCreated} channels)";
if ($groupsSkipped > 0) $msg .= ", {$groupsSkipped} skipped (already exist)";
$_SESSION['success'] = $msg;
$this->redirect('/groups');
}
/**
* Mask sensitive values in channel config for export
*/
private function maskChannelConfig(string $type, array $config): array
{
$masked = $config;
$sensitiveKeys = ['bot_token', 'api_token', 'user_key', 'pushover_api_token', 'pushover_user_key'];
$urlKeys = ['webhook_url', 'discord_webhook_url', 'slack_webhook_url', 'mattermost_webhook_url'];
foreach ($sensitiveKeys as $key) {
if (!empty($masked[$key])) {
$val = $masked[$key];
$masked[$key] = '****' . substr($val, -4);
}
}
foreach ($urlKeys as $key) {
if (!empty($masked[$key])) {
$parsed = parse_url($masked[$key]);
if ($parsed && isset($parsed['host'])) {
$scheme = $parsed['scheme'] ?? 'https';
$masked[$key] = "{$scheme}://{$parsed['host']}/****";
}
}
}
// Email is not masked
return $masked;
}
/**
* Check if config contains masked placeholder values
*/
private function configHasMaskedValues(array $config): bool
{
foreach ($config as $value) {
if (is_string($value) && (str_contains($value, '****'))) {
return true;
}
}
return false;
}
public function create() public function create()
{ {
$this->view('groups/create', [ $this->view('groups/create', [

View File

@@ -28,6 +28,7 @@ class SettingsController extends Controller
$captchaSettings = $this->settingModel->getCaptchaSettings(); $captchaSettings = $this->settingModel->getCaptchaSettings();
$twoFactorSettings = $this->settingModel->getTwoFactorSettings(); $twoFactorSettings = $this->settingModel->getTwoFactorSettings();
$isolationSettings = $this->getIsolationSettings(); $isolationSettings = $this->getIsolationSettings();
$updateSettings = $this->settingModel->getUpdateSettings();
// Predefined notification day options // Predefined notification day options
$notificationPresets = [ $notificationPresets = [
@@ -76,6 +77,7 @@ class SettingsController extends Controller
'captchaSettings' => $captchaSettings, 'captchaSettings' => $captchaSettings,
'twoFactorSettings' => $twoFactorSettings, 'twoFactorSettings' => $twoFactorSettings,
'isolationSettings' => $isolationSettings, 'isolationSettings' => $isolationSettings,
'updateSettings' => $updateSettings,
'notificationPresets' => $notificationPresets, 'notificationPresets' => $notificationPresets,
'checkIntervalPresets' => $checkIntervalPresets, 'checkIntervalPresets' => $checkIntervalPresets,
'statusTriggers' => $statusTriggers, 'statusTriggers' => $statusTriggers,

View File

@@ -49,15 +49,245 @@ class TagController extends Controller
$availableColors = $this->tagModel->getAvailableColors(); $availableColors = $this->tagModel->getAvailableColors();
// Get users for transfer functionality (admin only)
$users = [];
if (\Core\Auth::isAdmin()) {
$userModel = new \App\Models\User();
$users = $userModel->all();
}
$this->view('tags/index', [ $this->view('tags/index', [
'tags' => $result['tags'], 'tags' => $result['tags'],
'pagination' => $result['pagination'], 'pagination' => $result['pagination'],
'filters' => $filters, 'filters' => $filters,
'availableColors' => $availableColors, 'availableColors' => $availableColors,
'isolationMode' => $isolationMode 'isolationMode' => $isolationMode,
'users' => $users
]); ]);
} }
/**
* Export user's private tags as CSV or JSON
*/
public function export()
{
$logger = new \App\Services\Logger('export');
try {
$userId = \Core\Auth::id();
$format = $_GET['format'] ?? 'csv';
$logger->info("Tags export started", ['format' => $format, 'user_id' => $userId]);
if (!in_array($format, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid export format';
$this->redirect('/tags');
return;
}
// Get only the user's private tags (not global)
$allUserTags = $this->tagModel->where('user_id', $userId);
usort($allUserTags, fn($a, $b) => strcasecmp($a['name'], $b['name']));
// Map CSS class to readable color name for export
$colorNames = $this->tagModel->getAvailableColors(); // cssClass => 'Name'
$tags = array_map(fn($t) => [
'name' => $t['name'],
'color' => $colorNames[$t['color']] ?? 'Gray',
'description' => $t['description'] ?? ''
], $allUserTags);
$logger->info("Tags data prepared", ['count' => count($tags)]);
$date = date('Y-m-d');
$filename = "tags_export_{$date}";
// Clean any prior output buffers to prevent header conflicts
$obLevel = ob_get_level();
$logger->debug("Output buffer level before clean", ['ob_level' => $obLevel]);
while (ob_get_level()) {
ob_end_clean();
}
$headersSent = headers_sent($sentFile, $sentLine);
$logger->debug("Headers status before sending", [
'headers_already_sent' => $headersSent,
'sent_file' => $sentFile ?? null,
'sent_line' => $sentLine ?? null
]);
if ($format === 'json') {
header('Content-Type: application/json');
header("Content-Disposition: attachment; filename=\"{$filename}.json\"");
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo json_encode($tags, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} else {
// Build CSV in memory to avoid fopen('php://output') issues
$csvContent = $this->buildCsv($tags, ['name', 'color', 'description']);
$logger->info("CSV content built", ['bytes' => strlen($csvContent)]);
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"{$filename}.csv\"");
header('Content-Length: ' . strlen($csvContent));
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
echo $csvContent;
}
$logger->info("Tags export completed successfully");
exit;
} catch (\Throwable $e) {
$logger->error("Tags export failed", [
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
$_SESSION['error'] = 'Export failed: ' . $e->getMessage();
$this->redirect('/tags');
}
}
/**
* Build CSV string in memory from array data
*/
private function buildCsv(array $rows, array $headers): string
{
$handle = fopen('php://temp', 'r+');
fputcsv($handle, $headers, ',', '"', '\\');
foreach ($rows as $row) {
fputcsv($handle, array_values($row), ',', '"', '\\');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return $csv;
}
/**
* Import tags from CSV or JSON file
*/
public function import()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) {
$_SESSION['error'] = 'Please select a valid file to import';
$this->redirect('/tags');
return;
}
$file = $_FILES['import_file'];
// Validate file size (1MB max)
if ($file['size'] > 1048576) {
$_SESSION['error'] = 'File is too large. Maximum size is 1MB';
$this->redirect('/tags');
return;
}
// Detect format from extension
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['csv', 'json'])) {
$_SESSION['error'] = 'Invalid file type. Please upload a CSV or JSON file';
$this->redirect('/tags');
return;
}
$content = file_get_contents($file['tmp_name']);
$tagsData = [];
if ($ext === 'json') {
$parsed = json_decode($content, true);
if (!is_array($parsed)) {
$_SESSION['error'] = 'Invalid JSON file';
$this->redirect('/tags');
return;
}
$tagsData = $parsed;
} else {
$lines = array_filter(explode("\n", $content));
$header = null;
foreach ($lines as $line) {
$row = str_getcsv(trim($line), ',', '"', '\\');
if (!$header) {
$header = array_map('strtolower', array_map('trim', $row));
continue;
}
$item = [];
foreach ($header as $i => $col) {
$item[$col] = $row[$i] ?? '';
}
$tagsData[] = $item;
}
}
if (empty($tagsData)) {
$_SESSION['error'] = 'No tags found in file';
$this->redirect('/tags');
return;
}
$userId = \Core\Auth::id();
$colorMap = $this->tagModel->getAvailableColors(); // cssClass => 'Name'
$availableColorClasses = array_keys($colorMap);
// Build reverse map: lowercase name => cssClass (e.g. 'blue' => 'bg-blue-100 ...')
$nameToClass = [];
foreach ($colorMap as $cssClass => $colorName) {
$nameToClass[strtolower($colorName)] = $cssClass;
}
$defaultColor = $availableColorClasses[0] ?? 'bg-gray-100 text-gray-700 border-gray-300';
$created = 0;
$skipped = 0;
foreach ($tagsData as $tagRow) {
$name = trim($tagRow['name'] ?? '');
if (empty($name) || !preg_match('/^[a-z0-9-]+$/', $name)) {
$skipped++;
continue;
}
// Check if already exists for this user
$existing = $this->tagModel->findByName($name, $userId);
if ($existing) {
$skipped++;
continue;
}
// Accept both color names ("Blue") and raw CSS classes
$colorInput = trim($tagRow['color'] ?? '');
if (!empty($colorInput)) {
if (isset($nameToClass[strtolower($colorInput)])) {
// Human-readable name (e.g. "Blue")
$color = $nameToClass[strtolower($colorInput)];
} elseif (in_array($colorInput, $availableColorClasses)) {
// Raw CSS class (backward compatible)
$color = $colorInput;
} else {
$color = $defaultColor;
}
} else {
$color = $defaultColor;
}
$this->tagModel->create([
'name' => $name,
'color' => $color,
'description' => trim($tagRow['description'] ?? ''),
'user_id' => $userId
]);
$created++;
}
$_SESSION['success'] = "{$created} tag(s) imported successfully" . ($skipped > 0 ? ", {$skipped} skipped (already exist or invalid)" : '');
$this->redirect('/tags');
}
/** /**
* Create new tag * Create new tag
*/ */
@@ -442,12 +672,7 @@ class TagController extends Controller
return; return;
} }
// Verify CSRF token $this->verifyCsrf('/tags');
if (!\Core\Csrf::verify($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/tags');
return;
}
$tagIds = $_POST['tag_ids'] ?? []; $tagIds = $_POST['tag_ids'] ?? [];
if (empty($tagIds)) { if (empty($tagIds)) {
@@ -496,4 +721,97 @@ class TagController extends Controller
$this->redirect('/tags'); $this->redirect('/tags');
} }
/**
* Transfer tag to another user (Admin only)
*/
public function transfer()
{
\Core\Auth::requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
$tagId = (int)($_POST['tag_id'] ?? 0);
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
if (!$tagId || !$targetUserId) {
$_SESSION['error'] = 'Invalid tag or user selected';
$this->redirect('/tags');
return;
}
$tag = $this->tagModel->find($tagId);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/tags');
return;
}
$userModel = new \App\Models\User();
$targetUser = $userModel->find($targetUserId);
if (!$targetUser) {
$_SESSION['error'] = 'Target user not found';
$this->redirect('/tags');
return;
}
if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
$_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}";
} else {
$_SESSION['error'] = 'Failed to transfer tag. Please try again.';
}
$this->redirect('/tags');
}
/**
* Bulk transfer tags to another user (Admin only)
*/
public function bulkTransfer()
{
\Core\Auth::requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
$tagIds = $_POST['tag_ids'] ?? [];
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
if (empty($tagIds) || !$targetUserId) {
$_SESSION['error'] = 'No tags selected or invalid user';
$this->redirect('/tags');
return;
}
$userModel = new \App\Models\User();
$targetUser = $userModel->find($targetUserId);
if (!$targetUser) {
$_SESSION['error'] = 'Target user not found';
$this->redirect('/tags');
return;
}
$transferred = 0;
foreach ($tagIds as $tagId) {
$tagId = (int)$tagId;
if ($tagId > 0) {
$tag = $this->tagModel->find($tagId);
if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) {
$transferred++;
}
}
}
$_SESSION['success'] = $transferred . ' tag(s) transferred to ' . $targetUser['username'];
$this->redirect('/tags');
}
} }

View File

@@ -0,0 +1,290 @@
<?php
namespace App\Controllers;
use Core\Controller;
use Core\Auth;
use App\Services\UpdateService;
use App\Services\NotificationService;
use App\Models\Setting;
use App\Services\Logger;
class UpdateController extends Controller
{
private UpdateService $updateService;
private Setting $settingModel;
private Logger $logger;
public function __construct()
{
Auth::requireAdmin();
$this->updateService = new UpdateService();
$this->settingModel = new Setting();
$this->logger = new Logger('updater');
}
/**
* AJAX: Check for updates
* POST /api/updates/check
*/
public function check()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->json(['error' => 'Method not allowed'], 405);
return;
}
$forceCheck = isset($_POST['force']) && $_POST['force'] === '1';
$result = $this->updateService->checkForUpdate($forceCheck);
// When manual check finds an update, create in-app notification for admins (once per version/sha)
if (!empty($result['available']) && empty($result['error'])) {
$type = $result['type'] ?? 'release';
$notifiedRelease = $this->settingModel->getValue('last_update_available_notified_release', '');
$notifiedHotfixSha = $this->settingModel->getValue('last_update_available_notified_hotfix_sha', '');
$shouldNotify = false;
if ($type === 'release') {
$latestVersion = $result['latest_version'] ?? '';
if ($latestVersion !== '' && $latestVersion !== $notifiedRelease) {
$shouldNotify = true;
$this->settingModel->setValue('last_update_available_notified_release', $latestVersion);
}
} else {
$remoteSha = $result['remote_sha'] ?? '';
if ($remoteSha !== '' && $remoteSha !== $notifiedHotfixSha) {
$shouldNotify = true;
$this->settingModel->setValue('last_update_available_notified_hotfix_sha', $remoteSha);
}
}
if ($shouldNotify) {
try {
$notificationService = new NotificationService();
$currentVersion = $result['current_version'] ?? '';
$label = ($type === 'release') ? ($result['latest_version'] ?? 'latest') : 'hotfix';
$commitsBehind = $result['commits_behind'] ?? null;
$notificationService->notifyAdminsUpdateAvailable($currentVersion, $label, $type, $commitsBehind);
} catch (\Exception $e) {
$this->logger->warning('Failed to send update-available notification', ['error' => $e->getMessage()]);
}
}
}
$this->json($result);
}
/**
* Apply an update (download, extract, replace files)
* POST /settings/updates/apply
*/
public function apply()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/settings#updates');
return;
}
// CSRF Protection
$this->verifyCsrf('/settings#updates');
$type = $_POST['update_type'] ?? 'release';
if (!in_array($type, ['release', 'hotfix'])) {
$_SESSION['error'] = 'Invalid update type';
$this->redirect('/settings#updates');
return;
}
$this->logger->info('Update requested by admin', [
'type' => $type,
'user_id' => Auth::id(),
]);
$result = $this->updateService->performUpdate($type);
if ($result['success']) {
$fromVersion = $result['from_version'];
$toVersion = $result['to_version'] ?? 'latest';
$filesUpdated = $result['files_updated'];
// Check for pending migrations after file update
$hasMigrations = $this->updateService->hasPendingMigrations();
// Notify admins
try {
$notificationService = new NotificationService();
$notificationService->notifyAdminsUpgrade(
$fromVersion,
$toVersion,
0,
!empty($result['composer_manual_required'])
);
} catch (\Exception $e) {
// Non-critical
$this->logger->warning('Failed to send upgrade notification', [
'error' => $e->getMessage(),
]);
}
$message = "Update applied successfully! {$filesUpdated} file(s) updated.";
if (!empty($result['db_backup_warning'])) {
$message .= ' Note: Database backup was skipped (' . $result['db_backup_warning'] . '). Consider backing up your database manually.';
}
if ($hasMigrations) {
$message .= ' Database migrations are pending - please run them now.';
}
if (!empty($result['composer_manual_required'])) {
$message .= ' Composer could not be run here (e.g. exec disabled on cPanel). If dependencies changed, run "composer install --no-dev" manually via SSH or Terminal.';
}
$_SESSION['success'] = $message;
if ($hasMigrations) {
$this->redirect('/install/update');
return;
}
$this->redirect('/settings#updates');
} else {
$errors = implode('; ', $result['errors']);
$_SESSION['error'] = "Update failed: {$errors}";
$this->redirect('/settings#updates');
}
}
/**
* Rollback to last backup
* POST /settings/updates/rollback
*/
public function rollback()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/settings#updates');
return;
}
// CSRF Protection
$this->verifyCsrf('/settings#updates');
$this->logger->info('Rollback requested by admin', [
'user_id' => Auth::id(),
]);
$result = $this->updateService->rollback();
if ($result['success']) {
$msg = 'Rollback completed successfully. Files have been restored to the previous version.';
if (isset($result['db_restored'])) {
$msg .= $result['db_restored']
? ' Database has also been restored from the backup.'
: ' Database could not be restored automatically. You can import the SQL backup manually from the backups/ directory.';
}
$_SESSION['success'] = $msg;
} else {
$_SESSION['error'] = $result['error'] ?? 'Rollback failed';
}
$this->redirect('/settings#updates');
}
/**
* Save update preferences (channel + badge) from single form
* POST /settings/updates/preferences
*/
public function savePreferences()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/settings#updates');
return;
}
$this->verifyCsrf('/settings#updates');
$channel = $_POST['update_channel'] ?? 'stable';
if (!in_array($channel, ['stable', 'latest'])) {
$_SESSION['error'] = 'Invalid update channel';
$this->redirect('/settings#updates');
return;
}
$badgeEnabled = isset($_POST['update_badge_enabled']) && $_POST['update_badge_enabled'] === '1' ? '1' : '0';
$this->settingModel->setValue('update_channel', $channel);
$this->settingModel->setValue('update_badge_enabled', $badgeEnabled);
if ($channel === 'latest') {
$currentSha = $this->settingModel->getValue('installed_commit_sha', null);
if (!$currentSha) {
$_SESSION['info'] = 'Update preferences saved. Note: Commit tracking will begin after the first update is applied.';
} else {
$_SESSION['success'] = 'Update preferences saved.';
}
} else {
$_SESSION['success'] = 'Update preferences saved.';
}
$this->settingModel->setValue('last_update_check', null);
$this->redirect('/settings#updates');
}
/**
* Update the update channel preference
* POST /settings/updates/channel
*/
public function updateChannel()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/settings#updates');
return;
}
// CSRF Protection
$this->verifyCsrf('/settings#updates');
$channel = $_POST['update_channel'] ?? 'stable';
if (!in_array($channel, ['stable', 'latest'])) {
$_SESSION['error'] = 'Invalid update channel';
$this->redirect('/settings#updates');
return;
}
$this->settingModel->setValue('update_channel', $channel);
// If switching to "latest" and no commit SHA is tracked, try to fetch it
if ($channel === 'latest') {
$currentSha = $this->settingModel->getValue('installed_commit_sha', null);
if (!$currentSha) {
$_SESSION['info'] = 'Update channel set to Latest. Note: Commit tracking will begin after the first update is applied. Until then, only release updates will be detected.';
} else {
$_SESSION['success'] = 'Update channel set to Latest. You will now receive both releases and hotfix updates.';
}
} else {
$_SESSION['success'] = 'Update channel set to Stable. You will only receive tagged release updates.';
}
// Clear cached check results so next check uses new channel
$this->settingModel->setValue('last_update_check', null);
$this->redirect('/settings#updates');
}
/**
* Update the "show update badge in menu" preference
* POST /settings/updates/badge
*/
public function updateBadgePreference()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/settings#updates');
return;
}
$this->verifyCsrf('/settings#updates');
$enabled = isset($_POST['update_badge_enabled']) && $_POST['update_badge_enabled'] === '1' ? '1' : '0';
$this->settingModel->setValue('update_badge_enabled', $enabled);
$_SESSION['success'] = $enabled === '1'
? 'Update badge will be shown in the top menu when an update is available.'
: 'Update badge in the top menu is now disabled.';
$this->redirect('/settings#updates');
}
}

View File

@@ -132,6 +132,7 @@ class LayoutHelper
'whois_failed' => 'exclamation-circle', 'whois_failed' => 'exclamation-circle',
'system_welcome' => 'hand-sparkles', 'system_welcome' => 'hand-sparkles',
'system_upgrade' => 'arrow-up', 'system_upgrade' => 'arrow-up',
'update_available' => 'cloud-download-alt',
default => 'bell' default => 'bell'
}; };
} }
@@ -154,6 +155,7 @@ class LayoutHelper
'whois_failed' => 'gray', 'whois_failed' => 'gray',
'system_welcome' => 'purple', 'system_welcome' => 'purple',
'system_upgrade' => 'indigo', 'system_upgrade' => 'indigo',
'update_available' => 'blue',
default => 'gray' default => 'gray'
}; };
} }
@@ -182,5 +184,47 @@ class LayoutHelper
]; ];
} }
} }
/**
* Get update badge info for the top menu (admin only).
* Uses cached update check data; no GitHub API call.
* Returns ['show' => bool, 'available' => bool, 'label' => string].
*/
public static function getUpdateBadgeInfo(): array
{
try {
$settingModel = new Setting();
$updateSettings = $settingModel->getUpdateSettings();
$badgeEnabled = ($updateSettings['update_badge_enabled'] ?? '1') !== '0';
if (!$badgeEnabled) {
return ['show' => false, 'available' => false, 'label' => ''];
}
$current = $settingModel->getAppVersion();
$latestVersion = $updateSettings['latest_available_version'] ?? null;
$channel = $updateSettings['update_channel'] ?? 'stable';
$commitsBehind = (int) ($updateSettings['commits_behind_count'] ?? 0);
$available = false;
$label = '';
if ($latestVersion && version_compare($latestVersion, $current, '>')) {
$available = true;
$label = 'v' . $latestVersion;
}
if ($channel === 'latest' && $commitsBehind > 0 && !$available) {
$available = true;
$label = $commitsBehind . ' commit' . ($commitsBehind !== 1 ? 's' : '');
}
return [
'show' => $available,
'available' => $available,
'label' => $label,
];
} catch (\Exception $e) {
return ['show' => false, 'available' => false, 'label' => ''];
}
}
} }

View File

@@ -138,6 +138,37 @@ class ViewHelper
return $html; return $html;
} }
/**
* Get the PHP max upload size from ini settings.
* Returns the lower of upload_max_filesize and post_max_size as a human-readable string.
*
* @return string Human-readable size (e.g. "128 MB")
*/
public static function getMaxUploadSize(): string
{
$phpUploadMax = self::parseIniSize(ini_get('upload_max_filesize') ?: '2M');
$phpPostMax = self::parseIniSize(ini_get('post_max_size') ?: '8M');
$phpLimit = min($phpUploadMax, $phpPostMax);
return self::formatBytes($phpLimit, 0);
}
/**
* Parse a PHP ini size value (e.g. "2M", "128K", "1G") into bytes.
*/
private static function parseIniSize(string $size): int
{
$value = (int) $size;
$unit = strtolower(substr(trim($size), -1));
return match ($unit) {
'g' => $value * 1073741824,
'm' => $value * 1048576,
'k' => $value * 1024,
default => $value,
};
}
/** /**
* Generate alert message HTML * Generate alert message HTML
*/ */

View File

@@ -98,5 +98,21 @@ class NotificationGroup extends Model
$stmt->execute([$userId]); $stmt->execute([$userId]);
return $stmt->rowCount(); return $stmt->rowCount();
} }
/**
* Find a notification group by name
*/
public function findByName(string $name, ?int $userId = null): ?array
{
if ($userId) {
$stmt = $this->db->prepare("SELECT * FROM notification_groups WHERE name = ? AND user_id = ? LIMIT 1");
$stmt->execute([$name, $userId]);
} else {
$stmt = $this->db->prepare("SELECT * FROM notification_groups WHERE name = ? LIMIT 1");
$stmt->execute([$name]);
}
$result = $stmt->fetch();
return $result ?: null;
}
} }

View File

@@ -122,7 +122,7 @@ class Setting extends Model
*/ */
public function getAppVersion(): string public function getAppVersion(): string
{ {
return $this->getValue('app_version', '1.1.2'); return $this->getValue('app_version', '1.1.3');
} }
/** /**
@@ -322,6 +322,27 @@ class Setting extends Model
return $this->setValue('notification_status_triggers', $value); return $this->setValue('notification_status_triggers', $value);
} }
/**
* Get update settings
*/
public function getUpdateSettings(): array
{
return [
'update_channel' => $this->getValue('update_channel', 'stable'),
'last_update_check' => $this->getValue('last_update_check', null),
'latest_available_version' => $this->getValue('latest_available_version', null),
'latest_release_notes' => $this->getValue('latest_release_notes', ''),
'latest_release_url' => $this->getValue('latest_release_url', ''),
'latest_release_published_at' => $this->getValue('latest_release_published_at', ''),
'installed_commit_sha' => $this->getValue('installed_commit_sha', null),
'update_backup_path' => $this->getValue('update_backup_path', null),
'update_db_backup_path' => $this->getValue('update_db_backup_path', null),
'commits_behind_count' => (int) $this->getValue('commits_behind_count', 0),
'latest_remote_sha' => $this->getValue('latest_remote_sha', ''),
'update_badge_enabled' => $this->getValue('update_badge_enabled', '1'),
];
}
/** /**
* Clear old notification logs * Clear old notification logs
*/ */

View File

@@ -19,6 +19,7 @@ class ErrorHandler
private Logger $logger; private Logger $logger;
private ?ErrorLog $errorLogModel = null; private ?ErrorLog $errorLogModel = null;
private bool $isDevelopment; private bool $isDevelopment;
private bool $handling = false; // Recursion guard
public function __construct() public function __construct()
{ {
@@ -29,9 +30,8 @@ class ErrorHandler
// Initialize ErrorLog model if database is available // Initialize ErrorLog model if database is available
try { try {
$this->errorLogModel = new ErrorLog(); $this->errorLogModel = new ErrorLog();
} catch (\Exception $e) { } catch (\Throwable $e) {
// Database not available, will only use file logging // Database not available, will only use file logging
// Don't use error_log as it might fail too
} }
} }
@@ -40,6 +40,22 @@ class ErrorHandler
*/ */
public function handleException(\Throwable $exception): void public function handleException(\Throwable $exception): void
{ {
// Prevent infinite recursion if error handling itself triggers an error
if ($this->handling) {
// Fallback: just log to file and stop
try {
$this->logger->critical('Recursive error detected', [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine()
]);
} catch (\Throwable $e) {
// Last resort
}
return;
}
$this->handling = true;
$errorData = $this->captureError($exception); $errorData = $this->captureError($exception);
// Log to file // Log to file
@@ -62,8 +78,8 @@ class ErrorHandler
return false; return false;
} }
// Ignore certain non-critical errors during error handling itself // Prevent recursive handling (e.g. if logToDatabase triggers a warning)
if (error_reporting() === 0) { if ($this->handling) {
return false; return false;
} }
@@ -114,7 +130,7 @@ class ErrorHandler
'error_message' => $exception->getMessage(), 'error_message' => $exception->getMessage(),
'error_file' => $exception->getFile(), 'error_file' => $exception->getFile(),
'error_line' => $exception->getLine(), 'error_line' => $exception->getLine(),
'stack_trace' => json_encode($exception->getTrace()), 'stack_trace' => json_encode($exception->getTrace(), JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '[]',
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI', 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A', 'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A',
'request_data' => json_encode($requestData), 'request_data' => json_encode($requestData),
@@ -228,9 +244,19 @@ class ErrorHandler
try { try {
return $this->errorLogModel->logError($errorData); return $this->errorLogModel->logError($errorData);
} catch (\Exception $e) { } catch (\Throwable $e) {
// Database logging failed, continue with file logging only // Database logging failed — log to file so it's visible in the app's /logs folder
error_log("Failed to log error to database: " . $e->getMessage()); try {
$this->logger->error('Failed to log error to database', [
'db_error' => $e->getMessage(),
'db_error_file' => $e->getFile(),
'db_error_line' => $e->getLine(),
'original_error_id' => $errorData['error_id'] ?? 'unknown'
]);
} catch (\Throwable $e2) {
// Last resort — use PHP's native error_log
error_log("ErrorHandler: DB log failed: " . $e->getMessage());
}
return null; return null;
} }
} }

View File

@@ -569,30 +569,36 @@ class NotificationService
/** /**
* Create system upgrade notification for admins (in-app) * Create system upgrade notification for admins (in-app)
* @param bool $composerManualRequired If true, appends a note to run composer install manually (e.g. when exec is disabled on cPanel)
*/ */
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount): void public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
{ {
$message = "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)";
if ($composerManualRequired) {
$message .= ". Composer could not be run here (e.g. exec disabled). If dependencies changed, run \"composer install --no-dev\" manually via SSH or Terminal.";
}
$notificationModel = new \App\Models\Notification(); $notificationModel = new \App\Models\Notification();
$notificationModel->createNotification( $notificationModel->createNotification(
$userId, $userId,
'system_upgrade', 'system_upgrade',
'System Upgraded Successfully', 'System Upgraded Successfully',
"Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)", $message,
null null
); );
} }
/** /**
* Notify all admins about system upgrade (in-app) * Notify all admins about system upgrade (in-app)
* @param bool $composerManualRequired If true, in-app message will include a note to run composer install manually
*/ */
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount): void public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void
{ {
try { try {
$userModel = new \App\Models\User(); $userModel = new \App\Models\User();
$admins = $userModel->getAllAdmins(); $admins = $userModel->getAllAdmins();
foreach ($admins as $admin) { foreach ($admins as $admin) {
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount); $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount, $composerManualRequired);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$logger = new \App\Services\Logger(); $logger = new \App\Services\Logger();
@@ -602,6 +608,50 @@ class NotificationService
} }
} }
/**
* Create "update available" in-app notification for one user
*/
public function notifyUpdateAvailable(int $userId, string $currentVersion, string $latestVersion, string $type = 'release', ?int $commitsBehind = null): void
{
$notificationModel = new \App\Models\Notification();
$title = 'Update Available';
if ($type === 'release') {
$message = "A new version of Domain Monitor is available: v{$latestVersion} (you have v{$currentVersion}). Go to Settings → Updates to apply.";
} else {
$msg = $commitsBehind
? "{$commitsBehind} new commit(s) are available on the main branch. Go to Settings → Updates to apply the hotfix."
: "New commits are available. Go to Settings → Updates to apply the hotfix.";
$message = $msg;
}
$notificationModel->createNotification(
$userId,
'update_available',
$title,
$message,
null
);
}
/**
* Notify all admins that an update is available (in-app)
* Used by cron when it detects a new version or hotfix.
*/
public function notifyAdminsUpdateAvailable(string $currentVersion, string $latestVersionOrLabel, string $type = 'release', ?int $commitsBehind = null): void
{
try {
$userModel = new \App\Models\User();
$admins = $userModel->getAllAdmins();
foreach ($admins as $admin) {
$this->notifyUpdateAvailable($admin['id'], $currentVersion, $latestVersionOrLabel, $type, $commitsBehind);
}
} catch (\Exception $e) {
$logger = new \App\Services\Logger();
$logger->error("Failed to notify admins about available update", [
'error' => $e->getMessage()
]);
}
}
/** /**
* Delete old read notifications (cleanup) * Delete old read notifications (cleanup)
*/ */

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,21 @@ $pageIcon = 'fas fa-layer-group';
ob_start(); ob_start();
?> ?>
<!-- Main Form --> <!-- Main Container -->
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200"> <!-- Tabs -->
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <div class="flex border-b border-gray-200 bg-gray-50">
<i class="fas fa-layer-group text-gray-400 mr-2 text-sm"></i> <button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white transition-colors">
Bulk Add Domains <i class="fas fa-keyboard mr-2"></i>Paste Domains
</h2> </button>
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-colors">
<i class="fas fa-file-upload mr-2"></i>Import from File
</button>
</div> </div>
<div class="p-6"> <!-- Tab 1: Paste Domains (existing) -->
<div id="panel-paste" class="p-6">
<form method="POST" action="/domains/bulk-add" class="space-y-5"> <form method="POST" action="/domains/bulk-add" class="space-y-5">
<?= csrf_field() ?> <?= csrf_field() ?>
<!-- Domains Textarea --> <!-- Domains Textarea -->
@@ -44,10 +48,8 @@ ob_start();
<span class="text-gray-400 font-normal">(Optional)</span> <span class="text-gray-400 font-normal">(Optional)</span>
</label> </label>
<!-- Tag Display Area -->
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div> <div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
<!-- Tag Input -->
<div class="relative"> <div class="relative">
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i> <i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
<input type="text" <input type="text"
@@ -60,17 +62,15 @@ ob_start();
</button> </button>
</div> </div>
<!-- Hidden input to store tags for form submission -->
<input type="hidden" id="tags" name="tags" value=""> <input type="hidden" id="tags" name="tags" value="">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below. All imported domains will be tagged with these tags.
</p> </p>
<!-- Available Tags -->
<div class="mt-2"> <div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p> <p class="text-xs text-gray-600 mb-1.5">Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<?php foreach ($availableTags as $tag): ?> <?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')" <button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
@@ -116,11 +116,88 @@ ob_start();
</div> </div>
</form> </form>
</div> </div>
</div>
<!-- Tab 2: Import from File -->
<div id="panel-import" class="hidden p-6">
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
<?= csrf_field() ?>
<!-- Drag & Drop Zone -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Select File *
</label>
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
<input type="file" name="import_file" accept=".csv,.json" required id="domainFileInput"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div id="domainDropzoneContent">
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i>
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
<p class="text-xs text-gray-400 my-1.5">or</p>
<span class="inline-flex items-center px-4 py-2 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-3 text-xs text-gray-400">CSV, JSON &middot; Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
</div>
<div id="domainDropzoneFile" 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" id="domainFileName"></p>
<p class="text-xs text-gray-400" id="domainFileSize"></p>
<button type="button" id="domainFileRemove" 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>
<!-- Expected Format Info -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p class="text-sm font-medium text-gray-900 mb-2"><i class="fas fa-info-circle text-blue-500 mr-1.5"></i>Expected File Format</p>
<p class="text-xs text-gray-600 mb-2">CSV columns or JSON fields:</p>
<div class="flex flex-wrap gap-1.5">
<code class="px-2 py-0.5 bg-white rounded text-xs border border-blue-200 font-semibold text-blue-800">domain_name *</code>
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">tags</code>
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notes</code>
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notification_group</code>
</div>
<p class="text-xs text-gray-500 mt-2">Only <code class="bg-white px-1 rounded">domain_name</code> is required. Tags should be comma-separated. Notification group is matched by name.</p>
</div>
<!-- Fallback Notification Group -->
<div>
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
Default Notification Group
<span class="text-gray-400 font-normal">(for domains without a group in the file)</span>
</label>
<select id="import_notification_group_id"
name="notification_group_id"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
<option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 pt-3">
<button type="submit" id="domainImportBtn"
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-file-import mr-2"></i>
Import Domains
</button>
<a href="/domains"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</div>
</form>
</div>
</div><!-- end card -->
<!-- Info Cards --> <!-- Info Cards -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- How it works -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -131,14 +208,13 @@ ob_start();
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3> <h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
<p class="text-xs text-gray-600 leading-relaxed"> <p class="text-xs text-gray-600 leading-relaxed">
Paste multiple domain names, one per line. The system will fetch WHOIS information Paste domain names or upload a CSV/JSON file. The system will fetch WHOIS information
for each domain automatically. This may take a few moments depending on how many domains you're adding. for each domain automatically. This may take a few moments depending on how many domains you're adding.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Important notes -->
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4"> <div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -169,6 +245,23 @@ ob_start();
</div> </div>
<script> <script>
// Tab switching
function switchTab(tab) {
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
const pasteTab = document.getElementById('tab-paste');
const importTab = document.getElementById('tab-import');
[pasteTab, importTab].forEach(btn => {
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'border-transparent', 'text-gray-500');
});
const active = tab === 'paste' ? pasteTab : importTab;
const inactive = tab === 'paste' ? importTab : pasteTab;
active.classList.add('border-primary', 'text-primary', 'bg-white');
inactive.classList.add('border-transparent', 'text-gray-500');
}
let tags = []; let tags = [];
// Available tags with their colors from the database // Available tags with their colors from the database
@@ -254,6 +347,84 @@ function addTagFromInput() {
// Initialize display // Initialize display
updateTagsDisplay(); updateTagsDisplay();
// --- Domain Import drag-and-drop & loading ---
(function() {
const dropzone = document.getElementById('domainDropzone');
const fileInput = document.getElementById('domainFileInput');
const content = document.getElementById('domainDropzoneContent');
const fileInfo = document.getElementById('domainDropzoneFile');
const fileName = document.getElementById('domainFileName');
const fileSize = document.getElementById('domainFileSize');
const removeBtn = document.getElementById('domainFileRemove');
const form = document.getElementById('domainImportForm');
const submitBtn = document.getElementById('domainImportBtn');
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-2"></i>Importing & Fetching WHOIS...';
});
})();
</script> </script>
<?php <?php

View File

@@ -29,19 +29,25 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="mb-4 flex gap-2 justify-end"> <div class="mb-4 flex gap-2 justify-end">
<?php if (!empty($domains)): ?> <!-- Export Dropdown -->
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form"> <div class="relative" id="domainExportDropdownWrapper">
<?= csrf_field() ?> <button onclick="document.getElementById('domainExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<?php foreach ($domains as $domain): ?> <i class="fas fa-download mr-2"></i>
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>"> Export
<?php endforeach; ?> <i class="fas fa-chevron-down ml-2 text-xs"></i>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium" title="Refresh all domains on this page">
<i class="fas fa-sync-alt mr-2"></i>
Refresh Page (<?= count($domains) ?>)
</button> </button>
</form> <div id="domainExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
<?php endif; ?> <a href="/domains/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium"> <i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV
</a>
<a href="/domains/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON
</a>
</div>
</div>
<a href="/domains/bulk-add" 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-layer-group mr-2"></i> <i class="fas fa-layer-group mr-2"></i>
Bulk Add Bulk Add
</a> </a>
@@ -124,126 +130,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
</form> </form>
</div> </div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) --> <!-- Pagination Info & Per Page Selector -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-2"></i>
Refresh Selected
</button>
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
<i class="fas fa-exchange-alt mr-2"></i>
Transfer Selected
</button>
<?php endif; ?>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors font-medium">
<i class="fas fa-tags mr-2"></i>
Manage Tags
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div class="p-3">
<div class="flex items-center justify-between mb-3">
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
<i class="fas fa-cog mr-1"></i>
Manage Tags
</a>
</div>
<!-- Add Tags Section -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
<div class="flex flex-wrap gap-1.5 mb-3">
<?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
<?= htmlspecialchars($tag['name']) ?>
</button>
<?php endforeach; ?>
</div>
<div class="border-t border-gray-200 pt-2">
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
<i class="fas fa-plus mr-1"></i>
Add Custom Tag
</button>
</div>
</div>
<!-- Remove Tags Section -->
<div class="border-t border-gray-200 pt-3">
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
<div class="space-y-2">
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
<i class="fas fa-times mr-1"></i>
Remove All Tags
</button>
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
<i class="fas fa-minus mr-1"></i>
Remove Specific Tag
</button>
</div>
</div>
</div>
<div class="border-t border-gray-200 p-2">
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
Close
</button>
</div>
</div>
</div>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignGroupDropdown()" 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-bell mr-2"></i>
Assign Group
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
<?= csrf_field() ?>
<div class="p-3">
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">-- No Group --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="border-t border-gray-200 p-2 flex gap-2">
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Assign
</button>
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
Cancel
</button>
</div>
</form>
</div>
</div>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center"> <div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
@@ -271,6 +158,110 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<!-- Domains List --> <!-- Domains List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Bulk Actions Bar (shown when domains are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
<i class="fas fa-sync-alt mr-1"></i> Refresh Selected
</button>
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button>
<?php endif; ?>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
<i class="fas fa-tags mr-1"></i> Manage Tags
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div class="p-3">
<div class="flex items-center justify-between mb-3">
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
<i class="fas fa-cog mr-1"></i>
Manage Tags
</a>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
<div class="flex flex-wrap gap-1.5 mb-3">
<?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
<?= htmlspecialchars($tag['name']) ?>
</button>
<?php endforeach; ?>
</div>
<div class="border-t border-gray-200 pt-2">
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
<i class="fas fa-plus mr-1"></i>
Add Custom Tag
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-3">
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
<div class="space-y-2">
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
<i class="fas fa-times mr-1"></i>
Remove All Tags
</button>
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
<i class="fas fa-minus mr-1"></i>
Remove Specific Tag
</button>
</div>
</div>
</div>
<div class="border-t border-gray-200 p-2">
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
Close
</button>
</div>
</div>
</div>
<div class="relative inline-block">
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
<i class="fas fa-bell mr-1"></i> Assign Group
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
<?= csrf_field() ?>
<div class="p-3">
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">-- No Group --</option>
<?php foreach ($groups as $group): ?>
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="border-t border-gray-200 p-2 flex gap-2">
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Assign
</button>
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
Cancel
</button>
</div>
</form>
</div>
</div>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php if (!empty($domains)): ?> <?php if (!empty($domains)): ?>
<!-- Table View (Desktop) --> <!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto"> <div class="hidden lg:block overflow-x-auto">
@@ -575,11 +566,9 @@ function updateBulkActions() {
if (count > 0) { if (count > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex'); selectedCount.textContent = count + ' domain(s) selected';
selectedCount.textContent = `${count} domain(s) selected`;
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state
@@ -793,7 +782,7 @@ function bulkTransfer() {
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400"> <button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400">
Cancel Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
Transfer Domains Transfer Domains
</button> </button>
</div> </div>
@@ -1071,6 +1060,14 @@ function submitTagRemoval() {
// Tags are loaded server-side, no need for DOMContentLoaded // Tags are loaded server-side, no need for DOMContentLoaded
// Close export dropdown when clicking outside
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('domainExportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('domainExportMenu').classList.add('hidden');
}
});
</script> </script>
<?php <?php

View File

@@ -121,25 +121,6 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
</form> </form>
</div> </div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when errors are selected) -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Pagination Info --> <!-- Pagination Info -->
<div class="mb-4 flex justify-between items-center"> <div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
@@ -167,6 +148,22 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
<!-- Errors List --> <!-- Errors List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Bulk Actions Bar (shown when errors are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php if (!empty($errors)): ?> <?php if (!empty($errors)): ?>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
@@ -543,11 +540,9 @@ function updateBulkActions() {
if (checkboxes.length > 0) { if (checkboxes.length > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex');
selectedCount.textContent = checkboxes.length + ' error(s) selected'; selectedCount.textContent = checkboxes.length + ' error(s) selected';
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state

View File

@@ -7,8 +7,31 @@ ob_start();
?> ?>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="mb-4 flex justify-end"> <div class="mb-4 flex gap-2 justify-end">
<a href="/groups/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium"> <!-- Export Dropdown -->
<div class="relative" id="groupExportDropdownWrapper">
<button onclick="document.getElementById('groupExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<i class="fas fa-download mr-2"></i>
Export
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="groupExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
<a href="/groups/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV
</a>
<a href="/groups/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON
</a>
</div>
</div>
<!-- Import Button -->
<button onclick="document.getElementById('groupImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-upload mr-2"></i>
Import
</button>
<a href="/groups/create" 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-plus mr-2"></i> <i class="fas fa-plus mr-2"></i>
Create New Group Create New Group
</a> </a>
@@ -31,34 +54,29 @@ ob_start();
</div> </div>
</div> </div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when groups are selected) -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-exchange-alt mr-2"></i>
Transfer Selected
</button>
<?php endif; ?>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Groups List --> <!-- Groups List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Bulk Actions Bar (shown when groups are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button>
<?php endif; ?>
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php if (!empty($groups)): ?> <?php if (!empty($groups)): ?>
<!-- Table View (Desktop) --> <!-- Table View (Desktop) -->
<div class="hidden md:block overflow-x-auto"> <div class="hidden md:block overflow-x-auto">
@@ -208,11 +226,9 @@ function updateBulkActions() {
if (checkboxes.length > 0) { if (checkboxes.length > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex');
selectedCount.textContent = checkboxes.length + ' group(s) selected'; selectedCount.textContent = checkboxes.length + ' group(s) selected';
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state
@@ -283,30 +299,29 @@ function transferGroup(groupId, groupName) {
).join(''); ).join('');
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold mb-4">Transfer Group</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Group</h3>
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user:</p> <p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user.</p>
<form method="POST" action="/groups/transfer"> <form method="POST" action="/groups/transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
<input type="hidden" name="group_id" value="${groupId}"> <input type="hidden" name="group_id" value="${groupId}">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label> <label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg"> <select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">Select User</option> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end gap-3">
<button type="button" onclick="this.closest('.fixed').remove()" <button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
Cancel Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
Transfer Transfer
</button> </button>
</div> </div>
@@ -319,8 +334,8 @@ function transferGroup(groupId, groupName) {
// Bulk transfer groups // Bulk transfer groups
function bulkTransfer() { function bulkTransfer() {
const selectedCheckboxes = document.querySelectorAll('input[name="group_ids[]"]:checked'); const groupIds = getSelectedGroupIds();
if (selectedCheckboxes.length === 0) { if (groupIds.length === 0) {
alert('Please select groups to transfer'); alert('Please select groups to transfer');
return; return;
} }
@@ -337,32 +352,31 @@ function bulkTransfer() {
).join(''); ).join('');
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold mb-4">Transfer Groups</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Groups</h3>
<p class="text-sm text-gray-600 mb-4">Transfer ${selectedCheckboxes.length} selected group(s) to another user:</p> <p class="text-sm text-gray-600 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
<form method="POST" action="/groups/bulk-transfer"> <form method="POST" action="/groups/bulk-transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
${Array.from(selectedCheckboxes).map(cb => ${groupIds.map(id =>
`<input type="hidden" name="group_ids[]" value="${cb.value}">` `<input type="hidden" name="group_ids[]" value="${id}">`
).join('')} ).join('')}
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label> <label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg"> <select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">Select User</option> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end gap-3">
<button type="button" onclick="this.closest('.fixed').remove()" <button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
Cancel Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
Transfer All Transfer All
</button> </button>
</div> </div>
@@ -372,6 +386,159 @@ function bulkTransfer() {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Close export dropdown when clicking outside
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('groupExportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('groupExportMenu').classList.add('hidden');
}
});
// Close import modal on backdrop click
document.getElementById('groupImportModal')?.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
}
});
</script>
<!-- Import Modal -->
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
</h3>
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
<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 mb-1.5">Select File</label>
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
<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 mb-2"></i>
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
<p class="text-xs text-gray-400 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">CSV, JSON &middot; Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></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" id="groupFileName"></p>
<p class="text-xs text-gray-400" 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 border border-blue-200 rounded-lg p-3">
<p class="text-xs text-gray-700 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">CSV: <code class="bg-white px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
<p class="text-xs text-gray-600 mt-0.5">JSON: array of group objects with nested channels array</p>
<p class="text-xs text-gray-500 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 bg-gray-50 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 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 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>
// --- Group Import drag-and-drop & loading ---
(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> </script>
<?php <?php

View File

@@ -12,9 +12,12 @@ if ($userId) {
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId); $notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
$recentNotifications = $notificationData['items']; $recentNotifications = $notificationData['items'];
$unreadNotifications = $notificationData['unread_count']; $unreadNotifications = $notificationData['unread_count'];
// Update badge in top menu (admin only, uses cached update check data)
$updateBadge = \Core\Auth::isAdmin() ? \App\Helpers\LayoutHelper::getUpdateBadgeInfo() : ['show' => false, 'available' => false, 'label' => ''];
} else { } else {
$recentNotifications = []; $recentNotifications = [];
$unreadNotifications = 0; $unreadNotifications = 0;
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
} }
// Get domain stats for sidebar (available on all pages) // Get domain stats for sidebar (available on all pages)

View File

@@ -50,6 +50,14 @@
<!-- Right: Actions & User --> <!-- Right: Actions & User -->
<div class="flex items-center space-x-1 sm:space-x-2"> <div class="flex items-center space-x-1 sm:space-x-2">
<!-- Update available badge (admin only, when enabled in settings) -->
<?php if (!empty($updateBadge['show'])): ?>
<a href="/settings#updates" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition-colors text-xs font-semibold whitespace-nowrap" title="An update is available">
<i class="fas fa-cloud-download-alt"></i>
<span>Update<?= !empty($updateBadge['label']) ? ' ' . htmlspecialchars($updateBadge['label']) : '' ?></span>
</a>
<?php endif; ?>
<!-- Quick Actions Dropdown --> <!-- Quick Actions Dropdown -->
<div class="relative"> <div class="relative">
<button onclick="toggleQuickActions()" title="Quick Actions" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150"> <button onclick="toggleQuickActions()" title="Quick Actions" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
@@ -115,11 +123,15 @@
<?php if (!empty($recentNotifications)): ?> <?php if (!empty($recentNotifications)): ?>
<?php foreach ($recentNotifications as $notif): ?> <?php foreach ($recentNotifications as $notif): ?>
<?php <?php
// Build the click URL: if domain notification, go to domain; otherwise just mark as read // Build the click URL: update_available → settings#updates; domain → domain page; else mark as read only
$hasDomain = !empty($notif['domain_id']); $hasDomain = !empty($notif['domain_id']);
$notifUrl = $hasDomain if ($notif['type'] === 'update_available') {
? '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id'] $notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=settings';
: '/notifications/' . $notif['id'] . '/mark-read'; } elseif ($hasDomain) {
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id'];
} else {
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read';
}
?> ?>
<div class="px-4 py-3 hover:bg-gray-50 border-b border-gray-100 bg-blue-50 transition-colors notification-item" data-id="<?= $notif['id'] ?>"> <div class="px-4 py-3 hover:bg-gray-50 border-b border-gray-100 bg-blue-50 transition-colors notification-item" data-id="<?= $notif['id'] ?>">
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">

View File

@@ -68,6 +68,7 @@ $offset = $pagination['showing_from'] - 1;
<option value="session_failed" <?= $filterType === 'session_failed' ? 'selected' : '' ?>>Failed Login</option> <option value="session_failed" <?= $filterType === 'session_failed' ? 'selected' : '' ?>>Failed Login</option>
<option value="system_welcome" <?= $filterType === 'system_welcome' ? 'selected' : '' ?>>Welcome</option> <option value="system_welcome" <?= $filterType === 'system_welcome' ? 'selected' : '' ?>>Welcome</option>
<option value="system_upgrade" <?= $filterType === 'system_upgrade' ? 'selected' : '' ?>>System Upgrade</option> <option value="system_upgrade" <?= $filterType === 'system_upgrade' ? 'selected' : '' ?>>System Upgrade</option>
<option value="update_available" <?= $filterType === 'update_available' ? 'selected' : '' ?>>Update Available</option>
</optgroup> </optgroup>
</select> </select>
</div> </div>
@@ -137,7 +138,9 @@ $offset = $pagination['showing_from'] - 1;
$hasDomain = !empty($notification['domain_id']); $hasDomain = !empty($notification['domain_id']);
$domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null; $domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null;
$clickUrl = null; $clickUrl = null;
if ($hasDomain && !$notification['is_read']) { if ($notification['type'] === 'update_available') {
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=settings';
} elseif ($hasDomain && !$notification['is_read']) {
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=domain&domain_id=' . $notification['domain_id']; $clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=domain&domain_id=' . $notification['domain_id'];
} elseif ($hasDomain) { } elseif ($hasDomain) {
$clickUrl = $domainUrl; $clickUrl = $domainUrl;

View File

@@ -30,6 +30,37 @@ foreach ($notificationPresets as $key => $preset) {
break; break;
} }
} }
// Cached update state for Updates tab (tab badge + modal on load)
$cachedUpdateAvailable = false;
$cachedUpdateData = null;
$currentVer = $appSettings['app_version'] ?? '0';
$latestVer = $updateSettings['latest_available_version'] ?? null;
$updateChannel = $updateSettings['update_channel'] ?? 'stable';
$commitsBehind = (int)($updateSettings['commits_behind_count'] ?? 0);
if ($latestVer && version_compare($latestVer, $currentVer, '>')) {
$cachedUpdateAvailable = true;
$cachedUpdateData = [
'available' => true,
'type' => 'release',
'current_version' => $currentVer,
'latest_version' => $latestVer,
'release_notes' => $updateSettings['latest_release_notes'] ?? '',
'release_url' => $updateSettings['latest_release_url'] ?? '',
'published_at' => $updateSettings['latest_release_published_at'] ?? null,
'channel' => $updateChannel,
];
} elseif ($updateChannel === 'latest' && $commitsBehind > 0) {
$cachedUpdateAvailable = true;
$cachedUpdateData = [
'available' => true,
'type' => 'hotfix',
'current_version' => $currentVer,
'commits_behind' => $commitsBehind,
'commit_messages' => [],
'channel' => $updateChannel,
];
}
?> ?>
<!-- Tabs Navigation --> <!-- Tabs Navigation -->
@@ -64,6 +95,15 @@ foreach ($notificationPresets as $key => $preset) {
<i class="fas fa-tools mr-2"></i> <i class="fas fa-tools mr-2"></i>
Maintenance Maintenance
</button> </button>
<button onclick="switchTab('updates')" id="tab-updates" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap relative">
<i class="fas fa-cloud-download-alt mr-2"></i>
Updates
<?php if (!empty($cachedUpdateAvailable)): ?>
<span id="update-badge" class="ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full" title="Update available"></span>
<?php else: ?>
<span id="update-badge" class="hidden ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full"></span>
<?php endif; ?>
</button>
</nav> </nav>
</div> </div>
</div> </div>
@@ -892,6 +932,243 @@ foreach ($notificationPresets as $key => $preset) {
</div> </div>
</div> </div>
<!-- Tab Content: Updates -->
<div id="content-updates" class="tab-content hidden">
<!-- Update Status card: current version + Check button; show "Update available" card when cached -->
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
<div class="px-6 py-5 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<i class="fas fa-sync-alt text-primary text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">Update Status</h3>
<p class="text-sm text-gray-500 mt-0.5">Current version <code class="text-primary font-medium">v<?= htmlspecialchars($appSettings['app_version']) ?></code></p>
</div>
</div>
<div class="flex items-center gap-3">
<?php if ($updateSettings['last_update_check']): ?>
<span class="text-xs text-gray-400 hidden sm:inline">Last checked: <?= date('M d, H:i', strtotime($updateSettings['last_update_check'])) ?></span>
<?php endif; ?>
<button type="button" id="checkUpdatesBtn" onclick="checkForUpdates()"
class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-dark transition-colors shadow-sm">
<i class="fas fa-sync-alt mr-2" id="checkUpdatesIcon"></i>
Check for Updates
</button>
</div>
</div>
</div>
<div class="p-6">
<!-- Cached update available: show card that opens modal (so landing from top bar shows update without clicking Check) -->
<?php if ($cachedUpdateAvailable && $cachedUpdateData): ?>
<div id="cachedUpdateCard" class="mb-6 p-4 rounded-xl border-2 border-amber-200 bg-amber-50/80 hover:bg-amber-50 transition-colors cursor-pointer" onclick="openUpdateModal(window.__cachedUpdateData)">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
<i class="fas fa-cloud-download-alt text-amber-600"></i>
</div>
<div>
<p class="text-sm font-semibold text-amber-900">Update available</p>
<p class="text-xs text-amber-700">
<?php if (($cachedUpdateData['type'] ?? '') === 'release'): ?>
New release: v<?= htmlspecialchars($cachedUpdateData['latest_version'] ?? '') ?> — click to view details and apply
<?php else: ?>
<?= (int)($cachedUpdateData['commits_behind'] ?? 0) ?> new commit(s) — click to apply hotfix
<?php endif; ?>
</p>
</div>
</div>
<i class="fas fa-chevron-right text-amber-600 text-sm"></i>
</div>
</div>
<?php elseif (!empty($updateSettings['last_update_check'])): ?>
<!-- Last check found no update: show "up to date" from cache -->
<div id="cachedUpToDate">
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
<div>
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
<p class="text-xs text-green-600 mt-0.5">Version v<?= htmlspecialchars($appSettings['app_version']) ?> is the latest<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? ' stable release' : ' version' ?>.</p>
</div>
</div>
</div>
<?php endif; ?>
<!-- Inline result: loading / up-to-date / error (update-available goes in modal) -->
<div id="updateResultContainer" class="hidden"></div>
</div>
</div>
<!-- Update preferences (channel + badge) -->
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">Update Preferences</h3>
<p class="text-sm text-gray-600 mt-1">Choose update channel and display options</p>
</div>
<form method="POST" action="/settings/updates/preferences" class="p-6">
<?= csrf_field() ?>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Update Channel</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Stable Channel -->
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
<input type="radio" name="update_channel" value="stable"
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
<div class="ml-3">
<span class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-shield-alt text-green-600 mr-2"></i>
Stable
</span>
<p class="text-xs text-gray-600 mt-1">Only receive tagged release updates (e.g., v1.2.0). Recommended for production environments.</p>
</div>
</label>
<!-- Latest Channel -->
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
<input type="radio" name="update_channel" value="latest"
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
<div class="ml-3">
<span class="text-sm font-semibold text-gray-900 flex items-center">
<i class="fas fa-bolt text-amber-500 mr-2"></i>
Latest
</span>
<p class="text-xs text-gray-600 mt-1">Receive both releases and hotfix commits pushed to the main branch. Get fixes faster.</p>
</div>
</label>
</div>
</div>
<?php if (($updateSettings['update_channel'] ?? 'stable') === 'latest' && empty($updateSettings['installed_commit_sha'])): ?>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-sm text-amber-800">
<i class="fas fa-info-circle mr-1"></i>
Commit tracking is not yet active. It will begin after the first update is applied through this system. Until then, only release updates will be detected.
</p>
</div>
<?php endif; ?>
<!-- Show update badge in top menu -->
<div class="border-t border-gray-200 pt-4 mt-4">
<label class="flex items-start cursor-pointer">
<input type="checkbox" name="update_badge_enabled" value="1"
<?= ($updateSettings['update_badge_enabled'] ?? '1') !== '0' ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary mt-0.5">
<span class="ml-3 text-sm text-gray-700">
Show <strong>Update available</strong> badge in the top menu when an update is available (recommended so admins see it without opening the notification panel).
</span>
</label>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-save mr-2"></i>
Save update preferences
</button>
</div>
</form>
</div>
<!-- Rollback Section -->
<?php if (!empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path'])): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">Rollback</h3>
<p class="text-sm text-gray-600 mt-1">Revert to the previous version if something went wrong</p>
</div>
<div class="p-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<div class="flex">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-3"></i>
<div>
<p class="text-sm font-medium text-gray-900">Warning</p>
<p class="text-sm text-gray-700 mt-1">
Rolling back will restore application files and database to the state before the last update.
If the database restore fails automatically, you can import the SQL backup manually from the <code class="text-xs bg-gray-100 px-1 rounded">backups/</code> directory.
</p>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<p class="text-sm text-gray-600">
Backup available: <code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded"><?= htmlspecialchars(basename($updateSettings['update_backup_path'])) ?></code>
</p>
</div>
<form method="POST" action="/settings/updates/rollback" class="mt-4"
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
<?= csrf_field() ?>
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-undo mr-2"></i>
Rollback to Previous Version
</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- Update Available Modal: same content as before (blue/amber card inside), just in a popup -->
<div id="updateAvailableModal" class="fixed inset-0 z-50 hidden" aria-modal="true">
<div class="fixed inset-0 bg-black/50 transition-opacity" onclick="closeUpdateModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
<div id="updateAvailableModalContent" class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col pointer-events-auto overflow-hidden">
<div class="flex items-start justify-between px-4 py-3 flex-shrink-0 gap-3 bg-blue-50 border border-blue-200 rounded-t-xl">
<div class="flex items-start min-w-0">
<i id="updateModalIcon" class="fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3"></i>
<div>
<p id="updateModalTitle" class="text-sm font-semibold text-blue-800">New Release Available</p>
<p id="updateModalSubline" class="text-xs text-blue-600 mt-0.5"></p>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<a id="updateModalReleaseLink" href="#" target="_blank" rel="noopener" class="text-xs text-blue-600 hover:underline whitespace-nowrap"><i class="fab fa-github mr-1"></i>Release notes</a>
<button type="button" onclick="closeUpdateModal()" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" aria-label="Close">
<i class="fas fa-times text-lg"></i>
</button>
</div>
</div>
<div id="updateModalBody" class="update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0">
<!-- Filled by JS: changelog or commit list only -->
</div>
<div id="updateModalFooter" class="px-4 py-3 flex-shrink-0 border-t rounded-b-xl">
<!-- Filled by JS: Apply form (blue for release, amber for hotfix) -->
</div>
</div>
</div>
</div>
</div>
<script>
window.__cachedUpdateData = <?= $cachedUpdateData ? json_encode($cachedUpdateData) : 'null' ?>;
</script>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
<style>
.changelog-markdown { line-height: 1.5; }
.changelog-markdown h1, .changelog-markdown h2, .changelog-markdown h3 { font-weight: 600; margin-top: 0.5em; margin-bottom: 0.25em; }
.changelog-markdown h1 { font-size: 1em; }
.changelog-markdown h2 { font-size: 0.95em; }
.changelog-markdown h3 { font-size: 0.9em; }
.changelog-markdown p { margin-bottom: 0.5em; }
.changelog-markdown ul, .changelog-markdown ol { margin: 0.25em 0 0.5em 1em; padding-left: 1em; }
.changelog-markdown li { margin-bottom: 0.15em; }
.changelog-markdown code { background: rgba(0,0,0,0.06); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.95em; }
.changelog-markdown pre { background: rgba(0,0,0,0.06); padding: 0.5em; border-radius: 4px; overflow-x: auto; margin: 0.5em 0; font-size: 0.9em; }
.changelog-markdown pre code { background: none; padding: 0; }
.changelog-markdown a { color: #2563eb; text-decoration: underline; }
.changelog-markdown a:hover { color: #1d4ed8; }
.changelog-markdown hr { border: none; border-top: 1px solid rgba(0,0,0,0.1); margin: 0.5em 0; }
.changelog-markdown blockquote { border-left: 3px solid rgba(0,0,0,0.15); margin: 0.5em 0; padding-left: 0.75em; color: inherit; opacity: 0.95; }
.update-modal-body .changelog-markdown { max-height: 20rem; }
</style>
<script> <script>
// Auto-update encryption based on port // Auto-update encryption based on port
function updateEncryptionByPort() { function updateEncryptionByPort() {
@@ -957,7 +1234,7 @@ function switchTab(tabName) {
// Load tab from URL hash on page load // Load tab from URL hash on page load
window.addEventListener('DOMContentLoaded', function() { window.addEventListener('DOMContentLoaded', function() {
const hash = window.location.hash.substring(1); // Remove the # const hash = window.location.hash.substring(1); // Remove the #
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance']; const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance', 'updates'];
if (hash && validTabs.includes(hash)) { if (hash && validTabs.includes(hash)) {
switchTab(hash); switchTab(hash);
@@ -1021,6 +1298,208 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// Update check AJAX functionality
function checkForUpdates() {
const btn = document.getElementById('checkUpdatesBtn');
const icon = document.getElementById('checkUpdatesIcon');
const container = document.getElementById('updateResultContainer');
const badge = document.getElementById('update-badge');
// Hide any cached status cards
var cachedCard = document.getElementById('cachedUpdateCard');
var cachedUpToDate = document.getElementById('cachedUpToDate');
if (cachedCard) cachedCard.classList.add('hidden');
if (cachedUpToDate) cachedUpToDate.classList.add('hidden');
// Show loading state
btn.disabled = true;
btn.classList.add('opacity-75');
icon.classList.add('fa-spin');
container.classList.remove('hidden');
container.innerHTML = '<div class="flex items-center p-4 bg-gray-50 rounded-lg border border-gray-200"><i class="fas fa-spinner fa-spin text-primary mr-3"></i><span class="text-sm text-gray-600">Checking for updates...</span></div>';
// Get CSRF token
const csrfInput = document.querySelector('input[name="csrf_token"]');
const csrfToken = csrfInput ? csrfInput.value : '';
fetch('/api/updates/check', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'force=1&csrf_token=' + encodeURIComponent(csrfToken)
})
.then(response => response.json())
.then(data => {
btn.disabled = false;
btn.classList.remove('opacity-75');
icon.classList.remove('fa-spin');
if (data.error) {
container.innerHTML = renderUpdateError(data.error);
return;
}
if (data.available) {
badge.classList.remove('hidden');
container.classList.add('hidden');
container.innerHTML = '';
openUpdateModal(data);
} else {
badge.classList.add('hidden');
container.innerHTML = renderUpToDate(data);
}
})
.catch(error => {
btn.disabled = false;
btn.classList.remove('opacity-75');
icon.classList.remove('fa-spin');
container.innerHTML = renderUpdateError('Network error: ' + error.message);
});
}
window.checkForUpdates = checkForUpdates;
function openUpdateModal(data) {
const modal = document.getElementById('updateAvailableModal');
const bodyEl = document.getElementById('updateModalBody');
const footerEl = document.getElementById('updateModalFooter');
const titleEl = document.getElementById('updateModalTitle');
const sublineEl = document.getElementById('updateModalSubline');
const releaseLinkEl = document.getElementById('updateModalReleaseLink');
const iconEl = document.getElementById('updateModalIcon');
if (!modal || !bodyEl) return;
var isRelease = (data.type || 'release') === 'release';
if (titleEl) titleEl.textContent = isRelease ? 'New Release Available: v' + (data.latest_version || '') : 'Hotfix Available: ' + (data.commits_behind || 0) + ' commit(s) behind';
if (sublineEl) {
if (isRelease) {
var sub = 'Installed: v' + (data.current_version || '');
if (data.published_at) { var d = new Date(data.published_at); sub += ' · Released: ' + String(d.getDate()).padStart(2,'0') + '/' + String(d.getMonth()+1).padStart(2,'0') + '/' + d.getFullYear(); }
sublineEl.textContent = sub;
sublineEl.classList.remove('hidden');
} else {
sublineEl.textContent = 'New commits have been pushed to the main branch.';
sublineEl.classList.remove('hidden');
}
}
if (releaseLinkEl) {
if (isRelease && data.release_url) {
releaseLinkEl.href = data.release_url;
releaseLinkEl.classList.remove('hidden');
} else {
releaseLinkEl.href = '#';
releaseLinkEl.classList.add('hidden');
}
}
if (iconEl) iconEl.className = isRelease ? 'fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3' : 'fas fa-wrench text-amber-500 text-lg mt-0.5 mr-3';
bodyEl.className = 'update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0 ' + (isRelease ? 'bg-blue-50 border-x border-b border-blue-200' : 'bg-amber-50 border-x border-b border-amber-200');
bodyEl.innerHTML = renderUpdateAvailable(data);
var csrf = document.querySelector('input[name=csrf_token]') ? document.querySelector('input[name=csrf_token]').value : '';
if (footerEl) {
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 border-blue-200' : 'bg-amber-100 border-amber-200');
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.\')">' +
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
'<input type="hidden" name="update_type" value="release">' +
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
'</form>';
} else {
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
'<input type="hidden" name="update_type" value="hotfix">' +
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
'</form>';
}
}
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeUpdateModal() {
const modal = document.getElementById('updateAvailableModal');
if (modal) {
modal.classList.add('hidden');
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('updateAvailableModal');
if (modal && !modal.classList.contains('hidden')) closeUpdateModal();
}
});
window.openUpdateModal = openUpdateModal;
window.closeUpdateModal = closeUpdateModal;
// When landing on Settings#updates from an external link (e.g. top bar badge or notification),
// auto-open the modal. Skip if the user just submitted a form on this page (referrer is self).
if (window.location.hash === '#updates' && window.__cachedUpdateData) {
var ref = document.referrer || '';
var onSettingsPage = ref.indexOf('/settings') !== -1;
if (!onSettingsPage) {
setTimeout(function() { openUpdateModal(window.__cachedUpdateData); }, 200);
}
}
function renderReleaseNotesMarkdown(md) {
if (!md) return '';
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') return escapeHtml(md);
const raw = marked.parse(md, { gfm: true, breaks: true });
const allowedTags = ['p', 'br', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr'];
let out = DOMPurify.sanitize(raw, { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: ['href', 'target', 'rel'] });
out = out.replace(/<a href=/gi, '<a target="_blank" rel="noopener noreferrer" href=');
return out;
}
function renderUpToDate(data) {
return `
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
<div>
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
<p class="text-xs text-green-600 mt-0.5">Version ${escapeHtml(data.current_version)} is the latest${data.channel === 'stable' ? ' stable release' : ' version'}.</p>
</div>
</div>`;
}
function renderUpdateAvailable(data) {
var html = '';
if (data.type === 'release') {
html = data.release_notes
? '<p class="text-sm font-semibold text-blue-800 mb-2 -mt-1">Changelog:</p><hr class="border-blue-200 mb-3"><div class="changelog-markdown text-xs text-blue-700 max-h-40 overflow-y-auto">' + renderReleaseNotesMarkdown(data.release_notes) + '</div>'
: '<p class="text-gray-500 text-sm">No changelog available.</p>';
} else if (data.type === 'hotfix') {
if (data.commit_messages && data.commit_messages.length > 0) {
html = '<p class="text-xs font-semibold text-amber-800 mb-2">Recent commits:</p><div class="space-y-1 max-h-40 overflow-y-auto">';
data.commit_messages.forEach(function(c) {
var firstLine = (c.message || '').split('\n')[0];
html += '<div class="text-xs text-amber-700 flex items-start"><code class="text-amber-600 bg-amber-100 px-1 rounded mr-2 flex-shrink-0">' + escapeHtml((c.sha || '').substring(0, 7)) + '</code><span class="truncate">' + escapeHtml(firstLine) + '</span></div>';
});
html += '</div>';
} else {
html = '';
}
}
return html;
}
function renderUpdateError(message) {
return `
<div class="flex items-center p-4 bg-red-50 border border-red-200 rounded-lg">
<i class="fas fa-exclamation-circle text-red-500 text-lg mr-3"></i>
<div>
<p class="text-sm font-semibold text-red-800">Update check failed</p>
<p class="text-xs text-red-600 mt-0.5">${escapeHtml(message)}</p>
</div>
</div>`;
}
// Ensure escapeHtml is available (may also be defined in base layout)
if (typeof window.escapeHtml === 'undefined') {
window.escapeHtml = function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
}
// CAPTCHA provider selection logic // CAPTCHA provider selection logic
const captchaProvider = document.getElementById('captcha_provider'); const captchaProvider = document.getElementById('captcha_provider');
if (captchaProvider) { if (captchaProvider) {

View File

@@ -29,6 +29,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="mb-4 flex gap-2 justify-end"> <div class="mb-4 flex gap-2 justify-end">
<!-- Export Dropdown -->
<div class="relative" id="exportDropdownWrapper">
<button onclick="document.getElementById('exportDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<i class="fas fa-download mr-2"></i>
Export
<i class="fas fa-chevron-down ml-2 text-xs"></i>
</button>
<div id="exportDropdownMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
<a href="/tags/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV
</a>
<a href="/tags/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON
</a>
</div>
</div>
<!-- Import Button -->
<button onclick="document.getElementById('importModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-upload mr-2"></i>
Import
</button>
<button onclick="openCreateModal()" 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="openCreateModal()" 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-plus mr-2"></i> <i class="fas fa-plus mr-2"></i>
Create New Tag Create New Tag
@@ -105,27 +128,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</form> </form>
</div> </div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when tags are selected) -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<button type="button" onclick="bulkDeleteTags()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Tags List --> <!-- Tags List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Bulk Actions Bar (shown when tags are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" onclick="bulkTransferTags()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button>
<?php endif; ?>
<button type="button" onclick="bulkDeleteTags()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php if (!empty($tags)): ?> <?php if (!empty($tags)): ?>
<!-- Table View (Desktop) --> <!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto"> <div class="hidden lg:block overflow-x-auto">
@@ -189,6 +214,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
<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>
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>">
<i class="fas fa-exchange-alt"></i>
</button>
<?php endif; ?>
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?> <?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
<!-- Global tag - only admins can edit/delete --> <!-- Global tag - only admins can edit/delete -->
<span class="text-xs text-gray-500 italic">Global tag</span> <span class="text-xs text-gray-500 italic">Global tag</span>
@@ -197,7 +228,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
class="text-yellow-600 hover:text-yellow-800" title="Edit"> class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')" <button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
class="text-red-600 hover:text-red-800" title="Delete"> class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
@@ -242,6 +273,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
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>
<?php if (\Core\Auth::isAdmin()): ?>
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>">
<i class="fas fa-exchange-alt"></i>
</button>
<?php endif; ?>
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?> <?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
<!-- Global tag - only admins can edit/delete --> <!-- Global tag - only admins can edit/delete -->
<span class="text-xs text-gray-500 italic">Global tag</span> <span class="text-xs text-gray-500 italic">Global tag</span>
@@ -250,7 +287,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
class="text-yellow-600 hover:text-yellow-800" title="Edit"> class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')" <button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
class="text-red-600 hover:text-red-800" title="Delete"> class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
@@ -466,6 +503,64 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</div> </div>
</div> </div>
<!-- Import Modal -->
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">
<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 hover:text-gray-600">
<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 mb-1.5">Select File</label>
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
<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 mb-2"></i>
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
<p class="text-xs text-gray-400 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">CSV, JSON &middot; Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></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" id="tagFileName"></p>
<p class="text-xs text-gray-400" 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 border border-blue-200 rounded-lg p-3">
<p class="text-xs text-gray-700 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">CSV columns: <code class="bg-white px-1 rounded">name, color, description</code></p>
<p class="text-xs text-gray-600 mt-0.5">JSON: array of objects with same fields</p>
<p class="text-xs text-gray-500 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 bg-gray-50 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 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 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>
// Multi-select functionality // Multi-select functionality
function toggleSelectAll(checkbox) { function toggleSelectAll(checkbox) {
@@ -488,11 +583,9 @@ function updateBulkActions() {
if (count > 0) { if (count > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex'); selectedCount.textContent = count + ' tag(s) selected';
selectedCount.textContent = `${count} tag(s) selected`;
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state
@@ -552,6 +645,109 @@ function bulkDeleteTags() {
form.submit(); form.submit();
} }
function transferTag(tagId, tagName) {
const users = <?= json_encode($users ?? []) ?>;
if (users.length === 0) {
alert('No users available for transfer');
return;
}
const escapeHtml = (s) => String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const userOptions = users.map(user =>
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
).join('');
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tag</h3>
<p class="text-sm text-gray-600 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 mb-2">Transfer to User</label>
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<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 rounded-lg hover:bg-gray-50 text-sm font-medium">
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);
}
// Delegate click for table/card Transfer buttons (avoids onclick quote issues)
document.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-transfer-btn');
if (btn) {
e.preventDefault();
transferTag(parseInt(btn.dataset.tagId, 10), btn.dataset.tagName || '');
}
});
function bulkTransferTags() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select tags to transfer');
return;
}
const users = <?= json_encode($users ?? []) ?>;
if (users.length === 0) {
alert('No users available for transfer');
return;
}
const userOptions = users.map(user =>
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
).join('');
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tags</h3>
<p class="text-sm text-gray-600 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 mb-2">Transfer to User</label>
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<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 rounded-lg hover:bg-gray-50 text-sm font-medium">
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() {
document.getElementById('createModal').classList.remove('hidden'); document.getElementById('createModal').classList.remove('hidden');
document.getElementById('create_name').focus(); document.getElementById('create_name').focus();
@@ -618,6 +814,98 @@ document.getElementById('editModal').addEventListener('click', function(e) {
closeEditModal(); closeEditModal();
} }
}); });
document.getElementById('importModal').addEventListener('click', function(e) {
if (e.target === this) {
document.getElementById('importModal').classList.add('hidden');
}
});
// Close export dropdown when clicking outside
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('exportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('exportDropdownMenu').classList.add('hidden');
}
});
// --- Import drag-and-drop & loading ---
(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>
<?php <?php

View File

@@ -37,7 +37,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<form method="POST" action="/tld-registry/start-progressive-import" class="inline"> <form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="import_type" value="check_updates"> <input type="hidden" name="import_type" value="check_updates">
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>"> <button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary hover:bg-primary-dark' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
<i class="fas fa-sync-alt mr-2"></i> <i class="fas fa-sync-alt mr-2"></i>
Check Updates Check Updates
</button> </button>
@@ -45,7 +45,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<form method="POST" action="/tld-registry/start-progressive-import" class="inline"> <form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="import_type" value="complete_workflow"> <input type="hidden" name="import_type" value="complete_workflow">
<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" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs"> <button type="submit" 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" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
<i class="fas fa-rocket mr-2"></i> <i class="fas fa-rocket mr-2"></i>
Import TLDs Import TLDs
</button> </button>
@@ -194,32 +194,29 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
</form> </form>
</div> </div>
<!-- Bulk Actions Toolbar (Admin Only - Hidden by default, shown when TLDs are selected) -->
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
<?= csrf_field() ?>
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
</form>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<?php endif; ?>
<!-- TLD Registry Table --> <!-- TLD Registry Table -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<!-- Bulk Actions Bar (shown when TLDs are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
<?= csrf_field() ?>
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</form>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php endif; ?>
<?php if (!empty($tlds)): ?> <?php if (!empty($tlds)): ?>
<!-- Table View (Desktop) --> <!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto"> <div class="hidden lg:block overflow-x-auto">
@@ -550,11 +547,9 @@ function updateSelectedCount() {
if (count > 0) { if (count > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex'); selectedCount.textContent = count + ' TLD(s) selected';
selectedCount.textContent = `${count} TLD(s) selected`;
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state

View File

@@ -355,7 +355,7 @@ ob_start();
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" <button type="submit"
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors text-sm"> class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
<i class="fas fa-save mr-2"></i> <i class="fas fa-save mr-2"></i>
Save Changes Save Changes
</button> </button>

View File

@@ -95,35 +95,6 @@ $pagination = $pagination ?? [
</form> </form>
</div> </div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when users are selected) -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-user-check mr-2"></i>
Activate Selected
</button>
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700 transition-colors font-medium">
<i class="fas fa-user-slash mr-2"></i>
Deactivate Selected
</button>
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Pagination Info & Per Page Selector --> <!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center"> <div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
@@ -152,6 +123,28 @@ $pagination = $pagination ?? [
<!-- Users Table --> <!-- Users Table -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<!-- Bulk Actions Bar (shown when users are selected) -->
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2">
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
<i class="fas fa-user-check mr-1"></i> Activate Selected
</button>
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-1.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
<i class="fas fa-user-slash mr-1"></i> Deactivate Selected
</button>
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
<i class="fas fa-trash mr-1"></i> Delete Selected
</button>
</div>
</div>
</div>
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
<i class="fas fa-times mr-1.5"></i> Clear Selection
</button>
</div>
<?php if (!empty($users)): ?> <?php if (!empty($users)): ?>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
@@ -426,11 +419,9 @@ function updateBulkActions() {
if (checkboxes.length > 0) { if (checkboxes.length > 0) {
bulkActions.classList.remove('hidden'); bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex');
selectedCount.textContent = checkboxes.length + ' user(s) selected'; selectedCount.textContent = checkboxes.length + ' user(s) selected';
} else { } else {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
} }
// Update select all checkbox state // Update select all checkbox state

View File

@@ -22,6 +22,7 @@ use App\Models\Setting;
use App\Models\User; 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 Core\Database; use Core\Database;
// Load environment variables // Load environment variables
@@ -857,6 +858,42 @@ if ($stats['domains_with_notifications'] > 0) {
} }
} }
// Check for application updates (respects 6-hour cache) and notify admins if new version available
try {
$updateService = new UpdateService();
$updateResult = $updateService->checkForUpdate(false);
if (!empty($updateResult['error'])) {
logMessage("Update check skipped or failed: " . $updateResult['error']);
} elseif (!empty($updateResult['available'])) {
$currentVersion = $updateResult['current_version'];
$type = $updateResult['type'] ?? 'release';
$notifiedRelease = $settingModel->getValue('last_update_available_notified_release', '');
$notifiedHotfixSha = $settingModel->getValue('last_update_available_notified_hotfix_sha', '');
$shouldNotify = false;
if ($type === 'release') {
$latestVersion = $updateResult['latest_version'] ?? '';
if ($latestVersion !== '' && $latestVersion !== $notifiedRelease) {
$shouldNotify = true;
$settingModel->setValue('last_update_available_notified_release', $latestVersion);
}
} else {
$remoteSha = $updateResult['remote_sha'] ?? '';
if ($remoteSha !== '' && $remoteSha !== $notifiedHotfixSha) {
$shouldNotify = true;
$settingModel->setValue('last_update_available_notified_hotfix_sha', $remoteSha);
}
}
if ($shouldNotify) {
$label = ($type === 'release') ? ($updateResult['latest_version'] ?? 'latest') : 'hotfix';
$commitsBehind = $updateResult['commits_behind'] ?? null;
$notificationService->notifyAdminsUpdateAvailable($currentVersion, $label, $type, $commitsBehind);
logMessage("Update available (v{$currentVersion}{$label}): admins notified.");
}
}
} catch (\Throwable $e) {
logMessage("Update check error: " . $e->getMessage());
}
logMessage("==========================\n"); logMessage("==========================\n");
exit(0); exit(0);

View File

@@ -362,7 +362,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.2', 'string', 'Application version number'), ('app_version', '1.1.3', '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'),
@@ -395,7 +395,11 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
('two_factor_email_code_expiry_minutes', '10', 'string', 'Email code expiry time in minutes'), ('two_factor_email_code_expiry_minutes', '10', 'string', 'Email code expiry time in minutes'),
-- User isolation settings -- User isolation settings
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)') ('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'),
-- Update system settings
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')
ON DUPLICATE KEY UPDATE setting_key=setting_key; ON DUPLICATE KEY UPDATE setting_key=setting_key;

View File

@@ -0,0 +1,19 @@
-- Migration: Add application update system settings
-- Version: 1.1.3
-- This migration adds settings for the GitHub-based update system:
-- update_channel (stable/latest), installed_commit_sha for hotfix tracking
-- 1. Add update channel setting (stable = releases only, latest = releases + hotfixes)
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
VALUES ('update_channel', 'stable', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key = setting_key;
-- 2. Add update badge in menu setting (1 = show when update available, 0 = hide)
INSERT INTO settings (setting_key, setting_value, created_at, updated_at)
VALUES ('update_badge_enabled', '1', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key = setting_key;
-- 3. Update application version to 1.1.3
UPDATE settings
SET setting_value = '1.1.3'
WHERE setting_key = 'app_version';

View File

@@ -17,6 +17,7 @@ use App\Controllers\NotificationController;
use App\Controllers\ErrorLogController; use App\Controllers\ErrorLogController;
use App\Controllers\TwoFactorController; use App\Controllers\TwoFactorController;
use App\Controllers\TagController; use App\Controllers\TagController;
use App\Controllers\UpdateController;
$router = Application::$router; $router = Application::$router;
@@ -62,6 +63,8 @@ $router->get('/api/search/suggest', [SearchController::class, 'suggest']);
// Domains // Domains
$router->get('/domains', [DomainController::class, 'index']); $router->get('/domains', [DomainController::class, 'index']);
$router->get('/domains/export', [DomainController::class, 'export']);
$router->post('/domains/import', [DomainController::class, 'import']);
$router->get('/domains/create', [DomainController::class, 'create']); $router->get('/domains/create', [DomainController::class, 'create']);
$router->get('/domains/bulk-add', [DomainController::class, 'bulkAdd']); $router->get('/domains/bulk-add', [DomainController::class, 'bulkAdd']);
$router->post('/domains/bulk-add', [DomainController::class, 'bulkAdd']); $router->post('/domains/bulk-add', [DomainController::class, 'bulkAdd']);
@@ -86,6 +89,8 @@ $router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
// Notification Groups // Notification Groups
$router->get('/groups', [NotificationGroupController::class, 'index']); $router->get('/groups', [NotificationGroupController::class, 'index']);
$router->get('/groups/export', [NotificationGroupController::class, 'export']);
$router->post('/groups/import', [NotificationGroupController::class, 'import']);
$router->get('/groups/create', [NotificationGroupController::class, 'create']); $router->get('/groups/create', [NotificationGroupController::class, 'create']);
$router->post('/groups/store', [NotificationGroupController::class, 'store']); $router->post('/groups/store', [NotificationGroupController::class, 'store']);
$router->get('/groups/{id}/edit', [NotificationGroupController::class, 'edit']); $router->get('/groups/{id}/edit', [NotificationGroupController::class, 'edit']);
@@ -131,6 +136,14 @@ $router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']); $router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
$router->post('/settings/toggle-isolation', [SettingsController::class, 'toggleIsolationMode']); $router->post('/settings/toggle-isolation', [SettingsController::class, 'toggleIsolationMode']);
// Updates (Admin Only)
$router->post('/api/updates/check', [UpdateController::class, 'check']);
$router->post('/settings/updates/apply', [UpdateController::class, 'apply']);
$router->post('/settings/updates/rollback', [UpdateController::class, 'rollback']);
$router->post('/settings/updates/preferences', [UpdateController::class, 'savePreferences']);
$router->post('/settings/updates/channel', [UpdateController::class, 'updateChannel']);
$router->post('/settings/updates/badge', [UpdateController::class, 'updateBadgePreference']);
// Profile // Profile
$router->get('/profile', [ProfileController::class, 'index']); $router->get('/profile', [ProfileController::class, 'index']);
$router->post('/profile/update', [ProfileController::class, 'update']); $router->post('/profile/update', [ProfileController::class, 'update']);
@@ -182,10 +195,14 @@ $router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolv
// Tag Management // Tag Management
$router->get('/tags', [TagController::class, 'index']); $router->get('/tags', [TagController::class, 'index']);
$router->get('/tags/export', [TagController::class, 'export']);
$router->post('/tags/import', [TagController::class, 'import']);
$router->post('/tags/create', [TagController::class, 'create']); $router->post('/tags/create', [TagController::class, 'create']);
$router->post('/tags/update', [TagController::class, 'update']); $router->post('/tags/update', [TagController::class, 'update']);
$router->post('/tags/delete', [TagController::class, 'delete']); $router->post('/tags/delete', [TagController::class, 'delete']);
$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']); $router->post('/tags/transfer', [TagController::class, 'transfer']);
$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']);
$router->post('/tags/bulk-transfer', [TagController::class, 'bulkTransfer']);
$router->get('/tags/{id}', [TagController::class, 'show']); $router->get('/tags/{id}', [TagController::class, 'show']);
$router->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']); $router->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']);
$router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']); $router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']);