Add import/export and update system

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

View File

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

View File

@@ -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';

View File

@@ -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'
};
}

View File

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

View File

@@ -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,

View File

@@ -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');
}
}

View File

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