From 3688c8b71b66f962d5592852077a30ed286a9646 Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Wed, 11 Feb 2026 17:43:23 +0200 Subject: [PATCH] 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. --- .gitignore | 2 +- CHANGELOG.md | 68 +- app/Controllers/DomainController.php | 262 +++- app/Controllers/InstallerController.php | 50 +- app/Controllers/NotificationController.php | 8 +- .../NotificationGroupController.php | 326 +++++ app/Controllers/SettingsController.php | 2 + app/Controllers/TagController.php | 332 ++++- app/Controllers/UpdateController.php | 290 ++++ app/Helpers/LayoutHelper.php | 44 + app/Helpers/ViewHelper.php | 31 + app/Models/NotificationGroup.php | 16 + app/Models/Setting.php | 23 +- app/Services/ErrorHandler.php | 42 +- app/Services/NotificationService.php | 58 +- app/Services/UpdateService.php | 1240 +++++++++++++++++ app/Views/domains/bulk-add.php | 207 ++- app/Views/domains/index.php | 269 ++-- app/Views/errors/admin-index.php | 37 +- app/Views/groups/index.php | 275 +++- app/Views/layout/base.php | 3 + app/Views/layout/top-nav.php | 20 +- app/Views/notifications/index.php | 5 +- app/Views/settings/index.php | 481 ++++++- app/Views/tags/index.php | 336 ++++- app/Views/tld-registry/index.php | 53 +- app/Views/tld-registry/view.php | 2 +- app/Views/users/index.php | 53 +- cron/check_domains.php | 37 + .../migrations/000_initial_schema_v1.1.0.sql | 8 +- .../025_add_update_system_v1.1.3.sql | 19 + routes/web.php | 19 +- 32 files changed, 4268 insertions(+), 350 deletions(-) create mode 100644 app/Controllers/UpdateController.php create mode 100644 app/Services/UpdateService.php create mode 100644 database/migrations/025_add_update_system_v1.1.3.sql diff --git a/.gitignore b/.gitignore index 2cedc8a..5011f87 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,7 @@ Desktop.ini *.pem *.key *.crt -/database/backups/ +/backups/ # Development /tests/coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c708b46..8933387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added @@ -393,8 +443,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [ ] SMS notifications (Twilio) - [x] Google Chat notifications (completed - v1.1.2) - [ ] WhatsApp notifications -- [ ] Export functionality (CSV, PDF) -- [ ] Import domains from CSV +- [x] Export functionality (CSV, JSON) (completed - v1.1.3) +- [x] Import domains from CSV/JSON (completed - v1.1.3) - [ ] Domain transfer tracking - [ ] DNS record monitoring - [ ] SSL certificate monitoring @@ -416,6 +466,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 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) - **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 diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index bde5cfc..652a919 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -51,7 +51,13 @@ class DomainController extends Controller $sortBy = $_GET['sort'] ?? 'domain_name'; $sortOrder = $_GET['order'] ?? 'asc'; $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 $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() { // Get groups based on isolation mode diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 953c07f..55f0605 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -55,6 +55,7 @@ class InstallerController extends Controller '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', '024_add_status_notifications_v1.1.2.sql', + '025_add_update_system_v1.1.3.sql', ]; try { @@ -196,6 +197,7 @@ class InstallerController extends Controller '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.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 */ public function index() { if ($this->isInstalled()) { + // System is installed — require admin for any further access + $this->requireAdmin(); + // Check for pending migrations without executing them $pending = $this->getPendingMigrations(false); if (empty($pending)) { @@ -250,6 +273,13 @@ class InstallerController extends Controller */ public function checkDatabase() { + // Block access if already installed + if ($this->isInstalled()) { + $_SESSION['info'] = 'System is already installed.'; + $this->redirect('/'); + return; + } + try { $pdo = \Core\Database::getConnection(); $pdo->query("SELECT 1"); @@ -276,6 +306,13 @@ class InstallerController extends Controller $this->redirect('/install'); 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'] ?? ''); $adminPassword = trim($_POST['admin_password'] ?? ''); @@ -382,6 +419,7 @@ class InstallerController extends Controller '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.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"); @@ -505,6 +543,9 @@ class InstallerController extends Controller */ public function showUpdate() { + // Require admin authentication — updates are only for installed systems + $this->requireAdmin(); + $pending = $this->getPendingMigrations(); if (empty($pending)) { @@ -524,6 +565,9 @@ class InstallerController extends Controller */ public function runUpdate() { + // Require admin authentication + $this->requireAdmin(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('/install/update'); return; @@ -600,10 +644,12 @@ class InstallerController extends Controller // Determine from/to versions based on migrations $fromVersion = '1.0.0'; - $toVersion = '1.1.2'; + $toVersion = '1.1.3'; // 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'; } elseif (in_array('022_add_pushover_channel_type.sql', $executed)) { $toVersion = '1.1.1'; diff --git a/app/Controllers/NotificationController.php b/app/Controllers/NotificationController.php index c2b9828..9556a68 100644 --- a/app/Controllers/NotificationController.php +++ b/app/Controllers/NotificationController.php @@ -93,7 +93,7 @@ class NotificationController extends Controller $this->notificationModel->markAsRead($notificationId, $userId); - // If redirect=domain, go to the domain view page + // Optional redirect after marking read $redirect = $_GET['redirect'] ?? ''; if ($redirect === 'domain') { $domainId = (int)($_GET['domain_id'] ?? 0); @@ -102,6 +102,10 @@ class NotificationController extends Controller return; } } + if ($redirect === 'settings') { + $this->redirect('/settings#updates'); + return; + } // AJAX request - return JSON (check multiple detection methods) $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', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', + 'update_available' => 'cloud-download-alt', default => 'bell' }; } @@ -247,6 +252,7 @@ class NotificationController extends Controller 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', + 'update_available' => 'blue', default => 'gray' }; } diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index ac3771f..00cde84 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -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() { $this->view('groups/create', [ diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index a0eafee..b6d5351 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -28,6 +28,7 @@ class SettingsController extends Controller $captchaSettings = $this->settingModel->getCaptchaSettings(); $twoFactorSettings = $this->settingModel->getTwoFactorSettings(); $isolationSettings = $this->getIsolationSettings(); + $updateSettings = $this->settingModel->getUpdateSettings(); // Predefined notification day options $notificationPresets = [ @@ -76,6 +77,7 @@ class SettingsController extends Controller 'captchaSettings' => $captchaSettings, 'twoFactorSettings' => $twoFactorSettings, 'isolationSettings' => $isolationSettings, + 'updateSettings' => $updateSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, 'statusTriggers' => $statusTriggers, diff --git a/app/Controllers/TagController.php b/app/Controllers/TagController.php index 51a60c2..1c430e3 100644 --- a/app/Controllers/TagController.php +++ b/app/Controllers/TagController.php @@ -49,15 +49,245 @@ class TagController extends Controller $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', [ 'tags' => $result['tags'], 'pagination' => $result['pagination'], 'filters' => $filters, '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 */ @@ -442,12 +672,7 @@ class TagController extends Controller return; } - // Verify CSRF token - if (!\Core\Csrf::verify($_POST['csrf_token'] ?? '')) { - $_SESSION['error'] = 'Invalid request'; - $this->redirect('/tags'); - return; - } + $this->verifyCsrf('/tags'); $tagIds = $_POST['tag_ids'] ?? []; if (empty($tagIds)) { @@ -496,4 +721,97 @@ class TagController extends Controller $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'); + } } diff --git a/app/Controllers/UpdateController.php b/app/Controllers/UpdateController.php new file mode 100644 index 0000000..3ca519c --- /dev/null +++ b/app/Controllers/UpdateController.php @@ -0,0 +1,290 @@ +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'); + } +} diff --git a/app/Helpers/LayoutHelper.php b/app/Helpers/LayoutHelper.php index 29ff410..170365f 100644 --- a/app/Helpers/LayoutHelper.php +++ b/app/Helpers/LayoutHelper.php @@ -132,6 +132,7 @@ class LayoutHelper 'whois_failed' => 'exclamation-circle', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', + 'update_available' => 'cloud-download-alt', default => 'bell' }; } @@ -154,6 +155,7 @@ class LayoutHelper 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', + 'update_available' => 'blue', 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' => '']; + } + } } diff --git a/app/Helpers/ViewHelper.php b/app/Helpers/ViewHelper.php index 103d5b6..15f61fc 100644 --- a/app/Helpers/ViewHelper.php +++ b/app/Helpers/ViewHelper.php @@ -138,6 +138,37 @@ class ViewHelper 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 */ diff --git a/app/Models/NotificationGroup.php b/app/Models/NotificationGroup.php index 57124be..29dcfc3 100644 --- a/app/Models/NotificationGroup.php +++ b/app/Models/NotificationGroup.php @@ -98,5 +98,21 @@ class NotificationGroup extends Model $stmt->execute([$userId]); 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; + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index aa1d327..9d7b04d 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -122,7 +122,7 @@ class Setting extends Model */ 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); } + /** + * 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 */ diff --git a/app/Services/ErrorHandler.php b/app/Services/ErrorHandler.php index c3809d2..a204812 100644 --- a/app/Services/ErrorHandler.php +++ b/app/Services/ErrorHandler.php @@ -19,6 +19,7 @@ class ErrorHandler private Logger $logger; private ?ErrorLog $errorLogModel = null; private bool $isDevelopment; + private bool $handling = false; // Recursion guard public function __construct() { @@ -29,9 +30,8 @@ class ErrorHandler // Initialize ErrorLog model if database is available try { $this->errorLogModel = new ErrorLog(); - } catch (\Exception $e) { + } catch (\Throwable $e) { // 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 { + // 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); // Log to file @@ -62,8 +78,8 @@ class ErrorHandler return false; } - // Ignore certain non-critical errors during error handling itself - if (error_reporting() === 0) { + // Prevent recursive handling (e.g. if logToDatabase triggers a warning) + if ($this->handling) { return false; } @@ -114,7 +130,7 @@ class ErrorHandler 'error_message' => $exception->getMessage(), 'error_file' => $exception->getFile(), '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_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A', 'request_data' => json_encode($requestData), @@ -228,9 +244,19 @@ class ErrorHandler try { return $this->errorLogModel->logError($errorData); - } catch (\Exception $e) { - // Database logging failed, continue with file logging only - error_log("Failed to log error to database: " . $e->getMessage()); + } catch (\Throwable $e) { + // Database logging failed — log to file so it's visible in the app's /logs folder + 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; } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index a13c4e7..99124d2 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -569,30 +569,36 @@ class NotificationService /** * 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->createNotification( $userId, 'system_upgrade', 'System Upgraded Successfully', - "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)", + $message, null ); } /** * 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 { $userModel = new \App\Models\User(); $admins = $userModel->getAllAdmins(); foreach ($admins as $admin) { - $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount); + $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount, $composerManualRequired); } } catch (\Exception $e) { $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) */ diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php new file mode 100644 index 0000000..b671cf0 --- /dev/null +++ b/app/Services/UpdateService.php @@ -0,0 +1,1240 @@ +settingModel = new Setting(); + $this->logger = new Logger('updater'); + $this->rootPath = realpath(__DIR__ . '/../../'); + $this->httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'DomainMonitor/' . $this->settingModel->getAppVersion(), + ], + ]); + } + + /** + * Check for available updates based on the user's chosen update channel + * Returns structured info about what's available + */ + public function checkForUpdate(bool $forceCheck = false): array + { + $channel = $this->settingModel->getValue('update_channel', 'stable'); + $currentVersion = $this->settingModel->getAppVersion(); + $localSha = $this->settingModel->getValue('installed_commit_sha', null); + + // Check cache (unless forced) + if (!$forceCheck) { + $lastCheck = $this->settingModel->getValue('last_update_check', null); + if ($lastCheck && $this->isCacheValid($lastCheck)) { + return $this->getCachedResult($currentVersion, $channel, $localSha); + } + } + + $this->logger->info('Checking for updates', [ + 'channel' => $channel, + 'current_version' => $currentVersion, + 'local_sha' => $localSha ? substr($localSha, 0, 7) : 'unknown', + ]); + + $result = [ + 'available' => false, + 'type' => null, + 'current_version' => $currentVersion, + 'channel' => $channel, + 'error' => null, + ]; + + try { + // Always check for tagged releases + $release = $this->fetchLatestRelease(); + + if ($release) { + $latestVersion = ltrim($release['tag_name'], 'v'); + + $result['latest_version'] = $latestVersion; + $result['release_notes'] = $release['body'] ?? ''; + $result['release_url'] = $release['html_url'] ?? ''; + $result['published_at'] = $release['published_at'] ?? null; + $result['download_url'] = $release['zipball_url'] ?? null; + + if (version_compare($latestVersion, $currentVersion, '>')) { + $result['available'] = true; + $result['type'] = 'release'; + } + + // Cache release info + $this->settingModel->setValue('latest_available_version', $latestVersion); + $this->settingModel->setValue('latest_release_notes', $release['body'] ?? ''); + $this->settingModel->setValue('latest_release_url', $release['html_url'] ?? ''); + $this->settingModel->setValue('latest_release_published_at', $release['published_at'] ?? ''); + } + + // If on "latest" channel, also check for untagged commits + if ($channel === 'latest' && $localSha) { + $commits = $this->fetchCommitsSince($localSha); + + if ($commits !== null && !empty($commits)) { + // If there's no new version release but there ARE new commits, it's a hotfix + if (!$result['available']) { + $result['available'] = true; + $result['type'] = 'hotfix'; + } + + $result['commits_behind'] = count($commits); + $result['commit_messages'] = array_map(function ($c) { + return [ + 'sha' => substr($c['sha'], 0, 7), + 'message' => $c['commit']['message'] ?? '', + 'author' => $c['commit']['author']['name'] ?? 'Unknown', + 'date' => $c['commit']['author']['date'] ?? null, + ]; + }, array_slice($commits, 0, 20)); // Limit to 20 most recent + + $result['remote_sha'] = $commits[0]['sha'] ?? null; + + // Cache commit info + $this->settingModel->setValue('latest_remote_sha', $result['remote_sha'] ?? ''); + $this->settingModel->setValue('commits_behind_count', count($commits)); + } + } elseif ($channel === 'latest' && !$localSha) { + $result['commit_tracking_unavailable'] = true; + } + + // Update last check timestamp + $this->settingModel->setValue('last_update_check', date('Y-m-d H:i:s')); + + $this->logger->info('Update check completed', [ + 'available' => $result['available'], + 'type' => $result['type'], + 'latest_version' => $result['latest_version'] ?? 'N/A', + ]); + + } catch (\Exception $e) { + $this->logger->error('Update check failed', [ + 'error' => $e->getMessage(), + ]); + $result['error'] = 'Failed to check for updates: ' . $e->getMessage(); + } + + return $result; + } + + /** + * Download and apply an update (release or hotfix) + */ + public function performUpdate(string $type = 'release'): array + { + $this->logger->startOperation('Application Update'); + + $result = [ + 'success' => false, + 'from_version' => $this->settingModel->getAppVersion(), + 'files_updated' => 0, + 'backup_path' => null, + 'errors' => [], + ]; + + try { + // Step 1: Pre-flight checks + $this->logger->info('Running pre-flight checks'); + $preflight = $this->preflightChecks(); + if (!$preflight['pass']) { + $result['errors'] = $preflight['errors']; + return $result; + } + + // Step 2: Determine download URL + $downloadUrl = $this->getDownloadUrl($type); + if (!$downloadUrl) { + $result['errors'][] = 'Could not determine download URL for update'; + return $result; + } + + // Step 3a: Create database backup + $this->logger->info('Creating database backup'); + $dbBackupResult = $this->createDatabaseBackup(); + if ($dbBackupResult['success']) { + $result['db_backup_path'] = $dbBackupResult['path']; + $this->settingModel->setValue('update_db_backup_path', $dbBackupResult['path']); + $this->logger->info('Database backup created', ['path' => $dbBackupResult['path'], 'method' => $dbBackupResult['method']]); + } else { + $this->logger->warning('Database backup skipped: ' . $dbBackupResult['reason']); + $result['db_backup_warning'] = $dbBackupResult['reason']; + } + + // Step 3b: Create file backup + $this->logger->info('Creating file backup'); + $backupPath = $this->createBackup(); + $result['backup_path'] = $backupPath; + $this->settingModel->setValue('update_backup_path', $backupPath); + + // Step 4: Download the archive + $this->logger->info('Downloading update', ['url' => $downloadUrl]); + $archivePath = $this->downloadArchive($downloadUrl); + + // Step 5: Extract to staging directory + $this->logger->info('Extracting archive'); + $stagingDir = $this->extractArchive($archivePath); + + // Step 5b: Verify extracted archive matches expected commit (integrity check) + $this->verifyExtractedCommitSha($stagingDir, $type); + + // Step 5c: Check if composer dependencies changed (before we overwrite root) + $composerChanged = $this->checkComposerChanged($stagingDir); + + // Step 6: Copy files (respecting protected paths) + $this->logger->info('Applying update files'); + $filesUpdated = $this->applyFiles($stagingDir); + $result['files_updated'] = $filesUpdated; + + // Step 7: Run composer install if dependencies changed (skip if exec disabled, e.g. cPanel) + $result['composer_manual_required'] = false; + if ($composerChanged) { + if (!$this->canRunShellCommands()) { + $this->logger->warning('Composer dependencies changed but shell commands are disabled (e.g. exec in disable_functions). Run composer install manually.'); + $result['composer_manual_required'] = true; + } elseif (!$this->runComposerInstall()) { + $result['composer_manual_required'] = true; + } + if ($result['composer_manual_required']) { + $this->logger->info('Composer manual action required. If dependencies changed, run: composer install --no-dev (e.g. via SSH or cPanel Terminal).'); + } + } + + // Step 8: Update commit SHA tracking + $this->updateCommitSha(); + + // Step 9: Clean up + $this->cleanup($archivePath, $stagingDir); + + $result['success'] = true; + // Report the version we actually applied (DB app_version only changes after migrations) + $result['to_version'] = $type === 'release' + ? ($this->settingModel->getValue('latest_available_version') ?: $this->settingModel->getAppVersion()) + : ($this->settingModel->getValue('latest_remote_sha') ? substr($this->settingModel->getValue('latest_remote_sha'), 0, 7) : 'latest'); + + $this->logger->endOperation('Application Update', [ + 'success' => true, + 'files_updated' => $filesUpdated, + 'composer_updated' => $composerChanged, + ]); + + } catch (\Exception $e) { + $this->logger->error('Update failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $result['errors'][] = $e->getMessage(); + + // Attempt rollback + if (!empty($result['backup_path'])) { + $this->logger->info('Attempting rollback'); + try { + $this->restoreBackup($result['backup_path']); + $result['errors'][] = 'Update failed but rollback was successful'; + } catch (\Exception $rollbackError) { + $result['errors'][] = 'Rollback also failed: ' . $rollbackError->getMessage(); + } + } + } + + return $result; + } + + /** + * Rollback to last backup + */ + public function rollback(): array + { + $backupPath = $this->settingModel->getValue('update_backup_path', null); + + if (!$backupPath || !file_exists($backupPath)) { + return [ + 'success' => false, + 'error' => 'No backup available for rollback', + ]; + } + + try { + $this->logger->startOperation('Rollback'); + + // Restore database first (if backup exists) + $dbBackupPath = $this->settingModel->getValue('update_db_backup_path', null); + if ($dbBackupPath && file_exists($dbBackupPath)) { + $this->logger->info('Restoring database from backup', ['path' => $dbBackupPath]); + $dbRestored = $this->restoreDatabaseBackup($dbBackupPath); + if (!$dbRestored) { + $this->logger->warning('Database restore failed or skipped. SQL file is still available for manual import.', ['path' => $dbBackupPath]); + } + } else { + $this->logger->info('No database backup found, restoring files only'); + } + + // Restore files + $this->restoreBackup($backupPath); + $this->logger->endOperation('Rollback', ['success' => true]); + + return ['success' => true, 'db_restored' => isset($dbRestored) ? $dbRestored : null]; + } catch (\Exception $e) { + $this->logger->error('Rollback failed', ['error' => $e->getMessage()]); + return [ + 'success' => false, + 'error' => 'Rollback failed: ' . $e->getMessage(), + ]; + } + } + + // ======================================================================== + // Private: GitHub API methods + // ======================================================================== + + /** + * Fetch the latest release from GitHub + */ + private function fetchLatestRelease(): ?array + { + try { + $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/releases/latest'; + $response = $this->httpClient->get($url); + return json_decode($response->getBody()->getContents(), true); + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse()->getStatusCode() === 404) { + // No releases yet + return null; + } + throw $e; + } + } + + /** + * Fetch commits on main since a given SHA + * Uses the compare API: /repos/{owner}/{repo}/compare/{base}...{head} + */ + private function fetchCommitsSince(string $sinceCommitSha): ?array + { + try { + $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO + . '/compare/' . $sinceCommitSha . '...main'; + $response = $this->httpClient->get($url); + $data = json_decode($response->getBody()->getContents(), true); + + if (isset($data['status']) && $data['status'] === 'identical') { + return []; + } + + return $data['commits'] ?? []; + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse()->getStatusCode() === 404) { + $this->logger->warning('Commit comparison failed - SHA may not exist on remote', [ + 'sha' => substr($sinceCommitSha, 0, 7), + ]); + return null; + } + throw $e; + } + } + + /** + * Get the latest commit SHA from the main branch + */ + private function fetchLatestCommitSha(): ?string + { + try { + $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/commits/main'; + $response = $this->httpClient->get($url); + $data = json_decode($response->getBody()->getContents(), true); + return $data['sha'] ?? null; + } catch (\Exception $e) { + $this->logger->warning('Failed to fetch latest commit SHA', [ + 'error' => $e->getMessage(), + ]); + return null; + } + } + + // ======================================================================== + // Private: Cache methods + // ======================================================================== + + private function isCacheValid(string $lastCheckTimestamp): bool + { + $lastCheck = strtotime($lastCheckTimestamp); + $ttlSeconds = self::CACHE_TTL_HOURS * 3600; + return (time() - $lastCheck) < $ttlSeconds; + } + + private function getCachedResult(string $currentVersion, string $channel, ?string $localSha): array + { + $latestVersion = $this->settingModel->getValue('latest_available_version', null); + + $result = [ + 'available' => false, + 'type' => null, + 'current_version' => $currentVersion, + 'channel' => $channel, + 'cached' => true, + 'last_check' => $this->settingModel->getValue('last_update_check'), + 'error' => null, + ]; + + if ($latestVersion) { + $result['latest_version'] = $latestVersion; + $result['release_notes'] = $this->settingModel->getValue('latest_release_notes', ''); + $result['release_url'] = $this->settingModel->getValue('latest_release_url', ''); + $result['published_at'] = $this->settingModel->getValue('latest_release_published_at', ''); + + if (version_compare($latestVersion, $currentVersion, '>')) { + $result['available'] = true; + $result['type'] = 'release'; + } + } + + // Check cached commit info for "latest" channel + if ($channel === 'latest' && $localSha) { + $commitsBehind = (int) $this->settingModel->getValue('commits_behind_count', 0); + if ($commitsBehind > 0 && !$result['available']) { + $result['available'] = true; + $result['type'] = 'hotfix'; + $result['commits_behind'] = $commitsBehind; + $result['remote_sha'] = $this->settingModel->getValue('latest_remote_sha', ''); + } + } + + return $result; + } + + // ======================================================================== + // Private: Update process methods + // ======================================================================== + + /** + * Run pre-flight checks before updating + */ + private function preflightChecks(): array + { + $errors = []; + + // Check PHP extensions + if (!extension_loaded('zip')) { + $errors[] = 'PHP zip extension is required for updates'; + } + + // Check write permissions on key directories + $dirsToCheck = [ + $this->rootPath . '/app', + $this->rootPath . '/core', + $this->rootPath . '/public', + $this->rootPath . '/database', + $this->rootPath . '/routes', + ]; + + foreach ($dirsToCheck as $dir) { + if (is_dir($dir) && !is_writable($dir)) { + $errors[] = "Directory not writable: $dir"; + } + } + + // Check temp directory is writable + $tempDir = sys_get_temp_dir(); + if (!is_writable($tempDir)) { + $errors[] = "System temp directory not writable: $tempDir"; + } + + // Check available disk space (require at least 50MB free) + $freeSpace = disk_free_space($this->rootPath); + if ($freeSpace !== false && $freeSpace < 50 * 1024 * 1024) { + $errors[] = 'Insufficient disk space. At least 50MB required'; + } + + return [ + 'pass' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Determine the download URL based on update type + */ + private function getDownloadUrl(string $type): ?string + { + if ($type === 'release') { + // Use the cached release download URL or fetch fresh + $release = $this->fetchLatestRelease(); + return $release['zipball_url'] ?? null; + } + + // For hotfix updates, download the latest main branch as zip + return self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/zipball/main'; + } + + /** + * Download archive from URL to temp file + */ + private function downloadArchive(string $url): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'dm_update_') . '.zip'; + + $response = $this->httpClient->get($url, [ + 'sink' => $tempFile, + 'timeout' => 120, + ]); + + $fileSize = filesize($tempFile); + $sha256 = hash_file('sha256', $tempFile); + $this->logger->info('Archive downloaded', [ + 'size_bytes' => $fileSize, + 'sha256' => $sha256, + 'path' => $tempFile, + ]); + + if ($fileSize < 1000) { + unlink($tempFile); + throw new \RuntimeException('Downloaded file is too small - likely an error response'); + } + + return $tempFile; + } + + /** + * Extract zip archive to a staging directory + */ + private function extractArchive(string $archivePath): string + { + $stagingDir = sys_get_temp_dir() . '/dm_staging_' . uniqid(); + mkdir($stagingDir, 0755, true); + + $zip = new \ZipArchive(); + $openResult = $zip->open($archivePath); + + if ($openResult !== true) { + throw new \RuntimeException("Failed to open zip archive (error code: $openResult)"); + } + + $zip->extractTo($stagingDir); + $zip->close(); + + // GitHub zipballs have a top-level directory like "Owner-Repo-SHA/" + // Find it and return the actual content directory + $entries = scandir($stagingDir); + $topDir = null; + foreach ($entries as $entry) { + if ($entry !== '.' && $entry !== '..' && is_dir($stagingDir . '/' . $entry)) { + $topDir = $stagingDir . '/' . $entry; + break; + } + } + + if (!$topDir) { + throw new \RuntimeException('Unexpected archive structure - no top-level directory found'); + } + + $this->logger->info('Archive extracted', ['staging_dir' => $topDir]); + return $topDir; + } + + /** + * Get the expected short commit SHA (7 chars) for the update we are applying. + * Used to verify the downloaded zipball matches the expected ref. + */ + private function getExpectedShortSha(string $type): ?string + { + if ($type === 'hotfix') { + $fullSha = $this->fetchLatestCommitSha(); + return $fullSha ? substr($fullSha, 0, 7) : null; + } + + if ($type === 'release') { + $release = $this->fetchLatestRelease(); + if (empty($release['tag_name'])) { + return null; + } + $tag = $release['tag_name']; // e.g. v1.1.3 + try { + $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/commits/' . $tag; + $response = $this->httpClient->get($url); + $data = json_decode($response->getBody()->getContents(), true); + $fullSha = $data['sha'] ?? null; + return $fullSha ? substr($fullSha, 0, 7) : null; + } catch (\Exception $e) { + $this->logger->warning('Could not fetch commit SHA for release tag', [ + 'tag' => $tag, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + return null; + } + + /** + * Extract the short commit SHA from GitHub zipball top-level folder name. + * Format is "Owner-Repo-" (e.g. Hosteroid-domain-monitor-abc1234). + */ + private function getShortShaFromFolderName(string $stagingDirPath): ?string + { + $folderName = basename($stagingDirPath); + $parts = explode('-', $folderName); + $last = end($parts); + // GitHub uses 7-char short SHA; could also be 40-char full SHA in some cases + if (preg_match('/^[a-f0-9]{7,40}$/i', $last)) { + return strlen($last) >= 7 ? substr($last, 0, 7) : $last; + } + return null; + } + + /** + * Verify that the extracted archive's folder name matches the expected commit SHA. + * This ensures we applied the correct ref (tag or main) and detects corrupted/wrong downloads. + */ + private function verifyExtractedCommitSha(string $stagingDir, string $type): void + { + $expectedShort = $this->getExpectedShortSha($type); + if ($expectedShort === null) { + $this->logger->warning('Skipping commit SHA verification (could not get expected SHA)'); + return; + } + + $actualShort = $this->getShortShaFromFolderName($stagingDir); + if ($actualShort === null) { + throw new \RuntimeException( + 'Integrity check failed: could not read commit SHA from archive folder name. ' . + 'The download may be corrupted or from an unexpected source.' + ); + } + + if (strcasecmp($actualShort, $expectedShort) !== 0) { + throw new \RuntimeException( + "Integrity check failed: archive commit SHA does not match. " . + "Expected: {$expectedShort}, got: {$actualShort}. " . + "The download may be corrupted or from a different ref." + ); + } + + $this->logger->info('Commit SHA verified', [ + 'expected' => $expectedShort, + 'actual' => $actualShort, + 'type' => $type, + ]); + } + + /** + * Copy files from staging to the application root, respecting protected paths + */ + private function applyFiles(string $stagingDir): int + { + $count = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($stagingDir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $relativePath = str_replace($stagingDir . DIRECTORY_SEPARATOR, '', $item->getPathname()); + $relativePath = str_replace('\\', '/', $relativePath); // Normalize for Windows + + // Skip protected paths + if ($this->isProtected($relativePath)) { + continue; + } + + $targetPath = $this->rootPath . '/' . $relativePath; + + if ($item->isDir()) { + if (!is_dir($targetPath)) { + mkdir($targetPath, 0755, true); + } + } else { + // Ensure parent directory exists + $parentDir = dirname($targetPath); + if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + + copy($item->getPathname(), $targetPath); + $count++; + } + } + + $this->logger->info('Files applied', ['count' => $count]); + return $count; + } + + /** + * Check if a relative path is protected from overwriting + */ + private function isProtected(string $relativePath): bool + { + foreach (self::PROTECTED_PATHS as $protected) { + if ($relativePath === $protected || strpos($relativePath, $protected . '/') === 0) { + return true; + } + } + return false; + } + + /** + * Check if composer.json or composer.lock changed + */ + private function checkComposerChanged(string $stagingDir): bool + { + $currentLock = $this->rootPath . '/composer.lock'; + $newLock = $stagingDir . '/composer.lock'; + + if (!file_exists($currentLock) || !file_exists($newLock)) { + // If lock file doesn't exist in either place, check composer.json + $currentJson = $this->rootPath . '/composer.json'; + $newJson = $stagingDir . '/composer.json'; + + if (file_exists($currentJson) && file_exists($newJson)) { + return md5_file($currentJson) !== md5_file($newJson); + } + return false; + } + + return md5_file($currentLock) !== md5_file($newLock); + } + + /** + * Check if PHP is allowed to run shell commands (exec, etc.). + * On cPanel / shared hosting, disable_functions often includes exec. + */ + private function canRunShellCommands(): bool + { + $disabled = ini_get('disable_functions'); + if ($disabled === false || $disabled === '') { + return true; + } + $list = array_map('trim', explode(',', strtolower($disabled))); + return !in_array('exec', $list, true); + } + + /** + * Run composer install --no-dev. + * Returns true on success, false if skipped or failed (caller may set composer_manual_required). + */ + private function runComposerInstall(): bool + { + $composerPath = $this->findComposer(); + $command = "$composerPath install --no-dev --optimize-autoloader --no-interaction 2>&1"; + + $this->logger->info('Running composer install', ['command' => $command, 'cwd' => $this->rootPath]); + + $output = []; + $returnCode = 0; + $oldCwd = getcwd(); + try { + if (!@chdir($this->rootPath)) { + $this->logger->warning('Could not change to project directory for composer', [ + 'path' => $this->rootPath, + ]); + return false; + } + exec($command, $output, $returnCode); + } finally { + @chdir($oldCwd); + } + + $outputStr = implode("\n", $output); + + if ($returnCode !== 0) { + $this->logger->error('Composer install failed (run manually if needed)', [ + 'return_code' => $returnCode, + 'output' => $outputStr, + ]); + return false; + } + + $this->logger->info('Composer install completed', ['output' => $outputStr]); + return true; + } + + /** + * Find the composer executable + */ + private function findComposer(): string + { + // Check for local composer.phar first + if (file_exists($this->rootPath . '/composer.phar')) { + return 'php ' . $this->rootPath . '/composer.phar'; + } + + // Check if composer is in PATH + $command = PHP_OS_FAMILY === 'Windows' ? 'where composer 2>NUL' : 'which composer 2>/dev/null'; + $output = []; + exec($command, $output); + + if (!empty($output[0])) { + return trim($output[0]); + } + + // Fallback + return 'composer'; + } + + /** + * Find a system binary (e.g. mysqldump, mysql) in common paths + */ + private function findBinary(string $name): ?string + { + // Check PATH first + $command = PHP_OS_FAMILY === 'Windows' ? "where {$name} 2>NUL" : "which {$name} 2>/dev/null"; + $output = []; + @exec($command, $output); + if (!empty($output[0]) && is_executable(trim($output[0]))) { + return trim($output[0]); + } + + // Common locations on Linux/cPanel hosts + $commonPaths = [ + "/usr/bin/{$name}", + "/usr/local/bin/{$name}", + "/usr/local/mysql/bin/{$name}", + "/opt/cpanel/composer/bin/{$name}", + "/usr/sbin/{$name}", + ]; + foreach ($commonPaths as $path) { + if (file_exists($path) && is_executable($path)) { + return $path; + } + } + + return null; + } + + // ======================================================================== + // Private: Backup and rollback methods + // ======================================================================== + + /** + * Create a full database backup (.sql file) before updating. + * Tries mysqldump first; falls back to a pure-PDO dump of all tables. + * Returns ['success' => bool, 'path' => string|null, 'method' => string, 'reason' => string] + */ + private function createDatabaseBackup(): array + { + $backupDir = $this->rootPath . '/backups'; + if (!is_dir($backupDir)) { + mkdir($backupDir, 0775, true); + } + + $host = $_ENV['DB_HOST'] ?? 'localhost'; + $port = $_ENV['DB_PORT'] ?? '3306'; + $database = $_ENV['DB_DATABASE'] ?? ''; + $username = $_ENV['DB_USERNAME'] ?? ''; + $password = $_ENV['DB_PASSWORD'] ?? ''; + + if (empty($database) || empty($username)) { + return ['success' => false, 'path' => null, 'method' => 'none', 'reason' => 'Database credentials not available']; + } + + $version = $this->settingModel->getAppVersion(); + $timestamp = date('Y-m-d_His'); + $sqlFile = $backupDir . "/db_backup_v{$version}_{$timestamp}.sql"; + + // Try mysqldump first (fastest, most reliable) + if ($this->canRunShellCommands()) { + $mysqldumpPath = $this->findBinary('mysqldump'); + if ($mysqldumpPath) { + $cmd = sprintf( + '%s --host=%s --port=%s --user=%s --password=%s --single-transaction --routines --triggers --add-drop-table %s > %s 2>&1', + escapeshellarg($mysqldumpPath), + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($username), + escapeshellarg($password), + escapeshellarg($database), + escapeshellarg($sqlFile) + ); + exec($cmd, $output, $exitCode); + if ($exitCode === 0 && file_exists($sqlFile) && filesize($sqlFile) > 0) { + return ['success' => true, 'path' => $sqlFile, 'method' => 'mysqldump', 'reason' => '']; + } + // mysqldump failed, clean up and fall through to PDO + if (file_exists($sqlFile)) { + @unlink($sqlFile); + } + } + } + + // Fallback: pure PDO dump (works on cPanel/shared hosts without exec) + try { + $pdo = \Core\Database::getConnection(); + $handle = fopen($sqlFile, 'w'); + if (!$handle) { + return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'Could not create SQL file']; + } + + fwrite($handle, "-- Domain Monitor Database Backup\n"); + fwrite($handle, "-- Date: " . date('Y-m-d H:i:s') . "\n"); + fwrite($handle, "-- Database: {$database}\n"); + fwrite($handle, "-- Method: PDO dump (fallback)\n\n"); + fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n\n"); + + // Get all tables + $tables = $pdo->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN); + + foreach ($tables as $table) { + // DROP + CREATE + fwrite($handle, "DROP TABLE IF EXISTS `{$table}`;\n"); + $createStmt = $pdo->query("SHOW CREATE TABLE `{$table}`")->fetch(\PDO::FETCH_ASSOC); + fwrite($handle, $createStmt['Create Table'] . ";\n\n"); + + // Dump rows in batches + $count = (int)$pdo->query("SELECT COUNT(*) FROM `{$table}`")->fetchColumn(); + $batchSize = 500; + for ($offset = 0; $offset < $count; $offset += $batchSize) { + $rows = $pdo->query("SELECT * FROM `{$table}` LIMIT {$batchSize} OFFSET {$offset}")->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $values = array_map(function ($val) use ($pdo) { + if ($val === null) return 'NULL'; + return $pdo->quote($val); + }, $row); + $cols = '`' . implode('`, `', array_keys($row)) . '`'; + fwrite($handle, "INSERT INTO `{$table}` ({$cols}) VALUES (" . implode(', ', $values) . ");\n"); + } + } + fwrite($handle, "\n"); + } + + fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n"); + fclose($handle); + + if (filesize($sqlFile) > 0) { + return ['success' => true, 'path' => $sqlFile, 'method' => 'pdo', 'reason' => '']; + } + @unlink($sqlFile); + return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'PDO dump produced empty file']; + } catch (\Exception $e) { + if (isset($handle) && is_resource($handle)) { + fclose($handle); + } + if (file_exists($sqlFile)) { + @unlink($sqlFile); + } + return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'PDO dump failed: ' . $e->getMessage()]; + } + } + + /** + * Restore a database backup from a .sql file (used during rollback) + */ + private function restoreDatabaseBackup(string $sqlFile): bool + { + if (!file_exists($sqlFile)) { + $this->logger->warning('Database backup file not found for restore', ['path' => $sqlFile]); + return false; + } + + // Try mysql CLI first + if ($this->canRunShellCommands()) { + $mysqlPath = $this->findBinary('mysql'); + if ($mysqlPath) { + $host = $_ENV['DB_HOST'] ?? 'localhost'; + $port = $_ENV['DB_PORT'] ?? '3306'; + $database = $_ENV['DB_DATABASE'] ?? ''; + $username = $_ENV['DB_USERNAME'] ?? ''; + $password = $_ENV['DB_PASSWORD'] ?? ''; + + $cmd = sprintf( + '%s --host=%s --port=%s --user=%s --password=%s %s < %s 2>&1', + escapeshellarg($mysqlPath), + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($username), + escapeshellarg($password), + escapeshellarg($database), + escapeshellarg($sqlFile) + ); + exec($cmd, $output, $exitCode); + if ($exitCode === 0) { + $this->logger->info('Database restored via mysql CLI', ['path' => $sqlFile]); + return true; + } + } + } + + // Fallback: execute SQL via PDO (statement by statement) + try { + $pdo = \Core\Database::getConnection(); + $sql = file_get_contents($sqlFile); + if (empty($sql)) { + return false; + } + + $pdo->exec("SET FOREIGN_KEY_CHECKS=0"); + // Split on semicolons followed by newline (to avoid breaking on values containing semicolons) + $statements = preg_split('/;\s*\n/', $sql); + foreach ($statements as $stmt) { + $stmt = trim($stmt); + if (empty($stmt) || strpos($stmt, '--') === 0) continue; + $pdo->exec($stmt); + } + $pdo->exec("SET FOREIGN_KEY_CHECKS=1"); + + $this->logger->info('Database restored via PDO', ['path' => $sqlFile]); + return true; + } catch (\Exception $e) { + $this->logger->error('Database restore via PDO failed', ['error' => $e->getMessage()]); + return false; + } + } + + /** + * Create a zip backup of current application files + */ + private function createBackup(): string + { + $backupDir = $this->rootPath . '/backups'; + if (!is_dir($backupDir)) { + mkdir($backupDir, 0775, true); + } + // Ensure the directory is writable + if (!is_writable($backupDir)) { + @chmod($backupDir, 0775); + } + + // ZipArchive::close() writes a temp file; point TMPDIR to a writable location + // so it works on hosts where the system /tmp is not writable by the web user + $originalTmpDir = getenv('TMPDIR') ?: null; + putenv('TMPDIR=' . $backupDir); + + $version = $this->settingModel->getAppVersion(); + $timestamp = date('Y-m-d_His'); + $backupFile = $backupDir . "/backup_v{$version}_{$timestamp}.zip"; + + $zip = new \ZipArchive(); + if ($zip->open($backupFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + // Restore TMPDIR before throwing + if ($originalTmpDir !== null) { + putenv('TMPDIR=' . $originalTmpDir); + } else { + putenv('TMPDIR'); + } + throw new \RuntimeException("Failed to create backup archive: $backupFile"); + } + + // Back up key application directories + $dirsToBackup = ['app', 'core', 'public', 'database', 'routes', 'cron']; + $filesToBackup = ['composer.json', 'composer.lock']; + + foreach ($dirsToBackup as $dir) { + $fullDir = $this->rootPath . '/' . $dir; + if (is_dir($fullDir)) { + $this->addDirectoryToZip($zip, $fullDir, $dir); + } + } + + foreach ($filesToBackup as $file) { + $fullFile = $this->rootPath . '/' . $file; + if (file_exists($fullFile)) { + $zip->addFile($fullFile, $file); + } + } + + $zip->close(); + + // Restore original TMPDIR + if ($originalTmpDir !== null) { + putenv('TMPDIR=' . $originalTmpDir); + } else { + putenv('TMPDIR'); + } + + $this->logger->info('Backup created', [ + 'path' => $backupFile, + 'size_bytes' => filesize($backupFile), + ]); + + return $backupFile; + } + + /** + * Recursively add a directory to a zip archive + */ + private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $zipPath): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $relativePath = $zipPath . '/' . str_replace( + [$dir . DIRECTORY_SEPARATOR, $dir . '/'], + '', + $item->getPathname() + ); + $relativePath = str_replace('\\', '/', $relativePath); // Zip entries use forward slashes + + if ($item->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($item->getPathname(), $relativePath); + } + } + } + + /** + * Restore from a backup zip archive + */ + private function restoreBackup(string $backupPath): void + { + if (!file_exists($backupPath)) { + throw new \RuntimeException("Backup file not found: $backupPath"); + } + + $zip = new \ZipArchive(); + if ($zip->open($backupPath) !== true) { + throw new \RuntimeException("Failed to open backup archive: $backupPath"); + } + + $zip->extractTo($this->rootPath); + $zip->close(); + + $this->logger->info('Backup restored', ['path' => $backupPath]); + } + + // ======================================================================== + // Private: Utility methods + // ======================================================================== + + /** + * Update the stored commit SHA to the latest + */ + private function updateCommitSha(): void + { + $latestSha = $this->fetchLatestCommitSha(); + if ($latestSha) { + $this->settingModel->setValue('installed_commit_sha', $latestSha); + $this->logger->info('Updated installed commit SHA', [ + 'sha' => substr($latestSha, 0, 7), + ]); + } + } + + /** + * Clean up temporary files + */ + private function cleanup(string $archivePath, string $stagingDir): void + { + // Remove archive + if (file_exists($archivePath)) { + unlink($archivePath); + } + + // Remove staging directory + if (is_dir($stagingDir)) { + $this->removeDirectory($stagingDir); + } + + // Also clean parent staging dir if it exists + $parentStaging = dirname($stagingDir); + if (strpos(basename($parentStaging), 'dm_staging_') === 0 && is_dir($parentStaging)) { + $this->removeDirectory($parentStaging); + } + } + + /** + * Recursively remove a directory + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($dir); + } + + /** + * Get update channel label for display + */ + public static function getChannelLabel(string $channel): string + { + return match ($channel) { + 'stable' => 'Stable (Releases only)', + 'latest' => 'Latest (Releases + hotfixes)', + default => ucfirst($channel), + }; + } + + /** + * Check if there are pending database migrations + */ + public function hasPendingMigrations(): bool + { + try { + $pdo = \Core\Database::getConnection(); + + // Check if migrations table exists + try { + $stmt = $pdo->query("SELECT COUNT(*) FROM migrations"); + } catch (\Exception $e) { + return false; + } + + $executed = []; + $stmt = $pdo->query("SELECT migration FROM migrations"); + $executed = $stmt->fetchAll(\PDO::FETCH_COLUMN); + + // Scan migrations directory + $migrationsDir = $this->rootPath . '/database/migrations'; + $files = glob($migrationsDir . '/*.sql'); + $allMigrations = array_map('basename', $files); + + // Filter out the consolidated schema + $allMigrations = array_filter($allMigrations, function ($m) { + return strpos($m, '000_') !== 0; + }); + + $pending = array_diff($allMigrations, $executed); + return !empty($pending); + } catch (\Exception $e) { + return false; + } + } +} diff --git a/app/Views/domains/bulk-add.php b/app/Views/domains/bulk-add.php index d61696f..3898c48 100644 --- a/app/Views/domains/bulk-add.php +++ b/app/Views/domains/bulk-add.php @@ -6,17 +6,21 @@ $pageIcon = 'fas fa-layer-group'; ob_start(); ?> - +
-
-

