Improve user deletion summary and installer

This commit is contained in:
root
2026-01-29 20:18:36 +02:00
parent a3e7da7275
commit 12670f3546
10 changed files with 313 additions and 48 deletions

View File

@@ -5,7 +5,7 @@
A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4.
Version: 0.9-rc33 (release candidate)
Version: 0.9-rc34 (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
@@ -156,6 +156,7 @@ php artisan test --compact
## Initial Release
- 0.9-rc34: User deletion summary steps; notification re-dispatch on repeated actions; ModSecurity packages added to installer.
- 0.9-rc33: Email Logs unified with Mail Queue; journald fallback; agent response reading hardened.
- 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded.
- 0.9-rc31: File manager navigation uses Livewire actions; parent row excluded from bulk select.

View File

@@ -1 +1 @@
VERSION=0.9-rc33
VERSION=0.9-rc34

View File

@@ -17,6 +17,8 @@ use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
class EmailLogs extends Page implements HasActions, HasTable
{
@@ -132,7 +134,7 @@ class EmailLogs extends Page implements HasActions, HasTable
return $table
->paginated([25, 50, 100])
->defaultPaginationPageOption(25)
->records(function () {
->records(function (?array $filters, ?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection) {
if ($this->viewMode === 'queue') {
if (! $this->queueLoaded) {
$this->loadQueue(false);
@@ -145,20 +147,10 @@ class EmailLogs extends Page implements HasActions, HasTable
$records = $this->logs;
}
return collect($records)
->mapWithKeys(function (array $record, int $index): array {
$queueId = $record['queue_id'] ?? '';
$timestamp = (int) ($record['timestamp'] ?? 0);
$keyParts = array_filter([
$queueId,
$timestamp > 0 ? (string) $timestamp : '',
], fn (string $part): bool => $part !== '');
$records = $this->filterRecords($records, $search);
$records = $this->sortRecords($records, $sortColumn, $sortDirection);
$key = implode('-', $keyParts);
return [$key !== '' ? $key : (string) $index => $record];
})
->all();
return $this->paginateRecords($records, $page, $recordsPerPage);
})
->columns($this->viewMode === 'queue' ? $this->getQueueColumns() : $this->getLogColumns())
->recordActions($this->viewMode === 'queue' ? $this->getQueueActions() : [])
@@ -243,11 +235,31 @@ class EmailLogs extends Page implements HasActions, HasTable
->searchable(),
TextColumn::make('recipients')
->label(__('Recipients'))
->formatStateUsing(function (array $record): string {
$recipients = $record['recipients'] ?? [];
->formatStateUsing(function ($state): string {
if (is_array($state)) {
$recipients = $state;
} elseif ($state === null || $state === '') {
$recipients = [];
} else {
$recipients = [(string) $state];
}
$recipients = array_values(array_filter(array_map(function ($recipient): ?string {
if (is_array($recipient)) {
return (string) ($recipient['address']
?? $recipient['recipient']
?? $recipient['email']
?? $recipient[0]
?? '');
}
return $recipient === null ? null : (string) $recipient;
}, $recipients), static fn (?string $value): bool => $value !== null && $value !== ''));
if (empty($recipients)) {
return __('Unknown');
}
$first = $recipients[0] ?? '';
$count = count($recipients);
@@ -256,7 +268,7 @@ class EmailLogs extends Page implements HasActions, HasTable
->wrap(),
TextColumn::make('size')
->label(__('Size'))
->formatStateUsing(fn (array $record): string => $record['size'] ?? ''),
->formatStateUsing(fn ($state): string => is_scalar($state) ? (string) $state : ''),
TextColumn::make('status')
->label(__('Status'))
->wrap(),
@@ -303,4 +315,81 @@ class EmailLogs extends Page implements HasActions, HasTable
}),
];
}
protected function filterRecords(array $records, ?string $search): array
{
$search = trim((string) $search);
if ($search === '') {
return $records;
}
$search = Str::lower($search);
return array_values(array_filter($records, function (array $record) use ($search): bool {
if ($this->viewMode === 'queue') {
$recipients = $record['recipients'] ?? [];
$haystack = implode(' ', array_filter([
(string) ($record['id'] ?? ''),
(string) ($record['sender'] ?? ''),
implode(' ', $recipients),
(string) ($record['status'] ?? ''),
]));
} else {
$haystack = implode(' ', array_filter([
(string) ($record['queue_id'] ?? ''),
(string) ($record['from'] ?? ''),
(string) ($record['to'] ?? ''),
(string) ($record['status'] ?? ''),
(string) ($record['message'] ?? ''),
(string) ($record['component'] ?? ''),
]));
}
return str_contains(Str::lower($haystack), $search);
}));
}
protected function sortRecords(array $records, ?string $sortColumn, ?string $sortDirection): array
{
$direction = $sortDirection === 'asc' ? 'asc' : 'desc';
if (! $sortColumn) {
return $records;
}
usort($records, function (array $a, array $b) use ($sortColumn, $direction): int {
$aValue = $a[$sortColumn] ?? null;
$bValue = $b[$sortColumn] ?? null;
if (is_numeric($aValue) && is_numeric($bValue)) {
$result = (float) $aValue <=> (float) $bValue;
} else {
$result = strcmp((string) $aValue, (string) $bValue);
}
return $direction === 'asc' ? $result : -$result;
});
return $records;
}
protected function paginateRecords(array $records, int|string $page, int|string $recordsPerPage): LengthAwarePaginator
{
$page = max(1, (int) $page);
$perPage = max(1, (int) $recordsPerPage);
$total = count($records);
$items = array_slice($records, ($page - 1) * $perPage, $perPage);
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => request()->url(),
'pageName' => $this->getTablePaginationPageName(),
],
);
}
}

View File

@@ -96,22 +96,22 @@ class EditUser extends EditRecord
->action(function (array $data) {
$removeHome = $data['remove_home'] ?? false;
$username = $this->record->username;
$steps = [];
try {
$linuxService = new LinuxUserService;
$domains = $this->record->domains()->pluck('domain')->all();
if ($linuxService->userExists($username)) {
$linuxService->deleteUser($username, $removeHome);
$result = $linuxService->deleteUser($username, $removeHome, $domains);
$body = $removeHome
? __("System user ':username' has been deleted along with home directory.", ['username' => $username])
: __("System user ':username' has been deleted.", ['username' => $username]);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
}
Notification::make()
->title(__('Linux user deleted'))
->body($body)
->success()
->send();
$steps = array_merge($steps, $result['steps'] ?? []);
} else {
$steps[] = __('Linux user not found on the server');
}
} catch (Exception $e) {
Notification::make()
@@ -124,6 +124,15 @@ class EditUser extends EditRecord
// Delete from database
$this->record->delete();
$steps[] = __('Removed user from admin list');
$details = implode("\n", array_map(fn ($step): string => '• '.$step, $steps));
Notification::make()
->title(__('User :username removed', ['username' => $username]))
->body($details)
->success()
->send();
$this->redirect($this->getResource()::getUrl('index'));
}),
];

View File

@@ -125,18 +125,22 @@ class UsersTable
->action(function ($record, array $data) {
$removeHome = $data['remove_home'] ?? false;
$username = $record->username;
$steps = [];
try {
$linuxService = new LinuxUserService;
$domains = $record->domains()->pluck('domain')->all();
if ($linuxService->userExists($username)) {
$linuxService->deleteUser($username, $removeHome);
$result = $linuxService->deleteUser($username, $removeHome, $domains);
Notification::make()
->title(__('Linux user deleted'))
->body(__("System user ':username' has been deleted.", ['username' => $username]))
->success()
->send();
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? __('Failed to delete Linux user'));
}
$steps = array_merge($steps, $result['steps'] ?? []);
} else {
$steps[] = __('Linux user not found on the server');
}
} catch (Exception $e) {
Notification::make()
@@ -147,6 +151,15 @@ class UsersTable
}
$record->delete();
$steps[] = __('Removed user from admin list');
$details = implode("\n", array_map(fn ($step): string => '• '.$step, $steps));
Notification::make()
->title(__('User :username removed', ['username' => $username]))
->body($details)
->success()
->send();
}),
])
->bulkActions([

View File

@@ -2,9 +2,13 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Models\Domain;
use App\Observers\DomainObserver;
use Illuminate\Support\ServiceProvider;
use Livewire\Component;
use Livewire\Livewire;
use function Livewire\on;
class AppServiceProvider extends ServiceProvider
{
@@ -25,5 +29,23 @@ class AppServiceProvider extends ServiceProvider
// Note: AuthEventListener is auto-discovered by Laravel 11+
// Do not manually subscribe - it causes duplicate audit log entries
on('dehydrate', function (Component $component): void {
static $dispatched = false;
if ($dispatched) {
return;
}
if (! Livewire::isLivewireRequest()) {
return;
}
if (count(session()->get('filament.notifications') ?? []) <= 0) {
return;
}
$dispatched = true;
$component->dispatch('notificationsSent');
});
}
}

View File

@@ -14,7 +14,7 @@ class LinuxUserService
public function __construct(?AgentClient $agent = null)
{
$this->agent = $agent ?? new AgentClient();
$this->agent = $agent ?? new AgentClient;
}
/**
@@ -28,6 +28,7 @@ class LinuxUserService
$user->update([
'home_directory' => $response['home_directory'] ?? "/home/{$user->username}",
]);
return true;
}
@@ -36,11 +37,13 @@ class LinuxUserService
/**
* Delete a Linux system user
*
* @param array<string> $domains
* @return array<string, mixed>
*/
public function deleteUser(string $username, bool $removeHome = false): bool
public function deleteUser(string $username, bool $removeHome = false, array $domains = []): array
{
$response = $this->agent->deleteUser($username, $removeHome);
return $response['success'] ?? false;
return $this->agent->deleteUser($username, $removeHome, $domains);
}
/**
@@ -57,6 +60,7 @@ class LinuxUserService
public function setPassword(string $username, string $password): bool
{
$response = $this->agent->setUserPassword($username, $password);
return $response['success'] ?? false;
}

View File

@@ -834,6 +834,7 @@ function deleteUser(array $params): array
$username = $params['username'] ?? '';
$removeHome = $params['remove_home'] ?? false;
$domains = $params['domains'] ?? []; // List of user's domains to clean up
$steps = [];
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username format'];
@@ -860,12 +861,17 @@ function deleteUser(array $params): array
}
}
$domainConfigRemoved = false;
$domainsDirExists = is_dir("$homeDir/domains");
// Clean up domain-related files for each domain
foreach ($domains as $domain) {
if (!validateDomain($domain)) {
continue;
}
$domainTouched = false;
// Remove nginx vhost configs (with .conf extension)
$nginxAvailable = "/etc/nginx/sites-available/{$domain}.conf";
$nginxEnabled = "/etc/nginx/sites-enabled/{$domain}.conf";
@@ -877,12 +883,14 @@ function deleteUser(array $params): array
if (file_exists($file) || is_link($file)) {
@unlink($file);
logger("Removed nginx symlink: $file");
$domainTouched = true;
}
}
foreach ([$nginxAvailable, $nginxAvailableOld] as $file) {
if (file_exists($file)) {
@unlink($file);
logger("Removed nginx config: $file");
$domainTouched = true;
}
}
@@ -891,6 +899,7 @@ function deleteUser(array $params): array
if (file_exists($zoneFile)) {
@unlink($zoneFile);
logger("Removed DNS zone: $zoneFile");
$domainTouched = true;
// Remove from named.conf.local
$namedConf = '/etc/bind/named.conf.local';
@@ -911,11 +920,13 @@ function deleteUser(array $params): array
if (is_dir($mailDir)) {
exec("rm -rf " . escapeshellarg($mailDir));
logger("Removed mail directory: $mailDir");
$domainTouched = true;
}
$vmailDir = "/var/vmail/$domain";
if (is_dir($vmailDir)) {
exec("rm -rf " . escapeshellarg($vmailDir));
logger("Removed vmail directory: $vmailDir");
$domainTouched = true;
}
// Remove from Postfix virtual_mailbox_domains
@@ -952,14 +963,22 @@ function deleteUser(array $params): array
if (is_dir($certPath)) {
exec("rm -rf " . escapeshellarg($certPath));
logger("Removed SSL certificate: $certPath");
$domainTouched = true;
}
if (is_dir($certArchive)) {
exec("rm -rf " . escapeshellarg($certArchive));
logger("Removed SSL archive: $certArchive");
$domainTouched = true;
}
if (file_exists($certRenewal)) {
@unlink($certRenewal);
logger("Removed SSL renewal config: $certRenewal");
$domainTouched = true;
}
if ($domainTouched) {
$domainConfigRemoved = true;
$steps[] = "$domain config files removed";
}
}
@@ -967,6 +986,9 @@ function deleteUser(array $params): array
$dbPrefix = $username . '_';
$mysqli = getMysqlConnection();
if ($mysqli) {
$dbDeletedCount = 0;
$dbUserDeletedCount = 0;
// Get all databases belonging to this user
$result = $mysqli->query("SHOW DATABASES LIKE '{$mysqli->real_escape_string($dbPrefix)}%'");
if ($result) {
@@ -976,6 +998,7 @@ function deleteUser(array $params): array
if (strpos($dbName, $dbPrefix) === 0) {
$mysqli->query("DROP DATABASE IF EXISTS `{$mysqli->real_escape_string($dbName)}`");
logger("Deleted MySQL database: $dbName");
$dbDeletedCount++;
}
}
$result->free();
@@ -991,18 +1014,33 @@ function deleteUser(array $params): array
if (strpos($dbUser, $dbPrefix) === 0) {
$mysqli->query("DROP USER IF EXISTS '{$mysqli->real_escape_string($dbUser)}'@'{$mysqli->real_escape_string($dbHost)}'");
logger("Deleted MySQL user: $dbUser@$dbHost");
$dbUserDeletedCount++;
}
}
$result->free();
}
$mysqli->query("FLUSH PRIVILEGES");
$mysqli->close();
if ($dbDeletedCount > 0) {
$steps[] = "MySQL databases removed ({$dbDeletedCount})";
}
if ($dbUserDeletedCount > 0) {
$steps[] = "MySQL users removed ({$dbUserDeletedCount})";
}
}
// Remove PHP-FPM pool config
$fpmRemovedCount = 0;
foreach (glob("/etc/php/*/fpm/pool.d/$username.conf") as $poolConf) {
@unlink($poolConf);
if (@unlink($poolConf)) {
logger("Removed PHP-FPM pool: $poolConf");
$fpmRemovedCount++;
}
}
if ($fpmRemovedCount > 0) {
$steps[] = 'PHP-FPM pool removed';
$domainConfigRemoved = true;
}
// Delete user with --force to ignore warnings about mail spool
@@ -1018,12 +1056,16 @@ function deleteUser(array $params): array
return ['success' => false, 'error' => 'Failed to delete user: ' . implode("\n", $userdelOutput)];
}
$steps[] = 'User removed from SSH';
$steps[] = 'Unix user removed from the server';
// Delete Redis ACL user (and all their cached keys)
$redisResult = redisDeleteUser(['username' => $username]);
if (!$redisResult['success']) {
logger("Warning: Failed to delete Redis user for $username: " . ($redisResult['error'] ?? 'Unknown error'));
} else {
logger("Deleted Redis ACL user for $username");
$steps[] = 'Redis ACL user removed';
}
// Manually remove home directory if requested (since it's owned by root)
@@ -1031,6 +1073,11 @@ function deleteUser(array $params): array
exec(sprintf('rm -rf %s 2>&1', escapeshellarg($homeDir)), $rmOutput, $rmExit);
if ($rmExit !== 0) {
logger("Warning: Failed to remove home directory for $username");
} else {
$steps[] = "User's data directory removed";
if ($domainsDirExists) {
$steps[] = "User's domains directory removed";
}
}
}
@@ -1042,9 +1089,13 @@ function deleteUser(array $params): array
exec('rndc reload 2>/dev/null');
exec('systemctl reload php*-fpm 2>/dev/null');
if ($domainConfigRemoved) {
$steps[] = "User's config files deleted";
}
logger("Deleted user $username" . ($removeHome ? " with home directory" : "") . " and cleaned up " . count($domains) . " domain(s)");
return ['success' => true, 'message' => "User $username deleted successfully"];
return ['success' => true, 'message' => "User $username deleted successfully", 'steps' => $steps];
}
function setUserPassword(array $params): array
@@ -2784,21 +2835,28 @@ function ensureJabaliNginxIncludeFiles(): void
ensureWafUnicodeMapFile();
ensureWafMainConfig();
$modSecurityAvailable = isModSecurityModuleAvailable();
$baseConfig = findWafBaseConfig();
$shouldDisableWaf = $baseConfig === null;
$shouldDisableWaf = $baseConfig === null || !$modSecurityAvailable;
if (!file_exists(JABALI_WAF_INCLUDE)) {
$content = "# Managed by Jabali\n";
if ($shouldDisableWaf) {
if (!$modSecurityAvailable) {
$content .= "# ModSecurity module not available in nginx.\n";
} elseif ($shouldDisableWaf) {
$content .= "modsecurity off;\n";
}
file_put_contents(JABALI_WAF_INCLUDE, $content);
} elseif ($shouldDisableWaf) {
$current = file_get_contents(JABALI_WAF_INCLUDE);
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false) {
if ($current === false || strpos($current, 'modsecurity_rules_file') !== false || strpos($current, 'modsecurity on;') !== false || strpos($current, 'modsecurity off;') !== false) {
if (!$modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
} else {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
}
}
}
if (!file_exists(JABALI_GEO_INCLUDE)) {
file_put_contents(JABALI_GEO_INCLUDE, "# Managed by Jabali\n");
@@ -2944,6 +3002,26 @@ function findWafBaseConfig(): ?string
return null;
}
function isModSecurityModuleAvailable(): bool
{
$output = [];
exec('nginx -V 2>&1', $output);
$info = implode("\n", $output);
if (stripos($info, 'modsecurity') !== false) {
return true;
}
foreach (glob('/etc/nginx/modules-enabled/*.conf') ?: [] as $file) {
$content = file_get_contents($file);
if ($content !== false && stripos($content, 'modsecurity') !== false) {
return true;
}
}
return false;
}
function isWafBaseConfigUsable(string $path): bool
{
if (!is_readable($path)) {
@@ -3006,8 +3084,14 @@ function wafApplySettings(array $params): array
$prevInclude = file_exists(JABALI_WAF_INCLUDE) ? file_get_contents(JABALI_WAF_INCLUDE) : null;
$prevRules = file_exists(JABALI_WAF_RULES) ? file_get_contents(JABALI_WAF_RULES) : null;
$modSecurityAvailable = isModSecurityModuleAvailable();
if ($enabled) {
if (!$modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
return ['success' => false, 'error' => 'ModSecurity module not available in nginx'];
}
ensureWafUnicodeMapFile();
$baseConfig = findWafBaseConfig();
if (!$baseConfig) {
@@ -3035,7 +3119,11 @@ function wafApplySettings(array $params): array
file_put_contents(JABALI_WAF_INCLUDE, implode("\n", $include) . "\n");
} else {
if ($modSecurityAvailable) {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\nmodsecurity off;\n");
} else {
file_put_contents(JABALI_WAF_INCLUDE, "# Managed by Jabali\n# ModSecurity module not available in nginx.\n");
}
}
ensureNginxServerIncludes([

View File

@@ -16,6 +16,7 @@ This blueprint describes a modern web hosting control panel (cPanel/DirectAdmin-
### Control plane (panel)
- UI + API, RBAC, tenant/package/quota management
- UI stack: Tailwind CSS + Filament components for panels, forms, tables, and widgets
- Job runner + queue workers
- Audit log + job logs/artifacts
- Central configuration + templates

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc26}"
JABALI_VERSION="${JABALI_VERSION:-0.9-rc34}"
# Colors
RED='\033[0;31m'
@@ -487,6 +487,38 @@ install_packages() {
# Add Security packages if enabled
if [[ "$INSTALL_SECURITY" == "true" ]]; then
info "Including Security packages..."
if apt-cache show libnginx-mod-http-modsecurity &>/dev/null; then
base_packages+=(
libnginx-mod-http-modsecurity
)
elif apt-cache show libnginx-mod-http-modsecurity2 &>/dev/null; then
base_packages+=(
libnginx-mod-http-modsecurity2
)
elif apt-cache show nginx-extras &>/dev/null; then
base_packages+=(
nginx-extras
)
else
warn "ModSecurity nginx module not available in apt repositories"
fi
if apt-cache show libmodsecurity3t64 &>/dev/null; then
base_packages+=(
libmodsecurity3t64
)
elif apt-cache show libmodsecurity3 &>/dev/null; then
base_packages+=(
libmodsecurity3
)
fi
if apt-cache show modsecurity-crs &>/dev/null; then
base_packages+=(
modsecurity-crs
)
fi
base_packages+=(
clamav
clamav-daemon
@@ -3225,6 +3257,12 @@ uninstall() {
# Security
fail2ban
libnginx-mod-http-modsecurity
libnginx-mod-http-modsecurity2
libmodsecurity3t64
libmodsecurity3
modsecurity-crs
nginx-extras
clamav
clamav-daemon
clamav-freshclam