Update hosting features and resource usage

This commit is contained in:
root
2026-01-27 23:38:27 +02:00
parent 7c2d780b9d
commit cc1196a390
91 changed files with 9758 additions and 403 deletions

2019
AGENT.md Normal file

File diff suppressed because it is too large Load Diff

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. 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-rc2 (release candidate) Version: 0.9-rc8 (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0. This is a release candidate. Expect rapid iteration and breaking changes until 1.0.

View File

@@ -1 +1 @@
VERSION=0.9-rc5 VERSION=0.9-rc8

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Mailbox;
use App\Models\User;
use App\Models\UserResourceUsage;
use App\Models\UserSetting;
use App\Services\Agent\AgentClient;
use Exception;
use Illuminate\Console\Command;
class CollectUserUsage extends Command
{
protected $signature = 'jabali:collect-user-usage {--user=} {--retain=90}';
protected $description = 'Collect per-user resource usage snapshots';
public function handle(): int
{
$users = User::query()
->where('is_admin', false)
->where('is_active', true);
$userFilter = $this->option('user');
if ($userFilter) {
$users->where(function ($query) use ($userFilter) {
$query->where('id', $userFilter)
->orWhere('username', $userFilter);
});
}
$users = $users->get();
if ($users->isEmpty()) {
$this->info('No users found for usage collection.');
return 0;
}
$agent = new AgentClient;
$capturedAt = now();
foreach ($users as $user) {
$diskBytes = $user->getDiskUsageBytes();
$mailBytes = (int) Mailbox::where('user_id', $user->id)->sum('quota_used_bytes');
$dbBytes = $this->getDatabaseUsage($agent, $user);
$bandwidthTotal = $this->getBandwidthTotal($agent, $user);
$resourceStats = $this->getUserResourceStats($agent, $user);
$cpuPercent = (int) round($resourceStats['cpu_percent'] ?? 0);
$memoryBytes = (int) ($resourceStats['memory_bytes'] ?? 0);
$diskIoTotal = (int) ($resourceStats['disk_io_total_bytes'] ?? 0);
$lastBandwidthTotal = (int) UserSetting::getForUser($user->id, 'bandwidth_total_bytes', 0);
$lastDiskIoTotal = (int) UserSetting::getForUser($user->id, 'disk_io_total_bytes', 0);
$bandwidthDelta = $bandwidthTotal >= $lastBandwidthTotal
? $bandwidthTotal - $lastBandwidthTotal
: $bandwidthTotal;
$diskIoDelta = $diskIoTotal >= $lastDiskIoTotal
? $diskIoTotal - $lastDiskIoTotal
: $diskIoTotal;
$this->storeMetric($user->id, 'disk_bytes', $diskBytes, $capturedAt);
$this->storeMetric($user->id, 'mail_bytes', $mailBytes, $capturedAt);
$this->storeMetric($user->id, 'database_bytes', $dbBytes, $capturedAt);
$this->storeMetric($user->id, 'bandwidth_bytes', $bandwidthDelta, $capturedAt);
$this->storeMetric($user->id, 'cpu_percent', $cpuPercent, $capturedAt);
$this->storeMetric($user->id, 'memory_bytes', $memoryBytes, $capturedAt);
$this->storeMetric($user->id, 'disk_io_bytes', $diskIoDelta, $capturedAt);
UserSetting::setForUser($user->id, 'bandwidth_total_bytes', $bandwidthTotal);
UserSetting::setForUser($user->id, 'disk_io_total_bytes', $diskIoTotal);
$this->line("Collected usage for {$user->username}");
}
$retainDays = (int) $this->option('retain');
if ($retainDays > 0) {
UserResourceUsage::where('captured_at', '<', now()->subDays($retainDays))->delete();
}
return 0;
}
protected function getDatabaseUsage(AgentClient $agent, User $user): int
{
try {
$result = $agent->mysqlListDatabases($user->username);
$databases = $result['databases'] ?? [];
$total = 0;
foreach ($databases as $database) {
$total += (int) ($database['size_bytes'] ?? 0);
}
return $total;
} catch (Exception $e) {
$this->warn("Failed to fetch database usage for {$user->username}: {$e->getMessage()}");
}
return 0;
}
protected function getBandwidthTotal(AgentClient $agent, User $user): int
{
try {
$result = $agent->send('usage.bandwidth_total', [
'username' => $user->username,
]);
if ($result['success'] ?? false) {
return (int) ($result['total_bytes'] ?? 0);
}
} catch (Exception $e) {
$this->warn("Failed to fetch bandwidth usage for {$user->username}: {$e->getMessage()}");
}
return 0;
}
/**
* @return array{cpu_percent: float, memory_bytes: int, disk_io_total_bytes: int}
*/
protected function getUserResourceStats(AgentClient $agent, User $user): array
{
try {
$result = $agent->send('usage.user_resources', [
'username' => $user->username,
]);
if ($result['success'] ?? false) {
return [
'cpu_percent' => (float) ($result['cpu_percent'] ?? 0),
'memory_bytes' => (int) ($result['memory_bytes'] ?? 0),
'disk_io_total_bytes' => (int) ($result['disk_io_total_bytes'] ?? 0),
];
}
} catch (Exception $e) {
$this->warn("Failed to fetch CPU/memory/disk IO for {$user->username}: {$e->getMessage()}");
}
return [
'cpu_percent' => 0.0,
'memory_bytes' => 0,
'disk_io_total_bytes' => 0,
];
}
protected function storeMetric(int $userId, string $metric, int $value, $capturedAt): void
{
UserResourceUsage::create([
'user_id' => $userId,
'metric' => $metric,
'value' => max(0, $value),
'captured_at' => $capturedAt,
]);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
class AutomationApi extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
protected static ?int $navigationSort = 17;
protected static ?string $slug = 'automation-api';
protected string $view = 'filament.admin.pages.automation-api';
public array $tokens = [];
public ?string $plainToken = null;
public function getTitle(): string|Htmlable
{
return __('API for Automation');
}
public static function getNavigationLabel(): string
{
return __('API Access');
}
public function mount(): void
{
$this->loadTokens();
}
protected function loadTokens(): void
{
$user = Auth::user();
if (! $user) {
$this->tokens = [];
return;
}
$this->tokens = $user->tokens()
->orderByDesc('created_at')
->get()
->map(function ($token) {
return [
'id' => $token->id,
'name' => $token->name,
'abilities' => implode(', ', $token->abilities ?? []),
'last_used_at' => $token->last_used_at?->format('Y-m-d H:i') ?? __('Never'),
'created_at' => $token->created_at?->format('Y-m-d H:i') ?? '',
];
})
->toArray();
$this->resetTable();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->tokens)
->columns([
TextColumn::make('name')
->label(__('Token')),
TextColumn::make('abilities')
->label(__('Abilities'))
->wrap(),
TextColumn::make('last_used_at')
->label(__('Last Used')),
TextColumn::make('created_at')
->label(__('Created')),
])
->recordActions([
Action::make('revoke')
->label(__('Revoke'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $record): void {
$user = Auth::user();
if (! $user) {
return;
}
$user->tokens()->where('id', $record['id'])->delete();
Notification::make()->title(__('Token revoked'))->success()->send();
$this->loadTokens();
}),
])
->headerActions([
Action::make('createToken')
->label(__('Create Token'))
->icon('heroicon-o-plus-circle')
->color('primary')
->modalHeading(__('Create API Token'))
->form([
TextInput::make('name')
->label(__('Token Name'))
->required(),
CheckboxList::make('abilities')
->label(__('Abilities'))
->options([
'automation' => __('Automation API'),
'read' => __('Read Only'),
'write' => __('Write'),
])
->columns(2)
->default(['automation']),
])
->action(function (array $data): void {
$user = Auth::user();
if (! $user) {
return;
}
$abilities = $data['abilities'] ?? ['automation'];
if (empty($abilities)) {
$abilities = ['automation'];
}
$token = $user->createToken($data['name'], $abilities);
$this->plainToken = $token->plainTextToken;
Notification::make()->title(__('Token created'))->success()->send();
$this->loadTokens();
}),
])
->emptyStateHeading(__('No API tokens yet'))
->emptyStateDescription(__('Create a token to access the automation API.'));
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\DB;
class DatabaseTuning extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack;
protected static ?int $navigationSort = 19;
protected static ?string $slug = 'database-tuning';
protected string $view = 'filament.admin.pages.database-tuning';
public array $variables = [];
public function getTitle(): string|Htmlable
{
return __('Database Tuning');
}
public static function getNavigationLabel(): string
{
return __('Database Tuning');
}
public function mount(): void
{
$this->loadVariables();
}
public function loadVariables(): void
{
$names = [
'innodb_buffer_pool_size',
'max_connections',
'tmp_table_size',
'max_heap_table_size',
'innodb_log_file_size',
'innodb_flush_log_at_trx_commit',
];
try {
$placeholders = implode("','", $names);
$rows = DB::connection('mysql')->select("SHOW VARIABLES WHERE Variable_name IN ('$placeholders')");
$this->variables = collect($rows)->map(function ($row) {
return [
'name' => $row->Variable_name,
'value' => $row->Value,
];
})->toArray();
} catch (\Exception $e) {
$this->variables = [];
Notification::make()
->title(__('Unable to load MySQL variables'))
->body($e->getMessage())
->warning()
->send();
}
$this->resetTable();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->variables)
->columns([
TextColumn::make('name')
->label(__('Variable'))
->fontFamily('mono'),
TextColumn::make('value')
->label(__('Current Value'))
->fontFamily('mono'),
])
->recordActions([
Action::make('update')
->label(__('Update'))
->icon('heroicon-o-pencil-square')
->form([
TextInput::make('value')
->label(__('New Value'))
->required(),
])
->action(function (array $record, array $data): void {
try {
DB::connection('mysql')->statement('SET GLOBAL '.$record['name'].' = ?', [$data['value']]);
try {
$agent = new AgentClient;
$agent->databasePersistTuning($record['name'], (string) $data['value']);
Notification::make()
->title(__('Variable updated'))
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title(__('Variable updated, but not persisted'))
->body($e->getMessage())
->warning()
->send();
}
} catch (\Exception $e) {
Notification::make()->title(__('Update failed'))->body($e->getMessage())->danger()->send();
}
$this->loadVariables();
}),
])
->emptyStateHeading(__('No variables found'))
->emptyStateDescription(__('Database variables could not be loaded.'));
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
class EmailQueue extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList;
protected static ?int $navigationSort = 15;
protected static ?string $slug = 'email-queue';
protected string $view = 'filament.admin.pages.email-queue';
public array $queueItems = [];
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return __('Email Queue Manager');
}
public static function getNavigationLabel(): string
{
return __('Email Queue');
}
public function mount(): void
{
$this->loadQueue();
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function loadQueue(): void
{
try {
$result = $this->getAgent()->send('mail.queue_list');
$this->queueItems = $result['queue'] ?? [];
} catch (\Exception $e) {
$this->queueItems = [];
Notification::make()
->title(__('Failed to load mail queue'))
->body($e->getMessage())
->danger()
->send();
}
$this->resetTable();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->queueItems)
->columns([
TextColumn::make('id')
->label(__('Queue ID'))
->fontFamily('mono')
->copyable(),
TextColumn::make('arrival')
->label(__('Arrival')),
TextColumn::make('sender')
->label(__('Sender'))
->wrap()
->searchable(),
TextColumn::make('recipients')
->label(__('Recipients'))
->formatStateUsing(function (array $record): string {
$recipients = $record['recipients'] ?? [];
if (empty($recipients)) {
return __('Unknown');
}
$first = $recipients[0] ?? '';
$count = count($recipients);
return $count > 1 ? $first.' +'.($count - 1) : $first;
})
->wrap(),
TextColumn::make('size')
->label(__('Size'))
->formatStateUsing(fn (array $record): string => $record['size'] ?? ''),
TextColumn::make('status')
->label(__('Status'))
->wrap(),
])
->recordActions([
Action::make('retry')
->label(__('Retry'))
->icon('heroicon-o-arrow-path')
->color('info')
->action(function (array $record): void {
try {
$result = $this->getAgent()->send('mail.queue_retry', ['id' => $record['id'] ?? '']);
if ($result['success'] ?? false) {
Notification::make()->title(__('Message retried'))->success()->send();
$this->loadQueue();
} else {
throw new \Exception($result['error'] ?? __('Failed to retry message'));
}
} catch (\Exception $e) {
Notification::make()->title(__('Retry failed'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $record): void {
try {
$result = $this->getAgent()->send('mail.queue_delete', ['id' => $record['id'] ?? '']);
if ($result['success'] ?? false) {
Notification::make()->title(__('Message deleted'))->success()->send();
$this->loadQueue();
} else {
throw new \Exception($result['error'] ?? __('Failed to delete message'));
}
} catch (\Exception $e) {
Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send();
}
}),
])
->emptyStateHeading(__('Mail queue is empty'))
->emptyStateDescription(__('No deferred messages found.'))
->headerActions([
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->action(fn () => $this->loadQueue()),
]);
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\User;
use App\Models\UserResourceUsage;
use BackedEnum;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
class ResourceUsage extends Page implements HasForms
{
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static ?int $navigationSort = 14;
protected static ?string $slug = 'resource-usage';
protected string $view = 'filament.admin.pages.resource-usage';
public array $usageFormData = [];
public array $chartData = [];
public array $performanceChartData = [];
public bool $hasPerformanceData = false;
public ?int $cpuLimitPercent = null;
public ?float $memoryLimitGb = null;
public array $cpuStats = [];
public array $memoryStats = [];
public array $summary = [];
public function getTitle(): string|Htmlable
{
return __('Resource Usage Reports');
}
public static function getNavigationLabel(): string
{
return __('Resource Usage');
}
public function mount(): void
{
$defaultUser = User::query()->where('is_admin', false)->orderBy('name')->first();
$this->usageFormData = [
'user_id' => $defaultUser?->id,
];
$this->loadUsage();
}
protected function getForms(): array
{
return ['usageForm'];
}
public function usageForm(Schema $schema): Schema
{
return $schema
->statePath('usageFormData')
->schema([
Section::make(__('Filters'))
->schema([
Select::make('user_id')
->label(__('User'))
->options($this->getUserOptions())
->searchable()
->preload()
->live()
->afterStateUpdated(function () {
$this->loadUsage();
})
->required(),
])
->columns(1),
]);
}
protected function getUserOptions(): array
{
return User::query()
->where('is_admin', false)
->orderBy('name')
->pluck('name', 'id')
->toArray();
}
protected function loadUsage(): void
{
$userId = $this->usageFormData['user_id'] ?? null;
$userId = $userId ? (int) $userId : null;
if (! $userId) {
$this->chartData = [];
$this->summary = [];
return;
}
$user = User::query()->with('hostingPackage')->find($userId);
$package = $user?->hostingPackage;
$this->cpuLimitPercent = $package?->cpu_limit_percent ?: null;
$memoryLimitMb = $package?->memory_limit_mb ?: null;
$this->memoryLimitGb = $memoryLimitMb ? $this->bytesToGb((int) ($memoryLimitMb * 1024 * 1024)) : null;
$usageMetrics = ['disk_bytes', 'mail_bytes', 'database_bytes', 'bandwidth_bytes'];
$performanceMetrics = ['cpu_percent', 'memory_bytes', 'disk_io_bytes'];
$metrics = array_merge($usageMetrics, $performanceMetrics);
$start = now()->subDays(30);
$records = UserResourceUsage::query()
->where('user_id', $userId)
->whereIn('metric', $metrics)
->where('captured_at', '>=', $start)
->orderBy('captured_at')
->get();
$labels = $records
->map(fn ($record) => $record->captured_at->format('Y-m-d H:00'))
->unique()
->values();
$grouped = $records->groupBy('metric')->map(function ($collection) {
return $collection->keyBy(fn ($item) => $item->captured_at->format('Y-m-d H:00'));
});
$series = [];
foreach ($usageMetrics as $metric) {
$series[$metric] = [];
foreach ($labels as $label) {
$value = $grouped[$metric][$label]->value ?? 0;
$series[$metric][] = $this->bytesToGb((int) $value);
}
}
$this->chartData = [
'labels' => $labels->toArray(),
'disk' => $series['disk_bytes'],
'mail' => $series['mail_bytes'],
'database' => $series['database_bytes'],
'bandwidth' => $series['bandwidth_bytes'],
];
$performanceSeries = [];
foreach ($performanceMetrics as $metric) {
$performanceSeries[$metric] = [];
foreach ($labels as $label) {
$value = $grouped[$metric][$label]->value ?? 0;
if ($metric === 'cpu_percent') {
$performanceSeries[$metric][] = (int) $value;
} elseif ($metric === 'memory_bytes') {
$performanceSeries[$metric][] = $this->bytesToGb((int) $value);
} else {
$performanceSeries[$metric][] = $this->bytesToMb((int) $value);
}
}
}
$performanceRecords = $records->whereIn('metric', $performanceMetrics);
$this->hasPerformanceData = $performanceRecords->isNotEmpty();
$this->performanceChartData = [
'labels' => $labels->toArray(),
'cpu' => $performanceSeries['cpu_percent'],
'memory' => $performanceSeries['memory_bytes'],
'disk_io' => $performanceSeries['disk_io_bytes'],
];
$this->cpuStats = $this->buildPercentageStats($performanceRecords->where('metric', 'cpu_percent')->pluck('value'));
$this->memoryStats = $this->buildBytesStats($performanceRecords->where('metric', 'memory_bytes')->pluck('value'));
$this->summary = [
'disk' => $this->formatMetric($userId, 'disk_bytes'),
'mail' => $this->formatMetric($userId, 'mail_bytes'),
'database' => $this->formatMetric($userId, 'database_bytes'),
'bandwidth' => $this->formatMetric($userId, 'bandwidth_bytes'),
];
}
protected function formatMetric(int $userId, string $metric): string
{
$latest = UserResourceUsage::query()
->where('user_id', $userId)
->where('metric', $metric)
->latest('captured_at')
->value('value');
if ($latest === null) {
return __('No data');
}
return number_format($this->bytesToGb((int) $latest), 2).' GB';
}
protected function bytesToGb(int $bytes): float
{
return round($bytes / 1024 / 1024 / 1024, 4);
}
protected function bytesToMb(int $bytes): float
{
return round($bytes / 1024 / 1024, 2);
}
/**
* @param \Illuminate\Support\Collection<int, int|float> $values
* @return array{avg: ?string, max: ?string}
*/
protected function buildPercentageStats($values): array
{
if ($values->isEmpty()) {
return [
'avg' => null,
'max' => null,
];
}
return [
'avg' => number_format((float) $values->avg(), 1).'%',
'max' => number_format((float) $values->max(), 1).'%',
];
}
/**
* @param \Illuminate\Support\Collection<int, int|float> $values
* @return array{avg: ?string, max: ?string}
*/
protected function buildBytesStats($values): array
{
if ($values->isEmpty()) {
return [
'avg' => null,
'max' => null,
];
}
return [
'avg' => number_format($this->bytesToGb((int) $values->avg()), 2).' GB',
'max' => number_format($this->bytesToGb((int) $values->max()), 2).' GB',
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Admin\Pages; namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Security\BannedIpsTable; use App\Filament\Admin\Widgets\Security\BannedIpsTable;
use App\Filament\Admin\Widgets\Security\Fail2banLogsTable;
use App\Filament\Admin\Widgets\Security\JailsTable; use App\Filament\Admin\Widgets\Security\JailsTable;
use App\Filament\Admin\Widgets\Security\LynisResultsTable; use App\Filament\Admin\Widgets\Security\LynisResultsTable;
use App\Filament\Admin\Widgets\Security\NiktoResultsTable; use App\Filament\Admin\Widgets\Security\NiktoResultsTable;
@@ -90,6 +91,8 @@ class Security extends Page implements HasActions, HasForms, HasTable
public ?int $totalBanned = null; public ?int $totalBanned = null;
public array $fail2banLogs = [];
public int $maxRetry = 5; public int $maxRetry = 5;
public int $banTime = 600; public int $banTime = 600;
@@ -545,6 +548,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]), EmbeddedTable::make(BannedIpsTable::class, ['jails' => $this->jails]),
]) ])
->visible(fn () => ($this->totalBanned ?? 0) > 0), ->visible(fn () => ($this->totalBanned ?? 0) > 0),
Section::make(__('Fail2ban Logs'))
->icon('heroicon-o-document-text')
->schema([
EmbeddedTable::make(Fail2banLogsTable::class, ['logs' => $this->fail2banLogs]),
])
->collapsible(),
])->visible(fn () => $this->fail2banInstalled), ])->visible(fn () => $this->fail2banInstalled),
]; ];
} }
@@ -1012,6 +1021,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = []; $this->jails = [];
$this->availableJails = []; $this->availableJails = [];
$this->totalBanned = null; $this->totalBanned = null;
$this->fail2banLogs = [];
} catch (Exception $e) { } catch (Exception $e) {
$this->fail2banInstalled = false; $this->fail2banInstalled = false;
$this->fail2banRunning = false; $this->fail2banRunning = false;
@@ -1019,6 +1029,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = []; $this->jails = [];
$this->availableJails = []; $this->availableJails = [];
$this->totalBanned = null; $this->totalBanned = null;
$this->fail2banLogs = [];
} }
} }
@@ -1039,12 +1050,16 @@ class Security extends Page implements HasActions, HasForms, HasTable
$jailsResult = $this->getAgent()->send('fail2ban.list_jails'); $jailsResult = $this->getAgent()->send('fail2ban.list_jails');
$this->availableJails = $jailsResult['jails'] ?? []; $this->availableJails = $jailsResult['jails'] ?? [];
$logsResult = $this->getAgent()->send('fail2ban.logs');
$this->fail2banLogs = $logsResult['logs'] ?? [];
} else { } else {
$this->fail2banRunning = false; $this->fail2banRunning = false;
$this->fail2banVersion = ''; $this->fail2banVersion = '';
$this->jails = []; $this->jails = [];
$this->availableJails = []; $this->availableJails = [];
$this->totalBanned = null; $this->totalBanned = null;
$this->fail2banLogs = [];
} }
} catch (Exception $e) { } catch (Exception $e) {
$this->fail2banInstalled = false; $this->fail2banInstalled = false;
@@ -1053,6 +1068,7 @@ class Security extends Page implements HasActions, HasForms, HasTable
$this->jails = []; $this->jails = [];
$this->availableJails = []; $this->availableJails = [];
$this->totalBanned = null; $this->totalBanned = null;
$this->fail2banLogs = [];
} }
} }

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
class ServerUpdates extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare;
protected static ?int $navigationSort = 16;
protected static ?string $slug = 'server-updates';
protected string $view = 'filament.admin.pages.server-updates';
public array $packages = [];
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return __('Server Updates');
}
public static function getNavigationLabel(): string
{
return __('Server Updates');
}
public function mount(): void
{
$this->loadUpdates();
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
public function loadUpdates(): void
{
try {
$result = $this->getAgent()->updatesList();
$this->packages = $result['packages'] ?? [];
} catch (\Exception $e) {
$this->packages = [];
Notification::make()
->title(__('Failed to load updates'))
->body($e->getMessage())
->danger()
->send();
}
$this->resetTable();
}
public function runUpdates(): void
{
try {
$this->getAgent()->updatesRun();
Notification::make()
->title(__('Updates completed'))
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title(__('Update failed'))
->body($e->getMessage())
->danger()
->send();
}
$this->loadUpdates();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->packages)
->columns([
TextColumn::make('name')
->label(__('Package'))
->searchable(),
TextColumn::make('current_version')
->label(__('Current Version')),
TextColumn::make('new_version')
->label(__('New Version')),
])
->emptyStateHeading(__('No updates available'))
->emptyStateDescription(__('Your system packages are up to date.'))
->headerActions([
Action::make('refresh')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->action(fn () => $this->loadUpdates()),
Action::make('runUpdates')
->label(__('Run Updates'))
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(__('Install updates'))
->modalDescription(__('This will run apt-get upgrade on the server. Continue?'))
->action(fn () => $this->runUpdates()),
]);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\Setting;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
class Waf extends Page implements HasForms
{
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?int $navigationSort = 20;
protected static ?string $slug = 'waf';
protected string $view = 'filament.admin.pages.waf';
public bool $wafInstalled = false;
public array $wafFormData = [];
public function getTitle(): string|Htmlable
{
return __('ModSecurity / WAF');
}
public static function getNavigationLabel(): string
{
return __('ModSecurity / WAF');
}
public function mount(): void
{
$this->wafInstalled = $this->detectWaf();
$this->wafFormData = [
'enabled' => Setting::get('waf_enabled', '0') === '1',
'paranoia' => Setting::get('waf_paranoia', '1'),
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
];
}
protected function detectWaf(): bool
{
return file_exists('/etc/nginx/modsec/main.conf') || file_exists('/etc/nginx/modsecurity.conf');
}
protected function getForms(): array
{
return ['wafForm'];
}
public function wafForm(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
{
return $schema
->statePath('wafFormData')
->schema([
Section::make(__('WAF Settings'))
->schema([
Toggle::make('enabled')
->label(__('Enable ModSecurity')),
Select::make('paranoia')
->label(__('Paranoia Level'))
->options([
'1' => '1 - Basic',
'2' => '2 - Moderate',
'3' => '3 - Strict',
'4' => '4 - Very Strict',
])
->default('1'),
Toggle::make('audit_log')
->label(__('Enable Audit Log')),
])
->columns(2),
]);
}
public function saveWafSettings(): void
{
$data = $this->wafForm->getState();
Setting::set('waf_enabled', ! empty($data['enabled']) ? '1' : '0');
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
try {
$agent = new AgentClient;
$agent->wafApplySettings(
! empty($data['enabled']),
(string) ($data['paranoia'] ?? '1'),
! empty($data['audit_log'])
);
Notification::make()
->title(__('WAF settings applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('WAF settings saved, but apply failed'))
->body($e->getMessage())
->warning()
->send();
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\CreateGeoBlockRule;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\EditGeoBlockRule;
use App\Filament\Admin\Resources\GeoBlockRules\Pages\ListGeoBlockRules;
use App\Filament\Admin\Resources\GeoBlockRules\Schemas\GeoBlockRuleForm;
use App\Filament\Admin\Resources\GeoBlockRules\Tables\GeoBlockRulesTable;
use App\Models\GeoBlockRule;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class GeoBlockRuleResource extends Resource
{
protected static ?string $model = GeoBlockRule::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt;
protected static ?int $navigationSort = 22;
public static function getNavigationLabel(): string
{
return __('Geographic Blocking');
}
public static function getModelLabel(): string
{
return __('Geo Rule');
}
public static function getPluralModelLabel(): string
{
return __('Geo Rules');
}
public static function form(Schema $schema): Schema
{
return GeoBlockRuleForm::configure($schema);
}
public static function table(Table $table): Table
{
return GeoBlockRulesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListGeoBlockRules::route('/'),
'create' => CreateGeoBlockRule::route('/create'),
'edit' => EditGeoBlockRule::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
use App\Services\System\GeoBlockService;
use Exception;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateGeoBlockRule extends CreateRecord
{
protected static string $resource = GeoBlockRuleResource::class;
protected function afterCreate(): void
{
try {
app(GeoBlockService::class)->applyCurrentRules();
Notification::make()
->title(__('Geo rules applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Geo rules apply failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
use App\Services\System\GeoBlockService;
use Exception;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditGeoBlockRule extends EditRecord
{
protected static string $resource = GeoBlockRuleResource::class;
protected function afterSave(): void
{
$this->applyGeoRules();
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->after(fn () => $this->applyGeoRules()),
];
}
protected function applyGeoRules(): void
{
try {
app(GeoBlockService::class)->applyCurrentRules();
Notification::make()
->title(__('Geo rules applied'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Geo rules apply failed'))
->body($e->getMessage())
->danger()
->send();
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Pages;
use App\Filament\Admin\Resources\GeoBlockRules\GeoBlockRuleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListGeoBlockRules extends ListRecords
{
protected static string $resource = GeoBlockRuleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class GeoBlockRuleForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Geo Rule'))
->schema([
TextInput::make('country_code')
->label(__('Country Code'))
->maxLength(2)
->minLength(2)
->required()
->helperText(__('Use ISO-3166 alpha-2 code (e.g., US, DE, FR).')),
Select::make('action')
->label(__('Action'))
->options([
'block' => __('Block'),
'allow' => __('Allow'),
])
->default('block')
->required(),
TextInput::make('notes')
->label(__('Notes')),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\GeoBlockRules\Tables;
use App\Services\System\GeoBlockService;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class GeoBlockRulesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('country_code')
->label(__('Country'))
->formatStateUsing(fn ($state) => strtoupper($state))
->searchable(),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => $state === 'block' ? 'danger' : 'success'),
TextColumn::make('notes')
->label(__('Notes'))
->wrap(),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->after(function () {
try {
app(GeoBlockService::class)->applyCurrentRules();
} catch (\Exception $e) {
Notification::make()
->title(__('Geo rules sync failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->emptyStateHeading(__('No geo rules'))
->emptyStateDescription(__('Add a country rule to allow or block traffic.'));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages;
use App\Filament\Admin\Resources\HostingPackages\Pages\CreateHostingPackage;
use App\Filament\Admin\Resources\HostingPackages\Pages\EditHostingPackage;
use App\Filament\Admin\Resources\HostingPackages\Pages\ListHostingPackages;
use App\Filament\Admin\Resources\HostingPackages\Schemas\HostingPackageForm;
use App\Filament\Admin\Resources\HostingPackages\Tables\HostingPackagesTable;
use App\Models\HostingPackage;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class HostingPackageResource extends Resource
{
protected static ?string $model = HostingPackage::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;
protected static ?int $navigationSort = 13;
public static function getNavigationLabel(): string
{
return __('Hosting Packages');
}
public static function getModelLabel(): string
{
return __('Hosting Package');
}
public static function getPluralModelLabel(): string
{
return __('Hosting Packages');
}
public static function form(Schema $schema): Schema
{
return HostingPackageForm::configure($schema);
}
public static function table(Table $table): Table
{
return HostingPackagesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListHostingPackages::route('/'),
'create' => CreateHostingPackage::route('/create'),
'edit' => EditHostingPackage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreateHostingPackage extends CreateRecord
{
protected static string $resource = HostingPackageResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditHostingPackage extends EditRecord
{
protected static string $resource = HostingPackageResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Pages;
use App\Filament\Admin\Resources\HostingPackages\HostingPackageResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListHostingPackages extends ListRecords
{
protected static string $resource = HostingPackageResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class HostingPackageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Package Details'))
->schema([
TextInput::make('name')
->label(__('Name'))
->required()
->maxLength(120)
->unique(ignoreRecord: true),
Textarea::make('description')
->label(__('Description'))
->rows(3)
->columnSpanFull(),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
Section::make(__('Resource Limits'))
->description(__('Leave blank for unlimited.'))
->schema([
TextInput::make('disk_quota_mb')
->label(__('Disk Quota (MB)'))
->numeric()
->minValue(0)
->helperText(__('Example: 10240 = 10 GB')),
TextInput::make('bandwidth_gb')
->label(__('Bandwidth (GB / month)'))
->numeric()
->minValue(0),
TextInput::make('domains_limit')
->label(__('Domains Limit'))
->numeric()
->minValue(0),
TextInput::make('databases_limit')
->label(__('Databases Limit'))
->numeric()
->minValue(0),
TextInput::make('mailboxes_limit')
->label(__('Mailboxes Limit'))
->numeric()
->minValue(0),
])
->columns(2),
Section::make(__('System Resource Limits'))
->description(__('Optional cgroup limits applied to users in this package. Leave blank for unlimited.'))
->schema([
TextInput::make('cpu_limit_percent')
->label(__('CPU Limit (%)'))
->numeric()
->minValue(0)
->maxValue(100)
->helperText(__('Example: 50 = 50% CPU quota')),
TextInput::make('memory_limit_mb')
->label(__('Memory Limit (MB)'))
->numeric()
->minValue(0)
->helperText(__('Example: 1024 = 1 GB RAM')),
TextInput::make('io_limit_mb')
->label(__('Disk IO Limit (MB/s)'))
->numeric()
->minValue(0)
->helperText(__('Applied to read/write bandwidth')),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\HostingPackages\Tables;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class HostingPackagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable()
->sortable(),
TextColumn::make('disk_quota_mb')
->label(__('Disk'))
->getStateUsing(function ($record) {
$quota = $record->disk_quota_mb;
if (! $quota) {
return __('Unlimited');
}
return $quota >= 1024
? number_format($quota / 1024, 1).' GB'
: $quota.' MB';
}),
TextColumn::make('bandwidth_gb')
->label(__('Bandwidth'))
->getStateUsing(fn ($record) => $record->bandwidth_gb ? $record->bandwidth_gb.' GB' : __('Unlimited')),
TextColumn::make('domains_limit')
->label(__('Domains'))
->getStateUsing(fn ($record) => $record->domains_limit ?: __('Unlimited')),
TextColumn::make('databases_limit')
->label(__('Databases'))
->getStateUsing(fn ($record) => $record->databases_limit ?: __('Unlimited')),
TextColumn::make('mailboxes_limit')
->label(__('Mailboxes'))
->getStateUsing(fn ($record) => $record->mailboxes_limit ?: __('Unlimited')),
TextColumn::make('resource_limits')
->label(__('System Limits'))
->getStateUsing(function ($record) {
$cpu = $record->cpu_limit_percent ? $record->cpu_limit_percent.'%' : null;
$memory = $record->memory_limit_mb ? $record->memory_limit_mb.' MB' : null;
$io = $record->io_limit_mb ? $record->io_limit_mb.' MB/s' : null;
$parts = array_filter([
$cpu ? __('CPU: :value', ['value' => $cpu]) : null,
$memory ? __('RAM: :value', ['value' => $memory]) : null,
$io ? __('IO: :value', ['value' => $io]) : null,
]);
return ! empty($parts) ? implode(', ', $parts) : __('Unlimited');
}),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->defaultSort('name');
}
}

View File

@@ -5,24 +5,36 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages; namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource; use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService; use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Str;
use Exception;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeCreate(array $data): array protected function mutateFormDataBeforeCreate(array $data): array
{ {
// Generate SFTP password (same as user password or random) // Generate SFTP password (same as user password or random)
if (!empty($data['password'])) { if (! empty($data['password'])) {
$data['sftp_password'] = $data['password']; $data['sftp_password'] = $data['password'];
} }
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data; return $data;
} }
@@ -32,7 +44,7 @@ class CreateUser extends CreateRecord
if ($createLinuxUser) { if ($createLinuxUser) {
try { try {
$linuxService = new LinuxUserService(); $linuxService = new LinuxUserService;
// Get the plain password before it was hashed // Get the plain password before it was hashed
$password = $this->data['sftp_password'] ?? null; $password = $this->data['sftp_password'] ?? null;
@@ -47,6 +59,9 @@ class CreateUser extends CreateRecord
// Apply disk quota if enabled // Apply disk quota if enabled
$this->applyDiskQuota(); $this->applyDiskQuota();
// Apply resource limits from package
$this->syncResourceLimitsFromPackage($this->selectedPackage, true);
} catch (Exception $e) { } catch (Exception $e) {
Notification::make() Notification::make()
->title(__('Linux user creation failed')) ->title(__('Linux user creation failed'))
@@ -54,19 +69,30 @@ class CreateUser extends CreateRecord
->danger() ->danger()
->send(); ->send();
} }
} else {
// Store resource limits even if the Linux user was not created yet
$this->syncResourceLimitsFromPackage($this->selectedPackage, false);
}
if (! $this->record->hosting_package_id) {
Notification::make()
->title(__('No hosting package selected'))
->body(__('This user has unlimited resource limits.'))
->warning()
->send();
} }
} }
protected function applyDiskQuota(): void protected function applyDiskQuota(): void
{ {
$quotaMb = $this->record->disk_quota_mb; $quotaMb = $this->record->disk_quota_mb;
if (!$quotaMb || $quotaMb <= 0) { if (! $quotaMb || $quotaMb <= 0) {
return; return;
} }
// Always try to apply quota when set // Always try to apply quota when set
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
$result = $agent->quotaSet($this->record->username, (int) $quotaMb); $result = $agent->quotaSet($this->record->username, (int) $quotaMb);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -87,4 +113,50 @@ class CreateUser extends CreateRecord
->send(); ->send();
} }
} }
protected function syncResourceLimitsFromPackage(?HostingPackage $package, bool $apply): void
{
if (! $this->record) {
return;
}
$cpu = $package?->cpu_limit_percent;
$memory = $package?->memory_limit_mb;
$io = $package?->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
if (! $package || ! $hasLimits) {
if ($limit) {
$limit->fill([
'cpu_limit_percent' => null,
'memory_limit_mb' => null,
'io_limit_mb' => null,
'is_active' => false,
])->save();
if ($apply) {
app(ResourceLimitService::class)->clear($limit);
}
}
return;
}
if (! $limit) {
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
}
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
if ($apply) {
app(ResourceLimitService::class)->apply($limit);
}
}
} }

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Admin\Resources\Users\Pages; namespace App\Filament\Admin\Resources\Users\Pages;
use App\Filament\Admin\Resources\Users\UserResource; use App\Filament\Admin\Resources\Users\UserResource;
use App\Models\HostingPackage;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService; use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
@@ -19,6 +22,8 @@ class EditUser extends EditRecord
protected ?int $originalQuota = null; protected ?int $originalQuota = null;
protected ?HostingPackage $selectedPackage = null;
protected function mutateFormDataBeforeFill(array $data): array protected function mutateFormDataBeforeFill(array $data): array
{ {
$this->originalQuota = $data['disk_quota_mb'] ?? null; $this->originalQuota = $data['disk_quota_mb'] ?? null;
@@ -26,13 +31,23 @@ class EditUser extends EditRecord
return $data; return $data;
} }
protected function mutateFormDataBeforeSave(array $data): array
{
if (! empty($data['hosting_package_id'])) {
$this->selectedPackage = HostingPackage::find($data['hosting_package_id']);
$data['disk_quota_mb'] = $this->selectedPackage?->disk_quota_mb;
} else {
$this->selectedPackage = null;
$data['disk_quota_mb'] = null;
}
return $data;
}
protected function afterSave(): void protected function afterSave(): void
{ {
$newQuota = $this->record->disk_quota_mb; $newQuota = $this->record->disk_quota_mb;
if ($newQuota === $this->originalQuota) { if ($newQuota !== $this->originalQuota) {
return;
}
// Always try to apply quota when changed // Always try to apply quota when changed
try { try {
$agent = new AgentClient; $agent = new AgentClient;
@@ -61,6 +76,47 @@ class EditUser extends EditRecord
} }
} }
$this->syncResourceLimitsFromPackage($this->selectedPackage);
}
protected function syncResourceLimitsFromPackage(?HostingPackage $package): void
{
$cpu = $package?->cpu_limit_percent;
$memory = $package?->memory_limit_mb;
$io = $package?->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
$limit = UserResourceLimit::where('user_id', $this->record->id)->first();
if (! $package || ! $hasLimits) {
if ($limit) {
$limit->fill([
'cpu_limit_percent' => null,
'memory_limit_mb' => null,
'io_limit_mb' => null,
'is_active' => false,
])->save();
app(ResourceLimitService::class)->clear($limit);
}
return;
}
if (! $limit) {
$limit = new UserResourceLimit(['user_id' => $this->record->id]);
}
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
app(ResourceLimitService::class)->apply($limit);
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [

View File

@@ -2,18 +2,15 @@
namespace App\Filament\Admin\Resources\Users\Schemas; namespace App\Filament\Admin\Resources\Users\Schemas;
use App\Models\DnsSetting; use App\Models\HostingPackage;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Group;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class UserForm class UserForm
{ {
@@ -29,12 +26,12 @@ class UserForm
// Ensure at least one of each type // Ensure at least one of each type
$password = $uppercase[random_int(0, strlen($uppercase) - 1)] $password = $uppercase[random_int(0, strlen($uppercase) - 1)]
. $lowercase[random_int(0, strlen($lowercase) - 1)] .$lowercase[random_int(0, strlen($lowercase) - 1)]
. $numbers[random_int(0, strlen($numbers) - 1)] .$numbers[random_int(0, strlen($numbers) - 1)]
. $special[random_int(0, strlen($special) - 1)]; .$special[random_int(0, strlen($special) - 1)];
// Fill rest with random characters from all pools // Fill rest with random characters from all pools
$allChars = $uppercase . $lowercase . $numbers . $special; $allChars = $uppercase.$lowercase.$numbers.$special;
for ($i = 4; $i < $length; $i++) { for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)]; $password .= $allChars[random_int(0, strlen($allChars) - 1)];
} }
@@ -125,6 +122,24 @@ class UserForm
->helperText(__('Inactive users cannot log in')) ->helperText(__('Inactive users cannot log in'))
->inline(false), ->inline(false),
Placeholder::make('package_notice')
->label(__('Hosting Package'))
->content(__('No hosting package selected. This user will have unlimited resources.'))
->visible(fn ($get) => blank($get('hosting_package_id'))),
\Filament\Forms\Components\Select::make('hosting_package_id')
->label(__('Hosting Package'))
->searchable()
->preload()
->options(fn () => HostingPackage::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->toArray())
->placeholder(__('Unlimited (no package)'))
->helperText(__('Assign a package to set resource limits.'))
->columnSpanFull(),
Toggle::make('create_linux_user') Toggle::make('create_linux_user')
->label(__('Create Linux User')) ->label(__('Create Linux User'))
->default(true) ->default(true)
@@ -138,68 +153,11 @@ class UserForm
]) ])
->columns(4), ->columns(4),
Section::make(__('Disk Quota'))
->schema([
Placeholder::make('current_usage')
->label(__('Current Usage'))
->content(function ($record) {
if (!$record) {
return __('N/A');
}
$used = $record->disk_usage_formatted;
$quotaMb = $record->disk_quota_mb;
if (!$quotaMb || $quotaMb <= 0) {
return "{$used} (" . __('Unlimited') . ")";
}
$quota = $quotaMb >= 1024
? number_format($quotaMb / 1024, 1) . ' GB'
: $quotaMb . ' MB';
$percent = $record->disk_usage_percent;
return "{$used} / {$quota} ({$percent}%)";
})
->visibleOn('edit'),
Toggle::make('unlimited_quota')
->label(__('Unlimited Quota'))
->helperText(__('No disk space limit'))
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120) === 0)
->live()
->afterStateHydrated(function (Toggle $component, $state, $record) {
if ($record) {
$component->state(!$record->disk_quota_mb || $record->disk_quota_mb <= 0);
}
})
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$set('disk_quota_mb', 0);
} else {
$set('disk_quota_mb', (int) DnsSetting::get('default_quota_mb', 5120));
}
})
->inline(false),
TextInput::make('disk_quota_mb')
->label(__('Disk Quota'))
->numeric()
->minValue(1)
->default(fn () => (int) DnsSetting::get('default_quota_mb', 5120))
->helperText(fn ($state) => $state && $state > 0 ? number_format($state / 1024, 1) . ' GB' : null)
->suffix('MB')
->visible(fn ($get) => !$get('unlimited_quota'))
->required(fn ($get) => !$get('unlimited_quota')),
])
->description(fn () => !(bool) DnsSetting::get('quotas_enabled', false) ? __('Note: Quotas are currently disabled in Server Settings.') : null)
->columns(3),
Section::make(__('System Information')) Section::make(__('System Information'))
->schema([ ->schema([
Placeholder::make('home_directory_display') Placeholder::make('home_directory_display')
->label(__('Home Directory')) ->label(__('Home Directory'))
->content(fn ($record) => $record?->home_directory ?? '/home/' . __('username')), ->content(fn ($record) => $record?->home_directory ?? '/home/'.__('username')),
Placeholder::make('created_at_display') Placeholder::make('created_at_display')
->label(__('Created')) ->label(__('Created'))

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhookEndpoint extends CreateRecord
{
protected static string $resource = WebhookEndpointResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhookEndpoint extends EditRecord
{
protected static string $resource = WebhookEndpointResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Pages;
use App\Filament\Admin\Resources\WebhookEndpoints\WebhookEndpointResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWebhookEndpoints extends ListRecords
{
protected static string $resource = WebhookEndpointResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Schemas;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class WebhookEndpointForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make(__('Webhook Details'))
->schema([
TextInput::make('name')
->label(__('Name'))
->required()
->maxLength(120),
TextInput::make('url')
->label(__('URL'))
->url()
->required(),
CheckboxList::make('events')
->label(__('Events'))
->options([
'backup.completed' => __('Backup Completed'),
'ssl.expiring' => __('SSL Expiring'),
'migration.completed' => __('Migration Completed'),
'user.suspended' => __('User Suspended'),
])
->columns(2),
TextInput::make('secret_token')
->label(__('Secret Token'))
->helperText(__('Used to sign webhook payloads'))
->default(fn () => Str::random(32))
->password()
->revealable(),
Toggle::make('is_active')
->label(__('Active'))
->default(true),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints\Tables;
use App\Models\WebhookEndpoint;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class WebhookEndpointsTable
{
public static function configure(Table $table): Table
{
return $table
->query(WebhookEndpoint::query())
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable(),
TextColumn::make('url')
->label(__('URL'))
->limit(40)
->tooltip(fn (WebhookEndpoint $record) => $record->url),
TextColumn::make('events')
->label(__('Events'))
->formatStateUsing(function ($state): string {
if (is_array($state)) {
return implode(', ', $state);
}
return (string) $state;
})
->wrap(),
IconColumn::make('is_active')
->label(__('Active'))
->boolean(),
TextColumn::make('last_triggered_at')
->label(__('Last Triggered'))
->since(),
TextColumn::make('last_response_code')
->label(__('Last Response')),
])
->recordActions([
Action::make('test')
->label(__('Test'))
->icon('heroicon-o-signal')
->color('info')
->action(function (WebhookEndpoint $record): void {
$payload = [
'event' => 'test',
'timestamp' => now()->toIso8601String(),
'request_id' => Str::uuid()->toString(),
];
$headers = [];
if (! empty($record->secret_token)) {
$signature = hash_hmac('sha256', json_encode($payload), $record->secret_token);
$headers['X-Jabali-Signature'] = $signature;
}
try {
$response = Http::withHeaders($headers)->post($record->url, $payload);
$record->update([
'last_response_code' => $response->status(),
'last_triggered_at' => now(),
]);
Notification::make()
->title(__('Webhook delivered'))
->body(__('Status: :status', ['status' => $response->status()]))
->success()
->send();
} catch (\Exception $e) {
$record->update([
'last_response_code' => null,
'last_triggered_at' => now(),
]);
Notification::make()
->title(__('Webhook failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
EditAction::make(),
DeleteAction::make(),
])
->emptyStateHeading(__('No webhooks configured'))
->emptyStateDescription(__('Add a webhook to receive system notifications.'));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Resources\WebhookEndpoints;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\CreateWebhookEndpoint;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\EditWebhookEndpoint;
use App\Filament\Admin\Resources\WebhookEndpoints\Pages\ListWebhookEndpoints;
use App\Filament\Admin\Resources\WebhookEndpoints\Schemas\WebhookEndpointForm;
use App\Filament\Admin\Resources\WebhookEndpoints\Tables\WebhookEndpointsTable;
use App\Models\WebhookEndpoint;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WebhookEndpointResource extends Resource
{
protected static ?string $model = WebhookEndpoint::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert;
protected static ?int $navigationSort = 18;
public static function getNavigationLabel(): string
{
return __('Webhook Notifications');
}
public static function getModelLabel(): string
{
return __('Webhook');
}
public static function getPluralModelLabel(): string
{
return __('Webhooks');
}
public static function form(Schema $schema): Schema
{
return WebhookEndpointForm::configure($schema);
}
public static function table(Table $table): Table
{
return WebhookEndpointsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWebhookEndpoints::route('/'),
'create' => CreateWebhookEndpoint::route('/create'),
'edit' => EditWebhookEndpoint::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets\Security;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Component;
class Fail2banLogsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public array $logs = [];
public function makeFilamentTranslatableContentDriver(): ?\Filament\Support\Contracts\TranslatableContentDriver
{
return null;
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->logs)
->columns([
TextColumn::make('time')
->label(__('Time'))
->fontFamily('mono')
->sortable(),
TextColumn::make('jail')
->label(__('Jail'))
->badge()
->color('gray'),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match (strtolower($state)) {
'ban' => 'danger',
'unban' => 'success',
'found' => 'warning',
default => 'gray',
}),
TextColumn::make('ip')
->label(__('IP'))
->fontFamily('mono')
->copyable()
->placeholder('-'),
TextColumn::make('message')
->label(__('Message'))
->wrap(),
])
->emptyStateHeading(__('No log entries'))
->emptyStateDescription(__('Fail2ban logs will appear here once activity is detected.'))
->striped();
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\CloudflareZone;
use App\Models\Domain;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
class CdnIntegration extends Page implements HasActions, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud';
protected static ?int $navigationSort = 20;
protected static ?string $slug = 'cdn-integration';
protected string $view = 'filament.jabali.pages.cdn-integration';
public function getTitle(): string|Htmlable
{
return __('CDN Integration');
}
public static function getNavigationLabel(): string
{
return __('CDN Integration');
}
public function table(Table $table): Table
{
return $table
->query(Domain::query()->where('user_id', Auth::id()))
->columns([
TextColumn::make('domain')
->label(__('Domain'))
->searchable()
->sortable(),
IconColumn::make('cloudflare')
->label(__('Cloudflare'))
->boolean()
->state(fn (Domain $record) => $this->getZone($record) !== null)
->trueColor('success')
->falseColor('gray'),
])
->recordActions([
Action::make('connect')
->label(__('Connect'))
->icon('heroicon-o-link')
->color('primary')
->visible(fn (Domain $record) => $this->getZone($record) === null)
->form([
TextInput::make('api_token')
->label(__('Cloudflare API Token'))
->password()
->revealable()
->required(),
])
->action(function (Domain $record, array $data): void {
$token = $data['api_token'];
try {
$zone = $this->fetchCloudflareZone($record->domain, $token);
CloudflareZone::updateOrCreate(
[
'user_id' => Auth::id(),
'domain_id' => $record->id,
],
[
'zone_id' => $zone['id'],
'account_id' => $zone['account']['id'] ?? null,
'api_token' => $token,
]
);
Notification::make()->title(__('Cloudflare connected'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Connection failed'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('purge')
->label(__('Purge Cache'))
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (Domain $record) => $this->getZone($record) !== null)
->requiresConfirmation()
->action(function (Domain $record): void {
$zone = $this->getZone($record);
if (! $zone) {
return;
}
try {
$response = Http::withToken($zone->api_token)
->post("https://api.cloudflare.com/client/v4/zones/{$zone->zone_id}/purge_cache", [
'purge_everything' => true,
]);
if (! ($response->json('success') ?? false)) {
throw new Exception($response->json('errors.0.message') ?? 'Cloudflare request failed');
}
Notification::make()->title(__('Cache purged'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Purge failed'))->body($e->getMessage())->danger()->send();
}
}),
Action::make('disconnect')
->label(__('Disconnect'))
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Domain $record) => $this->getZone($record) !== null)
->requiresConfirmation()
->action(function (Domain $record): void {
$zone = $this->getZone($record);
if ($zone) {
$zone->delete();
}
Notification::make()->title(__('Cloudflare disconnected'))->success()->send();
}),
])
->emptyStateHeading(__('No domains found'))
->emptyStateDescription(__('Add a domain before connecting to a CDN'));
}
protected function getZone(Domain $record): ?CloudflareZone
{
return CloudflareZone::query()
->where('user_id', Auth::id())
->where('domain_id', $record->id)
->first();
}
protected function fetchCloudflareZone(string $domain, string $token): array
{
$response = Http::withToken($token)->get('https://api.cloudflare.com/client/v4/zones', [
'name' => $domain,
]);
if (! ($response->json('success') ?? false)) {
throw new Exception($response->json('errors.0.message') ?? 'Cloudflare request failed');
}
$zone = $response->json('result.0');
if (! $zone) {
throw new Exception(__('Zone not found for :domain', ['domain' => $domain]));
}
return $zone;
}
}

View File

@@ -452,6 +452,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
->helperText(__('This name will be used for both the database and user')), ->helperText(__('This name will be used for both the database and user')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->databases_limit;
if ($limit && count($this->databases) >= $limit) {
Notification::make()
->title(__('Database limit reached'))
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
->warning()
->send();
return;
}
$name = $this->getUsername().'_'.$data['name']; $name = $this->getUsername().'_'.$data['name'];
$password = $this->generateSecurePassword(); $password = $this->generateSecurePassword();
@@ -513,6 +524,17 @@ class Databases extends Page implements HasActions, HasForms, HasTable
->helperText(__('Only alphanumeric characters allowed')), ->helperText(__('Only alphanumeric characters allowed')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->databases_limit;
if ($limit && count($this->databases) >= $limit) {
Notification::make()
->title(__('Database limit reached'))
->body(__('Your hosting package allows up to :limit databases.', ['limit' => $limit]))
->warning()
->send();
return;
}
$name = $this->getUsername().'_'.$data['name']; $name = $this->getUsername().'_'.$data['name'];
try { try {
$this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name); $this->getAgent()->mysqlCreateDatabase($this->getUsername(), $name);

View File

@@ -6,6 +6,7 @@ namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour; use App\Filament\Concerns\HasPageTour;
use App\Models\Domain; use App\Models\Domain;
use App\Models\DomainAlias;
use App\Models\DomainHotlinkSetting; use App\Models\DomainHotlinkSetting;
use App\Models\DomainRedirect; use App\Models\DomainRedirect;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
@@ -35,12 +36,12 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class Domains extends Page implements HasForms, HasActions, HasTable class Domains extends Page implements HasActions, HasForms, HasTable
{ {
use InteractsWithForms;
use InteractsWithActions;
use InteractsWithTable;
use HasPageTour; use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-globe-alt';
@@ -63,8 +64,9 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function getAgent(): AgentClient public function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
$this->agent = new AgentClient(); $this->agent = new AgentClient;
} }
return $this->agent; return $this->agent;
} }
@@ -83,7 +85,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->icon('heroicon-o-globe-alt') ->icon('heroicon-o-globe-alt')
->iconColor('primary') ->iconColor('primary')
->description(fn (Domain $record) => $record->document_root) ->description(fn (Domain $record) => $record->document_root)
->url(fn (Domain $record) => 'http://' . $record->domain, shouldOpenInNewTab: true) ->url(fn (Domain $record) => 'http://'.$record->domain, shouldOpenInNewTab: true)
->searchable() ->searchable()
->sortable(), ->sortable(),
IconColumn::make('is_active') IconColumn::make('is_active')
@@ -136,6 +138,28 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->form(fn (Domain $record) => $this->getRedirectsForm($record)) ->form(fn (Domain $record) => $this->getRedirectsForm($record))
->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record)) ->fillForm(fn (Domain $record) => $this->getRedirectsFormData($record))
->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)), ->action(fn (Domain $record, array $data) => $this->saveRedirects($record, $data)),
Action::make('aliases')
->label(__('Aliases'))
->icon('heroicon-o-link')
->color('info')
->modalHeading(fn (Domain $record) => __('Aliases for :domain', ['domain' => $record->domain]))
->modalDescription(__('Point additional domains to the same website content.'))
->modalWidth(Width::Large)
->modalSubmitActionLabel(__('Save Aliases'))
->form(fn (Domain $record) => $this->getAliasesForm($record))
->fillForm(fn (Domain $record) => $this->getAliasesFormData($record))
->action(fn (Domain $record, array $data) => $this->saveAliases($record, $data)),
Action::make('errorPages')
->label(__('Error Pages'))
->icon('heroicon-o-document-text')
->color('gray')
->modalHeading(fn (Domain $record) => __('Custom Error Pages for :domain', ['domain' => $record->domain]))
->modalDescription(__('Customize the 404, 500 and 503 pages shown to visitors.'))
->modalWidth(Width::TwoExtraLarge)
->modalSubmitActionLabel(__('Save Error Pages'))
->form(fn (Domain $record) => $this->getErrorPagesForm())
->fillForm(fn (Domain $record) => $this->getErrorPagesFormData($record))
->action(fn (Domain $record, array $data) => $this->saveErrorPages($record, $data)),
Action::make('hotlink') Action::make('hotlink')
->label(__('Hotlink Protection')) ->label(__('Hotlink Protection'))
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
@@ -172,7 +196,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(__('Delete Domain')) ->modalHeading(__('Delete Domain'))
->modalDescription(fn (Domain $record) => __('Are you sure you want to delete') . " '{$record->domain}'? " . __('This will also delete the following associated data:')) ->modalDescription(fn (Domain $record) => __('Are you sure you want to delete')." '{$record->domain}'? ".__('This will also delete the following associated data:'))
->modalIcon('heroicon-o-trash') ->modalIcon('heroicon-o-trash')
->modalIconColor('danger') ->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete Domain')) ->modalSubmitActionLabel(__('Delete Domain'))
@@ -183,12 +207,12 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->helperText(__('Permanently delete all files in the domain folder')) ->helperText(__('Permanently delete all files in the domain folder'))
->default(true), ->default(true),
Toggle::make('delete_dns') Toggle::make('delete_dns')
->label(__('Delete DNS records') . ' (' . $record->dnsRecords()->count() . ')') ->label(__('Delete DNS records').' ('.$record->dnsRecords()->count().')')
->helperText(__('Remove all DNS records for this domain')) ->helperText(__('Remove all DNS records for this domain'))
->default(true) ->default(true)
->visible(fn () => $record->dnsRecords()->exists()), ->visible(fn () => $record->dnsRecords()->exists()),
Toggle::make('delete_email') Toggle::make('delete_email')
->label(__('Delete email accounts') . ' (' . ($record->emailDomain?->mailboxes()->count() ?? 0) . ')') ->label(__('Delete email accounts').' ('.($record->emailDomain?->mailboxes()->count() ?? 0).')')
->helperText(__('Remove all mailboxes and email configuration')) ->helperText(__('Remove all mailboxes and email configuration'))
->default(true) ->default(true)
->visible(fn () => $record->emailDomain()->exists()), ->visible(fn () => $record->emailDomain()->exists()),
@@ -287,13 +311,13 @@ class Domains extends Page implements HasForms, HasActions, HasTable
]) ])
->columns(['default' => 2, 'sm' => 3]), ->columns(['default' => 2, 'sm' => 3]),
]) ])
->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '') . ' → ' . ($state['redirect_type'] ?? '301')) ->itemLabel(fn (array $state): ?string => ($state['source_path'] ?? '').' → '.($state['redirect_type'] ?? '301'))
->collapsible() ->collapsible()
->collapsed(fn () => $record->redirects()->count() > 3) ->collapsed(fn () => $record->redirects()->count() > 3)
->addActionLabel(__('Add Page Redirect')) ->addActionLabel(__('Add Page Redirect'))
->reorderable() ->reorderable()
->defaultItems(0) ->defaultItems(0)
->visible(fn ($get) => !$get('domain_redirect_enabled')), ->visible(fn ($get) => ! $get('domain_redirect_enabled')),
]; ];
} }
@@ -370,7 +394,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
protected function getHotlinkFormData(Domain $record): array protected function getHotlinkFormData(Domain $record): array
{ {
$setting = $record->hotlinkSetting; $setting = $record->hotlinkSetting;
if (!$setting) { if (! $setting) {
return [ return [
'is_enabled' => false, 'is_enabled' => false,
'allowed_domains' => '', 'allowed_domains' => '',
@@ -379,6 +403,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
'redirect_url' => '', 'redirect_url' => '',
]; ];
} }
return [ return [
'is_enabled' => $setting->is_enabled, 'is_enabled' => $setting->is_enabled,
'allowed_domains' => $setting->allowed_domains, 'allowed_domains' => $setting->allowed_domains,
@@ -435,13 +460,25 @@ class Domains extends Page implements HasForms, HasActions, HasTable
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
try { try {
$user = Auth::user();
$limit = $user?->hostingPackage?->domains_limit;
if ($limit && Domain::where('user_id', $user->id)->count() >= $limit) {
Notification::make()
->title(__('Domain limit reached'))
->body(__('Your hosting package allows up to :limit domains.', ['limit' => $limit]))
->warning()
->send();
return;
}
$result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']); $result = $this->getAgent()->domainCreate($this->getUsername(), $data['domain']);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
Domain::create([ Domain::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'domain' => $data['domain'], 'domain' => $data['domain'],
'document_root' => '/home/' . $this->getUsername() . '/domains/' . $data['domain'] . '/public_html', 'document_root' => '/home/'.$this->getUsername().'/domains/'.$data['domain'].'/public_html',
'is_active' => true, 'is_active' => true,
'ssl_enabled' => false, 'ssl_enabled' => false,
'directory_index' => 'index.php index.html', 'directory_index' => 'index.php index.html',
@@ -495,7 +532,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$redirectsData = $data['redirects'] ?? []; $redirectsData = $data['redirects'] ?? [];
foreach ($redirectsData as $redirectData) { foreach ($redirectsData as $redirectData) {
if (!empty($redirectData['id'])) { if (! empty($redirectData['id'])) {
$redirect = DomainRedirect::find($redirectData['id']); $redirect = DomainRedirect::find($redirectData['id']);
if ($redirect && $redirect->domain_id === $domain->id) { if ($redirect && $redirect->domain_id === $domain->id) {
$redirect->update([ $redirect->update([
@@ -520,7 +557,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
} }
// Delete removed page redirects (but not domain-wide ones which we already handled) // Delete removed page redirects (but not domain-wide ones which we already handled)
if (!empty($existingIds)) { if (! empty($existingIds)) {
$domain->redirects() $domain->redirects()
->whereNotIn('id', $existingIds) ->whereNotIn('id', $existingIds)
->whereNotIn('source_path', ['/*', '*', '/']) ->whereNotIn('source_path', ['/*', '*', '/'])
@@ -565,7 +602,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
{ {
try { try {
$setting = $domain->hotlinkSetting; $setting = $domain->hotlinkSetting;
if (!$setting) { if (! $setting) {
$setting = new DomainHotlinkSetting(['domain_id' => $domain->id]); $setting = new DomainHotlinkSetting(['domain_id' => $domain->id]);
} }
@@ -632,7 +669,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function toggleDomain(Domain $domain): void public function toggleDomain(Domain $domain): void
{ {
try { try {
$newStatus = !$domain->is_active; $newStatus = ! $domain->is_active;
$result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus); $result = $this->getAgent()->domainToggle($this->getUsername(), $domain->domain, $newStatus);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -640,7 +677,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$status = $newStatus ? __('Enabled') : __('Disabled'); $status = $newStatus ? __('Enabled') : __('Disabled');
Notification::make() Notification::make()
->title(__('Domain') . " {$status}") ->title(__('Domain')." {$status}")
->success() ->success()
->send(); ->send();
} else { } else {
@@ -680,7 +717,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
} }
} }
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('WordPress: ') . $e->getMessage(); $errors[] = __('WordPress: ').$e->getMessage();
} }
} }
@@ -695,7 +732,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->sslCertificate->delete(); $domain->sslCertificate->delete();
$deletedItems[] = __('SSL certificate'); $deletedItems[] = __('SSL certificate');
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('SSL: ') . $e->getMessage(); $errors[] = __('SSL: ').$e->getMessage();
} }
} }
} }
@@ -722,7 +759,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->emailDomain->delete(); $domain->emailDomain->delete();
$deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]); $deletedItems[] = __(':count email account(s)', ['count' => $mailboxCount]);
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('Email: ') . $e->getMessage(); $errors[] = __('Email: ').$e->getMessage();
} }
} }
} }
@@ -739,7 +776,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]); $deletedItems[] = __(':count DNS record(s)', ['count' => $dnsCount]);
} }
} catch (Exception $e) { } catch (Exception $e) {
$errors[] = __('DNS: ') . $e->getMessage(); $errors[] = __('DNS: ').$e->getMessage();
} }
} }
@@ -758,8 +795,8 @@ class Domains extends Page implements HasForms, HasActions, HasTable
$domain->delete(); $domain->delete();
$message = __('Domain deleted successfully.'); $message = __('Domain deleted successfully.');
if (!empty($deletedItems)) { if (! empty($deletedItems)) {
$message .= ' ' . __('Also deleted: ') . implode(', ', $deletedItems); $message .= ' '.__('Also deleted: ').implode(', ', $deletedItems);
} }
Notification::make() Notification::make()
@@ -768,7 +805,7 @@ class Domains extends Page implements HasForms, HasActions, HasTable
->success() ->success()
->send(); ->send();
if (!empty($errors)) { if (! empty($errors)) {
Notification::make() Notification::make()
->title(__('Some items had warnings')) ->title(__('Some items had warnings'))
->body(implode("\n", $errors)) ->body(implode("\n", $errors))
@@ -789,7 +826,181 @@ class Domains extends Page implements HasForms, HasActions, HasTable
public function openFileManager(Domain $domain): void public function openFileManager(Domain $domain): void
{ {
$path = str_replace('/home/' . $this->getUsername() . '/', '', $domain->document_root); $path = str_replace('/home/'.$this->getUsername().'/', '', $domain->document_root);
$this->redirect(route('filament.jabali.pages.files', ['path' => $path])); $this->redirect(route('filament.jabali.pages.files', ['path' => $path]));
} }
protected function getAliasesForm(Domain $record): array
{
return [
Repeater::make('aliases')
->label(__('Domain Aliases'))
->schema([
TextInput::make('alias')
->label(__('Alias Domain'))
->placeholder('alias-example.com')
->required()
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
->helperText(__('Enter a full domain name.')),
])
->addActionLabel(__('Add Alias'))
->columns(1)
->defaultItems(0),
];
}
protected function getAliasesFormData(Domain $record): array
{
return [
'aliases' => $record->aliases()
->orderBy('alias')
->get()
->map(fn (DomainAlias $alias) => ['alias' => $alias->alias])
->toArray(),
];
}
protected function saveAliases(Domain $record, array $data): void
{
$aliases = collect($data['aliases'] ?? [])
->pluck('alias')
->map(fn ($alias) => strtolower(trim((string) $alias)))
->filter()
->unique()
->values();
$existing = $record->aliases()->pluck('alias')->map(fn ($alias) => strtolower($alias));
$toAdd = $aliases->diff($existing);
$toRemove = $existing->diff($aliases);
$errors = [];
foreach ($toAdd as $alias) {
try {
$result = $this->getAgent()->domainAliasAdd($this->getUsername(), $record->domain, $alias);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to add alias');
}
DomainAlias::firstOrCreate([
'domain_id' => $record->id,
'alias' => $alias,
]);
} catch (Exception $e) {
$errors[] = $alias.': '.$e->getMessage();
}
}
foreach ($toRemove as $alias) {
try {
$result = $this->getAgent()->domainAliasRemove($this->getUsername(), $record->domain, $alias);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to remove alias');
}
$record->aliases()->where('alias', $alias)->delete();
} catch (Exception $e) {
$errors[] = $alias.': '.$e->getMessage();
}
}
if (! empty($errors)) {
Notification::make()
->title(__('Some aliases failed'))
->body(implode("\n", $errors))
->warning()
->send();
return;
}
Notification::make()
->title(__('Aliases updated'))
->success()
->send();
}
protected function getErrorPagesForm(): array
{
return [
Textarea::make('page_404')
->label(__('404 Page (Not Found)'))
->rows(6)
->placeholder(__('HTML content for your 404 page')),
Textarea::make('page_500')
->label(__('500 Page (Server Error)'))
->rows(6)
->placeholder(__('HTML content for your 500 page')),
Textarea::make('page_503')
->label(__('503 Page (Maintenance)'))
->rows(6)
->placeholder(__('HTML content for your 503 page')),
];
}
protected function getErrorPagesFormData(Domain $record): array
{
return [
'page_404' => $this->readErrorPageContent($record, '404.html'),
'page_500' => $this->readErrorPageContent($record, '500.html'),
'page_503' => $this->readErrorPageContent($record, '503.html'),
];
}
protected function saveErrorPages(Domain $record, array $data): void
{
try {
$result = $this->getAgent()->domainEnsureErrorPages($this->getUsername(), $record->domain);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Failed to enable error pages');
}
foreach (['404' => 'page_404', '500' => 'page_500', '503' => 'page_503'] as $code => $key) {
$content = trim((string) ($data[$key] ?? ''));
$path = $this->getErrorPagePath($record, "{$code}.html");
if ($content === '') {
try {
$this->getAgent()->fileDelete($this->getUsername(), $path);
} catch (Exception) {
// Ignore missing file
}
continue;
}
$this->getAgent()->fileWrite($this->getUsername(), $path, $content);
}
Notification::make()
->title(__('Error pages updated'))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Failed to update error pages'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function readErrorPageContent(Domain $record, string $filename): string
{
$path = $this->getErrorPagePath($record, $filename);
try {
$result = $this->getAgent()->fileRead($this->getUsername(), $path);
if ($result['success'] ?? false) {
return (string) base64_decode($result['content'] ?? '');
}
} catch (Exception) {
// ignore
}
return '';
}
protected function getErrorPagePath(Domain $record, string $filename): string
{
return "domains/{$record->domain}/public_html/{$filename}";
}
} }

View File

@@ -11,7 +11,9 @@ use App\Models\Domain;
use App\Models\EmailDomain; use App\Models\EmailDomain;
use App\Models\EmailForwarder; use App\Models\EmailForwarder;
use App\Models\Mailbox; use App\Models\Mailbox;
use App\Models\UserSetting;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use App\Services\System\MailRoutingSyncService;
use BackedEnum; use BackedEnum;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -65,6 +67,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
public string $credPassword = ''; public string $credPassword = '';
public array $spamFormData = [];
protected ?AgentClient $agent = null; protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable public function getTitle(): string|Htmlable
@@ -76,11 +80,18 @@ class Email extends Page implements HasActions, HasForms, HasTable
{ {
// Normalize the tab value from URL // Normalize the tab value from URL
$this->activeTab = $this->normalizeTabName($this->activeTab); $this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
} }
public function updatedActiveTab(): void public function updatedActiveTab(): void
{ {
$this->activeTab = $this->normalizeTabName($this->activeTab); $this->activeTab = $this->normalizeTabName($this->activeTab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable(); $this->resetTable();
} }
@@ -99,6 +110,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders', 'Autoresponders' => 'autoresponders', 'autoresponders', 'Autoresponders' => 'autoresponders',
'catchall', 'catch-all', 'Catch-All' => 'catchall', 'catchall', 'catch-all', 'Catch-All' => 'catchall',
'logs', 'Logs' => 'logs', 'logs', 'Logs' => 'logs',
'spam', 'Spam' => 'spam',
default => 'mailboxes', default => 'mailboxes',
}; };
} }
@@ -111,13 +123,14 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => 3, 'autoresponders' => 3,
'catchall' => 4, 'catchall' => 4,
'logs' => 5, 'logs' => 5,
'spam' => 6,
default => 1, default => 1,
}; };
} }
protected function getForms(): array protected function getForms(): array
{ {
return ['emailForm']; return ['emailForm', 'spamForm'];
} }
public function emailForm(Schema $schema): Schema public function emailForm(Schema $schema): Schema
@@ -127,9 +140,37 @@ class Email extends Page implements HasActions, HasForms, HasTable
]); ]);
} }
public function spamForm(Schema $schema): Schema
{
return $schema
->statePath('spamFormData')
->schema([
Section::make(__('Spam Settings'))
->schema([
Textarea::make('whitelist')
->label(__('Whitelist (one per line)'))
->rows(6)
->placeholder("friend@example.com\ntrusted.com"),
Textarea::make('blacklist')
->label(__('Blacklist (one per line)'))
->rows(6)
->placeholder("spam@example.com\nbad-domain.com"),
TextInput::make('score')
->label(__('Spam Score Threshold'))
->numeric()
->default(6.0)
->helperText(__('Lower values are stricter, higher values are more permissive.')),
])
->columns(2),
]);
}
public function setTab(string $tab): void public function setTab(string $tab): void
{ {
$this->activeTab = $this->normalizeTabName($tab); $this->activeTab = $this->normalizeTabName($tab);
if ($this->activeTab === 'spam') {
$this->loadSpamSettings();
}
$this->resetTable(); $this->resetTable();
} }
@@ -170,6 +211,60 @@ class Email extends Page implements HasActions, HasForms, HasTable
return str_shuffle($password); return str_shuffle($password);
} }
protected function loadSpamSettings(): void
{
$settings = UserSetting::getForUser(Auth::id(), 'spam_settings', [
'whitelist' => [],
'blacklist' => [],
'score' => 6.0,
]);
$this->spamFormData = [
'whitelist' => implode("\n", $settings['whitelist'] ?? []),
'blacklist' => implode("\n", $settings['blacklist'] ?? []),
'score' => $settings['score'] ?? 6.0,
];
}
public function saveSpamSettings(): void
{
$data = $this->spamForm->getState();
$whitelist = $this->linesToArray($data['whitelist'] ?? '');
$blacklist = $this->linesToArray($data['blacklist'] ?? '');
$score = isset($data['score']) && $data['score'] !== '' ? (float) $data['score'] : null;
UserSetting::setForUser(Auth::id(), 'spam_settings', [
'whitelist' => $whitelist,
'blacklist' => $blacklist,
'score' => $score,
]);
$result = $this->getAgent()->rspamdUserSettings($this->getUsername(), $whitelist, $blacklist, $score);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to update spam settings'))
->body($result['error'] ?? '')
->danger()
->send();
return;
}
Notification::make()
->title(__('Spam settings updated'))
->success()
->send();
}
protected function linesToArray(string $value): array
{
return collect(preg_split('/\\r\\n|\\r|\\n/', $value))
->map(fn ($line) => trim((string) $line))
->filter()
->values()
->toArray();
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
@@ -178,6 +273,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
'autoresponders' => $this->autorespondersTable($table), 'autoresponders' => $this->autorespondersTable($table),
'catchall' => $this->catchAllTable($table), 'catchall' => $this->catchAllTable($table),
'logs' => $this->emailLogsTable($table), 'logs' => $this->emailLogsTable($table),
'spam' => $this->mailboxesTable($table),
default => $this->mailboxesTable($table), default => $this->mailboxesTable($table),
}; };
} }
@@ -778,6 +874,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
// Generate DKIM // Generate DKIM
try { try {
$dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain); $dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain);
@@ -830,6 +928,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
return $emailDomain; return $emailDomain;
} }
protected function syncMailRouting(): void
{
try {
app(MailRoutingSyncService::class)->sync();
} catch (Exception $e) {
Notification::make()
->title(__('Mail routing sync failed'))
->body($e->getMessage())
->warning()
->send();
}
}
protected function regenerateDnsZone(Domain $domain): void protected function regenerateDnsZone(Domain $domain): void
{ {
try { try {
@@ -924,6 +1035,17 @@ class Email extends Page implements HasActions, HasForms, HasTable
->helperText(__('Storage limit in megabytes')), ->helperText(__('Storage limit in megabytes')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
$limit = Auth::user()?->hostingPackage?->mailboxes_limit;
if ($limit && Mailbox::where('user_id', Auth::id())->count() >= $limit) {
Notification::make()
->title(__('Mailbox limit reached'))
->body(__('Your hosting package allows up to :limit mailboxes.', ['limit' => $limit]))
->warning()
->send();
return;
}
$domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']);
if (! $domain) { if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send(); Notification::make()->title(__('Domain not found'))->danger()->send();
@@ -965,6 +1087,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
$this->credEmail = $email; $this->credEmail = $email;
$this->credPassword = $data['password']; $this->credPassword = $data['password'];
@@ -1016,6 +1140,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus); $this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus);
$mailbox->update(['is_active' => $newStatus]); $mailbox->update(['is_active' => $newStatus]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled')) ->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled'))
->success() ->success()
@@ -1037,6 +1163,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$mailbox->delete(); $mailbox->delete();
$this->syncMailRouting();
Notification::make()->title(__('Mailbox deleted'))->success()->send(); Notification::make()->title(__('Mailbox deleted'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1122,6 +1250,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'is_active' => true, 'is_active' => true,
]); ]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder created'))->success()->send(); Notification::make()->title(__('Forwarder created'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send();
@@ -1149,6 +1279,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$forwarder->update(['destinations' => $destinations]); $forwarder->update(['destinations' => $destinations]);
$this->syncMailRouting();
Notification::make()->title(__('Forwarder updated'))->success()->send(); Notification::make()->title(__('Forwarder updated'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1173,6 +1305,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
]); ]);
$forwarder->update(['is_active' => $newStatus]); $forwarder->update(['is_active' => $newStatus]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled')) ->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled'))
->success() ->success()
@@ -1192,6 +1326,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
$forwarder->delete(); $forwarder->delete();
$this->syncMailRouting();
Notification::make()->title(__('Forwarder deleted'))->success()->send(); Notification::make()->title(__('Forwarder deleted'))->success()->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send();
@@ -1404,6 +1540,8 @@ class Email extends Page implements HasActions, HasForms, HasTable
'catch_all_address' => $enabled ? $address : null, 'catch_all_address' => $enabled ? $address : null,
]); ]);
$this->syncMailRouting();
Notification::make() Notification::make()
->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled')) ->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled'))
->success() ->success()

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Jobs\RunGitDeployment;
use App\Models\Domain;
use App\Models\GitDeployment as GitDeploymentModel;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class GitDeployment extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-code-bracket-square';
protected static ?int $navigationSort = 16;
protected static ?string $slug = 'git-deployment';
protected string $view = 'filament.jabali.pages.git-deployment';
protected ?AgentClient $agent = null;
public ?string $deployKey = null;
public function getTitle(): string|Htmlable
{
return __('Git Deployment');
}
public static function getNavigationLabel(): string
{
return __('Git Deployment');
}
protected function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
protected function getUsername(): string
{
return Auth::user()->username;
}
protected function getDomainOptions(): array
{
return Domain::query()
->where('user_id', Auth::id())
->orderBy('domain')
->pluck('domain', 'id')
->toArray();
}
protected function getWebhookUrl(GitDeploymentModel $deployment): string
{
return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}");
}
protected function getDeployKey(): string
{
if ($this->deployKey) {
return $this->deployKey;
}
try {
$result = $this->getAgent()->gitGenerateKey($this->getUsername());
$this->deployKey = $result['public_key'] ?? '';
} catch (Exception) {
$this->deployKey = '';
}
return $this->deployKey ?? '';
}
public function table(Table $table): Table
{
return $table
->query(GitDeploymentModel::query()->where('user_id', Auth::id())->with('domain'))
->columns([
TextColumn::make('domain.domain')
->label(__('Domain'))
->searchable()
->sortable(),
TextColumn::make('repo_url')
->label(__('Repository'))
->limit(40)
->tooltip(fn (GitDeploymentModel $record) => $record->repo_url)
->sortable(),
TextColumn::make('branch')
->label(__('Branch'))
->badge()
->color('gray'),
IconColumn::make('auto_deploy')
->label(__('Auto Deploy'))
->boolean(),
TextColumn::make('last_status')
->label(__('Status'))
->badge()
->color(fn (?string $state): string => match ($state) {
'success' => 'success',
'failed' => 'danger',
'running' => 'warning',
'queued' => 'info',
default => 'gray',
})
->default('never'),
TextColumn::make('last_deployed_at')
->label(__('Last Deployed'))
->since()
->sortable(),
])
->recordActions([
Action::make('deploy')
->label(__('Deploy'))
->icon('heroicon-o-arrow-path')
->color('primary')
->action(function (GitDeploymentModel $record): void {
$record->update(['last_status' => 'queued']);
RunGitDeployment::dispatch($record->id);
Notification::make()->title(__('Deployment queued'))->success()->send();
}),
Action::make('webhook')
->label(__('Webhook'))
->icon('heroicon-o-link')
->color('gray')
->modalHeading(__('Webhook URL'))
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->form([
Textarea::make('webhook_url')
->label(__('Webhook URL'))
->rows(2)
->disabled()
->dehydrated(false),
Textarea::make('deploy_key')
->label(__('Deploy Key'))
->rows(3)
->disabled()
->dehydrated(false),
])
->fillForm(fn (GitDeploymentModel $record): array => [
'webhook_url' => $this->getWebhookUrl($record),
'deploy_key' => $this->getDeployKey(),
]),
Action::make('edit')
->label(__('Edit'))
->icon('heroicon-o-pencil-square')
->color('gray')
->modalHeading(__('Edit Deployment'))
->form($this->getDeploymentForm())
->fillForm(fn (GitDeploymentModel $record): array => [
'domain_id' => $record->domain_id,
'repo_url' => $record->repo_url,
'branch' => $record->branch,
'deploy_path' => $record->deploy_path,
'auto_deploy' => $record->auto_deploy,
'deploy_script' => $record->deploy_script,
'framework_preset' => 'custom',
])
->action(function (GitDeploymentModel $record, array $data): void {
$record->update([
'domain_id' => $data['domain_id'],
'repo_url' => $data['repo_url'],
'branch' => $data['branch'],
'deploy_path' => $data['deploy_path'],
'auto_deploy' => $data['auto_deploy'] ?? false,
'deploy_script' => $data['deploy_script'] ?? null,
]);
Notification::make()->title(__('Deployment updated'))->success()->send();
}),
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (GitDeploymentModel $record): void {
$record->delete();
Notification::make()->title(__('Deployment deleted'))->success()->send();
}),
])
->emptyStateHeading(__('No deployments yet'))
->emptyStateDescription(__('Add a repository to deploy to your domain'))
->emptyStateIcon('heroicon-o-code-bracket-square');
}
protected function getHeaderActions(): array
{
return [
Action::make('addDeployment')
->label(__('Add Deployment'))
->icon('heroicon-o-plus')
->color('primary')
->form($this->getDeploymentForm())
->action(function (array $data): void {
GitDeploymentModel::create([
'user_id' => Auth::id(),
'domain_id' => $data['domain_id'],
'repo_url' => $data['repo_url'],
'branch' => $data['branch'],
'deploy_path' => $data['deploy_path'],
'auto_deploy' => $data['auto_deploy'] ?? false,
'deploy_script' => $data['deploy_script'] ?? null,
'secret_token' => Str::random(40),
'last_status' => 'never',
]);
Notification::make()->title(__('Deployment created'))->success()->send();
$this->resetTable();
}),
Action::make('deployKey')
->label(__('Deploy Key'))
->icon('heroicon-o-key')
->color('gray')
->modalHeading(__('SSH Deploy Key'))
->modalSubmitAction(false)
->modalCancelActionLabel(__('Close'))
->form([
Textarea::make('public_key')
->label(__('Public Key'))
->rows(3)
->disabled()
->dehydrated(false),
])
->fillForm(fn (): array => ['public_key' => $this->getDeployKey()]),
];
}
protected function getDeploymentForm(): array
{
return [
Select::make('domain_id')
->label(__('Domain'))
->options($this->getDomainOptions())
->searchable()
->required()
->live()
->afterStateUpdated(function ($state, callable $set): void {
if (! $state) {
return;
}
$domain = Domain::where('id', $state)->where('user_id', Auth::id())->first();
if ($domain) {
$set('deploy_path', $domain->document_root);
}
}),
TextInput::make('repo_url')
->label(__('Repository URL'))
->placeholder('git@github.com:org/repo.git')
->required(),
TextInput::make('branch')
->label(__('Branch'))
->default('main')
->required(),
TextInput::make('deploy_path')
->label(__('Deploy Path'))
->helperText(__('Must be inside your home directory'))
->required(),
Select::make('framework_preset')
->label(__('Framework Preset'))
->options([
'custom' => __('Custom'),
'laravel' => __('Laravel'),
'symfony' => __('Symfony'),
])
->default('custom')
->live()
->afterStateUpdated(function ($state, callable $set): void {
if ($state === 'laravel') {
$set('deploy_script', "composer install --no-dev --optimize-autoloader\nphp artisan migrate --force\nphp artisan config:cache\nphp artisan route:cache\nphp artisan view:cache");
} elseif ($state === 'symfony') {
$set('deploy_script', "composer install --no-dev --optimize-autoloader\nphp bin/console cache:clear --no-warmup\nphp bin/console cache:warmup");
}
}),
Textarea::make('deploy_script')
->label(__('Deploy Script (optional)'))
->rows(6)
->helperText(__('Run after code is deployed. Leave empty to skip.')),
Toggle::make('auto_deploy')
->label(__('Enable auto-deploy from webhook'))
->default(false),
];
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
class ImageOptimization extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-photo';
protected static ?int $navigationSort = 21;
protected static ?string $slug = 'image-optimization';
protected string $view = 'filament.jabali.pages.image-optimization';
protected ?AgentClient $agent = null;
public array $imageFormData = [];
public function getTitle(): string|Htmlable
{
return __('Image Optimization');
}
public static function getNavigationLabel(): string
{
return __('Image Optimization');
}
protected function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
protected function getUsername(): string
{
return Auth::user()->username;
}
protected function getDomainOptions(): array
{
return Domain::query()
->where('user_id', Auth::id())
->orderBy('domain')
->pluck('domain', 'id')
->toArray();
}
protected function getForms(): array
{
return ['imageForm'];
}
public function imageForm(Schema $schema): Schema
{
return $schema
->statePath('imageFormData')
->schema([
Section::make(__('Optimization Settings'))
->schema([
Select::make('domain_id')
->label(__('Domain'))
->options($this->getDomainOptions())
->searchable()
->required(),
TextInput::make('path')
->label(__('Custom Path (optional)'))
->helperText(__('Leave empty to optimize the selected domain root')),
Toggle::make('convert_webp')
->label(__('Generate WebP copies'))
->default(false),
TextInput::make('quality')
->label(__('Quality (40-95)'))
->numeric()
->default(82),
])
->columns(2),
]);
}
public function runOptimization(): void
{
$data = $this->imageForm->getState();
$domainId = $data['domain_id'] ?? null;
if (! $domainId) {
Notification::make()->title(__('Select a domain'))->danger()->send();
return;
}
$domain = Domain::where('id', $domainId)->where('user_id', Auth::id())->first();
if (! $domain) {
Notification::make()->title(__('Domain not found'))->danger()->send();
return;
}
$path = trim((string) ($data['path'] ?? ''));
if ($path === '') {
$path = $domain->document_root;
}
try {
$result = $this->getAgent()->imageOptimize(
$this->getUsername(),
$path,
(bool) ($data['convert_webp'] ?? false),
(int) ($data['quality'] ?? 82)
);
if ($result['success'] ?? false) {
$optimized = $result['optimized'] ?? [];
$message = __('Optimization complete');
if (! empty($optimized)) {
$message .= ' · JPG: '.($optimized['jpg'] ?? 0).', PNG: '.($optimized['png'] ?? 0).', WebP: '.($optimized['webp'] ?? 0);
}
Notification::make()->title($message)->success()->send();
return;
}
Notification::make()->title(__('Optimization failed'))->body($result['error'] ?? '')->danger()->send();
} catch (Exception $e) {
Notification::make()->title(__('Optimization failed'))->body($e->getMessage())->danger()->send();
}
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages; namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour; use App\Filament\Concerns\HasPageTour;
use App\Models\AuditLog;
use App\Models\UserResourceUsage;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -42,6 +44,9 @@ class Logs extends Page implements HasActions, HasForms
#[Url] #[Url]
public ?string $selectedDomain = null; public ?string $selectedDomain = null;
#[Url(as: 'tab')]
public string $activeTab = 'logs';
public string $logType = 'access'; public string $logType = 'access';
public int $logLines = 100; public int $logLines = 100;
@@ -64,6 +69,7 @@ class Logs extends Page implements HasActions, HasForms
public function mount(): void public function mount(): void
{ {
$this->loadDomains(); $this->loadDomains();
$this->activeTab = $this->normalizeTab($this->activeTab);
if (! empty($this->domains) && ! $this->selectedDomain) { if (! empty($this->domains) && ! $this->selectedDomain) {
$this->selectedDomain = $this->domains[0]['domain'] ?? null; $this->selectedDomain = $this->domains[0]['domain'] ?? null;
@@ -74,6 +80,27 @@ class Logs extends Page implements HasActions, HasForms
} }
} }
public function updatedActiveTab(): void
{
$this->activeTab = $this->normalizeTab($this->activeTab);
if ($this->activeTab === 'logs' && $this->selectedDomain) {
$this->loadLogs();
}
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTab($tab);
}
protected function normalizeTab(?string $tab): string
{
return match ($tab) {
'logs', 'usage', 'activity', 'stats' => (string) $tab,
default => 'logs',
};
}
protected function getAgent(): AgentClient protected function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
@@ -90,11 +117,15 @@ class Logs extends Page implements HasActions, HasForms
protected function loadDomains(): void protected function loadDomains(): void
{ {
try {
$result = $this->getAgent()->send('domain.list', [ $result = $this->getAgent()->send('domain.list', [
'username' => $this->getUsername(), 'username' => $this->getUsername(),
]); ]);
$this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : []; $this->domains = ($result['success'] ?? false) ? ($result['domains'] ?? []) : [];
} catch (\Throwable $exception) {
$this->domains = [];
}
} }
public function getDomainOptions(): array public function getDomainOptions(): array
@@ -164,6 +195,66 @@ class Logs extends Page implements HasActions, HasForms
->send(); ->send();
} }
public function getUsageChartData(): array
{
$start = now()->subDays(29)->startOfDay();
$end = now()->endOfDay();
$records = UserResourceUsage::query()
->where('user_id', Auth::id())
->whereBetween('captured_at', [$start, $end])
->get();
$labels = [];
for ($i = 0; $i < 30; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
$index = array_flip($labels);
$metrics = ['disk_bytes', 'database_bytes', 'mail_bytes', 'bandwidth_bytes'];
$values = [];
foreach ($metrics as $metric) {
$values[$metric] = array_fill(0, count($labels), 0);
}
foreach ($records as $record) {
$date = $record->captured_at?->format('Y-m-d');
if (! $date || ! isset($index[$date])) {
continue;
}
$idx = $index[$date];
$metric = $record->metric;
if (! isset($values[$metric])) {
continue;
}
if ($metric === 'bandwidth_bytes') {
$values[$metric][$idx] += (int) $record->value;
} else {
$values[$metric][$idx] = max($values[$metric][$idx], (int) $record->value);
}
}
$toGb = fn (int $bytes) => round($bytes / 1024 / 1024 / 1024, 2);
return [
'labels' => $labels,
'series' => [
['name' => __('Disk'), 'data' => array_map($toGb, $values['disk_bytes'])],
['name' => __('Databases'), 'data' => array_map($toGb, $values['database_bytes'])],
['name' => __('Mail'), 'data' => array_map($toGb, $values['mail_bytes'])],
['name' => __('Bandwidth'), 'data' => array_map($toGb, $values['bandwidth_bytes'])],
],
];
}
public function getActivityLogs()
{
return AuditLog::query()
->where('user_id', Auth::id())
->latest()
->limit(50)
->get();
}
public function generateStats(): void public function generateStats(): void
{ {
if (! $this->selectedDomain) { if (! $this->selectedDomain) {
@@ -216,14 +307,14 @@ class Logs extends Page implements HasActions, HasForms
->label(__('Generate Statistics')) ->label(__('Generate Statistics'))
->icon('heroicon-o-chart-bar') ->icon('heroicon-o-chart-bar')
->color('primary') ->color('primary')
->visible(fn () => $this->selectedDomain !== null) ->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'stats')
->action(fn () => $this->generateStats()), ->action(fn () => $this->generateStats()),
Action::make('refreshLogs') Action::make('refreshLogs')
->label(__('Refresh')) ->label(__('Refresh'))
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('gray') ->color('gray')
->visible(fn () => $this->selectedDomain !== null) ->visible(fn () => $this->selectedDomain !== null && $this->activeTab === 'logs')
->action(fn () => $this->refreshLogs()), ->action(fn () => $this->refreshLogs()),
]; ];
} }

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\UserSetting;
use BackedEnum;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
class MailingLists extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-paper-airplane';
protected static ?int $navigationSort = 22;
protected static ?string $slug = 'mailing-lists';
protected string $view = 'filament.jabali.pages.mailing-lists';
public array $mailingFormData = [];
public function getTitle(): string|Htmlable
{
return __('Mailing Lists');
}
public static function getNavigationLabel(): string
{
return __('Mailing Lists');
}
public function mount(): void
{
$this->mailingFormData = UserSetting::getForUser(Auth::id(), 'mailing_lists', [
'provider' => 'none',
'listmonk_url' => '',
'listmonk_token' => '',
'listmonk_list_id' => '',
'mailman_url' => '',
'mailman_admin' => '',
]);
}
protected function getForms(): array
{
return ['mailingForm'];
}
public function mailingForm(Schema $schema): Schema
{
return $schema
->statePath('mailingFormData')
->schema([
Section::make(__('Provider'))
->schema([
Select::make('provider')
->label(__('Mailing List Provider'))
->options([
'none' => __('None'),
'listmonk' => __('Listmonk'),
'mailman' => __('Mailman'),
])
->default('none')
->live(),
]),
Section::make(__('Listmonk'))
->schema([
TextInput::make('listmonk_url')
->label(__('Listmonk URL'))
->placeholder('https://lists.example.com')
->url()
->visible(fn ($get) => $get('provider') === 'listmonk'),
TextInput::make('listmonk_token')
->label(__('API Token'))
->password()
->revealable()
->visible(fn ($get) => $get('provider') === 'listmonk'),
TextInput::make('listmonk_list_id')
->label(__('Default List ID'))
->visible(fn ($get) => $get('provider') === 'listmonk'),
])
->columns(2),
Section::make(__('Mailman'))
->schema([
TextInput::make('mailman_url')
->label(__('Mailman URL'))
->placeholder('https://lists.example.com/mailman')
->url()
->visible(fn ($get) => $get('provider') === 'mailman'),
TextInput::make('mailman_admin')
->label(__('Mailman Admin Email'))
->email()
->visible(fn ($get) => $get('provider') === 'mailman'),
])
->columns(2),
]);
}
public function saveSettings(): void
{
UserSetting::setForUser(Auth::id(), 'mailing_lists', $this->mailingForm->getState());
Notification::make()
->title(__('Mailing list settings saved'))
->success()
->send();
}
}

