diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index 9a801e2d94..670cce9d65 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -28,6 +28,7 @@ fi mkdir /pelican-data/database ln -s /pelican-data/.env /var/www/html/ +chown -h www-data:www-data /var/www/html/.env ln -s /pelican-data/database/database.sqlite /var/www/html/database/ if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then @@ -58,7 +59,7 @@ else echo "Starting PHP-FPM only" fi -chown -R www-data:www-data . /pelican-data/.env /pelican-data/database +chown -R www-data:www-data /pelican-data/.env /pelican-data/database echo "Starting Supervisord" exec "$@" diff --git a/.github/docker/supervisord.conf b/.github/docker/supervisord.conf index 237c0dbdb0..1094f0d23d 100644 --- a/.github/docker/supervisord.conf +++ b/.github/docker/supervisord.conf @@ -1,5 +1,7 @@ [unix_http_server] file=/tmp/supervisor.sock ; path to your socket file +username=dummy +password=dummy [supervisord] logfile=/var/log/supervisord/supervisord.log ; supervisord log file @@ -18,6 +20,8 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [supervisorctl] serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket +username=dummy +password=dummy [program:php-fpm] command=/usr/local/sbin/php-fpm -F diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8f202d53f0..41b2cdd424 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,6 +9,10 @@ on: types: - published +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: build-and-push: name: Build and Push @@ -26,7 +30,7 @@ jobs: id: docker_meta uses: docker/metadata-action@v5 with: - images: ghcr.io/pelican-dev/panel + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: | latest=false tags: | diff --git a/Dockerfile b/Dockerfile index c759649f16..3e2da64825 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,8 @@ RUN touch .env RUN composer install --no-dev --optimize-autoloader # Set file permissions -RUN chmod -R 755 storage bootstrap/cache +RUN chmod -R 755 storage bootstrap/cache \ + && chown -R www-data:www-data ./ # Add scheduler to cron RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data - diff --git a/app/Console/Commands/Schedule/ProcessRunnableCommand.php b/app/Console/Commands/Schedule/ProcessRunnableCommand.php index c11ade6c35..d773cb2b2f 100644 --- a/app/Console/Commands/Schedule/ProcessRunnableCommand.php +++ b/app/Console/Commands/Schedule/ProcessRunnableCommand.php @@ -6,7 +6,6 @@ use App\Models\Schedule; use Illuminate\Database\Eloquent\Builder; use App\Services\Schedules\ProcessScheduleService; -use Carbon\Carbon; class ProcessRunnableCommand extends Command { @@ -24,7 +23,7 @@ public function handle(): int ->whereRelation('server', fn (Builder $builder) => $builder->whereNull('status')) ->where('is_active', true) ->where('is_processing', false) - ->where('next_run_at', '<=', Carbon::now()->toDateTimeString()) + ->where('next_run_at', '<=', now('UTC')->toDateTimeString()) ->get(); if ($schedules->count() < 1) { diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3c2fc96fad..8b49a43d81 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -9,6 +9,7 @@ use App\Console\Commands\Schedule\ProcessRunnableCommand; use App\Jobs\NodeStatistics; use App\Models\ActivityLog; +use App\Models\Webhook; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Database\Console\PruneCommand; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -48,5 +49,9 @@ protected function schedule(Schedule $schedule): void if (config('activity.prune_days')) { $schedule->command(PruneCommand::class, ['--model' => [ActivityLog::class]])->daily(); } + + if (config('panel.webhook.prune_days')) { + $schedule->command(PruneCommand::class, ['--model' => [Webhook::class]])->daily(); + } } } diff --git a/app/Events/Server/Created.php b/app/Events/Server/Created.php deleted file mode 100644 index 8692791044..0000000000 --- a/app/Events/Server/Created.php +++ /dev/null @@ -1,19 +0,0 @@ -writeToEnvironment(['APP_INSTALLED' => 'true']); diff --git a/app/Filament/Pages/Installer/Steps/DatabaseStep.php b/app/Filament/Pages/Installer/Steps/DatabaseStep.php index 04803352da..3a4449777e 100644 --- a/app/Filament/Pages/Installer/Steps/DatabaseStep.php +++ b/app/Filament/Pages/Installer/Steps/DatabaseStep.php @@ -72,7 +72,7 @@ public static function make(PanelInstaller $installer): Step }); } - private static function testConnection(string $driver, string $host, string $port, string $database, string $username, string $password): bool + private static function testConnection(string $driver, ?string $host, null|string|int $port, ?string $database, ?string $username, ?string $password): bool { if ($driver === 'sqlite') { return true; diff --git a/app/Filament/Pages/Installer/Steps/RedisStep.php b/app/Filament/Pages/Installer/Steps/RedisStep.php index d160e0b700..2f257c4016 100644 --- a/app/Filament/Pages/Installer/Steps/RedisStep.php +++ b/app/Filament/Pages/Installer/Steps/RedisStep.php @@ -56,7 +56,7 @@ public static function make(PanelInstaller $installer): Step }); } - private static function testConnection(string $host, string $port, string $username, string $password): bool + private static function testConnection(string $host, null|string|int $port, ?string $username, ?string $password): bool { try { config()->set('database.redis._panel_install_test', [ diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index f8d18a01c2..7043fe9d7d 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -540,7 +540,21 @@ private function miscSettings(): array ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state)) ->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))), ]), - + Section::make('Webhook') + ->description('Configure how often old webhook logs should be pruned.') + ->columns() + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('APP_WEBHOOK_PRUNE_DAYS') + ->label('Prune age') + ->required() + ->numeric() + ->minValue(1) + ->maxValue(365) + ->suffix('Days') + ->default(env('APP_WEBHOOK_PRUNE_DAYS', config('panel.webhook.prune_days'))), + ]), ]; } diff --git a/app/Filament/Resources/ApiKeyResource.php b/app/Filament/Resources/ApiKeyResource.php index 282ef6ecbb..46350714eb 100644 --- a/app/Filament/Resources/ApiKeyResource.php +++ b/app/Filament/Resources/ApiKeyResource.php @@ -19,7 +19,7 @@ class ApiKeyResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::where('key_type', '2')->count() ?: null; + return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->count() ?: null; } public static function canEdit(Model $record): bool diff --git a/app/Filament/Resources/NodeResource/Pages/EditNode.php b/app/Filament/Resources/NodeResource/Pages/EditNode.php index 10d5d10626..b5f3804e1e 100644 --- a/app/Filament/Resources/NodeResource/Pages/EditNode.php +++ b/app/Filament/Resources/NodeResource/Pages/EditNode.php @@ -4,9 +4,11 @@ use App\Filament\Resources\NodeResource; use App\Models\Node; +use App\Services\Nodes\NodeAutoDeployService; use App\Services\Nodes\NodeUpdateService; use Filament\Actions; use Filament\Forms; +use Filament\Forms\Components\Actions as FormActions; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Placeholder; @@ -21,6 +23,7 @@ use Filament\Forms\Set; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Enums\Alignment; use Illuminate\Support\HtmlString; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; @@ -149,19 +152,9 @@ public function form(Forms\Form $form): Forms\Form true => 'success', false => 'danger', ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]), + ->columnSpan(1), TextInput::make('daemon_listen') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->label(trans('strings.port')) ->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.') ->minValue(1) @@ -182,12 +175,7 @@ public function form(Forms\Form $form): Forms\Form ->maxLength(100), ToggleButtons::make('scheme') ->label('Communicate over SSL') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->inline() ->helperText(function (Get $get) { if (request()->isSecure()) { @@ -215,23 +203,48 @@ public function form(Forms\Form $form): Forms\Form ]) ->default(fn () => request()->isSecure() ? 'https' : 'http'), ]), Tab::make('Advanced Settings') - ->columns(['default' => 1, 'sm' => 1, 'md' => 4, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 4, + 'lg' => 6, + ]) ->icon('tabler-server-cog') ->schema([ TextInput::make('id') ->label('Node ID') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->disabled(), TextInput::make('uuid') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->label('Node UUID') ->hintAction(CopyAction::make()) ->disabled(), TagsInput::make('tags') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->placeholder('Add Tags'), TextInput::make('upload_size') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->label('Upload Limit') ->hintIcon('tabler-question-mark') ->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.') @@ -240,7 +253,12 @@ public function form(Forms\Form $form): Forms\Form ->maxValue(1024) ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), TextInput::make('daemon_sftp') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Port') ->minValue(1) ->maxValue(65535) @@ -248,11 +266,21 @@ public function form(Forms\Form $form): Forms\Form ->required() ->integer(), TextInput::make('daemon_sftp_alias') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Alias') ->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'), ToggleButtons::make('public') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Use Node for deployment?')->inline() ->options([ true => 'Yes', @@ -263,7 +291,12 @@ public function form(Forms\Form $form): Forms\Form false => 'danger', ]), ToggleButtons::make('maintenance_mode') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Maintenance Mode')->inline() ->hinticon('tabler-question-mark') ->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.") @@ -276,7 +309,12 @@ public function form(Forms\Form $form): Forms\Form true => 'danger', ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->columnSpanFull() ->schema([ ToggleButtons::make('unlimited_mem') @@ -293,14 +331,24 @@ public function form(Forms\Form $form): Forms\Form true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('memory') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_mem')) ->label('Memory Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('memory_overallocate') @@ -310,14 +358,24 @@ public function form(Forms\Form $form): Forms\Form ->hidden(fn (Get $get) => $get('unlimited_mem')) ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(-1) ->maxValue(100) ->suffix('%'), ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->schema([ ToggleButtons::make('unlimited_disk') ->label('Disk')->inlineLabel()->inline() @@ -333,14 +391,24 @@ public function form(Forms\Form $form): Forms\Form true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('disk') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_disk')) ->label('Disk Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('disk_overallocate') @@ -349,7 +417,12 @@ public function form(Forms\Form $form): Forms\Form ->label('Overallocate')->inlineLabel() ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->required() ->numeric() ->minValue(-1) @@ -412,19 +485,61 @@ public function form(Forms\Form $form): Forms\Form ->rows(19) ->hintAction(CopyAction::make()) ->columnSpanFull(), - Forms\Components\Actions::make([ - Forms\Components\Actions\Action::make('resetKey') - ->label('Reset Daemon Token') - ->color('danger') - ->requiresConfirmation() - ->modalHeading('Reset Daemon Token?') - ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') - ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { - $nodeUpdateService->handle($node, [], true); - Notification::make()->success()->title('Daemon Key Reset')->send(); - $this->fillForm(); - }), - ]), + Grid::make() + ->columns() + ->schema([ + FormActions::make([ + FormActions\Action::make('autoDeploy') + ->label('Auto Deploy Command') + ->color('primary') + ->modalHeading('Auto Deploy Command') + ->icon('tabler-rocket') + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalFooterActionsAlignment(Alignment::Center) + ->form([ + ToggleButtons::make('docker') + ->label('Type') + ->live() + ->helperText('Choose between Standalone and Docker install.') + ->inline() + ->default(false) + ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) + ->options([ + false => 'Standalone', + true => 'Docker', + ]) + ->colors([ + false => 'primary', + true => 'success', + ]) + ->columnSpan(1), + Textarea::make('generatedToken') + ->label('To auto-configure your node run the following command:') + ->readOnly() + ->autosize() + ->hintAction(fn (string $state) => CopyAction::make()->copyable($state)) + ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), + ]) + ->mountUsing(function (Forms\Form $form) { + Notification::make()->success()->title('Autodeploy Generated')->send(); + $form->fill(); + }), + ])->fullWidth(), + FormActions::make([ + FormActions\Action::make('resetKey') + ->label('Reset Daemon Token') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Reset Daemon Token?') + ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') + ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { + $nodeUpdateService->handle($node, [], true); + Notification::make()->success()->title('Daemon Key Reset')->send(); + $this->fillForm(); + }), + ])->fullWidth(), + ]), ]), ]), ]); diff --git a/app/Filament/Resources/NodeResource/Pages/ListNodes.php b/app/Filament/Resources/NodeResource/Pages/ListNodes.php index ab794ca296..d7d043c9a2 100644 --- a/app/Filament/Resources/NodeResource/Pages/ListNodes.php +++ b/app/Filament/Resources/NodeResource/Pages/ListNodes.php @@ -6,9 +6,7 @@ use App\Models\Node; use Filament\Actions; use Filament\Resources\Pages\ListRecords; -use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\CreateAction; -use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\EditAction; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -83,12 +81,6 @@ public function table(Table $table): Table ->actions([ EditAction::make(), ]) - ->bulkActions([ - BulkActionGroup::make([ - DeleteBulkAction::make() - ->authorize(fn () => auth()->user()->can('delete node')), - ]), - ]) ->emptyStateIcon('tabler-server-2') ->emptyStateDescription('') ->emptyStateHeading('No Nodes') diff --git a/app/Filament/Resources/RoleResource.php b/app/Filament/Resources/RoleResource.php index 306c9676da..510a03a88e 100644 --- a/app/Filament/Resources/RoleResource.php +++ b/app/Filament/Resources/RoleResource.php @@ -6,7 +6,6 @@ use App\Enums\RolePermissionPrefixes; use App\Filament\Resources\RoleResource\Pages; use App\Models\Role; -use Filament\Facades\Filament; use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Component; @@ -71,7 +70,7 @@ public static function form(Form $form): Form ->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN), TextInput::make('guard_name') ->label('Guard Name') - ->default(Filament::getCurrentPanel()?->getAuthGuard() ?? '') + ->default(Role::DEFAULT_GUARD_NAME) ->nullable() ->hidden(), Fieldset::make('Permissions') diff --git a/app/Filament/Resources/ServerResource/Pages/CreateServer.php b/app/Filament/Resources/ServerResource/Pages/CreateServer.php index 99fd1d4337..dcdda99220 100644 --- a/app/Filament/Resources/ServerResource/Pages/CreateServer.php +++ b/app/Filament/Resources/ServerResource/Pages/CreateServer.php @@ -627,14 +627,24 @@ public function form(Form $form): Form ->minValue(0) ->helperText('100% equals one CPU core.'), ]), + ]), + Fieldset::make('Advanced Limits') + ->columnSpan(6) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ Grid::make() ->columns(4) ->columnSpanFull() ->schema([ ToggleButtons::make('swap_support') ->live() - ->label('Enable Swap Memory') + ->label('Swap Memory') ->inlineLabel() ->inline() ->columnSpan(2) @@ -681,6 +691,36 @@ public function form(Form $form): Form ->label('Block IO Proportion') ->default(500), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label('CPU Pinning')->inlineLabel()->inline() + ->default(false) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->live() + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label('Pinned Threads')->inlineLabel() + ->required(fn (Get $get) => $get('cpu_pinning')) + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder('Add pinned thread, e.g. 0 or 2-4'), + ]), + Grid::make() ->columns(4) ->columnSpanFull() @@ -747,6 +787,7 @@ public function form(Form $form): Form ->schema([ Select::make('select_image') ->label('Image Name') + ->live() ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) ->options(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); @@ -771,7 +812,7 @@ public function form(Form $form): Form TextInput::make('image') ->label('Image') - ->debounce(500) + ->required() ->afterStateUpdated(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); $images = $egg->docker_images ?? []; diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index e32f2d2bd0..c13f219295 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -5,6 +5,7 @@ use App\Enums\ContainerStatus; use App\Enums\ServerState; use App\Filament\Resources\ServerResource; +use App\Filament\Resources\ServerResource\RelationManagers\AllocationsRelationManager; use App\Models\Database; use App\Models\Egg; use App\Models\Server; @@ -25,10 +26,13 @@ use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs\Tab; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; @@ -264,14 +268,23 @@ public function form(Form $form): Form ->numeric() ->minValue(0), ]), + ]), + Fieldset::make('Advanced Limits') + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ Grid::make() ->columns(4) ->columnSpanFull() ->schema([ ToggleButtons::make('swap_support') ->live() - ->label('Enable Swap Memory')->inlineLabel()->inline() + ->label('Swap Memory')->inlineLabel()->inline() ->columnSpan(2) ->afterStateUpdated(function ($state, Set $set) { $value = match ($state) { @@ -316,10 +329,41 @@ public function form(Form $form): Form ->integer(), ]), - Forms\Components\Hidden::make('io') + Hidden::make('io') ->helperText('The IO performance relative to other running containers') ->label('Block IO Proportion'), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label('CPU Pinning')->inlineLabel()->inline() + ->default(false) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) + ->live() + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label('Pinned Threads')->inlineLabel() + ->required(fn (Get $get) => $get('cpu_pinning')) + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder('Add pinned thread, e.g. 0 or 2-4'), + ]), + Grid::make() ->columns(4) ->columnSpanFull() @@ -376,6 +420,7 @@ public function form(Form $form): Form ->schema([ Select::make('select_image') ->label('Image Name') + ->live() ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) ->options(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); @@ -395,7 +440,7 @@ public function form(Form $form): Form TextInput::make('image') ->label('Image') - ->debounce(500) + ->required() ->afterStateUpdated(function ($state, Get $get, Set $set) { $egg = Egg::query()->find($get('egg_id')); $images = $egg->docker_images ?? []; @@ -409,7 +454,7 @@ public function form(Form $form): Form ->placeholder('Enter a custom Image') ->columnSpan(2), - Forms\Components\KeyValue::make('docker_labels') + KeyValue::make('docker_labels') ->label('Container Labels') ->keyLabel('Label Name') ->valueLabel('Label Description') @@ -785,7 +830,7 @@ protected function mutateFormDataBeforeSave(array $data): array public function getRelationManagers(): array { return [ - ServerResource\RelationManagers\AllocationsRelationManager::class, + AllocationsRelationManager::class, ]; } diff --git a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php index aa6658b4d3..99bc6bb482 100644 --- a/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php @@ -57,13 +57,13 @@ public function table(Table $table): Table true => 'warning', default => 'gray', }) - ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id])) + ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) ->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id) ->label('Primary'), ]) ->actions([ Action::make('make-primary') - ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id])) + ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) ->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'), ]) ->headerActions([ @@ -149,7 +149,7 @@ public function table(Table $table): Table ->multiple() ->associateAnother(false) ->preloadRecordSelect() - ->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)) + ->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id')) ->label('Add Allocation'), ]) ->bulkActions([ diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php index 5105302369..81939cc078 100644 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -91,6 +91,7 @@ protected function getHeaderActions(): array TextInput::make('username') ->alphaNum() ->required() + ->unique() ->maxLength(255), TextInput::make('email') ->email() diff --git a/app/Filament/Resources/WebhookResource.php b/app/Filament/Resources/WebhookResource.php new file mode 100644 index 0000000000..804c0e078a --- /dev/null +++ b/app/Filament/Resources/WebhookResource.php @@ -0,0 +1,73 @@ +schema([ + TextInput::make('endpoint') + ->activeUrl() + ->required(), + TextInput::make('description') + ->required(), + CheckboxList::make('events') + ->lazy() + ->options(fn () => WebhookConfiguration::filamentCheckboxList()) + ->searchable() + ->bulkToggleable() + ->columns(3) + ->columnSpanFull() + ->gridDirection('row') + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('description'), + TextColumn::make('endpoint'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWebhookConfigurations::route('/'), + 'create' => Pages\CreateWebhookConfiguration::route('/create'), + 'edit' => Pages\EditWebhookConfiguration::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php b/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php new file mode 100644 index 0000000000..96f45742c5 --- /dev/null +++ b/app/Filament/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php @@ -0,0 +1,11 @@ +getNextRunDate()); + ))->getNextRunDate(now('UTC'))); } public static function checked(string $name, mixed $default): string diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php index 0a1026ad59..7889b3e000 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -29,7 +29,7 @@ public function index(ClientApiRequest $request): array */ public function store(StoreApiKeyRequest $request): array { - if ($request->user()->apiKeys->count() >= 25) { + if ($request->user()->apiKeys->count() >= config('panel.api.key_limit')) { throw new DisplayException('You have reached the account limit for number of API keys.'); } diff --git a/app/Http/Controllers/Api/Client/Servers/SubuserController.php b/app/Http/Controllers/Api/Client/Servers/SubuserController.php index 82c73b5bf2..14cd56264a 100644 --- a/app/Http/Controllers/Api/Client/Servers/SubuserController.php +++ b/app/Http/Controllers/Api/Client/Servers/SubuserController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Client\Servers; use App\Models\User; +use App\Notifications\RemovedFromServer; use Illuminate\Http\Request; use App\Models\Server; use Illuminate\Http\JsonResponse; @@ -144,6 +145,11 @@ public function delete(DeleteSubuserRequest $request, Server $server, User $user $log->transaction(function ($instance) use ($server, $subuser) { $subuser->delete(); + $subuser->user->notify(new RemovedFromServer([ + 'user' => $subuser->user->name_first, + 'name' => $subuser->server->name, + ])); + try { $this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id); } catch (DaemonConnectionException $exception) { diff --git a/app/Jobs/NodeStatistics.php b/app/Jobs/NodeStatistics.php index 9546f0ba0d..d6815ce6c3 100644 --- a/app/Jobs/NodeStatistics.php +++ b/app/Jobs/NodeStatistics.php @@ -4,26 +4,16 @@ use App\Models\Node; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class NodeStatistics implements ShouldQueue +class NodeStatistics implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** - * Create a new job instance. - */ - public function __construct() - { - // - } - - /** - * Execute the job. - */ public function handle(): void { foreach (Node::all() as $node) { diff --git a/app/Jobs/ProcessWebhook.php b/app/Jobs/ProcessWebhook.php new file mode 100644 index 0000000000..2206e0028f --- /dev/null +++ b/app/Jobs/ProcessWebhook.php @@ -0,0 +1,40 @@ +webhookConfiguration->endpoint, $this->data)->throw(); + $successful = now(); + } catch (\Exception) { + $successful = null; + } + + $this->webhookConfiguration->webhooks()->create([ + 'payload' => $this->data, + 'successful_at' => $successful, + 'event' => $this->eventName, + 'endpoint' => $this->webhookConfiguration->endpoint, + ]); + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 0000000000..102782083e --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,39 @@ +eventIsWatched($eventName)) { + return; + } + + $matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) { + return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get(); + }); + + foreach ($matchingHooks ?? [] as $webhookConfig) { + if (in_array($eventName, $webhookConfig->events)) { + ProcessWebhook::dispatch($webhookConfig, $eventName, $data); + } + } + } + + protected function eventIsWatched(string $eventName): bool + { + $watchedEvents = cache()->rememberForever('watchedWebhooks', function () { + return WebhookConfiguration::pluck('events') + ->flatten() + ->unique() + ->values() + ->all(); + }); + + return in_array($eventName, $watchedEvents); + } +} diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 13ea175ca8..bbed53c2a3 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -69,15 +69,8 @@ class ApiKey extends Model public const TYPE_ACCOUNT = 1; - /* @deprecated */ public const TYPE_APPLICATION = 2; - /* @deprecated */ - public const TYPE_DAEMON_USER = 3; - - /* @deprecated */ - public const TYPE_DAEMON_APPLICATION = 4; - /** * The length of API key identifiers. */ @@ -136,7 +129,7 @@ class ApiKey extends Model */ public static array $validationRules = [ 'user_id' => 'required|exists:users,id', - 'key_type' => 'present|integer|min:0|max:4', + 'key_type' => 'present|integer|min:0|max:2', 'identifier' => 'required|string|size:16|unique:api_keys,identifier', 'token' => 'required|string', 'memo' => 'required|nullable|string|max:500', diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 001be85e3f..1ca0da55b1 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -58,7 +58,7 @@ class Egg extends Model /** * Defines the current egg export version. */ - public const EXPORT_VERSION = 'PTDL_v2'; + public const EXPORT_VERSION = 'PLCN_v1'; /** * Different features that can be enabled on any given egg. These are used internally diff --git a/app/Models/Role.php b/app/Models/Role.php index 1274b2d6c3..d93bbce128 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -19,6 +19,8 @@ class Role extends BaseRole public const ROOT_ADMIN = 'Root Admin'; + public const DEFAULT_GUARD_NAME = 'web'; + public const MODEL_SPECIFIC_PERMISSIONS = [ 'egg' => [ 'import', @@ -41,7 +43,7 @@ public function isRootAdmin(): bool public static function getRootAdmin(): self { /** @var self $role */ - $role = self::findOrCreate(self::ROOT_ADMIN); + $role = self::findOrCreate(self::ROOT_ADMIN, self::DEFAULT_GUARD_NAME); return $role; } diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index ae7b9baecf..d6d19a8e14 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -2,8 +2,7 @@ namespace App\Models; -use Cron\CronExpression; -use Carbon\CarbonImmutable; +use App\Helpers\Utilities; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -112,13 +111,9 @@ public function getRouteKeyName(): string * * @throws \Exception */ - public function getNextRunDate(): CarbonImmutable + public function getNextRunDate(): string { - $formatted = sprintf('%s %s %s %s %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week); - - return CarbonImmutable::createFromTimestamp( - (new CronExpression($formatted))->getNextRunDate()->getTimestamp() - ); + return Utilities::getScheduleNextRunDate($this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week)->toDateTimeString(); } /** diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php new file mode 100644 index 0000000000..7a8e78ca8c --- /dev/null +++ b/app/Models/Webhook.php @@ -0,0 +1,29 @@ + 'array', + 'successful_at' => 'datetime', + ]; + } + + public function prunable(): Builder + { + return static::where('created_at', '<=', Carbon::now()->subDays(config('panel.webhook.prune_days'))); + } +} diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php new file mode 100644 index 0000000000..d5a1b0ba0e --- /dev/null +++ b/app/Models/WebhookConfiguration.php @@ -0,0 +1,132 @@ + 'json', + ]; + } + + protected static function booted(): void + { + self::saved(static function (self $webhookConfiguration): void { + $changedEvents = collect([ + ...((array) $webhookConfiguration->events), + ...$webhookConfiguration->getOriginal('events', '[]'), + ])->unique(); + + $changedEvents->each(function (string $event) { + cache()->forever("webhooks.$event", WebhookConfiguration::query()->whereJsonContains('events', $event)->get()); + }); + + cache()->forever('watchedWebhooks', WebhookConfiguration::pluck('events')->flatten()->unique()->values()->all()); + }); + } + + public function webhooks(): HasMany + { + return $this->hasMany(Webhook::class); + } + + public static function allPossibleEvents(): array + { + return collect(static::discoverCustomEvents()) + ->merge(static::allModelEvents()) + ->unique() + ->filter(fn ($event) => !in_array($event, static::$eventBlacklist)) + ->all(); + } + + public static function filamentCheckboxList(): array + { + $list = []; + $events = static::allPossibleEvents(); + foreach ($events as $event) { + $list[$event] = static::transformClassName($event); + } + + return $list; + } + + public static function transformClassName(string $event): string + { + return str($event) + ->after('eloquent.') + ->replace('App\\Models\\', '') + ->replace('App\\Events\\', 'event: ') + ->toString(); + } + + public static function allModelEvents(): array + { + $eventTypes = ['created', 'updated', 'deleted']; + $models = static::discoverModels(); + + $events = []; + foreach ($models as $model) { + foreach ($eventTypes as $eventType) { + $events[] = "eloquent.$eventType: $model"; + } + } + + return $events; + } + + public static function discoverModels(): array + { + $namespace = 'App\\Models\\'; + $directory = app_path('Models'); + + $models = []; + foreach (File::allFiles($directory) as $file) { + $models[] = $namespace . str($file->getFilename()) + ->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']); + } + + return $models; + } + + public static function discoverCustomEvents(): array + { + $directory = app_path('Events'); + + $events = []; + foreach (File::allFiles($directory) as $file) { + $namespace = str($file->getPath()) + ->after(base_path()) + ->replace(DIRECTORY_SEPARATOR, '\\') + ->replace('\\app\\', 'App\\') + ->toString(); + + $events[] = $namespace . '\\' . str($file->getFilename()) + ->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']); + } + + return $events; + } +} diff --git a/app/Observers/EggVariableObserver.php b/app/Observers/EggVariableObserver.php deleted file mode 100644 index 6fd6b12e6d..0000000000 --- a/app/Observers/EggVariableObserver.php +++ /dev/null @@ -1,22 +0,0 @@ -field_type)) { - unset($variable->field_type); - } - } - - public function updating(EggVariable $variable): void - { - if (isset($variable->field_type)) { - unset($variable->field_type); - } - } -} diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php deleted file mode 100644 index 1804511a0c..0000000000 --- a/app/Observers/ServerObserver.php +++ /dev/null @@ -1,76 +0,0 @@ -user->notify(new AddedToServer([ - 'user' => $subuser->user->name_first, - 'name' => $subuser->server->name, - 'uuid_short' => $subuser->server->uuid_short, - ])); - } - - /** - * Listen to the Subuser deleting event. - */ - public function deleting(Subuser $subuser): void - { - event(new Events\Subuser\Deleting($subuser)); - } - - /** - * Listen to the Subuser deleted event. - */ - public function deleted(Subuser $subuser): void - { - event(new Events\Subuser\Deleted($subuser)); - - $subuser->user->notify(new RemovedFromServer([ - 'user' => $subuser->user->name_first, - 'name' => $subuser->server->name, - ])); - } -} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php deleted file mode 100644 index 6ec0888303..0000000000 --- a/app/Observers/UserObserver.php +++ /dev/null @@ -1,43 +0,0 @@ - [ServerInstalledNotification::class], + 'App\\*' => [DispatchWebhooks::class], + 'eloquent.created*' => [DispatchWebhooks::class], + 'eloquent.deleted*' => [DispatchWebhooks::class], + 'eloquent.updated*' => [DispatchWebhooks::class], ]; - - /** - * Register any events for your application. - */ - public function boot(): void - { - parent::boot(); - - User::observe(UserObserver::class); - Server::observe(ServerObserver::class); - Subuser::observe(SubuserObserver::class); - EggVariable::observe(EggVariableObserver::class); - } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f6b7c568b0..3df1b101cb 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -49,7 +49,6 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') - ->spa() ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ Widgets\AccountWidget::class, diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 90c9427e42..85fd07cb0b 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -64,12 +64,6 @@ protected function convertStopToNewFormat(string $stop): array } $signal = substr($stop, 1); - if (strtoupper($signal) === 'C') { - return [ - 'type' => 'stop', - 'value' => null, - ]; - } return [ 'type' => 'signal', diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index 19c9a5e123..b03b509289 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -48,8 +48,7 @@ public function handle(int $egg): string ], 'variables' => $egg->variables->map(function (EggVariable $eggVariable) { return Collection::make($eggVariable->toArray()) - ->except(['id', 'egg_id', 'created_at', 'updated_at']) - ->merge(['field_type' => 'text']); + ->except(['id', 'egg_id', 'created_at', 'updated_at']); }), ]; diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index e539a7f282..56942b2311 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -48,6 +48,11 @@ public function fromFile(UploadedFile $file, ?Egg $egg = null): Egg 'copy_script_from' => null, ]); + // Don't check for this anymore + for ($i = 0; $i < count($parsed['variables']); $i++) { + unset($parsed['variables'][$i]['field_type']); + } + $egg = $this->fillFromParsed($egg, $parsed); $egg->save(); @@ -105,6 +110,7 @@ protected function parseFile(UploadedFile $file): array $parsed = match ($version) { 'PTDL_v1' => $this->convertToV2($parsed), 'PTDL_v2' => $parsed, + 'PLCN_V1' => $parsed, default => throw new InvalidFileUploadException('The JSON file provided is not in a format that can be recognized.') }; @@ -157,17 +163,13 @@ protected function convertToV2(array $parsed): array $images = $parsed['images']; } - unset($parsed['images'], $parsed['image']); + unset($parsed['images'], $parsed['image'], $parsed['field_type']); $parsed['docker_images'] = []; foreach ($images as $image) { $parsed['docker_images'][$image] = $image; } - $parsed['variables'] = array_map(function ($value) { - return array_merge($value, ['field_type' => 'text']); - }, $parsed['variables']); - return $parsed; } } diff --git a/app/Services/Nodes/NodeAutoDeployService.php b/app/Services/Nodes/NodeAutoDeployService.php new file mode 100644 index 0000000000..5e9c55b223 --- /dev/null +++ b/app/Services/Nodes/NodeAutoDeployService.php @@ -0,0 +1,58 @@ +where('key_type', ApiKey::TYPE_APPLICATION) + ->where('r_nodes', true) + ->first(); + + // We couldn't find a key that exists for this user with only permission for + // reading nodes. Go ahead and create it now. + if (!$key) { + $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_APPLICATION)->handle([ + 'memo' => 'Automatically generated node deployment key.', + 'user_id' => $request->user()->id, + ], ['r_nodes' => true]); + } + + $token = $key->identifier . $key->token; + + if (!$token) { + return null; + } + + return sprintf( + '%s wings configure --panel-url %s --token %s --node %d%s', + $docker ? 'docker compose exec -it' : 'sudo', + config('app.url'), + $token, + $node->id, + $request->isSecure() ? '' : ' --allow-insecure' + ); + } +} diff --git a/app/Services/Subusers/SubuserCreationService.php b/app/Services/Subusers/SubuserCreationService.php index 0f9508ba72..e0c5ac39bf 100644 --- a/app/Services/Subusers/SubuserCreationService.php +++ b/app/Services/Subusers/SubuserCreationService.php @@ -3,6 +3,7 @@ namespace App\Services\Subusers; use App\Models\User; +use App\Notifications\AddedToServer; use Illuminate\Support\Str; use App\Models\Server; use App\Models\Subuser; @@ -59,11 +60,19 @@ public function handle(Server $server, string $email, array $permissions): Subus throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists')); } - return Subuser::query()->create([ + $subuser = Subuser::query()->create([ 'user_id' => $user->id, 'server_id' => $server->id, 'permissions' => array_unique($permissions), ]); + + $subuser->user->notify(new AddedToServer([ + 'user' => $subuser->user->name_first, + 'name' => $subuser->server->name, + 'uuid_short' => $subuser->server->uuid_short, + ])); + + return $subuser; }); } } diff --git a/config/panel.php b/config/panel.php index 487ee354b5..83c1859e2d 100644 --- a/config/panel.php +++ b/config/panel.php @@ -167,4 +167,29 @@ 'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true), 'editable_server_descriptions' => env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', true), + + /* + |-------------------------------------------------------------------------- + | API Settings + |-------------------------------------------------------------------------- + | + | This section controls Api Key configurations + */ + + 'api' => [ + 'key_limit' => env('API_KEYS_LIMIT', 25), + 'key_expire_time' => env('API_KEYS_EXPIRE_TIME', 720), + ], + + /* + |-------------------------------------------------------------------------- + | Webhook Settings + |-------------------------------------------------------------------------- + | + | This section controls Webhook configurations + */ + + 'webhook' => [ + 'prune_days' => env('APP_WEBHOOK_PRUNE_DAYS', 30), + ], ]; diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000000..7e0166af8e --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,28 @@ +getClientIp() + * always gets the originating client IP, no matter + * how many proxies that client's request has + * subsequently passed through. + */ + 'proxies' => in_array(env('TRUSTED_PROXIES', []), ['*', '**']) ? + env('TRUSTED_PROXIES') : explode(',', env('TRUSTED_PROXIES') ?? ''), +]; diff --git a/database/Factories/AllocationFactory.php b/database/Factories/AllocationFactory.php index 19405b7024..3cbcc3e33b 100644 --- a/database/Factories/AllocationFactory.php +++ b/database/Factories/AllocationFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Node; use App\Models\Server; use App\Models\Allocation; use Illuminate\Database\Eloquent\Factories\Factory; @@ -23,6 +24,7 @@ public function definition(): array return [ 'ip' => $this->faker->unique()->ipv4(), 'port' => $this->faker->unique()->numberBetween(1024, 65535), + 'node_id' => Node::factory(), ]; } diff --git a/database/Factories/EggFactory.php b/database/Factories/EggFactory.php index 36ec942fa8..219a179b05 100644 --- a/database/Factories/EggFactory.php +++ b/database/Factories/EggFactory.php @@ -22,6 +22,12 @@ public function definition(): array { return [ 'uuid' => Uuid::uuid4()->toString(), + 'author' => $this->faker->email(), + 'docker_images' => ['a', 'b', 'c'], + 'config_logs' => '{}', + 'config_startup' => '{}', + 'config_stop' => '{}', + 'config_files' => '{}', 'name' => $this->faker->name(), 'description' => implode(' ', $this->faker->sentences()), 'startup' => 'java -jar test.jar', diff --git a/database/Factories/NodeFactory.php b/database/Factories/NodeFactory.php index 19a1fbf7b1..030bd3e385 100644 --- a/database/Factories/NodeFactory.php +++ b/database/Factories/NodeFactory.php @@ -40,6 +40,7 @@ public function definition(): array 'daemon_listen' => 8080, 'daemon_sftp' => 2022, 'daemon_base' => '/var/lib/panel/volumes', + 'maintenance_mode' => false, ]; } } diff --git a/database/Factories/ServerFactory.php b/database/Factories/ServerFactory.php index e0ced8de5d..2f1d8c6526 100644 --- a/database/Factories/ServerFactory.php +++ b/database/Factories/ServerFactory.php @@ -2,6 +2,10 @@ namespace Database\Factories; +use App\Models\Allocation; +use App\Models\Egg; +use App\Models\Node; +use App\Models\User; use Carbon\Carbon; use Ramsey\Uuid\Uuid; use Illuminate\Support\Str; @@ -17,12 +21,28 @@ class ServerFactory extends Factory */ protected $model = Server::class; + public function withNode(?Node $node = null): static + { + $node ??= Node::factory()->create(); + + return $this->state(fn () => [ + 'node_id' => $node->id, + 'allocation_id' => Allocation::factory([ + 'node_id' => $node->id, + ]), + ]); + } + /** * Define the model's default state. */ public function definition(): array { return [ + 'owner_id' => User::factory(), + 'node_id' => Node::factory(), + 'allocation_id' => Allocation::factory(), + 'egg_id' => Egg::factory(), 'uuid' => Uuid::uuid4()->toString(), 'uuid_short' => Str::lower(Str::random(8)), 'name' => $this->faker->firstName(), diff --git a/database/Factories/WebhookConfigurationFactory.php b/database/Factories/WebhookConfigurationFactory.php new file mode 100644 index 0000000000..e1a70954e4 --- /dev/null +++ b/database/Factories/WebhookConfigurationFactory.php @@ -0,0 +1,20 @@ + fake()->url(), + 'description' => fake()->sentence(), + 'events' => [], + ]; + } +} diff --git a/database/Seeders/EggSeeder.php b/database/Seeders/EggSeeder.php index d6a3743b7b..e3f4103484 100644 --- a/database/Seeders/EggSeeder.php +++ b/database/Seeders/EggSeeder.php @@ -37,7 +37,6 @@ public function __construct( public function run(): void { foreach (static::$imports as $import) { - /* @noinspection PhpParamsInspection */ $this->parseEggFiles($import); } } diff --git a/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php b/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php new file mode 100644 index 0000000000..c39805f081 --- /dev/null +++ b/database/migrations/2024_04_21_162544_create_webhook_configurations_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('endpoint'); + $table->string('description'); + $table->json('events'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_configurations'); + } +}; diff --git a/database/migrations/2024_04_21_162552_create_webhooks_table.php b/database/migrations/2024_04_21_162552_create_webhooks_table.php new file mode 100644 index 0000000000..a7565e041a --- /dev/null +++ b/database/migrations/2024_04_21_162552_create_webhooks_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignIdFor(\App\Models\WebhookConfiguration::class)->constrained(); + $table->string('event'); + $table->string('endpoint'); + $table->timestamp('successful_at')->nullable(); + $table->json('payload'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhooks'); + } +}; diff --git a/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php b/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php new file mode 100644 index 0000000000..2f95aee58c --- /dev/null +++ b/database/migrations/2024_10_27_033218_update_webhook_configurations_softdelete.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 610eac74ed..191be938c9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,11 @@ - + ./tests/Integration diff --git a/resources/views/livewire/node-system-information.blade.php b/resources/views/livewire/node-system-information.blade.php index 77436db06e..59ea829f1b 100644 --- a/resources/views/livewire/node-system-information.blade.php +++ b/resources/views/livewire/node-system-information.blade.php @@ -1,14 +1,30 @@ -
+
@switch($node->systemInformation()['version'] ?? 'false') @case('false') true]) /> + @script + + @endscript @break @default true]) @style([\Filament\Support\get_color_css_variables('success', shades: [400, 500], alias: 'tables::columns.icon-column.item') => true]) diff --git a/tests/Feature/DispatchWebhooksTest.php b/tests/Feature/DispatchWebhooksTest.php new file mode 100644 index 0000000000..5c032761c4 --- /dev/null +++ b/tests/Feature/DispatchWebhooksTest.php @@ -0,0 +1,69 @@ +create([ + 'events' => ['eloquent.created: '.Server::class], + ]); + + $this->createServer(); + + Queue::assertPushed(ProcessWebhook::class); + } + + public function test_sends_multiple_webhooks() + { + WebhookConfiguration::factory(2) + ->create(['events' => ['eloquent.created: '.Server::class]]); + + $this->createServer(); + + Queue::assertPushed(ProcessWebhook::class, 2); + } + + public function test_it_sends_no_webhooks() + { + WebhookConfiguration::factory()->create(); + + $this->createServer(); + + Queue::assertNothingPushed(); + } + + public function test_it_sends_some_webhooks() + { + WebhookConfiguration::factory(2) + ->sequence( + ['events' => ['eloquent.created: '.Server::class]], + ['events' => ['eloquent.deleted: '.Server::class]] + )->create(); + + $this->createServer(); + + Queue::assertPushed(ProcessWebhook::class, 1); + } + + public function createServer(): Server + { + return Server::factory()->withNode()->create(); + } +} diff --git a/tests/Feature/ProcessWebhooksTest.php b/tests/Feature/ProcessWebhooksTest.php new file mode 100644 index 0000000000..7eb7a8d9ae --- /dev/null +++ b/tests/Feature/ProcessWebhooksTest.php @@ -0,0 +1,204 @@ +create([ + 'events' => [$eventName = 'eloquent.created: '.Server::class], + ]); + + Http::fake([$webhook->endpoint => Http::response()]); + + $data = [ + 'status' => null, + 'oom_killer' => false, + 'installed_at' => null, + 'owner_id' => 1, + 'node_id' => 1, + 'allocation_id' => 1, + 'egg_id' => 1, + 'uuid' => '9ff9885f-ab79-4a6e-a53e-466a84cdb2d8', + 'uuid_short' => 'ypk27val', + 'name' => 'Delmer', + 'description' => 'Est sed quibusdam sed eos quae est. Ut similique non impedit voluptas. Aperiam repellendus impedit voluptas officiis id.', + 'skip_scripts' => false, + 'memory' => 512, + 'swap' => 0, + 'disk' => 512, + 'io' => 500, + 'cpu' => 0, + 'threads' => null, + 'startup' => '/bin/bash echo "hello world"', + 'image' => 'foo/bar:latest', + 'allocation_limit' => null, + 'database_limit' => null, + 'backup_limit' => 0, + 'created_at' => '2024-09-12T20:21:29.000000Z', + 'updated_at' => '2024-09-12T20:21:29.000000Z', + 'id' => 1, + ]; + + ProcessWebhook::dispatchSync( + $webhook, + 'eloquent.created: '.Server::class, + $data, + ); + + $this->assertCount(1, cache()->get("webhooks.$eventName")); + $this->assertEquals($webhook->id, cache()->get("webhooks.$eventName")->first()->id); + + Http::assertSentCount(1); + Http::assertSent(function (Request $request) use ($webhook, $data) { + return $webhook->endpoint === $request->url() + && $request->data() === $data; + }); + } + + public function test_sends_multiple_webhooks() + { + [$webhook1, $webhook2] = WebhookConfiguration::factory(2) + ->create(['events' => [$eventName = 'eloquent.created: '.Server::class]]); + + Http::fake([ + $webhook1->endpoint => Http::response(), + $webhook2->endpoint => Http::response(), + ]); + + $this->createServer(); + + $this->assertCount(2, cache()->get("webhooks.$eventName")); + $this->assertContains($webhook1->id, cache()->get("webhooks.$eventName")->pluck('id')); + $this->assertContains($webhook2->id, cache()->get("webhooks.$eventName")->pluck('id')); + + Http::assertSentCount(2); + Http::assertSent(fn (Request $request) => $webhook1->endpoint === $request->url()); + Http::assertSent(fn (Request $request) => $webhook2->endpoint === $request->url()); + } + + public function test_it_sends_no_webhooks() + { + Http::fake(); + + WebhookConfiguration::factory()->create(); + + $this->createServer(); + + Http::assertSentCount(0); + } + + public function test_it_sends_some_webhooks() + { + [$webhook1, $webhook2] = WebhookConfiguration::factory(2) + ->sequence( + ['events' => ['eloquent.created: '.Server::class]], + ['events' => ['eloquent.deleted: '.Server::class]] + )->create(); + + Http::fake([ + $webhook1->endpoint => Http::response(), + $webhook2->endpoint => Http::response(), + ]); + + $this->createServer(); + + Http::assertSentCount(1); + Http::assertSent(fn (Request $request) => $webhook1->endpoint === $request->url()); + Http::assertNotSent(fn (Request $request) => $webhook2->endpoint === $request->url()); + } + + public function test_it_records_when_a_webhook_is_sent() + { + $webhookConfig = WebhookConfiguration::factory() + ->create(['events' => ['eloquent.created: '.Server::class]]); + + Http::fake([$webhookConfig->endpoint => Http::response()]); + + $this->assertDatabaseCount(Webhook::class, 0); + + $server = $this->createServer(); + + $this->assertDatabaseCount(Webhook::class, 1); + + $webhook = Webhook::query()->first(); + $this->assertEquals($server->uuid, $webhook->payload[0]['uuid']); + + $this->assertDatabaseHas(Webhook::class, [ + 'endpoint' => $webhookConfig->endpoint, + 'successful_at' => now()->startOfSecond(), + 'event' => 'eloquent.created: '.Server::class, + ]); + } + + public function test_it_records_when_a_webhook_fails() + { + $webhookConfig = WebhookConfiguration::factory()->create([ + 'events' => ['eloquent.created: '.Server::class], + ]); + + Http::fake([$webhookConfig->endpoint => Http::response(status: 500)]); + + $this->assertDatabaseCount(Webhook::class, 0); + + $server = $this->createServer(); + + $this->assertDatabaseCount(Webhook::class, 1); + $this->assertDatabaseHas(Webhook::class, [ + 'payload' => json_encode([$server->toArray()]), + 'endpoint' => $webhookConfig->endpoint, + 'successful_at' => null, + 'event' => 'eloquent.created: '.Server::class, + ]); + } + + public function test_it_is_triggered_on_custom_events() + { + $webhookConfig = WebhookConfiguration::factory()->create([ + 'events' => [Installed::class], + ]); + + Http::fake([$webhookConfig->endpoint => Http::response()]); + + $this->assertDatabaseCount(Webhook::class, 0); + + $server = $this->createServer(); + + event(new Installed($server)); + + $this->assertDatabaseCount(Webhook::class, 1); + $this->assertDatabaseHas(Webhook::class, [ + // 'payload' => json_encode([['server' => $server->toArray()]]), + 'endpoint' => $webhookConfig->endpoint, + 'successful_at' => now()->startOfSecond(), + 'event' => Installed::class, + ]); + + } + + public function createServer(): Server + { + return Server::factory()->withNode()->create(); + } +} diff --git a/tests/Integration/Api/Client/ApiKeyControllerTest.php b/tests/Integration/Api/Client/ApiKeyControllerTest.php index 462875c59b..aa87f62c6a 100644 --- a/tests/Integration/Api/Client/ApiKeyControllerTest.php +++ b/tests/Integration/Api/Client/ApiKeyControllerTest.php @@ -96,14 +96,14 @@ public function testApiKeyCannotSpecifyMoreThanFiftyIps(): void } /** - * Test that no more than 25 API keys can exist at any one time for an account. This prevents + * Test that no more than the Max number of API keys can exist at one time for an account. This prevents * a DoS attack vector against the panel. */ public function testApiKeyLimitIsApplied(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(); - ApiKey::factory()->times(25)->for($user)->create([ + ApiKey::factory()->times(config('panel.api.key_limit', 25))->for($user)->create([ 'key_type' => ApiKey::TYPE_ACCOUNT, ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index abcd9d84d8..71242d56e7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,6 +19,9 @@ protected function setUp(): void Carbon::setTestNow(Carbon::now()); CarbonImmutable::setTestNow(Carbon::now()); + // TODO: if unit tests suite, then force set DB_HOST=UNIT_NO_DB + // env('DB_DATABASE', 'UNIT_NO_DB'); + // Why, you ask? If we don't force this to false it is possible for certain exceptions // to show their error message properly in the integration test output, but not actually // be setup correctly to display their message in production. diff --git a/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php b/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php index abdcab33c7..8d1237294d 100644 --- a/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php +++ b/tests/Unit/Http/Middleware/MaintenanceMiddlewareTest.php @@ -29,10 +29,14 @@ protected function setUp(): void */ public function testHandle(): void { - $server = Server::factory()->make(); - $node = Node::factory()->make(['maintenance' => 0]); + // maintenance mode is off by default + $server = new Server(); + $node = new Node([ + 'maintenance_mode' => false, + ]); $server->setRelation('node', $node); + $this->setRequestAttribute('server', $server); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); @@ -43,10 +47,13 @@ public function testHandle(): void */ public function testHandleInMaintenanceMode(): void { - $server = Server::factory()->make(); - $node = Node::factory()->make(['maintenance_mode' => 1]); + $server = new Server(); + $node = new Node([ + 'maintenance_mode' => true, + ]); $server->setRelation('node', $node); + $this->setRequestAttribute('server', $server); $this->response->shouldReceive('view')