- - Bulk Add Domains -

+ +
+ +
- -
+ + +
@@ -44,10 +48,8 @@ ob_start(); (Optional) -
-
-

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

-
-

💡 Available Tags:

+

Available Tags:

-
+ + + +
-
@@ -131,14 +208,13 @@ ob_start();

How It Works

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

-
@@ -169,6 +245,23 @@ ob_start();
'', 'status' => '', 'group' => '', 's
- -
- - - - - -
- - + +
+ Bulk Add @@ -124,126 +130,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
- - - - +
Showing to @@ -271,6 +158,110 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
+ + @@ -1071,6 +1060,14 @@ function submitTagRemoval() { // 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'); + } +}); + '', 'type' => '', 'sort' => 'last_o
- - -
@@ -167,6 +148,22 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
+ +
@@ -543,11 +540,9 @@ function updateBulkActions() { if (checkboxes.length > 0) { bulkActions.classList.remove('hidden'); - bulkActions.classList.add('flex'); selectedCount.textContent = checkboxes.length + ' error(s) selected'; } else { bulkActions.classList.add('hidden'); - bulkActions.classList.remove('flex'); } // Update select all checkbox state diff --git a/app/Views/groups/index.php b/app/Views/groups/index.php index aef5290..e6bd9c5 100644 --- a/app/Views/groups/index.php +++ b/app/Views/groups/index.php @@ -7,8 +7,31 @@ ob_start(); ?> - - - -
+ +
@@ -426,11 +419,9 @@ function updateBulkActions() { if (checkboxes.length > 0) { bulkActions.classList.remove('hidden'); - bulkActions.classList.add('flex'); selectedCount.textContent = checkboxes.length + ' user(s) selected'; } else { bulkActions.classList.add('hidden'); - bulkActions.classList.remove('flex'); } // Update select all checkbox state diff --git a/cron/check_domains.php b/cron/check_domains.php index 504fe28..c7affe0 100644 --- a/cron/check_domains.php +++ b/cron/check_domains.php @@ -22,6 +22,7 @@ use App\Models\Setting; use App\Models\User; use App\Services\WhoisService; use App\Services\NotificationService; +use App\Services\UpdateService; use Core\Database; // 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"); exit(0); diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index 3bf1c27..8ff3326 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -362,7 +362,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES ('app_name', 'Domain Monitor', 'string', 'Application name'), ('app_url', 'http://localhost:8000', 'string', 'Application URL'), ('app_timezone', 'UTC', 'string', 'Application timezone'), -('app_version', '1.1.2', 'string', 'Application version number'), +('app_version', '1.1.3', 'string', 'Application version number'), -- Email settings ('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'), -- 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; diff --git a/database/migrations/025_add_update_system_v1.1.3.sql b/database/migrations/025_add_update_system_v1.1.3.sql new file mode 100644 index 0000000..6e9cd38 --- /dev/null +++ b/database/migrations/025_add_update_system_v1.1.3.sql @@ -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'; diff --git a/routes/web.php b/routes/web.php index 7c846f6..4e4c808 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,7 @@ use App\Controllers\NotificationController; use App\Controllers\ErrorLogController; use App\Controllers\TwoFactorController; use App\Controllers\TagController; +use App\Controllers\UpdateController; $router = Application::$router; @@ -62,6 +63,8 @@ $router->get('/api/search/suggest', [SearchController::class, 'suggest']); // Domains $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/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 $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->post('/groups/store', [NotificationGroupController::class, 'store']); $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/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 $router->get('/profile', [ProfileController::class, 'index']); $router->post('/profile/update', [ProfileController::class, 'update']); @@ -182,10 +195,14 @@ $router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolv // Tag Management $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/update', [TagController::class, 'update']); $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->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']); $router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']);