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