diff --git a/app/Enums/CustomizationKey.php b/app/Enums/CustomizationKey.php index 16437edd1..c76aa8157 100644 --- a/app/Enums/CustomizationKey.php +++ b/app/Enums/CustomizationKey.php @@ -18,7 +18,7 @@ enum CustomizationKey: string self::ConsoleFont => 'monospace', self::ConsoleFontSize => 14, self::ConsoleGraphPeriod => 30, - self::TopNavigation => false, + self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'), self::DashboardLayout => 'grid', }; } diff --git a/app/Extensions/Features/Schemas/JavaVersionSchema.php b/app/Extensions/Features/Schemas/JavaVersionSchema.php index fc18a4fd9..4e47c1baf 100644 --- a/app/Extensions/Features/Schemas/JavaVersionSchema.php +++ b/app/Extensions/Features/Schemas/JavaVersionSchema.php @@ -56,8 +56,7 @@ class JavaVersionSchema implements FeatureSchemaInterface ->default(fn () => $server->image) ->notIn(fn () => $server->image) ->required() - ->preload() - ->native(false), + ->preload(), ]) ->action(function (array $data, DaemonServerRepository $serverRepository) use ($server) { try { diff --git a/app/Extensions/OAuth/Schemas/AuthentikSchema.php b/app/Extensions/OAuth/Schemas/AuthentikSchema.php index 10e9be348..95f4fa935 100644 --- a/app/Extensions/OAuth/Schemas/AuthentikSchema.php +++ b/app/Extensions/OAuth/Schemas/AuthentikSchema.php @@ -4,6 +4,10 @@ namespace App\Extensions\OAuth\Schemas; use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\TextInput; +use Filament\Infolists\Components\TextEntry; +use Filament\Schemas\Components\Wizard\Step; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\HtmlString; use SocialiteProviders\Authentik\Provider; final class AuthentikSchema extends OAuthSchema @@ -20,11 +24,27 @@ final class AuthentikSchema extends OAuthSchema public function getServiceConfig(): array { - return [ + return array_merge(parent::getServiceConfig(), [ 'base_url' => env('OAUTH_AUTHENTIK_BASE_URL'), - 'client_id' => env('OAUTH_AUTHENTIK_CLIENT_ID'), - 'client_secret' => env('OAUTH_AUTHENTIK_CLIENT_SECRET'), - ]; + ]); + } + + public function getSetupSteps(): array + { + return array_merge([ + Step::make('Create Authentik Application') + ->schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('
On your Authentik dashboard select Applications, then select Create with Provider.
On the creation step select OAuth2/OpenID Provider and on the configure step set Redirect URIs/Origins to the value below.
'))), + TextInput::make('_noenv_callback') + ->label('Callback URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/authentik')), + ]), + ], parent::getSetupSteps()); } public function getSettingsForm(): array diff --git a/app/Extensions/OAuth/Schemas/BitbucketSchema.php b/app/Extensions/OAuth/Schemas/BitbucketSchema.php new file mode 100644 index 000000000..5252d5372 --- /dev/null +++ b/app/Extensions/OAuth/Schemas/BitbucketSchema.php @@ -0,0 +1,45 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Visit the
For the Callback URL use the value below.
'))), + TextInput::make('_noenv_callback') + ->label('Callback URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/bitbucket')), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-bitbucket-f'; + } + + public function getHexColor(): string + { + return '#205081'; + } +} diff --git a/app/Extensions/OAuth/Schemas/FacebookSchema.php b/app/Extensions/OAuth/Schemas/FacebookSchema.php new file mode 100644 index 000000000..ded300a9b --- /dev/null +++ b/app/Extensions/OAuth/Schemas/FacebookSchema.php @@ -0,0 +1,48 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Visit the
Once selected go to Use Cases and customize "Authenticate and request data from users with Facebook Login", from there go to Settings and add Valid OAuth Redirect URIs using the value below.
'))), + TextInput::make('_noenv_callback') + ->label('Valid OAuth Redirect URIs') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/facebook')), + TextEntry::make('get_app_info') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('To obtain the OAuth values go to App Settings > Basic.
'))), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-facebook-f'; + } + + public function getHexColor(): string + { + return '#1877f2'; + } +} diff --git a/app/Extensions/OAuth/Schemas/GoogleSchema.php b/app/Extensions/OAuth/Schemas/GoogleSchema.php new file mode 100644 index 000000000..6cb74d367 --- /dev/null +++ b/app/Extensions/OAuth/Schemas/GoogleSchema.php @@ -0,0 +1,54 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Visit the
Navigate or search Credentials, click on the Create Credentials button and select OAuth client ID. On the Application type select Web Application.
On Authorized JavaScript origins and Authorized redirect URIs add and use the values below.
'))), + TextInput::make('_noenv_origin') + ->label('Authorized JavaScript origins') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('')), + TextInput::make('_noenv_callback') + ->label('Authorized redirect URIs') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/google')), + TextEntry::make('register_application') + ->hiddenLabel() + ->state(new HtmlString('When you filled all fields click on Create.
')), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-google-f'; + } + + public function getHexColor(): string + { + return '#4285f4'; + } +} diff --git a/app/Extensions/OAuth/Schemas/LinkedinSchema.php b/app/Extensions/OAuth/Schemas/LinkedinSchema.php new file mode 100644 index 000000000..26736c020 --- /dev/null +++ b/app/Extensions/OAuth/Schemas/LinkedinSchema.php @@ -0,0 +1,45 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Select the Auth tab and set Authorized redirect URLs for your app to the value below.
'))), + TextInput::make('_noenv_callback') + ->label('Authorized redirect URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/linkedin')), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-linkedin-f'; + } + + public function getHexColor(): string + { + return '#0a66c2'; + } +} diff --git a/app/Extensions/OAuth/Schemas/SlackSchema.php b/app/Extensions/OAuth/Schemas/SlackSchema.php new file mode 100644 index 000000000..78ae5445e --- /dev/null +++ b/app/Extensions/OAuth/Schemas/SlackSchema.php @@ -0,0 +1,45 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Navigate to the OAuth & Permissions section and configure the Redirect URL using the value below.
'))), + TextInput::make('_noenv_callback') + ->label('Redirect URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/slack')), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-slack'; + } + + public function getHexColor(): string + { + return '#6ecadc'; + } +} diff --git a/app/Extensions/OAuth/Schemas/XSchema.php b/app/Extensions/OAuth/Schemas/XSchema.php new file mode 100644 index 000000000..dfa6b616e --- /dev/null +++ b/app/Extensions/OAuth/Schemas/XSchema.php @@ -0,0 +1,54 @@ +schema([ + TextEntry::make('create_application') + ->hiddenLabel() + ->state(new HtmlString(Blade::render('Visit the
Go to the app\'s settings and set up User authentication if not yet. Make sure to select Web App as the type of app.
For the Callback URI / Redirect URL and Website URL set it using the value below.
'))), + TextInput::make('_noenv_origin') + ->label('Website URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('')), + TextInput::make('_noenv_callback') + ->label('Callback URI / Redirect URL') + ->dehydrated() + ->disabled() + ->hintCopy() + ->default(fn () => url('/auth/oauth/callback/x')), + TextEntry::make('register_application') + ->hiddenLabel() + ->state(new HtmlString('If you have already set this up go to your app\'s Keys and tokens and obtain the Client ID and Secret there.
')), + ]), + ], parent::getSetupSteps()); + } + + public function getIcon(): string + { + return 'tabler-brand-x'; + } + + public function getHexColor(): string + { + return '#1da1f2'; + } +} diff --git a/app/Filament/Admin/Pages/Settings.php b/app/Filament/Admin/Pages/Settings.php index b894fafb4..bc9b0cf8a 100644 --- a/app/Filament/Admin/Pages/Settings.php +++ b/app/Filament/Admin/Pages/Settings.php @@ -181,7 +181,6 @@ class Settings extends Page implements HasSchemas ->schema([ Select::make('FILAMENT_AVATAR_PROVIDER') ->label(trans('admin/setting.general.avatar_provider')) - ->native(false) ->options($this->avatarService->getMappings()) ->selectablePlaceholder(false) ->default(env('FILAMENT_AVATAR_PROVIDER', config('panel.filament.avatar-provider'))), @@ -204,6 +203,15 @@ class Settings extends Page implements HasSchemas ]) ->stateCast(new BooleanStateCast(false, true)) ->default(env('PANEL_USE_BINARY_PREFIX', config('panel.use_binary_prefix'))), + ToggleButtons::make('FILAMENT_DEFAULT_NAVIGATION') + ->label(trans('admin/setting.general.default_navigation')) + ->inline() + ->options([ + 'sidebar' => trans('admin/setting.general.sidebar'), + 'topbar' => trans('admin/setting.general.topbar'), + 'mixed' => trans('admin/setting.general.mixed'), + ]) + ->default(env('FILAMENT_DEFAULT_NAVIGATION', config('panel.filament.default-navigation'))), ToggleButtons::make('APP_2FA_REQUIRED') ->label(trans('admin/setting.general.2fa_requirement')) ->inline() @@ -217,7 +225,6 @@ class Settings extends Page implements HasSchemas ->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))), Select::make('FILAMENT_WIDTH') ->label(trans('admin/setting.general.display_width')) - ->native(false) ->options(Width::class) ->selectablePlaceholder(false) ->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))), diff --git a/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php index b591848e8..533fc3c3c 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/CreateEgg.php @@ -257,7 +257,6 @@ class CreateEgg extends CreateRecord ->default('ghcr.io/pelican-eggs/installers:debian'), Select::make('script_entry') ->label(trans('admin/egg.script_entry')) - ->native(false) ->selectablePlaceholder(false) ->default('bash') ->options([ diff --git a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php index cf756de95..a1dd1e805 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php @@ -16,6 +16,7 @@ use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\CodeEditor; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; @@ -24,13 +25,19 @@ use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Infolists\Components\TextEntry; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Flex; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Image; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; +use Filament\Support\Enums\IconSize; use Illuminate\Validation\Rules\Unique; class EditEgg extends EditRecord @@ -50,36 +57,215 @@ class EditEgg extends EditRecord Tabs::make()->tabs([ Tab::make('configuration') ->label(trans('admin/egg.tabs.configuration')) - ->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4]) + ->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6]) ->icon('tabler-egg') ->schema([ + Grid::make(2) + ->columnSpan(1) + ->schema([ + Image::make('', '') + ->hidden(fn ($record) => !$record->image) + ->url(fn ($record) => $record->image) + ->alt('') + ->alignJustify() + ->imageSize(150) + ->columnSpanFull(), + Flex::make([ + Action::make('uploadImage') + ->iconButton() + ->iconSize(IconSize::Large) + ->icon('tabler-photo-up') + ->modal() + ->modalHeading('') + ->modalSubmitActionLabel(trans('admin/egg.import.import_image')) + ->schema([ + Tabs::make() + ->contained(false) + ->tabs([ + Tab::make(trans('admin/egg.import.url')) + ->schema([ + Hidden::make('base64Image'), + TextInput::make('image_url') + ->label(trans('admin/egg.import.image_url')) + ->reactive() + ->autocomplete(false) + ->debounce(500) + ->afterStateUpdated(function ($state, Set $set) { + if (!$state) { + $set('image_url_error', null); + + return; + } + + try { + if (!filter_var($state, FILTER_VALIDATE_URL)) { + throw new \Exception(trans('admin/egg.import.invalid_url')); + } + + $allowedExtensions = [ + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + ]; + + $extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION)); + + if (!array_key_exists($extension, $allowedExtensions)) { + throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', $allowedExtensions)])); + } + + $host = parse_url($state, PHP_URL_HOST); + $ip = gethostbyname($host); + + if ( + filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false + ) { + throw new \Exception(trans('admin/egg.import.no_local_ip')); + } + + $context = stream_context_create([ + 'http' => ['timeout' => 3], + 'https' => [ + 'timeout' => 3, + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $imageContent = @file_get_contents($state, false, $context, 0, 1048576); // 1024KB + + if (!$imageContent) { + throw new \Exception(trans('admin/egg.import.image_error')); + } + + if (strlen($imageContent) >= 1048576) { + throw new \Exception(trans('admin/egg.import.image_too_large')); + } + + $mimeType = $allowedExtensions[$extension]; + $base64 = 'data:' . $mimeType . ';base64,' . base64_encode($imageContent); + + $set('base64Image', $base64); + $set('image_url_error', null); + + } catch (\Exception $e) { + $set('image_url_error', $e->getMessage()); + $set('base64Image', null); + } + }), + TextEntry::make('image_url_error') + ->hiddenLabel() + ->visible(fn ($get) => $get('image_url_error') !== null) + ->afterStateHydrated(fn ($set, $get) => $get('image_url_error')), + Image::make(fn (Get $get) => $get('image_url'), '') + ->imageSize(150) + ->visible(fn ($get) => $get('image_url') && !$get('image_url_error')) + ->alignCenter(), + ]), + Tab::make(trans('admin/egg.import.file')) + ->schema([ + FileUpload::make('image') + ->hiddenLabel() + ->previewable() + ->openable(false) + ->downloadable(false) + ->maxSize(1024) + ->maxFiles(1) + ->columnSpanFull() + ->alignCenter() + ->imageEditor() + ->saveUploadedFileUsing(function ($file, Set $set) { + $base64 = "data:{$file->getMimeType()};base64,". base64_encode(file_get_contents($file->getRealPath())); + $set('base64Image', $base64); + + return $base64; + }), + ]), + ]), + ]) + ->action(function (array $data, $record): void { + $base64 = $data['base64Image'] ?? null; + + if (empty($base64) && !empty($data['image'])) { + $base64 = $data['image']; + } + + if (!empty($base64)) { + $record->update([ + 'image' => $base64, + ]); + + Notification::make() + ->title(trans('admin/egg.import.image_updated')) + ->success() + ->send(); + + $record->refresh(); + } else { + Notification::make() + ->title(trans('admin/egg.import.no_image')) + ->warning() + ->send(); + } + }), + Action::make('deleteImage') + ->visible(fn ($record) => $record->image) + ->label('') + ->icon('tabler-trash') + ->iconButton() + ->iconSize(IconSize::Large) + ->color('danger') + ->action(function ($record) { + + $record->update([ + 'image' => null, + ]); + + Notification::make() + ->title(trans('admin/egg.import.image_deleted')) + ->success() + ->send(); + + $record->refresh(); + }), + ]), + ]), TextInput::make('name') ->label(trans('admin/egg.name')) ->required() ->maxLength(255) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2]) ->helperText(trans('admin/egg.name_help')), + Textarea::make('description') + ->label(trans('admin/egg.description')) + ->rows(3) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3]) + ->helperText(trans('admin/egg.description_help')), + TextInput::make('id') + ->label(trans('admin/egg.egg_id')) + ->columnSpan(1) + ->disabled(), TextInput::make('uuid') ->label(trans('admin/egg.egg_uuid')) ->disabled() ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) ->helperText(trans('admin/egg.uuid_help')), - TextInput::make('id') - ->label(trans('admin/egg.egg_id')) - ->disabled(), - Textarea::make('description') - ->label(trans('admin/egg.description')) - ->rows(3) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) - ->helperText(trans('admin/egg.description_help')), TextInput::make('author') ->label(trans('admin/egg.author')) ->required() ->maxLength(255) ->email() ->disabled() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) ->helperText(trans('admin/egg.author_help_edit')), + Toggle::make('force_outgoing_ip') + ->inline(false) + ->label(trans('admin/egg.force_ip')) + ->columnSpan(1) + ->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')), KeyValue::make('startup_commands') ->label(trans('admin/egg.startup_commands')) ->live() @@ -93,24 +279,20 @@ class EditEgg extends EditRecord ->label(trans('admin/egg.file_denylist')) ->placeholder('denied-file.txt') ->helperText(trans('admin/egg.file_denylist_help')) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]), - TagsInput::make('features') - ->label(trans('admin/egg.features')) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]), - Toggle::make('force_outgoing_ip') - ->inline(false) - ->label(trans('admin/egg.force_ip')) - ->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')), - Hidden::make('script_is_privileged') - ->helperText('The docker images available to servers using this egg.'), - TagsInput::make('tags') - ->label(trans('admin/egg.tags')) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]), + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), TextInput::make('update_url') ->label(trans('admin/egg.update_url')) ->url() ->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help')) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]), + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + TagsInput::make('features') + ->label(trans('admin/egg.features')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + Hidden::make('script_is_privileged') + ->helperText('The docker images available to servers using this egg.'), + TagsInput::make('tags') + ->label(trans('admin/egg.tags')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), KeyValue::make('docker_images') ->label(trans('admin/egg.docker_images')) ->live() @@ -248,7 +430,6 @@ class EditEgg extends EditRecord ->placeholder('ghcr.io/pelican-eggs/installers:debian'), Select::make('script_entry') ->label(trans('admin/egg.script_entry')) - ->native(false) ->selectablePlaceholder(false) ->options([ 'bash' => 'bash', diff --git a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php index 251fea690..6117482f8 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php @@ -19,6 +19,7 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ReplicateAction; use Filament\Resources\Pages\ListRecords; +use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; @@ -42,6 +43,13 @@ class ListEggs extends ListRecords TextColumn::make('id') ->label('Id') ->hidden(), + ImageColumn::make('image') + ->label('') + ->alignCenter() + ->circular() + ->getStateUsing(fn ($record) => $record->image + ? $record->image + : 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))), TextColumn::make('name') ->label(trans('admin/egg.name')) ->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description) diff --git a/app/Filament/Admin/Resources/Mounts/MountResource.php b/app/Filament/Admin/Resources/Mounts/MountResource.php index d46a94799..bbf8aad31 100644 --- a/app/Filament/Admin/Resources/Mounts/MountResource.php +++ b/app/Filament/Admin/Resources/Mounts/MountResource.php @@ -24,6 +24,7 @@ use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Resource; use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\StateCasts\BooleanStateCast; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -125,6 +126,7 @@ class MountResource extends Resource ToggleButtons::make('read_only') ->label(trans('admin/mount.read_only')) ->helperText(trans('admin/mount.read_only_help')) + ->stateCast(new BooleanStateCast(false)) ->options([ false => trans('admin/mount.toggles.writable'), true => trans('admin/mount.toggles.read_only'), @@ -162,7 +164,8 @@ class MountResource extends Resource Section::make()->schema([ Select::make('eggs')->multiple() ->label(trans('admin/mount.eggs')) - ->relationship('eggs', 'name') + // Selecting only non-json fields to prevent Postgres from choking on DISTINCT JSON columns + ->relationship('eggs', 'name', fn (Builder $query) => $query->select(['eggs.id', 'eggs.name'])) ->preload(), Select::make('nodes')->multiple() ->label(trans('admin/mount.nodes')) diff --git a/app/Filament/Admin/Resources/Servers/Pages/ListServers.php b/app/Filament/Admin/Resources/Servers/Pages/ListServers.php index b2a3b200f..88e48cd98 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/ListServers.php +++ b/app/Filament/Admin/Resources/Servers/Pages/ListServers.php @@ -16,6 +16,7 @@ use Filament\Tables\Columns\SelectColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Grouping\Group; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class ListServers extends ListRecords { @@ -47,7 +48,9 @@ class ListServers extends ListRecords ->searchable(), TextColumn::make('name') ->label(trans('admin/server.name')) - ->searchable() + ->searchable(query: fn (Builder $query, string $search) => $query->where( + Server::query()->qualifyColumn('name'), 'like', "%{$search}%") + ) ->sortable(), TextColumn::make('node.name') ->label(trans('admin/server.node')) diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 052924d66..866ee3dda 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -188,7 +188,7 @@ class UserResource extends Resource ->hintAction( Action::make('password_reset') ->label(trans('admin/user.password_reset')) - ->hidden(fn () => config('mail.default', 'log') === 'log') + ->hidden(fn (string $operation) => $operation === 'create' || config('mail.default', 'log') === 'log') ->icon('tabler-send') ->action(function (User $user) { $status = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker())->sendResetLink([ @@ -236,8 +236,7 @@ class UserResource extends Resource ->default(fn () => config('app.timezone', 'UTC')) ->selectablePlaceholder(false) ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) - ->searchable() - ->native(false), + ->searchable(), Select::make('language') ->label(trans('profile.language')) ->columnSpan([ @@ -251,8 +250,7 @@ class UserResource extends Resource ->default('en') ->searchable() ->selectablePlaceholder(false) - ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) - ->native(false), + ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()), FileUpload::make('avatar') ->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false) ->avatar() @@ -414,7 +412,7 @@ class UserResource extends Resource $sshKey->delete(); Activity::event('user:ssh-key.delete') - ->actor(auth()->user()) + ->actor(user()) ->subject($user) ->subject($sshKey) ->property('fingerprint', $sshKey->fingerprint) diff --git a/app/Filament/Components/Forms/Fields/CopyFrom.php b/app/Filament/Components/Forms/Fields/CopyFrom.php index eefa95d52..fa94293fa 100644 --- a/app/Filament/Components/Forms/Fields/CopyFrom.php +++ b/app/Filament/Components/Forms/Fields/CopyFrom.php @@ -21,8 +21,6 @@ class CopyFrom extends Select $this->searchable(); - $this->native(false); - $this->live(); } diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index 2dca5db74..e9a9fad87 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -34,7 +34,6 @@ use Filament\Schemas\Components\Actions; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\StateCasts\BooleanStateCast; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; @@ -132,8 +131,7 @@ class EditProfile extends BaseEditProfile ->default(config('app.timezone', 'UTC')) ->selectablePlaceholder(false) ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) - ->searchable() - ->native(false), + ->searchable(), Select::make('language') ->label(trans('profile.language')) ->required() @@ -143,8 +141,7 @@ class EditProfile extends BaseEditProfile ->selectablePlaceholder(false) ->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' : trans('profile.language_help', ['state' => $state]) . ' Update On Crowdin')) - ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) - ->native(false), + ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()), FileUpload::make('avatar') ->visible(fn () => config('panel.filament.uploadable-avatars')) ->avatar() @@ -442,10 +439,10 @@ class EditProfile extends BaseEditProfile ->label(trans('profile.navigation')) ->inline() ->options([ - 1 => trans('profile.top'), - 0 => trans('profile.side'), - ]) - ->stateCast(new BooleanStateCast(false, true)), + 'sidebar' => trans('profile.sidebar'), + 'topbar' => trans('profile.topbar'), + 'mixed' => trans('profile.mixed'), + ]), ]), Section::make(trans('profile.console')) ->collapsible() @@ -555,6 +552,7 @@ class EditProfile extends BaseEditProfile { return [ $this->getSaveFormAction()->formId('form'), + $this->getCancelFormAction()->formId('form'), ]; } @@ -584,7 +582,14 @@ class EditProfile extends BaseEditProfile $data['console_rows'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleRows); $data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod); $data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout); - $data['top_navigation'] = (bool) $this->getUser()->getCustomization(CustomizationKey::TopNavigation); + + // Handle migration from boolean to string navigation types + $topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation); + if (is_bool($topNavigation)) { + $data['top_navigation'] = $topNavigation ? 'topbar' : 'sidebar'; + } else { + $data['top_navigation'] = $topNavigation; + } return $data; } diff --git a/app/Filament/Server/Pages/Startup.php b/app/Filament/Server/Pages/Startup.php index 386df191c..231875172 100644 --- a/app/Filament/Server/Pages/Startup.php +++ b/app/Filament/Server/Pages/Startup.php @@ -51,7 +51,7 @@ class Startup extends ServerFormPage ->label(trans('server/startup.command')) ->live() ->visible(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands)) - ->disabled(fn (Server $server) => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server)) + ->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server)) ->formatStateUsing(fn (Server $server) => $server->startup) ->afterStateUpdated(function ($state, Server $server, Set $set) { $original = $server->startup; diff --git a/app/Filament/Server/Resources/Files/Pages/ListFiles.php b/app/Filament/Server/Resources/Files/Pages/ListFiles.php index 3d1460be7..6dcb77151 100644 --- a/app/Filament/Server/Resources/Files/Pages/ListFiles.php +++ b/app/Filament/Server/Resources/Files/Pages/ListFiles.php @@ -26,12 +26,14 @@ use Filament\Facades\Filament; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CodeEditor; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Panel; use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\PageRegistration; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; @@ -297,13 +299,26 @@ class ListFiles extends ListRecords ->label(trans('server/file.actions.archive.title')) ->icon('tabler-archive')->iconSize(IconSize::Large) ->schema([ - TextInput::make('name') - ->label(trans('server/file.actions.archive.archive_name')) - ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') - ->suffix('.tar.gz'), + Grid::make(3) + ->schema([ + TextInput::make('name') + ->label(trans('server/file.actions.archive.archive_name')) + ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') + ->columnSpan(2), + Select::make('extension') + ->label(trans('server/file.actions.archive.extension')) + ->selectablePlaceholder(false) + ->options([ + 'tar.gz' => 'tar.gz', + 'zip' => 'zip', + 'tar.bz2' => 'tar.bz2', + 'tar.xz' => 'tar.xz', + ]) + ->columnSpan(1), + ]), ]) ->action(function ($data, File $file) { - $archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name']); + $archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name'], $data['extension']); Activity::event('server:file.compress') ->property('name', $archive['name']) @@ -392,15 +407,28 @@ class ListFiles extends ListRecords BulkAction::make('archive') ->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server)) ->schema([ - TextInput::make('name') - ->label(trans('server/file.actions.archive.archive_name')) - ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') - ->suffix('.tar.gz'), + Grid::make(3) + ->schema([ + TextInput::make('name') + ->label(trans('server/file.actions.archive.archive_name')) + ->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z') + ->columnSpan(2), + Select::make('extension') + ->label(trans('server/file.actions.archive.extension')) + ->selectablePlaceholder(false) + ->options([ + 'tar.gz' => 'tar.gz', + 'zip' => 'zip', + 'tar.bz2' => 'tar.bz2', + 'tar.xz' => 'tar.xz', + ]) + ->columnSpan(1), + ]), ]) ->action(function ($data, Collection $files) { $files = $files->map(fn ($file) => $file['name'])->toArray(); - $archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']); + $archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name'], $data['extension']); Activity::event('server:file.compress') ->property('name', $archive['name']) diff --git a/app/Filament/Server/Resources/Schedules/ScheduleResource.php b/app/Filament/Server/Resources/Schedules/ScheduleResource.php index ccb949705..4ccca31df 100644 --- a/app/Filament/Server/Resources/Schedules/ScheduleResource.php +++ b/app/Filament/Server/Resources/Schedules/ScheduleResource.php @@ -255,8 +255,7 @@ class ScheduleResource extends Resource '6' => trans('server/schedule.time.saturday'), '0' => trans('server/schedule.time.sunday'), ]) - ->selectablePlaceholder(false) - ->native(false), + ->selectablePlaceholder(false), ]) ->action(function (Set $set, $data) { $set('cron_minute', '0'); diff --git a/app/Filament/Server/Widgets/ServerConsole.php b/app/Filament/Server/Widgets/ServerConsole.php index e668ab96c..c6ba06314 100644 --- a/app/Filament/Server/Widgets/ServerConsole.php +++ b/app/Filament/Server/Widgets/ServerConsole.php @@ -121,11 +121,11 @@ class ServerConsole extends Widget foreach ($data as $key => $value) { $cacheKey = "servers.{$this->server->id}.$key"; - $data = cache()->get($cacheKey, []); + $cachedStats = cache()->get($cacheKey, []); - $data[$timestamp] = $value; + $cachedStats[$timestamp] = $value; - cache()->put($cacheKey, $data, now()->addMinute()); + cache()->put($cacheKey, array_slice($cachedStats, -120), now()->addMinute()); } } diff --git a/app/Http/Controllers/Api/Application/DatabaseHosts/DatabaseHostController.php b/app/Http/Controllers/Api/Application/DatabaseHosts/DatabaseHostController.php index 660c3fffd..08e01a354 100644 --- a/app/Http/Controllers/Api/Application/DatabaseHosts/DatabaseHostController.php +++ b/app/Http/Controllers/Api/Application/DatabaseHosts/DatabaseHostController.php @@ -77,7 +77,7 @@ class DatabaseHostController extends ApplicationApiController return $this->fractal->item($databaseHost) ->transformWith($this->getTransformer(DatabaseHostTransformer::class)) ->addMeta([ - 'resource' => route('api.application.databases.view', [ + 'resource' => route('api.application.databasehosts.view', [ 'database_host' => $databaseHost->id, ]), ]) diff --git a/app/Http/Controllers/Api/Client/AccountController.php b/app/Http/Controllers/Api/Client/AccountController.php index 644b80541..627c493ee 100644 --- a/app/Http/Controllers/Api/Client/AccountController.php +++ b/app/Http/Controllers/Api/Client/AccountController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Client; use App\Facades\Activity; use App\Http\Requests\Api\Client\Account\UpdateEmailRequest; use App\Http\Requests\Api\Client\Account\UpdatePasswordRequest; +use App\Http\Requests\Api\Client\Account\UpdateUsernameRequest; use App\Services\Users\UserUpdateService; use App\Transformers\Api\Client\UserTransformer; use Illuminate\Auth\AuthManager; @@ -36,6 +37,25 @@ class AccountController extends ClientApiController ->toArray(); } + /** + * Update username + * + * Update the authenticated user's username. + */ + public function updateUsername(UpdateUsernameRequest $request): JsonResponse + { + $original = $request->user()->username; + $this->updateService->handle($request->user(), $request->validated()); + + if ($original !== $request->input('username')) { + Activity::event('user:account.username-changed') + ->property(['old' => $original, 'new' => $request->input('username')]) + ->log(); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + /** * Update email * diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index ba1088e95..f9da65caa 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -212,7 +212,8 @@ class FileController extends ClientApiController $file = $this->fileRepository->setServer($server)->compressFiles( $request->input('root'), $request->input('files'), - $request->input('name') + $request->input('name'), + $request->input('extension') ); Activity::event('server:file.compress') diff --git a/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php b/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php new file mode 100644 index 000000000..430fa3c71 --- /dev/null +++ b/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php @@ -0,0 +1,38 @@ +make(Hasher::class); + + // Verify password matches when changing password or email. + if (!$hasher->check($this->input('password'), $this->user()->password)) { + throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')); + } + + return true; + } + + public function rules(): array + { + $rules = User::getRulesForUpdate($this->user()); + + return ['username' => $rules['username']]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Files/CompressFilesRequest.php b/app/Http/Requests/Api/Client/Servers/Files/CompressFilesRequest.php index e7e0e3578..ca3993718 100644 --- a/app/Http/Requests/Api/Client/Servers/Files/CompressFilesRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Files/CompressFilesRequest.php @@ -22,6 +22,7 @@ class CompressFilesRequest extends ClientApiRequest 'files' => 'required|array', 'files.*' => 'string', 'name' => 'sometimes|nullable|string', + 'extension' => 'sometimes|in:zip,tgz,tar.gz,txz,tar.xz,tbz2,tar.bz2', ]; } } diff --git a/app/Livewire/Installer/PanelInstaller.php b/app/Livewire/Installer/PanelInstaller.php index 276517b56..ba0f43c47 100644 --- a/app/Livewire/Installer/PanelInstaller.php +++ b/app/Livewire/Installer/PanelInstaller.php @@ -117,7 +117,6 @@ class PanelInstaller extends SimplePage implements HasForms ->selectablePlaceholder(false) ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()) ->afterStateUpdated(fn ($state, Application $app) => $app->setLocale($state ?? config('app.locale'))) - ->native(false) ->columnStart(4); } diff --git a/app/Livewire/ServerEntry.php b/app/Livewire/ServerEntry.php index 356ea5275..1abcccc86 100644 --- a/app/Livewire/ServerEntry.php +++ b/app/Livewire/ServerEntry.php @@ -25,6 +25,18 @@ class ServerEntry extends Component$username — $this->event
diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 103e82eec..083a0c113 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -21,6 +21,7 @@ use Illuminate\Support\Str; * @property string $author * @property string $name * @property string|null $description + * @property string|null $image * @property string[]|null $features * @property arrayX&&j.top4){let H=a.pos-(C-4);for(;a.pos>H;)w=m(S,x,w)}x[--w]=q,x[--w]=ie-S,x[--w]=E-S,x[--w]=j}else C==-3?l=j:C==-4&&(c=j);return w}let g=[],X=[];for(;a.pos>0;)h(O.start||0,O.bufferStart||0,g,X,-1,0);let T=(e=O.length)!==null&&e!==void 0?e:g.length?X[0]+g[0].length:0;return new D(o[O.topID],g.reverse(),X.reverse(),T)}var Ud=new WeakMap;function _n(O,e){if(!O.isAnonymous||e instanceof Kt||e.type!=O)return 1;let t=Ud.get(e);if(t==null){t=1;for(let i of e.children){if(i.type!=O||!(i instanceof D)){t=1;break}t+=_n(O,i)}Ud.set(e,t)}return t}function So(O,e,t,i,r,n,s,a,o){let l=0;for(let u=i;ua&&t.splice(n+1,0,new Ue(a,o.to))):o.to>a?t[n--]=new Ue(a,o.to):t.splice(n--,1))}}return i}function JS(O,e,t,i){let r=0,n=0,s=!1,a=!1,o=-1e9,l=[];for(;;){let c=r==O.length?1e9:s?O[r].to:O[r].from,h=n==e.length?1e9:a?e[n].to:e[n].from;if(s!=a){let f=Math.max(o,t),Q=Math.min(c,h,i);fnew Ue(f.from+i,f.to+i)),h=JS(e,c,o,l);for(let f=0,Q=o;;f++){let u=f==h.length,$=u?l:h[f].from;if($>Q&&t.push(new Vt(Q,$,r.tree,-s,n.from>=Q||n.openStart,n.to<=$||n.openEnd)),u)break;Q=h[f].to}}else t.push(new Vt(o,l,r.tree,-s,n.from>=s||n.openStart,n.to<=a||n.openEnd))}return t}var eX=0,Ne=class O{constructor(e,t,i,r){this.name=e,this.set=t,this.base=i,this.modified=r,this.id=eX++}toString(){let{name:e}=this;for(let t of this.modified)t.name&&(e=`${t.name}(${e})`);return e}static define(e,t){let i=typeof e=="string"?e:"?";if(e instanceof O&&(t=e),t?.base)throw new Error("Can not derive from a modified tag");let r=new O(i,[],null,[]);if(r.set.push(r),t)for(let n of t.set)r.set.push(n);return r}static defineModifier(e){let t=new Cn(e);return i=>i.modified.indexOf(t)>-1?i:Cn.get(i.base||i,i.modified.concat(t).sort((r,n)=>r.id-n.id))}},tX=0,Cn=class O{constructor(e){this.name=e,this.instances=[],this.id=tX++}static get(e,t){if(!t.length)return e;let i=t[0].instances.find(a=>a.base==e&&OX(t,a.modified));if(i)return i;let r=[],n=new Ne(e.name,r,e,t);for(let a of t)a.instances.push(n);let s=iX(t);for(let a of e.set)if(!a.modified.length)for(let o of s)r.push(O.get(a,o));return n}};function OX(O,e){return O.length==e.length&&O.every((t,i)=>t==e[i])}function iX(O){let e=[[]];for(let t=0;t