View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Url;
class PostgreSQL extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-circle-stack';
protected static ?int $navigationSort = 19;
protected static ?string $slug = 'postgresql';
protected string $view = 'filament.jabali.pages.postgresql';
protected ?AgentClient $agent = null;
#[Url(as: 'tab')]
public string $activeTab = 'databases';
public array $databases = [];
public array $users = [];
public function getTitle(): string|Htmlable
{
return __('PostgreSQL');
}
public static function getNavigationLabel(): string
{
return __('PostgreSQL');
}
public function mount(): void
{
$this->activeTab = $this->normalizeTab($this->activeTab);
$this->loadData();
}
public function updatedActiveTab(): void
{
$this->activeTab = $this->normalizeTab($this->activeTab);
$this->loadData();
$this->resetTable();
}
protected function normalizeTab(string $tab): string
{
return in_array($tab, ['databases', 'users'], true) ? $tab : 'databases';
}
protected function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient;
}
return $this->agent;
}
protected function getUsername(): string
{
return Auth::user()->username;
}
protected function loadData(): void
{
if ($this->activeTab === 'users') {
$this->loadUsers();
} else {
$this->loadDatabases();
}
}
protected function loadDatabases(): void
{
try {
$result = $this->getAgent()->postgresListDatabases($this->getUsername());
$this->databases = $result['databases'] ?? [];
} catch (Exception) {
$this->databases = [];
}
}
protected function loadUsers(): void
{
try {
$result = $this->getAgent()->postgresListUsers($this->getUsername());
$this->users = $result['users'] ?? [];
} catch (Exception) {
$this->users = [];
}
}
protected function getUserOptions(): array
{
if (empty($this->users)) {
$this->loadUsers();
}
$options = [];
foreach ($this->users as $user) {
$options[$user['username']] = $user['username'];
}
return $options;
}
public function table(Table $table): Table
{
if ($this->activeTab === 'users') {
return $table
->records(fn () => $this->users)
->columns([
TextColumn::make('username')
->label(__('User'))
->searchable(),
])
->recordActions([
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $record): void {
$result = $this->getAgent()->postgresDeleteUser($this->getUsername(), $record['username']);
if ($result['success'] ?? false) {
Notification::make()->title(__('User deleted'))->success()->send();
$this->loadUsers();
$this->resetTable();
return;
}
Notification::make()->title(__('Deletion failed'))->body($result['error'] ?? '')->danger()->send();
}),
])
->emptyStateHeading(__('No PostgreSQL users'))
->emptyStateDescription(__('Create a PostgreSQL user to manage databases'));
}
return $table
->records(fn () => $this->databases)
->columns([
TextColumn::make('name')
->label(__('Database'))
->searchable(),
TextColumn::make('size_bytes')
->label(__('Size'))
->formatStateUsing(fn ($state) => $this->formatBytes((int) $state))
->color('gray'),
])
->recordActions([
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (array $record): void {
$result = $this->getAgent()->postgresDeleteDatabase($this->getUsername(), $record['name']);
if ($result['success'] ?? false) {
Notification::make()->title(__('Database deleted'))->success()->send();
$this->loadDatabases();
$this->resetTable();
return;
}
Notification::make()->title(__('Deletion failed'))->body($result['error'] ?? '')->danger()->send();
}),
])
->emptyStateHeading(__('No PostgreSQL databases'))
->emptyStateDescription(__('Create a PostgreSQL database to get started'));
}
protected function getHeaderActions(): array
{
return [
Action::make('createDatabase')
->label(__('Create Database'))
->icon('heroicon-o-circle-stack')
->color('primary')
->visible(fn () => $this->activeTab === 'databases')
->form([
TextInput::make('database')
->label(__('Database Name'))
->helperText(__('Use a name like :prefix_db', ['prefix' => $this->getUsername()]))
->required(),
Select::make('owner')
->label(__('Owner User'))
->options($this->getUserOptions())
->required(),
])
->action(function (array $data): void {
$result = $this->getAgent()->postgresCreateDatabase(
$this->getUsername(),
$data['database'],
$data['owner']
);
if ($result['success'] ?? false) {
Notification::make()->title(__('Database created'))->success()->send();
$this->loadDatabases();
$this->resetTable();
return;
}
Notification::make()->title(__('Creation failed'))->body($result['error'] ?? '')->danger()->send();
}),
Action::make('createUser')
->label(__('Create User'))
->icon('heroicon-o-user-plus')
->color('primary')
->visible(fn () => $this->activeTab === 'users')
->form([
TextInput::make('db_user')
->label(__('Username'))
->helperText(__('Use a name like :prefix_user', ['prefix' => $this->getUsername()]))
->required(),
TextInput::make('password')
->label(__('Password'))
->password()
->revealable()
->default(fn () => Str::random(16))
->required(),
])
->action(function (array $data): void {
$result = $this->getAgent()->postgresCreateUser(
$this->getUsername(),
$data['db_user'],
$data['password']
);
if ($result['success'] ?? false) {
Notification::make()->title(__('User created'))->success()->send();
$this->loadUsers();
$this->resetTable();
return;
}
Notification::make()->title(__('Creation failed'))->body($result['error'] ?? '')->danger()->send();
}),
];
}
protected function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision).' '.$units[$pow];
}
}

View File

@@ -4,22 +4,25 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages; namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Domain; use App\Models\Domain;
use App\Models\MysqlCredential; use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions; use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn; use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Concerns\InteractsWithTable;
@@ -28,20 +31,18 @@ use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use App\Filament\Concerns\HasPageTour;
use BackedEnum;
use Exception;
class WordPress extends Page implements HasForms, HasActions, HasTable class WordPress extends Page implements HasActions, HasForms, HasTable
{ {
protected static ?string $slug = 'wordpress'; protected static ?string $slug = 'wordpress';
use InteractsWithForms;
use InteractsWithActions;
use InteractsWithTable;
use HasPageTour;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-pencil-square'; use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
protected static ?int $navigationSort = 4; protected static ?int $navigationSort = 4;
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
@@ -52,28 +53,37 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
protected string $view = 'filament.jabali.pages.wordpress'; protected string $view = 'filament.jabali.pages.wordpress';
public array $sites = []; public array $sites = [];
public array $domains = []; public array $domains = [];
public ?string $selectedSiteId = null; public ?string $selectedSiteId = null;
// Credentials modal // Credentials modal
public bool $showCredentials = false; public bool $showCredentials = false;
public array $credentials = []; public array $credentials = [];
// Scan modal // Scan modal
public bool $showScanModal = false; public bool $showScanModal = false;
public array $scannedSites = []; public array $scannedSites = [];
public bool $isScanning = false; public bool $isScanning = false;
// Security scan // Security scan
public bool $showSecurityScanModal = false; public bool $showSecurityScanModal = false;
public array $securityScanResults = []; public array $securityScanResults = [];
public bool $isSecurityScanning = false; public bool $isSecurityScanning = false;
public ?string $scanningSiteId = null; public ?string $scanningSiteId = null;
public ?string $scanningSiteUrl = null; public ?string $scanningSiteUrl = null;
protected ?AgentClient $agent = null; protected ?AgentClient $agent = null;
public function getTitle(): string | Htmlable public function getTitle(): string|Htmlable
{ {
return __('WordPress Manager'); return __('WordPress Manager');
} }
@@ -81,8 +91,9 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function getAgent(): AgentClient public function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
$this->agent = new AgentClient(); $this->agent = new AgentClient;
} }
return $this->agent; return $this->agent;
} }
@@ -199,12 +210,23 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
TextInput::make('staging_subdomain') TextInput::make('staging_subdomain')
->label(__('Staging Subdomain')) ->label(__('Staging Subdomain'))
->prefix('staging-') ->prefix('staging-')
->suffix(fn (array $record): string => '.' . ($record['domain'] ?? '')) ->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test') ->default('test')
->required() ->required()
->alphaNum(), ->alphaNum(),
]) ])
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])), ->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
Action::make('pushStaging')
->label(__('Push to Production'))
->icon('heroicon-o-arrow-up-tray')
->color('warning')
->requiresConfirmation()
->visible(fn (array $record): bool => (bool) ($record['is_staging'] ?? false))
->modalHeading(__('Push Staging to Production'))
->modalDescription(__('This will replace the live site files and database with the staging version.'))
->modalIcon('heroicon-o-arrow-up-tray')
->modalIconColor('warning')
->action(fn (array $record) => $this->pushStaging($record['id'])),
Action::make('security') Action::make('security')
->label(__('Security Scan')) ->label(__('Security Scan'))
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
@@ -239,7 +261,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
// Delete screenshot if exists // Delete screenshot if exists
$screenshotPath = storage_path('app/public/screenshots/wp-' . $record['id'] . '.png'); $screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) { if (file_exists($screenshotPath)) {
@unlink($screenshotPath); @unlink($screenshotPath);
} }
@@ -285,12 +307,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
// Ensure at least one of each required type // Ensure at least one of each required type
$password = $lowercase[random_int(0, strlen($lowercase) - 1)] $password = $lowercase[random_int(0, strlen($lowercase) - 1)]
. $uppercase[random_int(0, strlen($uppercase) - 1)] .$uppercase[random_int(0, strlen($uppercase) - 1)]
. $numbers[random_int(0, strlen($numbers) - 1)] .$numbers[random_int(0, strlen($numbers) - 1)]
. $special[random_int(0, strlen($special) - 1)]; .$special[random_int(0, strlen($special) - 1)];
// Fill the rest with random characters from all types // Fill the rest with random characters from all types
$allChars = $lowercase . $uppercase . $numbers . $special; $allChars = $lowercase.$uppercase.$numbers.$special;
for ($i = strlen($password); $i < $length; $i++) { for ($i = strlen($password); $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)]; $password .= $allChars[random_int(0, strlen($allChars) - 1)];
} }
@@ -340,7 +362,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
$options = []; $options = [];
foreach ($this->scannedSites as $site) { foreach ($this->scannedSites as $site) {
$label = ($site['site_url'] ?? $site['path']) . ' (v' . ($site['version'] ?? '?') . ')'; $label = ($site['site_url'] ?? $site['path']).' (v'.($site['version'] ?? '?').')';
$options[$site['path']] = $label; $options[$site['path']] = $label;
} }
@@ -365,6 +387,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Please select at least one site to import.')) ->body(__('Please select at least one site to import.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -575,7 +598,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Store MySQL credentials for phpMyAdmin SSO // Store MySQL credentials for phpMyAdmin SSO
if (!empty($result['db_user']) && !empty($result['db_password'])) { if (! empty($result['db_user']) && ! empty($result['db_password'])) {
MysqlCredential::updateOrCreate( MysqlCredential::updateOrCreate(
[ [
'user_id' => Auth::id(), 'user_id' => Auth::id(),
@@ -689,7 +712,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
try { try {
// Get site info to get domain // Get site info to get domain
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
throw new Exception(__('Site not found')); throw new Exception(__('Site not found'));
} }
$siteDomain = $site['domain'] ?? ''; $siteDomain = $site['domain'] ?? '';
@@ -741,7 +764,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->send(); ->send();
} else { } else {
// Check if there are conflicting plugins // Check if there are conflicting plugins
if (isset($result['conflicts']) && !empty($result['conflicts'])) { if (isset($result['conflicts']) && ! empty($result['conflicts'])) {
$conflictNames = array_column($result['conflicts'], 'name'); $conflictNames = array_column($result['conflicts'], 'name');
Notification::make() Notification::make()
->title(__('Conflicting Plugins Detected')) ->title(__('Conflicting Plugins Detected'))
@@ -875,7 +898,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
$result = $this->getAgent()->send('wp.create_staging', [ $result = $this->getAgent()->send('wp.create_staging', [
'username' => $this->getUsername(), 'username' => $this->getUsername(),
'site_id' => $siteId, 'site_id' => $siteId,
'subdomain' => 'staging-' . $subdomain, 'subdomain' => 'staging-'.$subdomain,
]); ]);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
@@ -899,6 +922,36 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
} }
public function pushStaging(string $stagingSiteId): void
{
try {
Notification::make()
->title(__('Pushing staging to production...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->wpPushStaging($this->getUsername(), $stagingSiteId);
if ($result['success'] ?? false) {
Notification::make()
->title(__('Staging pushed to production'))
->success()
->send();
$this->loadData();
$this->resetTable();
} else {
throw new Exception($result['error'] ?? __('Failed to push staging site'));
}
} catch (Exception $e) {
Notification::make()
->title(__('Push Failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function flushCache(string $siteId): void public function flushCache(string $siteId): void
{ {
try { try {
@@ -953,11 +1006,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
{ {
// Find the site // Find the site
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
Notification::make() Notification::make()
->title(__('Site not found')) ->title(__('Site not found'))
->danger() ->danger()
->send(); ->send();
return; return;
} }
@@ -969,6 +1023,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->body(__('Please contact your administrator to enable security scanning.')) ->body(__('Please contact your administrator to enable security scanning.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -985,12 +1040,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
// Run WPScan // Run WPScan
$url = $site['url']; $url = $site['url'];
exec("wpscan --url " . escapeshellarg($url) . " --format json --no-banner 2>&1", $scanOutput, $scanCode); exec('wpscan --url '.escapeshellarg($url).' --format json --no-banner 2>&1', $scanOutput, $scanCode);
$jsonOutput = implode("\n", $scanOutput); $jsonOutput = implode("\n", $scanOutput);
$results = json_decode($jsonOutput, true); $results = json_decode($jsonOutput, true);
if (!$results) { if (! $results) {
$this->securityScanResults = [ $this->securityScanResults = [
'error' => __('Failed to parse scan results'), 'error' => __('Failed to parse scan results'),
'raw_output' => $jsonOutput, 'raw_output' => $jsonOutput,
@@ -1042,7 +1097,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
'vulnerabilities' => [], 'vulnerabilities' => [],
]; ];
if (!empty($results['version']['vulnerabilities'])) { if (! empty($results['version']['vulnerabilities'])) {
foreach ($results['version']['vulnerabilities'] as $vuln) { foreach ($results['version']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('WordPress Core'), 'type' => __('WordPress Core'),
@@ -1061,7 +1116,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
'version' => $results['main_theme']['version']['number'] ?? __('Unknown'), 'version' => $results['main_theme']['version']['number'] ?? __('Unknown'),
]; ];
if (!empty($results['main_theme']['vulnerabilities'])) { if (! empty($results['main_theme']['vulnerabilities'])) {
foreach ($results['main_theme']['vulnerabilities'] as $vuln) { foreach ($results['main_theme']['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]), 'type' => __('Theme: :name', ['name' => $results['main_theme']['slug']]),
@@ -1074,14 +1129,14 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Plugins // Plugins
if (!empty($results['plugins'])) { if (! empty($results['plugins'])) {
foreach ($results['plugins'] as $slug => $plugin) { foreach ($results['plugins'] as $slug => $plugin) {
$parsed['plugins'][] = [ $parsed['plugins'][] = [
'name' => $slug, 'name' => $slug,
'version' => $plugin['version']['number'] ?? __('Unknown'), 'version' => $plugin['version']['number'] ?? __('Unknown'),
]; ];
if (!empty($plugin['vulnerabilities'])) { if (! empty($plugin['vulnerabilities'])) {
foreach ($plugin['vulnerabilities'] as $vuln) { foreach ($plugin['vulnerabilities'] as $vuln) {
$parsed['vulnerabilities'][] = [ $parsed['vulnerabilities'][] = [
'type' => __('Plugin: :name', ['name' => $slug]), 'type' => __('Plugin: :name', ['name' => $slug]),
@@ -1095,7 +1150,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Interesting findings // Interesting findings
if (!empty($results['interesting_findings'])) { if (! empty($results['interesting_findings'])) {
foreach ($results['interesting_findings'] as $finding) { foreach ($results['interesting_findings'] as $finding) {
$parsed['interesting_findings'][] = [ $parsed['interesting_findings'][] = [
'type' => $finding['type'] ?? 'info', 'type' => $finding['type'] ?? 'info',
@@ -1201,11 +1256,10 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->infolist([ ->infolist([
Section::make(__('Discovered Sites')) Section::make(__('Discovered Sites'))
->schema( ->schema(
collect($this->scannedSites)->map(fn ($site, $index) => collect($this->scannedSites)->map(fn ($site, $index) => TextEntry::make("site_{$index}")
TextEntry::make("site_{$index}")
->label($site['site_url'] ?? __('WordPress Site')) ->label($site['site_url'] ?? __('WordPress Site'))
->state($site['path']) ->state($site['path'])
->helperText(isset($site['version']) ? 'v' . $site['version'] : '') ->helperText(isset($site['version']) ? 'v'.$site['version'] : '')
)->toArray() )->toArray()
), ),
]); ]);
@@ -1253,7 +1307,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->label(__('Version')) ->label(__('Version'))
->state($results['wordpress_version']['number']) ->state($results['wordpress_version']['number'])
->badge() ->badge()
->color(match($results['wordpress_version']['status'] ?? '') { ->color(match ($results['wordpress_version']['status'] ?? '') {
'insecure' => 'danger', 'insecure' => 'danger',
'outdated' => 'warning', 'outdated' => 'warning',
default => 'success', default => 'success',
@@ -1271,7 +1325,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '') ->helperText($vuln['fixed_in'] ? __('Fixed in: :version', ['version' => $vuln['fixed_in']]) : '')
->color('danger'); ->color('danger');
} }
$schema[] = Section::make(__('Vulnerabilities Found') . " ({$vulnCount})") $schema[] = Section::make(__('Vulnerabilities Found')." ({$vulnCount})")
->icon('heroicon-o-exclamation-triangle') ->icon('heroicon-o-exclamation-triangle')
->iconColor('danger') ->iconColor('danger')
->schema($vulnEntries); ->schema($vulnEntries);
@@ -1283,7 +1337,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Interesting findings // Interesting findings
if (!empty($results['interesting_findings'])) { if (! empty($results['interesting_findings'])) {
$findingEntries = []; $findingEntries = [];
foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) { foreach (array_slice($results['interesting_findings'], 0, 10) as $index => $finding) {
$findingEntries[] = TextEntry::make("finding_{$index}") $findingEntries[] = TextEntry::make("finding_{$index}")
@@ -1297,16 +1351,16 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} }
// Detected plugins // Detected plugins
if (!empty($results['plugins'])) { if (! empty($results['plugins'])) {
$pluginEntries = []; $pluginEntries = [];
foreach ($results['plugins'] as $index => $plugin) { foreach ($results['plugins'] as $index => $plugin) {
$pluginEntries[] = TextEntry::make("plugin_{$index}") $pluginEntries[] = TextEntry::make("plugin_{$index}")
->hiddenLabel() ->hiddenLabel()
->state($plugin['name'] . ' v' . $plugin['version']) ->state($plugin['name'].' v'.$plugin['version'])
->badge() ->badge()
->color('gray'); ->color('gray');
} }
$schema[] = Section::make(__('Detected Plugins') . ' (' . count($results['plugins']) . ')') $schema[] = Section::make(__('Detected Plugins').' ('.count($results['plugins']).')')
->icon('heroicon-o-puzzle-piece') ->icon('heroicon-o-puzzle-piece')
->collapsed() ->collapsed()
->schema($pluginEntries); ->schema($pluginEntries);
@@ -1319,11 +1373,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function captureScreenshot(string $siteId): void public function captureScreenshot(string $siteId): void
{ {
$site = collect($this->sites)->firstWhere('id', $siteId); $site = collect($this->sites)->firstWhere('id', $siteId);
if (!$site) { if (! $site) {
Notification::make() Notification::make()
->title(__('Site not found')) ->title(__('Site not found'))
->danger() ->danger()
->send(); ->send();
return; return;
} }
@@ -1332,22 +1387,23 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
try { try {
// Ensure screenshots directory exists // Ensure screenshots directory exists
$screenshotDir = storage_path('app/public/screenshots'); $screenshotDir = storage_path('app/public/screenshots');
if (!is_dir($screenshotDir)) { if (! is_dir($screenshotDir)) {
mkdir($screenshotDir, 0755, true); mkdir($screenshotDir, 0755, true);
} }
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
$filepath = $screenshotDir . '/' . $filename; $filepath = $screenshotDir.'/'.$filename;
// Use screenshot wrapper script that handles Chromium crashpad issues // Use screenshot wrapper script that handles Chromium crashpad issues
$screenshotBin = base_path('bin/screenshot'); $screenshotBin = base_path('bin/screenshot');
if (!file_exists($screenshotBin) || !is_executable($screenshotBin)) { if (! file_exists($screenshotBin) || ! is_executable($screenshotBin)) {
Notification::make() Notification::make()
->title(__('Screenshot failed')) ->title(__('Screenshot failed'))
->body(__('Screenshot script not found.')) ->body(__('Screenshot script not found.'))
->warning() ->warning()
->send(); ->send();
return; return;
} }
@@ -1370,7 +1426,7 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
} else { } else {
Notification::make() Notification::make()
->title(__('Screenshot failed')) ->title(__('Screenshot failed'))
->body(__('Could not capture screenshot. Error: ') . implode("\n", $output)) ->body(__('Could not capture screenshot. Error: ').implode("\n", $output))
->warning() ->warning()
->send(); ->send();
} }
@@ -1385,12 +1441,12 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function getScreenshotUrl(string $siteId): ?string public function getScreenshotUrl(string $siteId): ?string
{ {
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
$filepath = storage_path('app/public/screenshots/' . $filename); $filepath = storage_path('app/public/screenshots/'.$filename);
if (file_exists($filepath)) { if (file_exists($filepath)) {
// Add timestamp to bust cache // Add timestamp to bust cache
return asset('storage/screenshots/' . $filename) . '?t=' . filemtime($filepath); return asset('storage/screenshots/'.$filename).'?t='.filemtime($filepath);
} }
return null; return null;
@@ -1398,7 +1454,8 @@ class WordPress extends Page implements HasForms, HasActions, HasTable
public function hasScreenshot(string $siteId): bool public function hasScreenshot(string $siteId): bool
{ {
$filename = 'wp_' . $siteId . '.png'; $filename = 'wp_'.$siteId.'.png';
return file_exists(storage_path('app/public/screenshots/' . $filename));
return file_exists(storage_path('app/public/screenshots/'.$filename));
} }
} }

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Domain;
use App\Models\HostingPackage;
use App\Models\User;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\LinuxUserService;
use App\Services\System\ResourceLimitService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AutomationApiController extends Controller
{
public function listUsers(): JsonResponse
{
$users = User::query()
->where('is_admin', false)
->get(['id', 'name', 'username', 'email', 'is_active', 'hosting_package_id']);
return response()->json(['users' => $users]);
}
public function createUser(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]{0,31}$/', 'unique:users,username'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
'hosting_package_id' => ['nullable', 'exists:hosting_packages,id'],
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$data = $validator->validated();
$package = null;
if (! empty($data['hosting_package_id'])) {
$package = HostingPackage::find($data['hosting_package_id']);
}
$user = User::create([
'name' => $data['name'],
'username' => $data['username'],
'email' => $data['email'],
'password' => $data['password'],
'sftp_password' => $data['password'],
'is_admin' => false,
'is_active' => true,
'hosting_package_id' => $package?->id,
'disk_quota_mb' => $package?->disk_quota_mb,
]);
try {
$linux = new LinuxUserService;
$linux->createUser($user, $data['password']);
} catch (\Exception $e) {
$user->delete();
return response()->json(['error' => $e->getMessage()], 500);
}
if ($package && $package->disk_quota_mb) {
try {
$agent = new AgentClient;
$agent->quotaSet($user->username, (int) $package->disk_quota_mb);
} catch (\Exception) {
// keep user created, quota can be applied later
}
}
if ($package) {
$cpu = $package->cpu_limit_percent;
$memory = $package->memory_limit_mb;
$io = $package->io_limit_mb;
$hasLimits = ($cpu && $cpu > 0) || ($memory && $memory > 0) || ($io && $io > 0);
if ($hasLimits) {
$limit = UserResourceLimit::firstOrNew(['user_id' => $user->id]);
$limit->fill([
'cpu_limit_percent' => $cpu,
'memory_limit_mb' => $memory,
'io_limit_mb' => $io,
'is_active' => true,
])->save();
try {
app(ResourceLimitService::class)->apply($limit);
} catch (\Exception) {
// cgroup apply failure shouldn't block user creation
}
}
}
return response()->json(['user' => $user], 201);
}
public function createDomain(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'username' => ['nullable', 'string'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'domain' => ['required', 'string'],
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$data = $validator->validated();
$user = null;
if (! empty($data['user_id'])) {
$user = User::where('id', $data['user_id'])->where('is_admin', false)->first();
} elseif (! empty($data['username'])) {
$user = User::where('username', $data['username'])->where('is_admin', false)->first();
}
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$limit = $user->hostingPackage?->domains_limit;
if ($limit && Domain::where('user_id', $user->id)->count() >= $limit) {
return response()->json(['error' => 'Domain limit reached'], 409);
}
$agent = new AgentClient;
$result = $agent->domainCreate($user->username, $data['domain']);
if (! ($result['success'] ?? false)) {
return response()->json(['error' => $result['error'] ?? 'Domain creation failed'], 500);
}
$domain = Domain::create([
'user_id' => $user->id,
'domain' => $data['domain'],
'document_root' => '/home/'.$user->username.'/domains/'.$data['domain'].'/public_html',
'is_active' => true,
'ssl_enabled' => false,
'directory_index' => 'index.php index.html',
'page_cache_enabled' => false,
]);
return response()->json(['domain' => $domain], 201);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\RunGitDeployment;
use App\Models\GitDeployment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GitWebhookController extends Controller
{
public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse
{
if (! hash_equals($deployment->secret_token, $token)) {
return response()->json(['message' => 'Invalid token'], 403);
}
if (! $deployment->auto_deploy) {
return response()->json(['message' => 'Auto-deploy disabled'], 202);
}
RunGitDeployment::dispatch($deployment->id);
return response()->json(['message' => 'Deployment queued']);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\GitDeployment;
use App\Services\Agent\AgentClient;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RunGitDeployment implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public int $deploymentId) {}
public function handle(): void
{
$deployment = GitDeployment::find($this->deploymentId);
if (! $deployment) {
return;
}
$deployment->update([
'last_status' => 'running',
'last_error' => null,
]);
$agent = new AgentClient;
try {
$result = $agent->send('git.deploy', [
'username' => $deployment->user->username,
'repo_url' => $deployment->repo_url,
'branch' => $deployment->branch,
'deploy_path' => $deployment->deploy_path,
'deploy_script' => $deployment->deploy_script,
]);
if (! ($result['success'] ?? false)) {
throw new Exception($result['error'] ?? 'Deployment failed');
}
$deployment->update([
'last_status' => 'success',
'last_deployed_at' => now(),
'last_error' => null,
]);
} catch (Exception $e) {
$deployment->update([
'last_status' => 'failed',
'last_error' => $e->getMessage(),
]);
Log::error('Git deployment failed', [
'deployment_id' => $deployment->id,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class CloudflareZone extends Model
{
protected $fillable = [
'user_id',
'domain_id',
'zone_id',
'account_id',
'api_token',
];
protected $hidden = [
'api_token',
];
public function setApiTokenAttribute(string $value): void
{
$this->attributes['api_token'] = Crypt::encryptString($value);
}
public function getApiTokenAttribute(): string
{
$value = $this->attributes['api_token'] ?? '';
if ($value === '') {
return '';
}
return Crypt::decryptString($value);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function domain(): BelongsTo
{
return $this->belongsTo(Domain::class);
}
}

View File

@@ -48,6 +48,9 @@ class Domain extends Model
// Delete hotlink settings // Delete hotlink settings
$domain->hotlinkSetting?->delete(); $domain->hotlinkSetting?->delete();
// Delete aliases
$domain->aliases()->delete();
// Delete email domain and related records // Delete email domain and related records
if ($domain->emailDomain) { if ($domain->emailDomain) {
// Delete mailboxes (which will cascade to autoresponders) // Delete mailboxes (which will cascade to autoresponders)
@@ -103,6 +106,11 @@ class Domain extends Model
return $this->hasMany(DomainRedirect::class); return $this->hasMany(DomainRedirect::class);
} }
public function aliases(): HasMany
{
return $this->hasMany(DomainAlias::class);
}
public function hotlinkSetting(): HasOne public function hotlinkSetting(): HasOne
{ {
return $this->hasOne(DomainHotlinkSetting::class); return $this->hasOne(DomainHotlinkSetting::class);

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DomainAlias extends Model
{
protected $fillable = [
'domain_id',
'alias',
];
public function domain(): BelongsTo
{
return $this->belongsTo(Domain::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GeoBlockRule extends Model
{
use HasFactory;
protected $fillable = [
'country_code',
'action',
'notes',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GitDeployment extends Model
{
protected $fillable = [
'user_id',
'domain_id',
'repo_url',
'branch',
'deploy_path',
'auto_deploy',
'deploy_script',
'last_status',
'last_deployed_at',
'last_error',
'secret_token',
];
protected $casts = [
'auto_deploy' => 'boolean',
'last_deployed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function domain(): BelongsTo
{
return $this->belongsTo(Domain::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class HostingPackage extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'disk_quota_mb',
'bandwidth_gb',
'domains_limit',
'databases_limit',
'mailboxes_limit',
'cpu_limit_percent',
'memory_limit_mb',
'io_limit_mb',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

@@ -11,10 +11,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser class User extends Authenticatable implements FilamentUser
{ {
use HasFactory, Notifiable, TwoFactorAuthenticatable; use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -25,6 +26,7 @@ class User extends Authenticatable implements FilamentUser
'sftp_password', 'sftp_password',
'is_admin', 'is_admin',
'is_active', 'is_active',
'hosting_package_id',
'locale', 'locale',
'disk_quota_mb', 'disk_quota_mb',
]; ];
@@ -147,6 +149,11 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(Domain::class); return $this->hasMany(Domain::class);
} }
public function hostingPackage(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(HostingPackage::class);
}
/** /**
* Get disk usage in bytes. * Get disk usage in bytes.
*/ */

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserResourceLimit extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'cpu_limit_percent',
'memory_limit_mb',
'io_limit_mb',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserResourceUsage extends Model
{
protected $fillable = [
'user_id',
'metric',
'value',
'captured_at',
];
protected $casts = [
'value' => 'integer',
'captured_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
class UserSetting extends Model
{
protected $fillable = [
'user_id',
'key',
'value',
];
protected $casts = [
'value' => 'json',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function getForUser(int $userId, string $key, mixed $default = null): mixed
{
return Cache::remember("user_setting.{$userId}.{$key}", 3600, function () use ($userId, $key, $default) {
return static::query()
->where('user_id', $userId)
->where('key', $key)
->value('value') ?? $default;
});
}
public static function setForUser(int $userId, string $key, mixed $value): void
{
static::updateOrCreate(
['user_id' => $userId, 'key' => $key],
['value' => $value]
);
Cache::forget("user_setting.{$userId}.{$key}");
}
public static function forgetForUser(int $userId, string $key): void
{
static::query()
->where('user_id', $userId)
->where('key', $key)
->delete();
Cache::forget("user_setting.{$userId}.{$key}");
}
public static function getAllForUser(int $userId): array
{
return static::query()
->where('user_id', $userId)
->pluck('value', 'key')
->toArray();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WebhookEndpoint extends Model
{
use HasFactory;
protected $fillable = [
'name',
'url',
'events',
'is_active',
'secret_token',
'last_response_code',
'last_triggered_at',
];
protected $casts = [
'events' => 'array',
'is_active' => 'boolean',
'last_triggered_at' => 'datetime',
];
}

View File

@@ -227,6 +227,45 @@ class AgentClient
return $this->send('file.list_trash', ['username' => $username]); return $this->send('file.list_trash', ['username' => $username]);
} }
// Git deployment
public function gitGenerateKey(string $username): array
{
return $this->send('git.generate_key', ['username' => $username]);
}
public function gitDeploy(string $username, string $repoUrl, string $branch, string $deployPath, ?string $deployScript = null): array
{
return $this->send('git.deploy', [
'username' => $username,
'repo_url' => $repoUrl,
'branch' => $branch,
'deploy_path' => $deployPath,
'deploy_script' => $deployScript,
]);
}
// Spam settings (Rspamd)
public function rspamdUserSettings(string $username, array $whitelist = [], array $blacklist = [], ?float $score = null): array
{
return $this->send('rspamd.user_settings', [
'username' => $username,
'whitelist' => $whitelist,
'blacklist' => $blacklist,
'score' => $score,
]);
}
// Image optimization
public function imageOptimize(string $username, string $path, bool $convertWebp = false, int $quality = 82): array
{
return $this->send('image.optimize', [
'username' => $username,
'path' => $path,
'convert_webp' => $convertWebp,
'quality' => $quality,
]);
}
// MySQL operations // MySQL operations
public function mysqlListDatabases(string $username): array public function mysqlListDatabases(string $username): array
{ {
@@ -293,12 +332,101 @@ class AgentClient
return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]); return $this->send('mysql.export_database', ['username' => $username, 'database' => $database, 'output_file' => $outputFile, 'compress' => $compress]);
} }
// PostgreSQL operations
public function postgresListDatabases(string $username): array
{
return $this->send('postgres.list_databases', ['username' => $username]);
}
public function postgresListUsers(string $username): array
{
return $this->send('postgres.list_users', ['username' => $username]);
}
public function postgresCreateDatabase(string $username, string $database, string $owner): array
{
return $this->send('postgres.create_database', [
'username' => $username,
'database' => $database,
'owner' => $owner,
]);
}
public function postgresDeleteDatabase(string $username, string $database): array
{
return $this->send('postgres.delete_database', [
'username' => $username,
'database' => $database,
]);
}
public function postgresCreateUser(string $username, string $dbUser, string $password): array
{
return $this->send('postgres.create_user', [
'username' => $username,
'db_user' => $dbUser,
'password' => $password,
]);
}
public function postgresDeleteUser(string $username, string $dbUser): array
{
return $this->send('postgres.delete_user', [
'username' => $username,
'db_user' => $dbUser,
]);
}
public function postgresChangePassword(string $username, string $dbUser, string $password): array
{
return $this->send('postgres.change_password', [
'username' => $username,
'db_user' => $dbUser,
'password' => $password,
]);
}
public function postgresGrantPrivileges(string $username, string $database, string $dbUser): array
{
return $this->send('postgres.grant_privileges', [
'username' => $username,
'database' => $database,
'db_user' => $dbUser,
]);
}
// Domain operations // Domain operations
public function domainCreate(string $username, string $domain): array public function domainCreate(string $username, string $domain): array
{ {
return $this->send('domain.create', ['username' => $username, 'domain' => $domain]); return $this->send('domain.create', ['username' => $username, 'domain' => $domain]);
} }
public function domainAliasAdd(string $username, string $domain, string $alias): array
{
return $this->send('domain.alias_add', [
'username' => $username,
'domain' => $domain,
'alias' => $alias,
]);
}
public function domainAliasRemove(string $username, string $domain, string $alias): array
{
return $this->send('domain.alias_remove', [
'username' => $username,
'domain' => $domain,
'alias' => $alias,
]);
}
public function domainEnsureErrorPages(string $username, string $domain): array
{
return $this->send('domain.ensure_error_pages', [
'username' => $username,
'domain' => $domain,
]);
}
public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array public function domainDelete(string $username, string $domain, bool $deleteFiles = false): array
{ {
return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]); return $this->send('domain.delete', ['username' => $username, 'domain' => $domain, 'delete_files' => $deleteFiles]);
@@ -360,6 +488,23 @@ class AgentClient
return $this->send('wp.import', $params); return $this->send('wp.import', $params);
} }
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array
{
return $this->send('wp.create_staging', [
'username' => $username,
'site_id' => $siteId,
'subdomain' => $subdomain,
]);
}
public function wpPushStaging(string $username, string $stagingSiteId): array
{
return $this->send('wp.push_staging', [
'username' => $username,
'staging_site_id' => $stagingSiteId,
]);
}
// WordPress Cache Methods // WordPress Cache Methods
public function wpCacheEnable(string $username, string $siteId): array public function wpCacheEnable(string $username, string $siteId): array
{ {
@@ -554,6 +699,15 @@ class AgentClient
return $this->send('email.sync_virtual_users', ['domain' => $domain]); return $this->send('email.sync_virtual_users', ['domain' => $domain]);
} }
public function emailSyncMaps(array $domains, array $mailboxes, array $aliases): array
{
return $this->send('email.sync_maps', [
'domains' => $domains,
'mailboxes' => $mailboxes,
'aliases' => $aliases,
]);
}
public function emailReloadServices(): array public function emailReloadServices(): array
{ {
return $this->send('email.reload_services'); return $this->send('email.reload_services');
@@ -1151,4 +1305,77 @@ class AgentClient
{ {
return $this->send('scanner.get_scan_status', ['scanner' => $scanner]); return $this->send('scanner.get_scan_status', ['scanner' => $scanner]);
} }
// Mail queue operations
public function mailQueueList(): array
{
return $this->send('mail.queue_list');
}
public function mailQueueRetry(string $id): array
{
return $this->send('mail.queue_retry', ['id' => $id]);
}
public function mailQueueDelete(string $id): array
{
return $this->send('mail.queue_delete', ['id' => $id]);
}
// Server updates
public function updatesList(): array
{
return $this->send('updates.list');
}
public function updatesRun(): array
{
return $this->send('updates.run');
}
// WAF / Geo / Resource limits
public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog): array
{
return $this->send('waf.apply', [
'enabled' => $enabled,
'paranoia' => $paranoia,
'audit_log' => $auditLog,
]);
}
public function geoApplyRules(array $rules): array
{
return $this->send('geo.apply_rules', [
'rules' => $rules,
]);
}
public function cgroupApplyUserLimits(string $username, ?int $cpuLimit, ?int $memoryLimit, ?int $ioLimit, bool $isActive = true): array
{
if (! $isActive) {
return $this->cgroupClearUserLimits($username);
}
return $this->send('cgroup.apply_user_limits', [
'username' => $username,
'cpu_limit_percent' => $cpuLimit,
'memory_limit_mb' => $memoryLimit,
'io_limit_mb' => $ioLimit,
]);
}
public function cgroupClearUserLimits(string $username): array
{
return $this->send('cgroup.clear_user_limits', [
'username' => $username,
]);
}
public function databasePersistTuning(string $name, string $value): array
{
return $this->send('database.persist_tuning', [
'name' => $name,
'value' => $value,
]);
}
} }

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\System;
use App\Models\GeoBlockRule;
use App\Services\Agent\AgentClient;
class GeoBlockService
{
public function applyCurrentRules(): void
{
$rules = GeoBlockRule::query()
->where('is_active', true)
->get(['country_code', 'action'])
->map(static function ($rule): array {
return [
'country_code' => strtoupper((string) $rule->country_code),
'action' => $rule->action,
'is_active' => true,
];
})
->values()
->toArray();
$agent = new AgentClient;
$agent->geoApplyRules($rules);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\System;
use App\Models\EmailDomain;
use App\Models\EmailForwarder;
use App\Models\Mailbox;
use App\Services\Agent\AgentClient;
class MailRoutingSyncService
{
public function sync(): void
{
$domains = EmailDomain::query()
->where('is_active', true)
->with('domain')
->get()
->map(fn ($domain) => $domain->domain?->domain)
->filter()
->unique()
->values()
->toArray();
$mailboxes = Mailbox::query()
->where('is_active', true)
->with('emailDomain.domain')
->get()
->map(function (Mailbox $mailbox): array {
return [
'email' => $mailbox->email,
'path' => $mailbox->maildir_path,
];
})
->toArray();
$aliases = EmailForwarder::query()
->where('is_active', true)
->with('emailDomain.domain')
->get()
->map(function (EmailForwarder $forwarder): array {
return [
'source' => $forwarder->email,
'destinations' => $forwarder->destinations ?? [],
];
})
->toArray();
$catchAll = EmailDomain::query()
->where('catch_all_enabled', true)
->with('domain')
->get()
->map(function (EmailDomain $domain): array {
return [
'source' => '@'.$domain->domain->domain,
'destinations' => $domain->catch_all_address ? [$domain->catch_all_address] : [],
];
})
->filter(fn (array $entry) => ! empty($entry['destinations']))
->toArray();
$aliases = array_merge($aliases, $catchAll);
$agent = new AgentClient;
$agent->emailSyncMaps($domains, $mailboxes, $aliases);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\System;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use RuntimeException;
class ResourceLimitService
{
public function apply(UserResourceLimit $limit): void
{
$user = $limit->user;
if (! $user) {
throw new RuntimeException('User not found for resource limit.');
}
$agent = new AgentClient;
$agent->cgroupApplyUserLimits(
$user->username,
$limit->cpu_limit_percent ? (int) $limit->cpu_limit_percent : null,
$limit->memory_limit_mb ? (int) $limit->memory_limit_mb : null,
$limit->io_limit_mb ? (int) $limit->io_limit_mb : null,
(bool) $limit->is_active
);
}
public function clear(UserResourceLimit $limit): void
{
$user = $limit->user;
if (! $user) {
return;
}
$agent = new AgentClient;
$agent->cgroupClearUserLimits($user->username);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,77 @@
return [ return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
],
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Class Namespace | Class Namespace
@@ -15,6 +86,19 @@ return [
'class_namespace' => 'App\\Livewire', 'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| View Path | View Path
@@ -28,30 +112,6 @@ return [
'view_path' => resource_path('views/livewire'), 'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
| In this case, the view returned by CreatePost will render into $slot.
|
*/
'layout' => 'components.layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'lazy_placeholder' => null,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
| Temporary File Uploads | Temporary File Uploads
@@ -64,8 +124,8 @@ return [
*/ */
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default' 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => ['required', 'file', 'max:524288'], // 512MB max 'rules' => ['required', 'file', 'max:524288'], // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
@@ -156,7 +216,7 @@ return [
| |
*/ */
'smart_wire_keys' => false, 'smart_wire_keys' => true,
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
@@ -183,4 +243,35 @@ return [
*/ */
'release_token' => 'a', 'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 20, // Maximum components per batch request
],
]; ];

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_resource_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('metric', 50);
$table->unsignedBigInteger('value');
$table->timestamp('captured_at')->index();
$table->timestamps();
$table->index(['user_id', 'metric', 'captured_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_resource_usages');
}
};

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('key', 100);
$table->json('value')->nullable();
$table->timestamps();
$table->unique(['user_id', 'key']);
});
}
public function down(): void
{
Schema::dropIfExists('user_settings');
}
};

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('domain_aliases', function (Blueprint $table) {
$table->id();
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
$table->string('alias');
$table->timestamps();
$table->unique(['domain_id', 'alias']);
$table->index('alias');
});
}
public function down(): void
{
Schema::dropIfExists('domain_aliases');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('git_deployments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
$table->string('repo_url');
$table->string('branch')->default('main');
$table->string('deploy_path');
$table->boolean('auto_deploy')->default(false);
$table->text('deploy_script')->nullable();
$table->string('last_status')->nullable();
$table->timestamp('last_deployed_at')->nullable();
$table->text('last_error')->nullable();
$table->string('secret_token', 80)->unique();
$table->timestamps();
$table->index(['user_id', 'domain_id']);
});
}
public function down(): void
{
Schema::dropIfExists('git_deployments');
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cloudflare_zones', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
$table->string('zone_id');
$table->string('account_id')->nullable();
$table->text('api_token');
$table->timestamps();
$table->unique(['user_id', 'domain_id']);
});
}
public function down(): void
{
Schema::dropIfExists('cloudflare_zones');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('hosting_packages', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->text('description')->nullable();
$table->unsignedInteger('disk_quota_mb')->nullable();
$table->unsignedInteger('bandwidth_gb')->nullable();
$table->unsignedInteger('domains_limit')->nullable();
$table->unsignedInteger('databases_limit')->nullable();
$table->unsignedInteger('mailboxes_limit')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('hosting_packages');
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('hosting_package_id')
->nullable()
->constrained('hosting_packages')
->nullOnDelete()
->after('is_active');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['hosting_package_id']);
$table->dropColumn('hosting_package_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_endpoints', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url');
$table->json('events')->nullable();
$table->string('secret_token')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('last_response_code')->nullable();
$table->timestamp('last_triggered_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webhook_endpoints');
}
};

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_resource_limits', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('cpu_limit_percent')->nullable();
$table->unsignedInteger('memory_limit_mb')->nullable();
$table->unsignedInteger('io_limit_mb')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_resource_limits');
}
};

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('geo_block_rules', function (Blueprint $table) {
$table->id();
$table->string('country_code', 2);
$table->enum('action', ['allow', 'block'])->default('block');
$table->string('notes')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('geo_block_rules');
}
};

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('hosting_packages', function (Blueprint $table) {
$table->unsignedInteger('cpu_limit_percent')->nullable()->after('mailboxes_limit');
$table->unsignedInteger('memory_limit_mb')->nullable()->after('cpu_limit_percent');
$table->unsignedInteger('io_limit_mb')->nullable()->after('memory_limit_mb');
});
}
public function down(): void
{
Schema::table('hosting_packages', function (Blueprint $table) {
$table->dropColumn(['cpu_limit_percent', 'memory_limit_mb', 'io_limit_mb']);
});
}
};

View File

@@ -0,0 +1,30 @@
<x-filament-panels::page>
@if($plainToken)
<x-filament::section icon="heroicon-o-key" icon-color="success">
<x-slot name="heading">{{ __('New Token') }}</x-slot>
<x-slot name="description">{{ __('Copy this token now. You will not be able to see it again.') }}</x-slot>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ __('API Token') }}</p>
<p class="mt-2 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $plainToken }}</p>
</div>
</x-filament::section>
@endif
<x-filament::section icon="heroicon-o-document-text" class="mt-6">
<x-slot name="heading">{{ __('Automation API Endpoints') }}</x-slot>
<x-slot name="description">{{ __('Use these endpoints with a token that has the automation ability.') }}</x-slot>
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div><span class="font-mono">GET</span> /api/automation/users</div>
<div><span class="font-mono">POST</span> /api/automation/users</div>
<div><span class="font-mono">POST</span> /api/automation/domains</div>
</div>
</x-filament::section>
<div class="mt-6">
{{ $this->table }}
</div>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,14 @@
<x-filament-panels::page>
<x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="warning">
<x-slot name="heading">{{ __('Runtime Changes') }}</x-slot>
<x-slot name="description">
{{ __('Changes apply immediately but may reset after a database restart. Persisting configuration will be added in a later step.') }}
</x-slot>
</x-filament::section>
<div class="mt-6">
{{ $this->table }}
</div>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,383 @@
<x-filament-panels::page>
@once
@vite('resources/js/server-charts.js')
@endonce
{{ $this->usageForm }}
@php($hasRealData = !empty($chartData) && !empty($chartData['labels']))
@if(! $hasRealData)
<x-filament::section class="mt-6">
<div class="flex flex-col items-center justify-center py-10">
<div class="mb-3 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
<x-filament::icon icon="heroicon-o-chart-bar" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
</div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
{{ __('No usage data yet') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __('Usage snapshots are collected hourly. Data will appear after the first collection run.') }}
</p>
</div>
</x-filament::section>
@else
<div class="mt-6 space-y-6">
<x-filament::section icon="heroicon-o-square-3-stack-3d" icon-color="primary">
<x-slot name="heading">{{ __('Latest Snapshot') }}</x-slot>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Disk') }}</p>
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['disk'] ?? __('No data') }}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Mail') }}</p>
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['mail'] ?? __('No data') }}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Databases') }}</p>
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['database'] ?? __('No data') }}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Bandwidth (last hour)') }}</p>
<p class="text-lg font-semibold text-gray-950 dark:text-white">{{ $summary['bandwidth'] ?? __('No data') }}</p>
</div>
</div>
</x-filament::section>
<x-filament::section icon="heroicon-o-chart-bar" icon-color="success">
<x-slot name="heading">{{ __('Resource Usage (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Historical snapshots collected hourly.') }}</x-slot>
<div
wire:key="resource-usage-chart-{{ $usageFormData['user_id'] ?? 'none' }}"
x-data="{
chart: null,
init() {
let data = @js($chartData);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !data.labels?.length || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: {
data: ['Disk', 'Mail', 'Databases', 'Bandwidth'],
bottom: 0,
},
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value} GB' },
},
series: [
{ name: 'Disk', type: 'line', smooth: true, data: data.disk, areaStyle: {} },
{ name: 'Mail', type: 'line', smooth: true, data: data.mail, areaStyle: {} },
{ name: 'Databases', type: 'line', smooth: true, data: data.database, areaStyle: {} },
{ name: 'Bandwidth', type: 'line', smooth: true, data: data.bandwidth, areaStyle: {} },
],
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
}
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
@if($hasPerformanceData)
<div class="grid gap-6 lg:grid-cols-2">
<x-filament::section icon="heroicon-o-cpu-chip" icon-color="info">
<x-slot name="heading">{{ __('CPU Usage (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Average CPU percent per snapshot.') }}</x-slot>
<div class="mb-4 flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-300">
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Limit') }}:</span>
{{ $cpuLimitPercent ? $cpuLimitPercent . '%' : __('Unlimited') }}
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Average') }}:</span>
{{ $cpuStats['avg'] ?? __('No data') }}
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Peak') }}:</span>
{{ $cpuStats['max'] ?? __('No data') }}
</div>
</div>
<div
x-data="{
chart: null,
init() {
const data = @js($performanceChartData);
const limit = @js($cpuLimitPercent);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !data.labels?.length || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
const series = [
{ name: 'CPU', type: 'line', smooth: true, data: data.cpu, areaStyle: {} },
];
if (limit) {
series.push({
name: 'Limit',
type: 'line',
data: data.labels.map(() => limit),
lineStyle: { type: 'dashed', width: 2, color: '#ef4444' },
symbol: 'none',
});
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: series.map(item => item.name), bottom: 0 },
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
margin: 12,
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value}%' },
},
series,
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
}
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
<x-filament::section icon="heroicon-o-circle-stack" icon-color="warning">
<x-slot name="heading">{{ __('Memory Usage (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Resident memory per snapshot.') }}</x-slot>
<div class="mb-4 flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-300">
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Limit') }}:</span>
{{ $memoryLimitGb ? number_format($memoryLimitGb, 2) . ' GB' : __('Unlimited') }}
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Average') }}:</span>
{{ $memoryStats['avg'] ?? __('No data') }}
</div>
<div>
<span class="font-semibold text-gray-900 dark:text-white">{{ __('Peak') }}:</span>
{{ $memoryStats['max'] ?? __('No data') }}
</div>
</div>
<div
x-data="{
chart: null,
init() {
const data = @js($performanceChartData);
const limit = @js($memoryLimitGb);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !data.labels?.length || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
const series = [
{ name: 'Memory', type: 'line', smooth: true, data: data.memory, areaStyle: {} },
];
if (limit) {
series.push({
name: 'Limit',
type: 'line',
data: data.labels.map(() => limit),
lineStyle: { type: 'dashed', width: 2, color: '#ef4444' },
symbol: 'none',
});
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: series.map(item => item.name), bottom: 0 },
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
margin: 12,
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value} GB' },
},
series,
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
}
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
<x-filament::section icon="heroicon-o-arrow-path-rounded-square" icon-color="primary">
<x-slot name="heading">{{ __('Disk IO (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Read/write MB per snapshot.') }}</x-slot>
<div
x-data="{
chart: null,
init() {
const data = @js($performanceChartData);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !data.labels?.length || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['Disk IO'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: 50, containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
margin: 12,
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value} MB' },
},
series: [
{ name: 'Disk IO', type: 'line', smooth: true, data: data.disk_io, areaStyle: {} },
],
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
}
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
</div>
@else
<x-filament::section icon="heroicon-o-cpu-chip" icon-color="info">
<x-slot name="heading">{{ __('CPU, Memory & Disk IO') }}</x-slot>
<div class="flex flex-col items-center justify-center py-10 text-center">
<div class="mb-3 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
<x-filament::icon icon="heroicon-o-cpu-chip" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
</div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
{{ __('No performance data yet') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __('CPU, memory, and disk IO snapshots will appear after the next collection run.') }}
</p>
</div>
</x-filament::section>
@endif
</div>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,21 @@
<x-filament-panels::page>
@if(! $wafInstalled)
<x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="warning">
<x-slot name="heading">{{ __('ModSecurity not detected') }}</x-slot>
<x-slot name="description">
{{ __('Install ModSecurity on the server to enable WAF controls. Settings can be saved now and applied later.') }}
</x-slot>
</x-filament::section>
@endif
<div class="mt-6">
{{ $this->wafForm }}
<div class="mt-4">
<x-filament::button wire:click="saveWafSettings" icon="heroicon-o-check">
{{ __('Save WAF Settings') }}
</x-filament::button>
</div>
</div>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -5,6 +5,7 @@
'autoresponders' => ['label' => __('Autoresponders'), 'icon' => 'heroicon-o-clock'], 'autoresponders' => ['label' => __('Autoresponders'), 'icon' => 'heroicon-o-clock'],
'catchall' => ['label' => __('Catch-All'), 'icon' => 'heroicon-o-inbox-stack'], 'catchall' => ['label' => __('Catch-All'), 'icon' => 'heroicon-o-inbox-stack'],
'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'], 'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
'spam' => ['label' => __('Spam Settings'), 'icon' => 'heroicon-o-shield-check'],
]; ];
@endphp @endphp

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -3,9 +3,21 @@
{{ $this->emailForm }} {{ $this->emailForm }}
@if($activeTab === 'spam')
<div class="mt-4">
{{ $this->spamForm }}
<div class="mt-6">
<x-filament::button wire:click="saveSpamSettings" icon="heroicon-o-check" color="primary">
{{ __('Save Spam Settings') }}
</x-filament::button>
</div>
</div>
@else
<div class="-mt-4"> <div class="-mt-4">
{{ $this->table }} {{ $this->table }}
</div> </div>
@endif
<x-filament-actions::modals /> <x-filament-actions::modals />
</x-filament-panels::page> </x-filament-panels::page>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,11 @@
<x-filament-panels::page>
{{ $this->imageForm }}
<div class="mt-6">
<x-filament::button color="primary" wire:click="runOptimization" icon="heroicon-o-bolt">
{{ __('Run Optimization') }}
</x-filament::button>
</div>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -1,10 +1,48 @@
<x-filament-panels::page> <x-filament-panels::page>
@if(count($this->getDomainOptions()) > 0) @php
{{-- Domain Selector --}} $tabs = [
<x-filament::section 'logs' => ['label' => __('Logs'), 'icon' => 'heroicon-o-document-text'],
icon="heroicon-o-globe-alt" 'stats' => ['label' => __('Statistics'), 'icon' => 'heroicon-o-chart-bar'],
icon-color="primary" 'usage' => ['label' => __('Resource Usage'), 'icon' => 'heroicon-o-chart-pie'],
'activity' => ['label' => __('Activity Log'), 'icon' => 'heroicon-o-clipboard-document-list'],
];
@endphp
<nav class="fi-tabs flex max-w-full gap-x-1 overflow-x-auto mx-auto rounded-xl bg-white p-2 shadow-sm ring-1 ring-gray-950/5 dark:bg-white/5 dark:ring-white/10" role="tablist">
@foreach($tabs as $key => $tab)
<button
type="button"
role="tab"
aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}"
wire:click="setTab('{{ $key }}')"
@class([
'fi-tabs-item group flex items-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium outline-none transition duration-75',
'fi-active bg-gray-50 dark:bg-white/5' => $activeTab === $key,
'hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5' => $activeTab !== $key,
])
> >
<x-filament::icon
:icon="$tab['icon']"
@class([
'fi-tabs-item-icon h-5 w-5 shrink-0 transition duration-75',
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
'text-gray-400 group-hover:text-gray-500 group-focus-visible:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400 dark:group-focus-visible:text-gray-400' => $activeTab !== $key,
])
/>
<span @class([
'fi-tabs-item-label transition duration-75',
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
'text-gray-500 group-hover:text-gray-700 group-focus-visible:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 dark:group-focus-visible:text-gray-200' => $activeTab !== $key,
])>
{{ $tab['label'] }}
</span>
</button>
@endforeach
</nav>
@if(in_array($activeTab, ['logs', 'stats'], true))
@if(count($this->getDomainOptions()) > 0)
<x-filament::section icon="heroicon-o-globe-alt" icon-color="primary" class="mt-4">
<x-slot name="heading"> <x-slot name="heading">
{{ __('Select Domain') }} {{ __('Select Domain') }}
</x-slot> </x-slot>
@@ -24,12 +62,9 @@
</x-filament::section> </x-filament::section>
@if($selectedDomain) @if($selectedDomain)
{{-- Statistics Banner (shown when generated) --}} @if($activeTab === 'stats')
@if($statsGenerated) @if($statsGenerated)
<x-filament::section <x-filament::section icon="heroicon-o-check-circle" icon-color="success" class="mt-4">
icon="heroicon-o-check-circle"
icon-color="success"
>
<x-slot name="heading"> <x-slot name="heading">
{{ __('Statistics Report Ready') }} {{ __('Statistics Report Ready') }}
</x-slot> </x-slot>
@@ -47,11 +82,10 @@
</x-filament::button> </x-filament::button>
</x-filament::section> </x-filament::section>
@endif @endif
@endif
{{-- Log Viewer --}} @if($activeTab === 'logs')
<x-filament::section <x-filament::section icon="heroicon-o-document-text" class="mt-4">
icon="heroicon-o-document-text"
>
<x-slot name="heading"> <x-slot name="heading">
{{ __('Log Viewer') }} {{ __('Log Viewer') }}
</x-slot> </x-slot>
@@ -62,7 +96,6 @@
@endif @endif
</x-slot> </x-slot>
{{-- Log Type Buttons --}}
<div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4"> <div class="flex flex-wrap items-center gap-2 gap-y-2 mb-4">
<x-filament::button <x-filament::button
wire:click="setLogType('access')" wire:click="setLogType('access')"
@@ -85,7 +118,6 @@
</x-filament::button> </x-filament::button>
</div> </div>
{{-- Log Content --}}
@if($logContent) @if($logContent)
<div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden"> <div class="fi-input-wrp rounded-lg shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 overflow-hidden">
<textarea <textarea
@@ -109,15 +141,12 @@
@endif @endif
</x-filament::section> </x-filament::section>
@endif @endif
@endif
@else @else
{{-- No Domains Empty State --}} <x-filament::section class="mt-4">
<x-filament::section>
<div class="flex flex-col items-center justify-center py-12"> <div class="flex flex-col items-center justify-center py-12">
<div class="mb-4 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20"> <div class="mb-4 rounded-full bg-gray-100 p-3 dark:bg-gray-500/20">
<x-filament::icon <x-filament::icon icon="heroicon-o-globe-alt" class="h-6 w-6 text-gray-500 dark:text-gray-400" />
icon="heroicon-o-globe-alt"
class="h-6 w-6 text-gray-500 dark:text-gray-400"
/>
</div> </div>
<h3 class="text-base font-semibold text-gray-950 dark:text-white"> <h3 class="text-base font-semibold text-gray-950 dark:text-white">
{{ __('No Domains Yet') }} {{ __('No Domains Yet') }}
@@ -126,16 +155,124 @@
{{ __('Add a domain first to view logs and statistics.') }} {{ __('Add a domain first to view logs and statistics.') }}
</p> </p>
<div class="mt-6"> <div class="mt-6">
<x-filament::button <x-filament::button href="{{ route('filament.jabali.pages.domains') }}" tag="a">
href="{{ route('filament.jabali.pages.domains') }}"
tag="a"
>
{{ __('Add Domain') }} {{ __('Add Domain') }}
</x-filament::button> </x-filament::button>
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>
@endif @endif
@endif
@if($activeTab === 'usage')
@php($usageData = $this->getUsageChartData())
<x-filament::section class="mt-4" icon="heroicon-o-chart-pie">
<x-slot name="heading">{{ __('Resource Usage (Last 30 Days)') }}</x-slot>
<x-slot name="description">{{ __('Historical usage snapshots collected hourly.') }}</x-slot>
<div
x-data="{
chart: null,
init() {
const data = @js($usageData);
const isDemo = Boolean(data.demo);
const boot = () => {
const element = this.$refs.chart ?? this.$el;
if (!window.echarts || !element) {
return false;
}
if (this.chart) {
this.chart.dispose();
}
this.chart = window.echarts.init(element);
this.chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: data.series.map(s => s.name) },
grid: { left: '3%', right: '3%', bottom: 50, containLabel: true },
xAxis: {
type: 'category',
data: data.labels,
axisLabel: {
formatter: (value) => value.slice(5),
margin: 12,
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '{value} GB' },
},
series: data.series.map((series) => ({
name: series.name,
type: 'line',
smooth: true,
areaStyle: {},
data: series.data,
})),
});
window.addEventListener('resize', () => this.chart?.resize());
requestAnimationFrame(() => this.chart?.resize());
setTimeout(() => this.chart?.resize(), 150);
return true;
};
if (!boot()) {
const interval = setInterval(() => {
if (boot()) {
clearInterval(interval);
}
}, 200);
}
},
}"
x-init="init"
class="w-full"
wire:ignore
>
<div x-ref="chart" class="h-80 w-full" style="height: 320px;"></div>
</div>
</x-filament::section>
@endif
@if($activeTab === 'activity')
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Time') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Category') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Action') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Description') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ __('IP') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@forelse($this->getActivityLogs() as $log)
<tr>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->category }}</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->action }}</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->description }}</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{{ $log->ip_address }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">
{{ __('No activity recorded yet.') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
<x-filament-actions::modals /> <x-filament-actions::modals />
</x-filament-panels::page> </x-filament-panels::page>

View File

@@ -0,0 +1,16 @@
<x-filament-panels::page>
<x-filament::section>
<x-slot name="heading">{{ __('Mailing Lists') }}</x-slot>
<x-slot name="description">{{ __('Connect a mailing list provider or store settings for your administrator to configure.') }}</x-slot>
{{ $this->mailingForm }}
<div class="mt-6">
<x-filament::button wire:click="saveSettings" icon="heroicon-o-check" color="primary">
{{ __('Save Settings') }}
</x-filament::button>
</div>
</x-filament::section>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,46 @@
<x-filament-panels::page>
@php
$tabs = [
'databases' => ['label' => __('Databases'), 'icon' => 'heroicon-o-circle-stack'],
'users' => ['label' => __('Users'), 'icon' => 'heroicon-o-users'],
];
@endphp
<nav class="fi-tabs flex max-w-full gap-x-1 overflow-x-auto mx-auto rounded-xl bg-white p-2 shadow-sm ring-1 ring-gray-950/5 dark:bg-white/5 dark:ring-white/10" role="tablist">
@foreach($tabs as $key => $tab)
<button
type="button"
role="tab"
aria-selected="{{ $activeTab === $key ? 'true' : 'false' }}"
wire:click="$set('activeTab', '{{ $key }}')"
@class([
'fi-tabs-item group flex items-center gap-x-2 rounded-lg px-3 py-2 text-sm font-medium outline-none transition duration-75',
'fi-active bg-gray-50 dark:bg-white/5' => $activeTab === $key,
'hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5' => $activeTab !== $key,
])
>
<x-filament::icon
:icon="$tab['icon']"
@class([
'fi-tabs-item-icon h-5 w-5 shrink-0 transition duration-75',
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
'text-gray-400 group-hover:text-gray-500 group-focus-visible:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400 dark:group-focus-visible:text-gray-400' => $activeTab !== $key,
])
/>
<span @class([
'fi-tabs-item-label transition duration-75',
'text-primary-600 dark:text-primary-400' => $activeTab === $key,
'text-gray-500 group-hover:text-gray-700 group-focus-visible:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 dark:group-focus-visible:text-gray-200' => $activeTab !== $key,
])>
{{ $tab['label'] }}
</span>
</button>
@endforeach
</nav>
<div class="mt-4">
{{ $this->table }}
</div>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -1,9 +1,11 @@
<?php <?php
use Illuminate\Http\Request; use App\Http\Controllers\AutomationApiController;
use Illuminate\Support\Facades\Route; use App\Http\Controllers\GitWebhookController;
use Illuminate\Support\Facades\Cache;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) { Route::get('/user', function (Request $request) {
return $request->user(); return $request->user();
@@ -13,14 +15,14 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
$token = $request->input('token'); $token = $request->input('token');
// Use Cache::get() which handles the prefix automatically // Use Cache::get() which handles the prefix automatically
$data = Cache::get('phpmyadmin_token_' . $token); $data = Cache::get('phpmyadmin_token_'.$token);
if (!$data) { if (! $data) {
return response()->json(['error' => 'Invalid token'], 401); return response()->json(['error' => 'Invalid token'], 401);
} }
// Delete token after use (single use) // Delete token after use (single use)
Cache::forget('phpmyadmin_token_' . $token); Cache::forget('phpmyadmin_token_'.$token);
return response()->json($data); return response()->json($data);
}); });
@@ -29,7 +31,7 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
Route::post('/internal/page-cache', function (Request $request) { Route::post('/internal/page-cache', function (Request $request) {
// Only allow requests from localhost // Only allow requests from localhost
$clientIp = $request->ip(); $clientIp = $request->ip();
if (!in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -52,13 +54,13 @@ Route::post('/internal/page-cache', function (Request $request) {
$query->where('domain', $domain); $query->where('domain', $domain);
})->first(); })->first();
if (!$user) { if (! $user) {
return response()->json(['error' => 'Domain not found'], 404); return response()->json(['error' => 'Domain not found'], 404);
} }
// Verify the secret by checking wp-config.php // Verify the secret by checking wp-config.php
$wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php"; $wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php";
if (!file_exists($wpConfigPath)) { if (! file_exists($wpConfigPath)) {
return response()->json(['error' => 'WordPress not found'], 404); return response()->json(['error' => 'WordPress not found'], 404);
} }
@@ -74,7 +76,7 @@ Route::post('/internal/page-cache', function (Request $request) {
} }
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
if ($enabled) { if ($enabled) {
$result = $agent->send('wp.page_cache_enable', [ $result = $agent->send('wp.page_cache_enable', [
@@ -98,7 +100,7 @@ Route::post('/internal/page-cache', function (Request $request) {
Route::post('/internal/page-cache-purge', function (Request $request) { Route::post('/internal/page-cache-purge', function (Request $request) {
// Only allow requests from localhost // Only allow requests from localhost
$clientIp = $request->ip(); $clientIp = $request->ip();
if (!in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) { if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -121,13 +123,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
$query->where('domain', $domain); $query->where('domain', $domain);
})->first(); })->first();
if (!$user) { if (! $user) {
return response()->json(['error' => 'Domain not found'], 404); return response()->json(['error' => 'Domain not found'], 404);
} }
// Verify the secret by checking wp-config.php // Verify the secret by checking wp-config.php
$wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php"; $wpConfigPath = "/home/{$user->username}/domains/{$domain}/public_html/wp-config.php";
if (!file_exists($wpConfigPath)) { if (! file_exists($wpConfigPath)) {
return response()->json(['error' => 'WordPress not found'], 404); return response()->json(['error' => 'WordPress not found'], 404);
} }
@@ -143,7 +145,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
} }
try { try {
$agent = new AgentClient(); $agent = new AgentClient;
if ($purgeAll || empty($paths)) { if ($purgeAll || empty($paths)) {
// Purge entire domain cache // Purge entire domain cache
@@ -163,3 +165,13 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
return response()->json(['error' => $e->getMessage()], 500); return response()->json(['error' => $e->getMessage()], 500);
} }
}); });
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class);
Route::middleware(['auth:sanctum', 'abilities:automation'])
->prefix('automation')
->group(function () {
Route::get('/users', [AutomationApiController::class, 'listUsers']);
Route::post('/users', [AutomationApiController::class, 'createUser']);
Route::post('/domains', [AutomationApiController::class, 'createDomain']);
});

View File

@@ -66,6 +66,13 @@ Schedule::command('jabali:sync-mailbox-quotas')
->runInBackground() ->runInBackground()
->appendOutputTo(storage_path('logs/mailbox-quota-sync.log')); ->appendOutputTo(storage_path('logs/mailbox-quota-sync.log'));
// User Resource Usage - runs hourly to capture per-user usage history
Schedule::command('jabali:collect-user-usage')
->hourly()
->withoutOverlapping()
->runInBackground()
->appendOutputTo(storage_path('logs/user-usage.log'));
// Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention) // Audit Log Rotation - runs daily to prune old audit logs (default: 90 days retention)
Schedule::call(function () { Schedule::call(function () {
$deleted = AuditLog::prune(); $deleted = AuditLog::prune();

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use App\Models\UserResourceUsage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ResourceUsageChartsTest extends TestCase
{
use RefreshDatabase;
public function test_admin_resource_usage_page_renders_chart_with_data(): void
{
$admin = User::factory()->admin()->create();
$user = User::factory()->create();
$metrics = ['disk_bytes', 'mail_bytes', 'database_bytes', 'bandwidth_bytes'];
foreach ($metrics as $metric) {
UserResourceUsage::create([
'user_id' => $user->id,
'metric' => $metric,
'value' => 1024 * 1024 * 250,
'captured_at' => now(),
]);
}
$response = $this->actingAs($admin, 'admin')->get('/jabali-admin/resource-usage');
$response->assertStatus(200);
$response->assertSee('Resource Usage (Last 30 Days)');
$response->assertSee('resource-usage-chart-', false);
}
public function test_user_logs_usage_tab_renders_chart(): void
{
$user = User::factory()->create();
UserResourceUsage::create([
'user_id' => $user->id,
'metric' => 'disk_bytes',
'value' => 1024 * 1024 * 250,
'captured_at' => now(),
]);
$response = $this->actingAs($user)->get('/jabali-panel/logs?tab=usage');
$response->assertStatus(200);
$response->assertSee('Resource Usage (Last 30 Days)');
$response->assertSee('x-ref="chart"', false);
}
}