diff --git a/app/Console/Commands/Egg/UpdateEggIndexCommand.php b/app/Console/Commands/Egg/UpdateEggIndexCommand.php index ee80a998c..620bd5358 100644 --- a/app/Console/Commands/Egg/UpdateEggIndexCommand.php +++ b/app/Console/Commands/Egg/UpdateEggIndexCommand.php @@ -13,7 +13,7 @@ class UpdateEggIndexCommand extends Command public function handle(): int { try { - $data = Http::timeout(5)->connectTimeout(1)->get('https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json')->throw()->json(); + $data = Http::timeout(5)->connectTimeout(1)->get(config('panel.cdn.egg_index_url'))->throw()->json(); } catch (Exception $exception) { $this->error($exception->getMessage()); diff --git a/app/Enums/SubuserPermission.php b/app/Enums/SubuserPermission.php index f0d7a343d..ace02086e 100644 --- a/app/Enums/SubuserPermission.php +++ b/app/Enums/SubuserPermission.php @@ -50,6 +50,9 @@ enum SubuserPermission: string case ActivityRead = 'activity.read'; + case MountRead = 'mount.read'; + case MountUpdate = 'mount.update'; + case StartupRead = 'startup.read'; case StartupUpdate = 'startup.update'; case StartupDockerImage = 'startup.docker-image'; @@ -57,6 +60,7 @@ enum SubuserPermission: string case SettingsRename = 'settings.rename'; case SettingsDescription = 'settings.description'; case SettingsReinstall = 'settings.reinstall'; + case SettingsChangeIcon = 'settings.change-icon'; /** @return string[] */ public function split(): array @@ -84,6 +88,7 @@ enum SubuserPermission: string 'schedule' => TablerIcon::Clock, 'settings' => TablerIcon::Settings, 'activity' => TablerIcon::Stack, + 'mount' => TablerIcon::LayersLinked, default => null, }; } diff --git a/app/Events/User/Deleting.php b/app/Events/User/Deleting.php new file mode 100644 index 000000000..d8ab610c0 --- /dev/null +++ b/app/Events/User/Deleting.php @@ -0,0 +1,17 @@ +icon(TablerIcon::Egg) ->schema([ Grid::make(2) - ->columnSpan(1) + ->columnStart(1) ->schema([ - Image::make('', '') - ->hidden(fn ($record) => !$record->image) - ->url(fn ($record) => $record->image) - ->alt('') - ->alignJustify() + Image::make('', 'icon') + ->hidden(fn ($record) => !$record->icon) + ->url(fn ($record) => $record->icon) ->imageSize(150) - ->columnSpanFull(), - Flex::make([ - Action::make('uploadImage') - ->hiddenLabel() - ->tooltip(trans('admin/egg.import.import_image')) - ->iconSize(IconSize::Large) - ->icon(TablerIcon::PhotoUp) - ->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('imageUrl'), - Hidden::make('imageExtension'), - 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); - $set('imageUrl', null); - $set('imageExtension', null); - - return; - } - - try { - if (!filter_var($state, FILTER_VALIDATE_URL)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - $extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION)); - - if (!array_key_exists($extension, Egg::IMAGE_FORMATS)) { - throw new Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Egg::IMAGE_FORMATS))])); - } - - $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')); - } - - $set('imageUrl', $state); - $set('imageExtension', $extension); - $set('image_url_error', null); - - } catch (Exception $e) { - $set('image_url_error', $e->getMessage()); - $set('imageUrl', null); - $set('imageExtension', 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(256) - ->maxFiles(1) - ->columnSpanFull() - ->alignCenter() - ->imageEditor() - ->image() - ->disk('public') - ->directory(Egg::ICON_STORAGE_PATH) - ->acceptedFileTypes([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/svg+xml', - ]) - ->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) { - return $record->uuid . '.' . $file->getClientOriginalExtension(); - }), - ]), - ]), - ]) - ->action(function (array $data, $record): void { - if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) { - $this->saveImageFromUrl($data['imageUrl'], $data['imageExtension'], $record); - - Notification::make() - ->title(trans('admin/egg.import.image_updated')) - ->success() - ->send(); - - return; - } - - if (!empty($data['image'])) { - Notification::make() - ->title(trans('admin/egg.import.image_updated')) - ->success() - ->send(); - - return; - } - - if (empty($data['imageUrl']) && empty($data['image'])) { - Notification::make() - ->title(trans('admin/egg.import.no_image')) - ->warning() - ->send(); - } - }), - Action::make('delete_image') - ->visible(fn ($record) => $record->image) - ->hiddenLabel() - ->tooltip(trans('admin/egg.import.delete_image')) - ->icon(TablerIcon::Trash) - ->iconSize(IconSize::Large) - ->color('danger') - ->action(function ($record) { - foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) { - $path = Egg::ICON_STORAGE_PATH . "/$record->uuid.$ext"; - if (Storage::disk('public')->exists($path)) { - Storage::disk('public')->delete($path); - } - } - - Notification::make() - ->title(trans('admin/egg.import.image_deleted')) - ->success() - ->send(); - - $record->refresh(); - }), - ]), + ->columnSpanFull() + ->alignJustify(), + UploadIcon::make(), + DeleteIcon::make() + ->iconStoragePath(Egg::getIconStoragePath()), ]), TextInput::make('name') ->label(trans('admin/egg.name')) @@ -469,39 +317,6 @@ class EditEgg extends EditRecord $this->fillForm(); } - /** - * Save an image from URL download to a file. - * - * @throws Exception - */ - private function saveImageFromUrl(string $imageUrl, string $extension, Egg $egg): void - { - $context = stream_context_create([ - 'http' => ['timeout' => 3], - 'https' => [ - 'timeout' => 3, - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - - $normalizedExtension = match ($extension) { - 'svg+xml', 'svg' => 'svg', - 'jpeg', 'jpg' => 'jpg', - 'png' => 'png', - 'webp' => 'webp', - default => throw new Exception(trans('admin/egg.import.unknown_extension')), - }; - - $data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB - - if (empty($data)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data); - } - protected function getFormActions(): array { return []; diff --git a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php index 49a7873ca..f9b60ed19 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php @@ -38,6 +38,8 @@ class ListEggs extends ListRecords */ public function table(Table $table): Table { + $defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg'))); + return $table ->searchable(true) ->defaultPaginationPageOption(25) @@ -45,13 +47,11 @@ class ListEggs extends ListRecords TextColumn::make('id') ->label('Id') ->hidden(), - ImageColumn::make('image') + ImageColumn::make('icon') ->label('') ->alignCenter() ->circular() - ->getStateUsing(fn ($record) => $record->image - ? $record->image - : 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))), + ->getStateUsing(fn (Egg $record) => $record->icon ?: $defaultEggIcon), 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 462ae595c..75e183614 100644 --- a/app/Filament/Admin/Resources/Mounts/MountResource.php +++ b/app/Filament/Admin/Resources/Mounts/MountResource.php @@ -95,6 +95,12 @@ class MountResource extends Resource ->icon(fn ($state) => $state ? TablerIcon::WritingOff : TablerIcon::Writing) ->color(fn ($state) => $state ? 'success' : 'warning') ->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writable')), + TextColumn::make('user_mountable') + ->label(trans('admin/mount.table.user_mountable')) + ->badge() + ->icon(fn ($state) => $state ? TablerIcon::User : TablerIcon::UserOff) + ->color(fn ($state) => $state ? 'success' : 'warning') + ->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.user_mountable') : trans('admin/mount.toggles.not_user_mountable')), ]) ->recordActions([ ViewAction::make() @@ -124,7 +130,8 @@ class MountResource extends Resource ->label(trans('admin/mount.name')) ->required() ->helperText(trans('admin/mount.name_help')) - ->maxLength(64), + ->maxLength(64) + ->columnSpanFull(), ToggleButtons::make('read_only') ->label(trans('admin/mount.read_only')) ->helperText(trans('admin/mount.read_only_help')) @@ -143,6 +150,24 @@ class MountResource extends Resource ]) ->inline() ->default(false), + ToggleButtons::make('user_mountable') + ->label(trans('admin/mount.user_mountable')) + ->helperText(trans('admin/mount.user_mountable_help')) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + false => trans('admin/mount.toggles.not_user_mountable'), + true => trans('admin/mount.toggles.user_mountable'), + ]) + ->icons([ + false => TablerIcon::UserOff, + true => TablerIcon::User, + ]) + ->colors([ + false => 'warning', + true => 'success', + ]) + ->inline() + ->default(true), TextInput::make('source') ->label(trans('admin/mount.source')) ->required() diff --git a/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php b/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php index e35eb5ec1..c6dd95c9c 100644 --- a/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php +++ b/app/Filament/Admin/Resources/Mounts/Pages/CreateMount.php @@ -42,7 +42,6 @@ class CreateMount extends CreateRecord protected function handleRecordCreation(array $data): Model { $data['uuid'] ??= Str::uuid()->toString(); - $data['user_mountable'] = 1; return parent::handleRecordCreation($data); } diff --git a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php index 0e6fc9e95..6a270c2a8 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php @@ -7,8 +7,9 @@ use App\Enums\TablerIcon; use App\Extensions\BackupAdapter\BackupAdapterService; use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema; use App\Filament\Admin\Resources\Servers\ServerResource; -use App\Filament\Components\Actions\DeleteServerIcon; +use App\Filament\Components\Actions\DeleteIcon; use App\Filament\Components\Actions\PreviewStartupAction; +use App\Filament\Components\Actions\UploadIcon; use App\Filament\Components\Forms\Fields\MonacoEditor; use App\Filament\Components\Forms\Fields\StartupVariable; use App\Filament\Components\StateCasts\ServerConditionStateCast; @@ -32,7 +33,6 @@ use Exception; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Forms\Components\CheckboxList; -use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; @@ -42,7 +42,6 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Components\ToggleButtons; -use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Actions; @@ -61,9 +60,7 @@ use Filament\Support\Enums\Alignment; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\HtmlString; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use LogicException; use Random\RandomException; @@ -112,141 +109,18 @@ class EditServer extends EditRecord ->icon(TablerIcon::InfoCircle) ->schema([ Grid::make() - ->columns(2) ->columnStart(1) ->schema([ Image::make('', 'icon') - ->hidden(fn ($record) => !$record->icon && !$record->egg->image) - ->url(fn ($record) => $record->icon ?: $record->egg->image) + ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) + ->url(fn ($record) => $record->icon ?: $record->egg->icon) ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) - ->columnSpan(2) + ->imageSize(150) + ->columnSpanFull() ->alignJustify(), - Action::make('uploadIcon') - ->hiddenLabel() - ->icon(TablerIcon::PhotoUp) - ->tooltip(trans('admin/server.import_image')) - ->modal() - ->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload')) - ->schema([ - Tabs::make() - ->contained(false) - ->tabs([ - Tab::make(trans('admin/egg.import.url')) - ->schema([ - Hidden::make('imageUrl'), - Hidden::make('imageExtension'), - 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); - $set('imageUrl', null); - $set('imageExtension', null); - - return; - } - - try { - if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - if (!filter_var($state, FILTER_VALIDATE_URL)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - $extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION)); - - if (!array_key_exists($extension, Server::IMAGE_FORMATS)) { - throw new Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))])); - } - - $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')); - } - - $set('imageUrl', $state); - $set('imageExtension', $extension); - $set('image_url_error', null); - - } catch (Exception $e) { - $set('image_url_error', $e->getMessage()); - $set('imageUrl', null); - $set('imageExtension', null); - } - }), - TextEntry::make('image_url_error') - ->hiddenLabel() - ->visible(fn (Get $get) => $get('image_url_error') !== null) - ->afterStateHydrated(fn (Get $get) => $get('image_url_error')), - Image::make(fn (Get $get) => $get('image_url'), '') - ->imageSize(150) - ->visible(fn (Get $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(256) - ->maxFiles(1) - ->columnSpanFull() - ->alignCenter() - ->imageEditor() - ->image() - ->disk('public') - ->directory(Server::ICON_STORAGE_PATH) - ->acceptedFileTypes([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/svg+xml', - ]) - ->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) { - return $record->uuid . '.' . $file->getClientOriginalExtension(); - }), - ]), - ]), - ]) - ->action(function (array $data, $record): void { - if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) { - $this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record); - Notification::make() - ->title(trans('server/setting.server_info.icon.updated')) - ->success() - ->send(); - - return; - } - - if (!empty($data['image'])) { - Notification::make() - ->title(trans('server/setting.server_info.icon.updated')) - ->success() - ->send(); - - return; - } - - if (empty($data['imageUrl']) && empty($data['image'])) { - Notification::make() - ->title(trans('admin/egg.import.no_image')) - ->warning() - ->send(); - } - }), - DeleteServerIcon::make(), + UploadIcon::make(), + DeleteIcon::make() + ->iconStoragePath(Server::getIconStoragePath()), ]), Grid::make() ->columns(3) @@ -1207,37 +1081,4 @@ class EditServer extends EditRecord { return null; } - - /** - * Save an icon from URL download to a file. - * - * @throws Exception - */ - private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void - { - $context = stream_context_create([ - 'http' => ['timeout' => 3], - 'https' => [ - 'timeout' => 3, - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - - $normalizedExtension = match ($extension) { - 'svg+xml', 'svg' => 'svg', - 'jpeg', 'jpg' => 'jpg', - 'png' => 'png', - 'webp' => 'webp', - default => throw new Exception(trans('admin/egg.import.unknown_extension')), - }; - - $data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB - - if (empty($data)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data); - } } diff --git a/app/Filament/App/Resources/Servers/Pages/ListServers.php b/app/Filament/App/Resources/Servers/Pages/ListServers.php index a5854ba6a..4245ccfee 100644 --- a/app/Filament/App/Resources/Servers/Pages/ListServers.php +++ b/app/Filament/App/Resources/Servers/Pages/ListServers.php @@ -70,7 +70,7 @@ class ListServers extends ListRecords ImageColumn::make('icon') ->label('') ->imageSize(46) - ->state(fn (Server $server) => $server->icon ?: $server->egg->image), + ->state(fn (Server $server) => $server->icon ?: $server->egg->icon), TextColumn::make('condition') ->label(trans('server/dashboard.status')) ->badge() @@ -81,7 +81,8 @@ class ListServers extends ListRecords ->label(trans('server/dashboard.title')) ->description(fn (Server $server) => $server->description) ->grow() - ->searchable(), + ->searchable() + ->sortable(), TextColumn::make('allocation.address') ->label('') ->badge() diff --git a/app/Filament/Components/Actions/DeleteIcon.php b/app/Filament/Components/Actions/DeleteIcon.php new file mode 100644 index 000000000..12c55319a --- /dev/null +++ b/app/Filament/Components/Actions/DeleteIcon.php @@ -0,0 +1,79 @@ +visible(fn ($record) => $record->icon); + + $this->hiddenLabel(); + + $this->tooltip(trans('admin/egg.import.delete_icon')); + + $this->icon(TablerIcon::Trash); + + $this->color('danger'); + + $this->action(function ($record) { + foreach ($this->getIconFormats() as $ext) { + $path = $this->getIconStoragePath() . "/$record->uuid.$ext"; + if (Storage::disk('public')->exists($path)) { + Storage::disk('public')->delete($path); + } + } + + Notification::make() + ->title(trans('admin/egg.import.icon_deleted')) + ->success() + ->send(); + + $record->refresh(); + }); + } + + /** @param string[] $iconFormats */ + public function iconFormats(?array $iconFormats): static + { + $this->iconFormats = $iconFormats; + + return $this; + } + + public function iconStoragePath(?string $iconStoragePath): static + { + $this->iconStoragePath = $iconStoragePath; + + return $this; + } + + /** @return string[] */ + public function getIconFormats(): array + { + return $this->iconFormats ?? array_keys(HasIcon::$iconFormats); + } + + public function getIconStoragePath(): ?string + { + return $this->iconStoragePath; + } +} diff --git a/app/Filament/Components/Actions/DeleteServerIcon.php b/app/Filament/Components/Actions/DeleteServerIcon.php deleted file mode 100644 index d2d1e8398..000000000 --- a/app/Filament/Components/Actions/DeleteServerIcon.php +++ /dev/null @@ -1,48 +0,0 @@ -visible(fn ($record) => $record->icon); - - $this->hiddenLabel(); - - $this->tooltip(trans('admin/server.import_image')); - - $this->icon(TablerIcon::Trash); - - $this->color('danger'); - - $this->action(function ($record) { - foreach (array_keys(Server::IMAGE_FORMATS) as $ext) { - $path = Server::ICON_STORAGE_PATH . "/$record->uuid.$ext"; - if (Storage::disk('public')->exists($path)) { - Storage::disk('public')->delete($path); - } - } - - Notification::make() - ->title(trans('server/setting.server_info.icon.deleted')) - ->success() - ->send(); - - $record->refresh(); - }); - } -} diff --git a/app/Filament/Components/Actions/UploadIcon.php b/app/Filament/Components/Actions/UploadIcon.php new file mode 100644 index 000000000..c088a4705 --- /dev/null +++ b/app/Filament/Components/Actions/UploadIcon.php @@ -0,0 +1,164 @@ +hiddenLabel(); + + $this->tooltip(trans('admin/egg.import.import_icon')); + + $this->icon(TablerIcon::PhotoUp); + + $this->modal(); + + $this->modalHeading(''); + + $this->modalSubmitActionLabel(trans('admin/egg.import.import_icon')); + + $this->schema([ + Tabs::make() + ->contained(false) + ->tabs([ + Tab::make(trans('admin/egg.import.url')) + ->schema([ + TextInput::make('icon_url') + ->label(trans('admin/egg.import.icon_url')) + ->reactive() + ->autocomplete(false) + ->debounce(500) + ->afterStateUpdated(function ($state, Set $set) { + if (!$state) { + $set('icon_url_error', null); + + return; + } + + try { + $this->validateIconUrl($state); + + $set('icon_url_error', null); + } catch (Exception $exception) { + $set('icon_url_error', $exception->getMessage()); + } + }), + TextEntry::make('icon_url_error') + ->hiddenLabel() + ->visible(fn (Get $get) => $get('icon_url_error') !== null) + ->afterStateHydrated(fn (Get $get) => $get('icon_url_error')), + Image::make(fn (Get $get) => $get('icon_url'), '') + ->imageSize(150) + ->visible(fn (Get $get) => $get('icon_url') && !$get('icon_url_error')) + ->alignCenter(), + ]), + Tab::make(trans('admin/egg.import.file')) + ->schema([ + FileUpload::make('icon') + ->hiddenLabel() + ->previewable() + ->openable(false) + ->downloadable(false) + ->maxSize(256) + ->maxFiles(1) + ->columnSpanFull() + ->alignCenter() + ->imageEditor() + ->image() + ->acceptedFileTypes(fn () => $this->getIconFormats()) + ->saveUploadedFileUsing(fn (TemporaryUploadedFile $file, $record) => $record->writeIcon($file->getClientOriginalExtension(), $file->getContent())), + ]), + ]), + ]); + + $this->action(function (array $data, $record) { + if (!empty($data['icon_url'])) { + $this->validateIconUrl($data['icon_url']); + + $content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body(); + + if (empty($content)) { + throw new Exception(trans('admin/egg.import.invalid_url')); + } + + $extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION)); + + $record->writeIcon($extension, $content); + + Notification::make() + ->title(trans('admin/egg.import.icon_updated')) + ->success() + ->send(); + } elseif (!empty($data['icon'])) { + Notification::make() + ->title(trans('admin/egg.import.icon_updated')) + ->success() + ->send(); + } else { + Notification::make() + ->title(trans('admin/egg.import.no_icon')) + ->warning() + ->send(); + } + }); + } + + protected function validateIconUrl(string $url): void + { + if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) { + throw new Exception(trans('admin/egg.import.invalid_url')); + } + + if (!filter_var($url, FILTER_VALIDATE_URL)) { + throw new Exception(trans('admin/egg.import.invalid_url')); + } + + $host = parse_url($url, 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')); + } + } + + /** @param string[] $iconFormats */ + public function iconFormats(?array $iconFormats): static + { + $this->iconFormats = $iconFormats; + + return $this; + } + + /** @return string[] */ + public function getIconFormats(): array + { + return $this->iconFormats ?? array_values(HasIcon::$iconFormats); + } +} diff --git a/app/Filament/Components/Forms/Fields/StartupVariable.php b/app/Filament/Components/Forms/Fields/StartupVariable.php index 6d277fa07..61710a1d2 100644 --- a/app/Filament/Components/Forms/Fields/StartupVariable.php +++ b/app/Filament/Components/Forms/Fields/StartupVariable.php @@ -49,7 +49,7 @@ class StartupVariable extends Field $this->hintIcon(TablerIcon::Code, fn (StartupVariable $component) => implode('|', $component->getVariableRules())); - $this->helperText(fn (StartupVariable $component) => !$component->getVariableDesc() ? '—' : $component->getVariableDesc()); + $this->helperText(fn (StartupVariable $component) => $component->getVariableDesc()); $this->rules(fn (StartupVariable $component) => $component->getVariableRules()); @@ -70,7 +70,7 @@ class StartupVariable extends Field ], StartupVariableType::Toggle => [ ...parent::getDefaultStateCasts(), - new BooleanStateCast(false), + new BooleanStateCast(false, true), ], default => parent::getDefaultStateCasts() }; diff --git a/app/Filament/Server/Pages/Mounts.php b/app/Filament/Server/Pages/Mounts.php new file mode 100644 index 000000000..c4267e686 --- /dev/null +++ b/app/Filament/Server/Pages/Mounts.php @@ -0,0 +1,114 @@ +can(SubuserPermission::MountRead, Filament::getTenant()); + } + + protected function authorizeAccess(): void + { + abort_unless(user()?->can(SubuserPermission::MountRead, Filament::getTenant()), 403); + } + + protected function fillForm(): void + { + $this->form->fill([ + 'mounts' => $this->getRecord()->mounts->pluck('id')->toArray(), + ]); + } + + public function form(Schema $schema): Schema + { + $server = $this->getRecord(); + + $allowedMounts = Mount::query() + ->where('user_mountable', true) + ->where(function ($query) use ($server) { + $query->whereDoesntHave('nodes') + ->orWhereHas('nodes', fn ($q) => $q->where('nodes.id', $server->node_id)); + }) + ->where(function ($query) use ($server) { + $query->whereDoesntHave('eggs') + ->orWhereHas('eggs', fn ($q) => $q->where('eggs.id', $server->egg_id)); + }) + ->get(); + + return parent::form($schema) + ->components([ + Section::make([ + CheckboxList::make('mounts') + ->label(trans('server/mount.description')) + ->relationship('mounts') + ->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name])) + ->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => new HtmlString(str("$mount->source -> $mount->target")->stripTags() . ($mount->description ? '
' . str($mount->description)->stripTags() : ''))])) + ->helperText(fn () => $allowedMounts->isEmpty() ? trans('server/mount.no_mounts') : null) + ->disabled(fn (Server $server) => !user()?->can(SubuserPermission::MountUpdate, $server)) + ->bulkToggleable() + ->live() + ->afterStateUpdated(function ($state) { + $this->save(); + }) + ->columnSpanFull(), + ]), + ]); + } + + public function save(): void + { + abort_unless(user()?->can(SubuserPermission::MountUpdate, $this->getRecord()), 403); + + try { + $this->form->getState(); + $this->form->saveRelationships(); + + Activity::event('server:mount.update') + ->log(); + + Notification::make() + ->title(trans('server/mount.notification_updated')) + ->body(trans('server/mount.notification_updated_body')) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + Notification::make() + ->title(trans('server/mount.notification_failed')) + ->body($exception->getMessage()) + ->danger() + ->send(); + } + } + + public function getTitle(): string + { + return trans('server/mount.title'); + } + + public static function getNavigationLabel(): string + { + return trans('server/mount.title'); + } +} diff --git a/app/Filament/Server/Pages/Settings.php b/app/Filament/Server/Pages/Settings.php index f7fab31b1..cda96c19c 100644 --- a/app/Filament/Server/Pages/Settings.php +++ b/app/Filament/Server/Pages/Settings.php @@ -5,14 +5,13 @@ namespace App\Filament\Server\Pages; use App\Enums\SubuserPermission; use App\Enums\TablerIcon; use App\Facades\Activity; -use App\Filament\Components\Actions\DeleteServerIcon; +use App\Filament\Components\Actions\DeleteIcon; +use App\Filament\Components\Actions\UploadIcon; use App\Models\Server; use App\Services\Servers\ReinstallServerService; use BackedEnum; use Exception; use Filament\Actions\Action; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; @@ -21,20 +20,14 @@ use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Image; use Filament\Schemas\Components\Section; -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\Alignment; -use Illuminate\Support\Facades\Storage; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class Settings extends ServerFormPage { protected static string|BackedEnum|null $navigationIcon = TablerIcon::Settings; - protected static ?int $navigationSort = 10; + protected static ?int $navigationSort = 11; /** * @throws Exception @@ -79,140 +72,20 @@ class Settings extends ServerFormPage ->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)), ]), Grid::make() - ->columns(2) ->columnStart(6) ->schema([ Image::make('', 'icon') - ->hidden(fn ($record) => !$record->icon && !$record->egg->image) - ->url(fn ($record) => $record->icon ?: $record->egg->image) + ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) + ->url(fn ($record) => $record->icon ?: $record->egg->icon) ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) - ->columnSpan(2) + ->imageSize(150) + ->columnSpanFull() ->alignJustify(), - Action::make('uploadIcon') - ->hiddenLabel() - ->tooltip(trans('admin/server.import_image')) - ->icon(TablerIcon::PhotoUp) - ->modal() - ->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload')) - ->schema([ - Tabs::make() - ->contained(false) - ->tabs([ - Tab::make(trans('admin/egg.import.url')) - ->schema([ - Hidden::make('imageUrl'), - Hidden::make('imageExtension'), - 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); - $set('imageUrl', null); - $set('imageExtension', null); - - return; - } - - try { - if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - if (!filter_var($state, FILTER_VALIDATE_URL)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - $extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION)); - - if (!array_key_exists($extension, Server::IMAGE_FORMATS)) { - throw new Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))])); - } - - $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')); - } - - $set('imageUrl', $state); - $set('imageExtension', $extension); - $set('image_url_error', null); - - } catch (Exception $e) { - $set('image_url_error', $e->getMessage()); - $set('imageUrl', null); - $set('imageExtension', null); - } - }), - TextEntry::make('image_url_error') - ->hiddenLabel() - ->visible(fn (Get $get) => $get('image_url_error') !== null) - ->afterStateHydrated(fn (Get $get) => $get('image_url_error')), - Image::make(fn (Get $get) => $get('image_url'), '') - ->imageSize(150) - ->visible(fn (Get $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(256) - ->maxFiles(1) - ->columnSpanFull() - ->alignCenter() - ->imageEditor() - ->image() - ->disk('public') - ->directory(Server::ICON_STORAGE_PATH) - ->acceptedFileTypes([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/svg+xml', - ]) - ->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) { - return $record->uuid . '.' . $file->getClientOriginalExtension(); - }), - ]), - ]), - ]) - ->action(function (array $data, $record): void { - - if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) { - $this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record); - Notification::make() - ->title(trans('server/setting.server_info.icon.updated')) - ->success() - ->send(); - - return; - } - - if (!empty($data['image'])) { - Notification::make() - ->title(trans('server/setting.server_info.icon.updated')) - ->success() - ->send(); - } - - if (empty($data['imageUrl']) && empty($data['image'])) { - Notification::make() - ->title(trans('admin/egg.import.no_image')) - ->warning() - ->send(); - } - }), - DeleteServerIcon::make(), + UploadIcon::make() + ->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)), + DeleteIcon::make() + ->iconStoragePath(Server::getIconStoragePath()) + ->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)), ]), TextInput::make('uuid') ->label(trans('server/setting.server_info.uuid')) @@ -446,39 +319,6 @@ class Settings extends ServerFormPage } } - /** - * Save an icon from URL download to a file. - * - * @throws Exception - */ - private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void - { - $context = stream_context_create([ - 'http' => ['timeout' => 3], - 'https' => [ - 'timeout' => 3, - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - - $normalizedExtension = match ($extension) { - 'svg+xml', 'svg' => 'svg', - 'jpeg', 'jpg' => 'jpg', - 'png' => 'png', - 'webp' => 'webp', - default => throw new Exception(trans('admin/egg.import.unknown_extension')), - }; - - $data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB - - if (empty($data)) { - throw new Exception(trans('admin/egg.import.invalid_url')); - } - - Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data); - } - public function getTitle(): string { return trans('server/setting.title'); diff --git a/app/Filament/Server/Pages/Startup.php b/app/Filament/Server/Pages/Startup.php index 3bd714f51..b9be624e0 100644 --- a/app/Filament/Server/Pages/Startup.php +++ b/app/Filament/Server/Pages/Startup.php @@ -28,7 +28,7 @@ class Startup extends ServerFormPage { protected static string|BackedEnum|null $navigationIcon = TablerIcon::PlayerPlay; - protected static ?int $navigationSort = 9; + protected static ?int $navigationSort = 10; /** * @throws Exception @@ -149,12 +149,16 @@ class Startup extends ServerFormPage return parent::canAccess() && user()?->can(SubuserPermission::StartupRead, Filament::getTenant()); } - public function update(?string $state, ServerVariable $serverVariable): void + public function update(null|string|bool $state, ServerVariable $serverVariable): void { if (!$serverVariable->variable->user_editable) { return; } + if (is_bool($state)) { + $state = $state ? '1' : '0'; + } + $original = $serverVariable->variable_value; try { diff --git a/app/Filament/Server/Resources/Subusers/SubuserResource.php b/app/Filament/Server/Resources/Subusers/SubuserResource.php index bcb1976c4..ad8662dc6 100644 --- a/app/Filament/Server/Resources/Subusers/SubuserResource.php +++ b/app/Filament/Server/Resources/Subusers/SubuserResource.php @@ -79,15 +79,15 @@ class SubuserResource extends Resource foreach ($data['permissions'] as $permission) { $options[$permission] = str($permission)->headline(); - $descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_')); + $descriptions[$permission] = trans($data['translation_prefix']. '.' . $data['name'] . '_' . str($permission)->replace('-', '_')); $permissionsArray[$data['name']][] = $permission; } $tabs[] = Tab::make($data['name']) - ->label(str($data['name'])->headline()) + ->label(trans($data['translation_prefix']. '.' . $data['name'] . '_title')) ->schema([ Section::make() - ->description(trans('server/user.permissions.' . $data['name'] . '_desc')) + ->description(trans($data['translation_prefix']. '.' . $data['name'] . '_desc')) ->icon($data['icon']) ->contained(false) ->schema([ diff --git a/app/Http/Controllers/Api/Client/AccountController.php b/app/Http/Controllers/Api/Client/AccountController.php index 627c493ee..50a993e94 100644 --- a/app/Http/Controllers/Api/Client/AccountController.php +++ b/app/Http/Controllers/Api/Client/AccountController.php @@ -13,10 +13,17 @@ use Illuminate\Auth\SessionGuard; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\RateLimiter; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Throwable; class AccountController extends ClientApiController { + /** + * The number of seconds that must elapse before the email change throttle resets. + */ + private const EMAIL_UPDATE_THROTTLE = 60 * 60 * 24; + /** * AccountController constructor. */ @@ -63,10 +70,22 @@ class AccountController extends ClientApiController */ public function updateEmail(UpdateEmailRequest $request): JsonResponse { - $original = $request->user()->email; - $this->updateService->handle($request->user(), $request->validated()); + $user = $request->user(); + + // Only allow a user to change their email three times in the span + // of 24 hours. This prevents malicious users from trying to find + // existing accounts in the system by constantly changing their email. + if (RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3)) { + throw new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.'); + } + + $original = $user->email; + + if (mb_strtolower($original) !== mb_strtolower($request->validated('email'))) { + RateLimiter::hit($key, self::EMAIL_UPDATE_THROTTLE); + + $this->updateService->handle($user, $request->validated()); - if ($original !== $request->input('email')) { Activity::event('user:account.email-changed') ->property(['old' => $original, 'new' => $request->input('email')]) ->log(); @@ -85,7 +104,9 @@ class AccountController extends ClientApiController */ public function updatePassword(UpdatePasswordRequest $request): JsonResponse { - $user = $this->updateService->handle($request->user(), $request->validated()); + $user = Activity::event('user:account.password-changed')->transaction(function () use ($request) { + return $this->updateService->handle($request->user(), $request->validated()); + }); $guard = $this->manager->guard(); // If you do not update the user in the session you'll end up working with a @@ -98,8 +119,6 @@ class AccountController extends ClientApiController $guard->logoutOtherDevices($request->input('password')); } - Activity::event('user:account.password-changed')->log(); - return new JsonResponse([], Response::HTTP_NO_CONTENT); } } diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index 284b07e2b..2e8d28496 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -89,7 +89,7 @@ class BackupController extends ClientApiController } $backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) { - $server->backups()->lockForUpdate(); + $server->backups()->lockForUpdate()->count(); $backup = $action->handle($server, $request->input('name')); diff --git a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php index 445c8b2bc..c74154441 100644 --- a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php +++ b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php @@ -60,7 +60,7 @@ class DatabaseController extends ClientApiController public function store(StoreDatabaseRequest $request, Server $server): array { $database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) { - $server->databases()->lockForUpdate(); + $server->databases()->lockForUpdate()->count(); $database = $this->deployDatabaseService->handle($server, $request->validated()); @@ -87,15 +87,12 @@ class DatabaseController extends ClientApiController */ public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array { - $this->managementService->rotatePassword($database); - $database->refresh(); - Activity::event('server:database.rotate-password') ->subject($database) ->property('name', $database->database) - ->log(); + ->transaction(fn () => $this->managementService->rotatePassword($database)); - return $this->fractal->item($database) + return $this->fractal->item($database->refresh()) ->parseIncludes(['password']) ->transformWith($this->getTransformer(DatabaseTransformer::class)) ->toArray(); diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php index 36f485b74..8ee9a9d24 100644 --- a/app/Http/Controllers/Api/Client/Servers/StartupController.php +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -87,13 +87,13 @@ class StartupController extends ClientApiController $startup = $this->startupCommandService->handle($server); - if ($variable->env_variable !== $request->input('value')) { + if ($original !== $request->input('value')) { Activity::event('server:startup.edit') ->subject($variable) ->property([ 'variable' => $variable->env_variable, 'old' => $original, - 'new' => $request->input('value'), + 'new' => $request->input('value') ?? '', ]) ->log(); } diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php index cb9727773..6c2e4f875 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php @@ -46,7 +46,7 @@ class BackupRemoteUploadController extends Controller // Check that the backup is "owned" by the node making the request. This avoids other nodes // from messing with backups that they don't own. if ($backup->server->node_id !== $node->id) { - throw new HttpForbiddenException('You do not have permission to access that backup.'); + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); } // Prevent backups that have already been completed from trying to be uploaded again. diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index 66ed94932..008c50ca5 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -45,7 +45,7 @@ class BackupStatusController extends Controller /** @var Server $server */ $server = $model->server; if ($server->node_id !== $node->id) { - throw new HttpForbiddenException('You do not have permission to access that backup.'); + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); } if ($model->is_successful) { @@ -95,6 +95,11 @@ class BackupStatusController extends Controller /** @var Backup $model */ $model = Backup::query()->where('uuid', $backup)->firstOrFail(); + $node = $request->attributes->get('node'); + if (!$model->server->node->is($node)) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + $model->server->update(['status' => null]); Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed') diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerContainersController.php b/app/Http/Controllers/Api/Remote/Servers/ServerContainersController.php index ee24ee7dc..424733240 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerContainersController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerContainersController.php @@ -3,18 +3,23 @@ namespace App\Http\Controllers\Api\Remote\Servers; use App\Enums\ContainerStatus; +use App\Exceptions\Http\HttpForbiddenException; use App\Http\Controllers\Controller; -use App\Http\Requests\Api\Remote\ServerRequest; use App\Models\Server; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class ServerContainersController extends Controller { /** * Updates the server container's status on the Panel */ - public function status(ServerRequest $request, Server $server): JsonResponse + public function status(Request $request, Server $server): JsonResponse { + if (!$server->node->is($request->attributes->get('node'))) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + $status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing; cache()->put("servers.$server->uuid.status", $status, now()->addHour()); diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php b/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php index 0d1a18a16..7c53af6ee 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Api\Remote\Servers; use App\Enums\ServerState; +use App\Exceptions\Http\HttpForbiddenException; use App\Facades\Activity; use App\Http\Controllers\Controller; -use App\Http\Requests\Api\Remote\ServerRequest; use App\Http\Resources\Daemon\ServerConfigurationCollection; use App\Models\ActivityLog; use App\Models\Backup; @@ -17,6 +17,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Throwable; +use Webmozart\Assert\Assert; class ServerDetailsController extends Controller { @@ -33,8 +34,21 @@ class ServerDetailsController extends Controller * Returns details about the server that allows daemon to self-recover and ensure * that the state of the server matches the Panel at all times. */ - public function __invoke(ServerRequest $request, Server $server): JsonResponse + public function __invoke(Request $request, Server $server): JsonResponse { + Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class); + + $transfer = $server->transfer; + + // If the server is being transferred allow either node to request information about + // the server. If the server is not being transferred only the target node is allowed + // to fetch these details. + $valid = $transfer ? $node->id === $transfer->old_node || $node->id === $transfer->new_node : $node->id === $server->node_id; + + if (!$valid) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + return new JsonResponse([ 'settings' => $this->configurationStructureService->handle($server), 'process_configuration' => $this->eggConfigurationService->handle($server), diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php b/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php index a6b8cf1ca..bedf6adc6 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php @@ -4,12 +4,13 @@ namespace App\Http\Controllers\Api\Remote\Servers; use App\Enums\ServerState; use App\Events\Server\Installed as ServerInstalled; +use App\Exceptions\Http\HttpForbiddenException; use App\Exceptions\Model\DataValidationException; use App\Http\Controllers\Controller; use App\Http\Requests\Api\Remote\InstallationDataRequest; -use App\Http\Requests\Api\Remote\ServerRequest; use App\Models\Server; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; class ServerInstallController extends Controller @@ -17,12 +18,18 @@ class ServerInstallController extends Controller /** * Returns installation information for a server. */ - public function index(ServerRequest $request, Server $server): JsonResponse + public function index(Request $request, Server $server): JsonResponse { + if (!$server->node->is($request->attributes->get('node'))) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + + $egg = $server->egg; + return new JsonResponse([ - 'container_image' => $server->egg->copy_script_container, - 'entrypoint' => $server->egg->copy_script_entry, - 'script' => $server->egg->copy_script_install, + 'container_image' => $egg->copy_script_container, + 'entrypoint' => $egg->copy_script_entry, + 'script' => $egg->copy_script_install, ]); } @@ -35,6 +42,10 @@ class ServerInstallController extends Controller { $status = null; + if (!$server->node->is($request->attributes->get('node'))) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + $successful = $request->boolean('successful'); // Make sure the type of failure is accurate diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php index 6423d532a..f3db17c50 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php @@ -2,17 +2,20 @@ namespace App\Http\Controllers\Api\Remote\Servers; +use App\Exceptions\Http\HttpForbiddenException; use App\Http\Controllers\Controller; -use App\Http\Requests\Api\Remote\ServerRequest; use App\Models\Allocation; +use App\Models\Node; use App\Models\Server; use App\Repositories\Daemon\DaemonServerRepository; use Illuminate\Database\ConnectionInterface; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Throwable; +use Webmozart\Assert\Assert; class ServerTransferController extends Controller { @@ -29,13 +32,22 @@ class ServerTransferController extends Controller * * @throws Throwable */ - public function failure(ServerRequest $request, Server $server): JsonResponse + public function failure(Request $request, Server $server): JsonResponse { $transfer = $server->transfer; if (is_null($transfer)) { throw new ConflictHttpException('Server is not being transferred.'); } + /* @var Node $node */ + Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class); + + // Either node can tell the panel that the transfer has failed. Only the new node + // can tell the panel that it was successful. + if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + $this->connection->transaction(function () use ($transfer) { $transfer->forceFill(['successful' => false])->saveOrFail(); @@ -53,13 +65,22 @@ class ServerTransferController extends Controller * * @throws Throwable */ - public function success(ServerRequest $request, Server $server): JsonResponse + public function success(Request $request, Server $server): JsonResponse { $transfer = $server->transfer; if (is_null($transfer)) { throw new ConflictHttpException('Server is not being transferred.'); } + /* @var Node $node */ + Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class); + + // Only the new node communicates a successful state to the panel, so we should + // not allow the old node to hit this endpoint. + if (!$node->is($transfer->newNode)) { + throw new HttpForbiddenException('Requesting node does not have permission to access this server.'); + } + /** @var Server $server */ $server = $this->connection->transaction(function () use ($server, $transfer) { $data = []; diff --git a/app/Http/Middleware/SetSecurityHeaders.php b/app/Http/Middleware/SetSecurityHeaders.php new file mode 100644 index 000000000..89dac3721 --- /dev/null +++ b/app/Http/Middleware/SetSecurityHeaders.php @@ -0,0 +1,46 @@ + + */ + protected static array $headers = [ + 'X-Frame-Options' => 'DENY', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Referrer-Policy' => 'no-referrer-when-downgrade', + ]; + + /** + * Enforces some basic security headers on all responses returned by the software. + * If a header has already been set in another location within the code it will be + * skipped over here. + * + * @param (\Closure(mixed): Response) $next + */ + public function handle(Request $request, \Closure $next): mixed + { + $response = $next($request); + + foreach (static::$headers as $key => $value) { + if (!$response->headers->has($key)) { + $response->headers->set($key, $value); + } + } + + return $response; + } +} diff --git a/app/Http/Requests/Api/Remote/InstallationDataRequest.php b/app/Http/Requests/Api/Remote/InstallationDataRequest.php index 59d52c978..99adcef5f 100644 --- a/app/Http/Requests/Api/Remote/InstallationDataRequest.php +++ b/app/Http/Requests/Api/Remote/InstallationDataRequest.php @@ -2,8 +2,15 @@ namespace App\Http\Requests\Api\Remote; -class InstallationDataRequest extends ServerRequest +use Illuminate\Foundation\Http\FormRequest; + +class InstallationDataRequest extends FormRequest { + public function authorize(): bool + { + return true; + } + /** * @return array */ diff --git a/app/Http/Requests/Api/Remote/ServerRequest.php b/app/Http/Requests/Api/Remote/ServerRequest.php deleted file mode 100644 index 12da385f1..000000000 --- a/app/Http/Requests/Api/Remote/ServerRequest.php +++ /dev/null @@ -1,29 +0,0 @@ -attributes->get('node'); - - /** @var ?Server $server */ - $server = $this->route()->parameter('server'); - - if ($server) { - if ($server->transfer) { - return $server->transfer->old_node === $node->id || $server->transfer->new_node === $node->id; - } - - return $server->node_id === $node->id; - } - - return false; - } -} diff --git a/app/Jobs/Job.php b/app/Jobs/Job.php deleted file mode 100644 index 55ece29ac..000000000 --- a/app/Jobs/Job.php +++ /dev/null @@ -1,21 +0,0 @@ -target instanceof Node ? "node:{$this->target->uuid}" : "server:{$this->target->uuid}"; + + return "revoke-sftp:{$this->user}:{$target}"; + } + + public function handle(DaemonServerRepository $repository): void + { + try { + if ($this->target instanceof Server) { + $repository->setServer($this->target)->deauthorize($this->user); + } else { + $repository->setNode($this->target)->deauthorize($this->user); + } + } catch (ConnectionException) { + // Keep retrying this job with a longer and longer backoff until we hit three + // attempts at which point we stop and will assume the node is fully offline + // and we are just wasting time. + $this->release($this->attempts() * 10); + } + } +} diff --git a/app/Jobs/Schedule/RunTaskJob.php b/app/Jobs/Schedule/RunTaskJob.php index 476222a5f..56d1a4756 100644 --- a/app/Jobs/Schedule/RunTaskJob.php +++ b/app/Jobs/Schedule/RunTaskJob.php @@ -7,6 +7,7 @@ use App\Jobs\Job; use App\Models\Task; use Carbon\CarbonImmutable; use Exception; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Http\Client\ConnectionException; use Illuminate\Queue\InteractsWithQueue; @@ -14,9 +15,10 @@ use Illuminate\Queue\SerializesModels; use InvalidArgumentException; use Throwable; -class RunTaskJob extends Job implements ShouldQueue +class RunTaskJob implements ShouldQueue { use InteractsWithQueue; + use Queueable; use SerializesModels; /** diff --git a/app/Listeners/RevocationListener.php b/app/Listeners/RevocationListener.php new file mode 100644 index 000000000..d01a71187 --- /dev/null +++ b/app/Listeners/RevocationListener.php @@ -0,0 +1,25 @@ +user; + + // Look at all of the nodes that a user is associated with and trigger a job + // that disconnects them from websockets and SFTP. + Node::query() + ->whereIn('nodes.id', $user->directAccessibleServers()->select('servers.node_id')->distinct()) + ->chunk(50, function (Collection $nodes) use ($user) { + $nodes->each(fn (Node $node) => RevokeSftpAccessJob::dispatch($user->uuid, $node)); + }); + } +} diff --git a/app/Models/Egg.php b/app/Models/Egg.php index be9dae236..9be069fae 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Contracts\Validatable; use App\Exceptions\Service\Egg\HasChildrenException; use App\Exceptions\Service\HasActiveServersException; +use App\Models\Traits\HasIcon; use App\Traits\HasValidation; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +14,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; /** @@ -47,7 +47,7 @@ use Illuminate\Support\Str; * @property-read string $copy_script_container * @property-read string $copy_script_entry * @property-read string|null $copy_script_install - * @property-read string|null $image + * @property-read string|null $icon * @property-read string|null $inherit_config_files * @property-read string|null $inherit_config_logs * @property-read string|null $inherit_config_startup @@ -94,6 +94,7 @@ use Illuminate\Support\Str; class Egg extends Model implements Validatable { use HasFactory; + use HasIcon; use HasValidation; /** @@ -107,22 +108,6 @@ class Egg extends Model implements Validatable */ public const EXPORT_VERSION = 'PLCN_v3'; - /** - * Path to store egg icons relative to storage path. - */ - public const ICON_STORAGE_PATH = 'icons/egg'; - - /** - * Supported image formats: file extension => MIME type - */ - public const IMAGE_FORMATS = [ - 'png' => 'image/png', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'webp' => 'image/webp', - 'svg' => 'image/svg+xml', - ]; - /** * Fields that are not mass assignable. */ @@ -377,16 +362,4 @@ class Egg extends Model implements Validatable { return str($this->name)->kebab()->lower()->trim()->split('/[^\w\-]/')->join(''); } - - public function getImageAttribute(): ?string - { - foreach (array_keys(static::IMAGE_FORMATS) as $ext) { - $path = static::ICON_STORAGE_PATH . "/$this->uuid.$ext"; - if (Storage::disk('public')->exists($path)) { - return Storage::disk('public')->url($path); - } - } - - return null; - } } diff --git a/app/Models/Server.php b/app/Models/Server.php index b9ecf70d8..073a39be2 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -7,10 +7,12 @@ use App\Enums\ContainerStatus; use App\Enums\ServerResourceType; use App\Enums\ServerState; use App\Exceptions\Http\Server\ServerStateConflictException; +use App\Models\Traits\HasIcon; use App\Repositories\Daemon\DaemonServerRepository; use App\Services\Subusers\SubuserDeletionService; use App\Traits\HasValidation; use Carbon\CarbonInterface; +use Exception; use Filament\Models\Contracts\HasAvatar; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -29,7 +31,6 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Psr\Http\Message\ResponseInterface; /** @@ -129,6 +130,7 @@ use Psr\Http\Message\ResponseInterface; class Server extends Model implements HasAvatar, Validatable { use HasFactory; + use HasIcon; use HasValidation; use Notifiable; @@ -138,22 +140,6 @@ class Server extends Model implements HasAvatar, Validatable */ public const RESOURCE_NAME = 'server'; - /** - * Path to store server icons relative to storage path. - */ - public const ICON_STORAGE_PATH = 'icons/server'; - - /** - * Supported image formats: file extension => MIME type - */ - public const IMAGE_FORMATS = [ - 'png' => 'image/png', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'webp' => 'image/webp', - 'svg' => 'image/svg+xml', - ]; - /** * Default values when creating the model. We want to switch to disabling OOM killer * on server instances unless the user specifies otherwise in the request. @@ -527,20 +513,8 @@ class Server extends Model implements HasAvatar, Validatable ); } - public function getIconAttribute(): ?string - { - foreach (array_keys(static::IMAGE_FORMATS) as $ext) { - $path = static::ICON_STORAGE_PATH . "/$this->uuid.$ext"; - if (Storage::disk('public')->exists($path)) { - return Storage::disk('public')->url($path); - } - } - - return null; - } - public function getFilamentAvatarUrl(): ?string { - return $this->icon ?? $this->egg->image; + return $this->icon ?? $this->egg->icon; } } diff --git a/app/Models/ServerTransfer.php b/app/Models/ServerTransfer.php index d1c0c0664..46e5f02e2 100644 --- a/app/Models/ServerTransfer.php +++ b/app/Models/ServerTransfer.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Contracts\Validatable; use App\Traits\HasValidation; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -44,6 +45,7 @@ use Illuminate\Support\Carbon; */ class ServerTransfer extends Model implements Validatable { + use HasFactory; use HasValidation; /** diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index 1a5fe29a1..36b4fbcae 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Contracts\Validatable; use App\Enums\SubuserPermission; use App\Traits\HasValidation; +use BackedEnum; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -44,17 +45,21 @@ class Subuser extends Model implements Validatable */ public const RESOURCE_NAME = 'server_subuser'; - /** @var array */ + /** @var array */ protected static array $customPermissions = []; /** @param string[] $permissions */ - public static function registerCustomPermissions(string $name, array $permissions, ?string $icon = null, ?bool $hidden = null): void + public static function registerCustomPermissions(string $name, array $permissions, ?string $translationPrefix = null, null|string|BackedEnum $icon = null, ?bool $hidden = null): void { $customPermission = static::$customPermissions[$name] ?? []; $customPermission['name'] = $name; $customPermission['permissions'] = array_merge($customPermission['permissions'] ?? [], $permissions); + if (!is_null($translationPrefix)) { + $customPermission['translation_prefix'] = $translationPrefix; + } + if (!is_null($icon)) { $customPermission['icon'] = $icon; } @@ -104,7 +109,7 @@ class Subuser extends Model implements Validatable return $this->belongsTo(User::class); } - /** @return array */ + /** @return array */ public static function allPermissionData(): array { $allPermissions = []; @@ -117,6 +122,7 @@ class Subuser extends Model implements Validatable 'hidden' => $subuserPermission->isHidden(), 'icon' => $subuserPermission->getIcon(), 'permissions' => array_merge($allPermissions[$group]['permissions'] ?? [], [$permission]), + 'translation_prefix' => 'server/user.permissions', ]; } @@ -130,6 +136,7 @@ class Subuser extends Model implements Validatable 'hidden' => $customPermission['hidden'] ?? $groupData['hidden'] ?? false, 'icon' => $customPermission['icon'] ?? $groupData['icon'], 'permissions' => array_unique(array_merge($groupData['permissions'] ?? [], $customPermission['permissions'])), + 'translation_prefix' => $customPermission['translation_prefix'] ?? $groupData['translation_prefix'] ?? 'server/user.permissions', ]; $allPermissions[$name] = $groupData; diff --git a/app/Models/Traits/HasIcon.php b/app/Models/Traits/HasIcon.php new file mode 100644 index 000000000..b8693d081 --- /dev/null +++ b/app/Models/Traits/HasIcon.php @@ -0,0 +1,74 @@ + MIME type + * + * @var array + */ + public static array $iconFormats = [ + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'webp' => 'image/webp', + ]; + + public static function getIconStoragePath(): string + { + return 'icons/' . static::RESOURCE_NAME; + } + + public function getIconAttribute(): ?string + { + foreach (array_keys(static::$iconFormats) as $ext) { + $path = $this->getIconStoragePath() . "/$this->uuid.$ext"; + if (Storage::disk('public')->exists($path)) { + return Storage::disk('public')->url($path); + } + } + + return null; + } + + public function writeIcon(string $extension, string $data): string + { + $normalizedExtension = match (strtolower($extension)) { + 'jpeg', 'jpg' => 'jpg', + 'png' => 'png', + 'webp' => 'webp', + default => null, + }; + + if (is_null($normalizedExtension)) { + throw new Exception(trans('admin/egg.import.unknown_extension', ['extension' => $extension])); + } + + $fileName = static::getIconStoragePath() . "/$this->uuid.$normalizedExtension"; + + if (!Storage::disk('public')->put($fileName, $data)) { + throw new Exception(trans('admin/egg.import.could_not_write')); + } + + foreach (['png', 'jpg', 'jpeg', 'webp', 'svg'] as $ext) { + if ($ext === $normalizedExtension) { + continue; + } + + $path = static::getIconStoragePath() . "/$this->uuid.$ext"; + if (Storage::disk('public')->exists($path)) { + Storage::disk('public')->delete($path); + } + } + + return $fileName; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0ca6279c6..ca94f7701 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Contracts\Validatable; use App\Enums\CustomizationKey; use App\Enums\SubuserPermission; +use App\Events\User\Deleting; use App\Exceptions\DisplayException; use App\Extensions\Avatar\AvatarService; use App\Models\Traits\HasAccessTokens; @@ -225,6 +226,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers'))); throw_if(request()->user()?->id === $user->id, new DisplayException(trans('exceptions.users.is_self'))); + + event(new Deleting($user)); }); } diff --git a/app/Providers/Filament/PanelProvider.php b/app/Providers/Filament/PanelProvider.php index 76968af75..524a55c19 100644 --- a/app/Providers/Filament/PanelProvider.php +++ b/app/Providers/Filament/PanelProvider.php @@ -7,6 +7,7 @@ use App\Filament\Pages\Auth\EditProfile; use App\Filament\Pages\Auth\Login; use App\Http\Middleware\LanguageMiddleware; use App\Http\Middleware\RequireTwoFactorAuthentication; +use App\Http\Middleware\SetSecurityHeaders; use Filament\Actions\Action; use Filament\Auth\MultiFactor\App\AppAuthentication; use Filament\Auth\MultiFactor\Email\EmailAuthentication; @@ -70,6 +71,7 @@ abstract class PanelProvider extends BasePanelProvider DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, LanguageMiddleware::class, + SetSecurityHeaders::class, ]) ->authMiddleware([ Authenticate::class, diff --git a/app/Repositories/Daemon/DaemonServerRepository.php b/app/Repositories/Daemon/DaemonServerRepository.php index 6088acf13..0109ba6c4 100644 --- a/app/Repositories/Daemon/DaemonServerRepository.php +++ b/app/Repositories/Daemon/DaemonServerRepository.php @@ -147,7 +147,7 @@ class DaemonServerRepository extends DaemonRepository } /** - * Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server. + * Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server (or all servers of a node). * * @throws ConnectionException */ @@ -156,7 +156,7 @@ class DaemonServerRepository extends DaemonRepository $this->getHttpClient()->post('/api/deauthorize-user', [ 'json' => [ 'user' => $user, - 'servers' => [$this->server->uuid], + 'servers' => $this->server ? [$this->server->uuid] : [], ], ]); } diff --git a/app/Services/Allocations/FindAssignableAllocationService.php b/app/Services/Allocations/FindAssignableAllocationService.php index 69aa8470d..7000dabdf 100644 --- a/app/Services/Allocations/FindAssignableAllocationService.php +++ b/app/Services/Allocations/FindAssignableAllocationService.php @@ -56,6 +56,7 @@ class FindAssignableAllocationService // those belonging to the current server (making it impossible to find unassigned ones) /** @var Allocation|null $allocation */ $allocation = Allocation::withoutGlobalScopes() + ->lockForUpdate() ->where('node_id', $server->node_id) ->when($server->allocation, function ($query) use ($server) { $query->where('ip', $server->allocation->ip); @@ -125,6 +126,7 @@ class FindAssignableAllocationService /** @var Allocation $allocation */ $allocation = Allocation::withoutGlobalScopes() + ->lockForUpdate() ->where('node_id', $server->node_id) ->where('ip', $server->allocation->ip) ->where('port', $port) diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index 94fbcda3f..579bb0dff 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -18,7 +18,7 @@ class EggExporterService public function handle(int $egg, EggFormat $format): string { $egg = Egg::with(['scriptFrom', 'configFrom', 'variables'])->findOrFail($egg); - $imageBase64 = $this->getEggImageAsBase64($egg); + $iconBase64 = $this->getEggIconAsBase64($egg); $struct = [ '_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL', @@ -31,7 +31,7 @@ class EggExporterService 'author' => $egg->author, 'uuid' => $egg->uuid, 'description' => $egg->description, - 'image' => $imageBase64, + 'icon' => $iconBase64, 'tags' => $egg->tags, 'features' => $egg->features, 'docker_images' => $egg->docker_images, @@ -63,16 +63,14 @@ class EggExporterService } /** - * Get the egg image as base64 for export. + * Get the egg icon as base64 for export. */ - private function getEggImageAsBase64(Egg $egg): ?string + private function getEggIconAsBase64(Egg $egg): ?string { - foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) { - $path = Egg::ICON_STORAGE_PATH . "/$egg->uuid.$ext"; + foreach (Egg::$iconFormats as $ext => $mimeType) { + $path = Egg::getIconStoragePath() . "/$egg->uuid.$ext"; if (Storage::disk('public')->exists($path)) { - $mimeType = Egg::IMAGE_FORMATS[$ext]; - return 'data:' . $mimeType . ';base64,' . base64_encode(Storage::disk('public')->get($path)); } } diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index c8fced139..23f46f20e 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -6,12 +6,12 @@ use App\Enums\EggFormat; use App\Exceptions\Service\InvalidFileUploadException; use App\Models\Egg; use App\Models\EggVariable; +use Exception; use Illuminate\Database\ConnectionInterface; use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use JsonException; use Ramsey\Uuid\Uuid; use stdClass; @@ -223,6 +223,11 @@ class EggImporterService } } + if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) { + $parsed['icon'] = $parsed['image']; + unset($parsed['image']); + } + return $parsed; } @@ -231,9 +236,9 @@ class EggImporterService */ protected function fillFromParsed(Egg $model, array $parsed): Egg { - // Handle image data if present - if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) { - $this->saveEggImageFromBase64($parsed['image'], $model); + // Handle icon data if present + if (!empty($parsed['icon']) && str_starts_with($parsed['icon'], 'data:')) { + $this->saveEggIconFromBase64($parsed['icon'], $model); } return $model->forceFill([ @@ -256,28 +261,24 @@ class EggImporterService } /** - * Save an egg image from base64 data to a file. + * Save an egg icon from base64 data to a file. */ - private function saveEggImageFromBase64(string $base64String, Egg $egg): void + private function saveEggIconFromBase64(string $base64String, Egg $egg): void { if (!preg_match('/^data:image\/([\w+]+);base64,(.+)$/', $base64String, $matches)) { return; } - $extension = $matches[1]; - $data = base64_decode($matches[2]); + try { + $extension = strtolower($matches[1]); + $data = base64_decode($matches[2]); - if (!$data) { - return; + if ($data) { + $egg->writeIcon($extension, $data); + } + } catch (Exception $exception) { + report($exception); } - - $normalizedExtension = match ($extension) { - 'svg+xml' => 'svg', - 'jpeg' => 'jpg', - default => $extension, - }; - - Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data); } /** diff --git a/app/Services/Nodes/NodeUpdateService.php b/app/Services/Nodes/NodeUpdateService.php index 6ef132a46..1f7123bb2 100644 --- a/app/Services/Nodes/NodeUpdateService.php +++ b/app/Services/Nodes/NodeUpdateService.php @@ -40,6 +40,7 @@ class NodeUpdateService /** @var Node $updated */ $updated = $node->replicate(); $updated->exists = true; + $data = array_merge($data, ['created_at' => $node->created_at, 'updated_at' => now()]); $updated->forceFill($data)->save(); try { $node->fqdn = $updated->fqdn; diff --git a/app/Services/Servers/DetailsModificationService.php b/app/Services/Servers/DetailsModificationService.php index e8c40e3c5..988ab70dc 100644 --- a/app/Services/Servers/DetailsModificationService.php +++ b/app/Services/Servers/DetailsModificationService.php @@ -2,11 +2,10 @@ namespace App\Services\Servers; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Server; -use App\Repositories\Daemon\DaemonServerRepository; use App\Traits\Services\ReturnsUpdatedModels; use Illuminate\Database\ConnectionInterface; -use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Arr; use Throwable; @@ -17,7 +16,7 @@ class DetailsModificationService /** * DetailsModificationService constructor. */ - public function __construct(private ConnectionInterface $connection, private DaemonServerRepository $serverRepository) {} + public function __construct(private ConnectionInterface $connection) {} /** * Update the details for a single server instance. @@ -34,7 +33,7 @@ class DetailsModificationService public function handle(Server $server, array $data): Server { return $this->connection->transaction(function () use ($data, $server) { - $owner = $server->owner_id; + $oldOwner = $server->user; $server->forceFill([ 'external_id' => Arr::get($data, 'external_id'), @@ -46,14 +45,8 @@ class DetailsModificationService // If the owner_id value is changed we need to revoke any tokens that exist for the server // on the daemon instance so that the old owner no longer has any permission to access the // websockets. - if ($server->owner_id !== $owner) { - try { - $this->serverRepository->setServer($server)->deauthorize($server->user->uuid); - } catch (ConnectionException) { - // Do nothing. A failure here is not ideal, but it is likely to be caused by daemon - // being offline, or in an entirely broken state. Remember, these tokens reset every - // few minutes by default, we're just trying to help it along a little quicker. - } + if ($server->owner_id !== $oldOwner->id) { + RevokeSftpAccessJob::dispatch($oldOwner->uuid, $server); } return $server; diff --git a/app/Services/Subusers/SubuserDeletionService.php b/app/Services/Subusers/SubuserDeletionService.php index bbf232995..f34a3a2c1 100644 --- a/app/Services/Subusers/SubuserDeletionService.php +++ b/app/Services/Subusers/SubuserDeletionService.php @@ -4,17 +4,12 @@ namespace App\Services\Subusers; use App\Events\Server\SubUserRemoved; use App\Facades\Activity; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Server; use App\Models\Subuser; -use App\Repositories\Daemon\DaemonServerRepository; -use Illuminate\Http\Client\ConnectionException; class SubuserDeletionService { - public function __construct( - private DaemonServerRepository $serverRepository, - ) {} - public function handle(Subuser $subuser, Server $server): void { $log = Activity::event('server:subuser.delete') @@ -27,14 +22,7 @@ class SubuserDeletionService event(new SubUserRemoved($subuser->server, $subuser->user)); - try { - $this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid); - } catch (ConnectionException $exception) { - // Don't block this request if we can't connect to the daemon instance. - logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]); - - $instance->property('revoked', false); - } + RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server); }); } } diff --git a/app/Services/Subusers/SubuserUpdateService.php b/app/Services/Subusers/SubuserUpdateService.php index 1ce9c0ac5..c2d693c95 100644 --- a/app/Services/Subusers/SubuserUpdateService.php +++ b/app/Services/Subusers/SubuserUpdateService.php @@ -4,17 +4,12 @@ namespace App\Services\Subusers; use App\Enums\SubuserPermission; use App\Facades\Activity; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Server; use App\Models\Subuser; -use App\Repositories\Daemon\DaemonServerRepository; -use Illuminate\Http\Client\ConnectionException; class SubuserUpdateService { - public function __construct( - private DaemonServerRepository $serverRepository, - ) {} - /** * @param string[] $permissions */ @@ -42,18 +37,10 @@ class SubuserUpdateService // Only update the database and hit up the daemon instance to invalidate JTI's if the permissions // have actually changed for the user. if ($cleanedPermissions !== $current) { - $log->transaction(function ($instance) use ($subuser, $cleanedPermissions, $server) { + $log->transaction(function () use ($subuser, $cleanedPermissions, $server) { $subuser->update(['permissions' => $cleanedPermissions]); - try { - $this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid); - } catch (ConnectionException $exception) { - // Don't block this request if we can't connect to the daemon instance. Chances are it is - // offline and the token will be invalid once daemon boots back. - logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]); - - $instance->property('revoked', false); - } + RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server); }); } diff --git a/app/Services/Users/UserUpdateService.php b/app/Services/Users/UserUpdateService.php index aacb0cb47..e5aaa3ad1 100644 --- a/app/Services/Users/UserUpdateService.php +++ b/app/Services/Users/UserUpdateService.php @@ -2,6 +2,7 @@ namespace App\Services\Users; +use App\Events\User\PasswordChanged; use App\Models\User; use App\Traits\Services\HasUserLevels; use Illuminate\Contracts\Hashing\Hasher; @@ -30,6 +31,10 @@ class UserUpdateService $user->forceFill($data)->saveOrFail(); + if (isset($data['password'])) { + PasswordChanged::dispatch($user); + } + return $user->refresh(); } } diff --git a/app/Transformers/Api/Application/EggTransformer.php b/app/Transformers/Api/Application/EggTransformer.php index 947a4613a..d18826075 100644 --- a/app/Transformers/Api/Application/EggTransformer.php +++ b/app/Transformers/Api/Application/EggTransformer.php @@ -46,10 +46,10 @@ class EggTransformer extends BaseTransformer 'name' => $model->name, 'author' => $model->author, 'description' => $model->description, - 'image' => $model->image, + 'icon' => $model->icon, 'features' => $model->features, 'tags' => $model->tags, - 'docker_image' => Arr::first($model->docker_images, default: ''), // docker_images, use startup_commands + 'docker_image' => Arr::first($model->docker_images, default: ''), // deprecated, use docker_images 'docker_images' => $model->docker_images, 'config' => [ 'files' => $files, diff --git a/bootstrap/app.php b/bootstrap/app.php index d0862ec3e..a64834b6f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,7 @@ use App\Http\Middleware\EnsureStatefulRequests; use App\Http\Middleware\LanguageMiddleware; use App\Http\Middleware\MaintenanceMiddleware; use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\SetSecurityHeaders; use App\Http\Middleware\VerifyCsrfToken; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Application; @@ -28,7 +29,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware) { $middleware->redirectGuestsTo(fn () => route('filament.app.auth.login')); - $middleware->web(LanguageMiddleware::class); + $middleware->web([ + LanguageMiddleware::class, + SetSecurityHeaders::class, + ]); $middleware->api([ EnsureStatefulRequests::class, diff --git a/composer.json b/composer.json index adb59665f..f0622b3da 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "filament/filament": "^4.8", "gboquizosanchez/filament-log-viewer": "^2.2", "guzzlehttp/guzzle": "^7.10", - "laravel/framework": "^12.54", + "laravel/framework": "^12.56", "laravel/helpers": "^1.8", "laravel/sanctum": "^4.3", "laravel/socialite": "^5.25", @@ -35,7 +35,7 @@ "spatie/laravel-data": "^4.20", "spatie/laravel-fractal": "^6.4", "spatie/laravel-health": "^1.39", - "spatie/laravel-permission": "^6.24", + "spatie/laravel-permission": "^6.25", "spatie/laravel-query-builder": "^6.4", "spatie/temporary-directory": "^2.3", "symfony/http-client": "^7.4", @@ -45,7 +45,7 @@ "webmozart/assert": "^1.12" }, "require-dev": { - "barryvdh/laravel-ide-helper": "^3.6", + "barryvdh/laravel-ide-helper": "^3.7", "fakerphp/faker": "^1.24", "larastan/larastan": "^3.9", "laravel/pail": "^1.2.6", @@ -97,4 +97,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index ae03f4cd5..5d74276f6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,74 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "069a41352997bdd2cd829fa3f216f07f", + "content-hash": "f8e1aad900e174805da1a90d1ddec56d", "packages": [ - { - "name": "anourvalar/eloquent-serialize", - "version": "1.3.5", - "source": { - "type": "git", - "url": "https://github.com/AnourValar/eloquent-serialize.git", - "reference": "1a7dead8d532657e5358f8f27c0349373517681e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/1a7dead8d532657e5358f8f27c0349373517681e", - "reference": "1a7dead8d532657e5358f8f27c0349373517681e", - "shasum": "" - }, - "require": { - "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.4|^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.26", - "laravel/legacy-factories": "^1.1", - "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^9.5|^10.5|^11.0", - "psalm/plugin-laravel": "^2.8|^3.0", - "squizlabs/php_codesniffer": "^3.7" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "EloquentSerialize": "AnourValar\\EloquentSerialize\\Facades\\EloquentSerializeFacade" - } - } - }, - "autoload": { - "psr-4": { - "AnourValar\\EloquentSerialize\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Laravel Query Builder (Eloquent) serialization", - "homepage": "https://github.com/AnourValar/eloquent-serialize", - "keywords": [ - "anourvalar", - "builder", - "copy", - "eloquent", - "job", - "laravel", - "query", - "querybuilder", - "queue", - "serializable", - "serialization", - "serialize" - ], - "support": { - "issues": "https://github.com/AnourValar/eloquent-serialize/issues", - "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.5" - }, - "time": "2025-12-04T13:38:21+00:00" - }, { "name": "aws/aws-crt-php", "version": "v1.2.7", @@ -128,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.373.2", + "version": "3.374.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18" + "reference": "18525c8cc2a3aa2c1db23ceca5f57c5f8380d9c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18", - "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/18525c8cc2a3aa2c1db23ceca5f57c5f8380d9c4", + "reference": "18525c8cc2a3aa2c1db23ceca5f57c5f8380d9c4", "shasum": "" }, "require": { @@ -219,32 +153,32 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.373.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.374.0" }, - "time": "2026-03-13T18:08:30+00:00" + "time": "2026-03-25T18:05:48+00:00" }, { "name": "blade-ui-kit/blade-heroicons", - "version": "2.6.0", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/driesvints/blade-heroicons.git", - "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19" + "reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", - "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/66fa8ba09dba12e0cdb410b8cb94f3b890eca440", + "reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440", "shasum": "" }, "require": { "blade-ui-kit/blade-icons": "^1.6", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^9.0|^10.5|^11.0" + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0|^12.0" }, "type": "library", "extra": { @@ -270,7 +204,7 @@ } ], "description": "A package to easily make use of Heroicons in your Laravel Blade views.", - "homepage": "https://github.com/blade-ui-kit/blade-heroicons", + "homepage": "https://github.com/driesvints/blade-heroicons", "keywords": [ "Heroicons", "blade", @@ -278,7 +212,7 @@ ], "support": { "issues": "https://github.com/driesvints/blade-heroicons/issues", - "source": "https://github.com/driesvints/blade-heroicons/tree/2.6.0" + "source": "https://github.com/driesvints/blade-heroicons/tree/2.7.0" }, "funding": [ { @@ -290,7 +224,7 @@ "type": "paypal" } ], - "time": "2025-02-13T20:53:33+00:00" + "time": "2026-03-16T13:00:23+00:00" }, { "name": "blade-ui-kit/blade-icons", @@ -651,16 +585,16 @@ }, { "name": "chillerlan/php-settings-container", - "version": "3.2.1", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/chillerlan/php-settings-container.git", - "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681" + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/95ed3e9676a1d47cab2e3174d19b43f5dbf52681", - "reference": "95ed3e9676a1d47cab2e3174d19b43f5dbf52681", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", "shasum": "" }, "require": { @@ -668,11 +602,13 @@ "php": "^8.1" }, "require-dev": { + "phan/phan": "^5.5.2", "phpmd/phpmd": "^2.15", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-deprecation-rules": "^1.2", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.10" + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { @@ -697,7 +633,8 @@ "Settings", "configuration", "container", - "helper" + "helper", + "property hook" ], "support": { "issues": "https://github.com/chillerlan/php-settings-container/issues", @@ -713,7 +650,7 @@ "type": "ko_fi" } ], - "time": "2024-07-16T11:13:48+00:00" + "time": "2026-03-20T21:10:52+00:00" }, { "name": "danharrin/date-format-converter", @@ -822,16 +759,16 @@ }, { "name": "dedoc/scramble", - "version": "v0.13.15", + "version": "v0.13.16", "source": { "type": "git", "url": "https://github.com/dedoc/scramble.git", - "reference": "8101fb042c178fa8506740380b8067d571951a2c" + "reference": "43a5dd62b6a5d7d6fd0125092985d78ae22f0ffe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dedoc/scramble/zipball/8101fb042c178fa8506740380b8067d571951a2c", - "reference": "8101fb042c178fa8506740380b8067d571951a2c", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/43a5dd62b6a5d7d6fd0125092985d78ae22f0ffe", + "reference": "43a5dd62b6a5d7d6fd0125092985d78ae22f0ffe", "shasum": "" }, "require": { @@ -890,7 +827,7 @@ ], "support": { "issues": "https://github.com/dedoc/scramble/issues", - "source": "https://github.com/dedoc/scramble/tree/v0.13.15" + "source": "https://github.com/dedoc/scramble/tree/v0.13.16" }, "funding": [ { @@ -898,7 +835,7 @@ "type": "github" } ], - "time": "2026-03-12T13:16:40+00:00" + "time": "2026-03-17T21:42:41+00:00" }, { "name": "dflydev/dot-access-data", @@ -1323,20 +1260,19 @@ }, { "name": "filament/actions", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "61ad65f478dadc877050779de6b603d9328259dc" + "reference": "6c4a408be7bd55b8717b20c9d97f18db8a03c450" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/61ad65f478dadc877050779de6b603d9328259dc", - "reference": "61ad65f478dadc877050779de6b603d9328259dc", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/6c4a408be7bd55b8717b20c9d97f18db8a03c450", + "reference": "6c4a408be7bd55b8717b20c9d97f18db8a03c450", "shasum": "" }, "require": { - "anourvalar/eloquent-serialize": "^1.2", "filament/forms": "self.version", "filament/infolists": "self.version", "filament/notifications": "self.version", @@ -1368,20 +1304,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-14T07:52:39+00:00" + "time": "2026-03-17T20:01:10+00:00" }, { "name": "filament/filament", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "2d8bf7cfcf23faace1abfc4e44317a5748d1c42c" + "reference": "38c4d4d169d6589129ff46236503e2cc00bbcdbe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/2d8bf7cfcf23faace1abfc4e44317a5748d1c42c", - "reference": "2d8bf7cfcf23faace1abfc4e44317a5748d1c42c", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/38c4d4d169d6589129ff46236503e2cc00bbcdbe", + "reference": "38c4d4d169d6589129ff46236503e2cc00bbcdbe", "shasum": "" }, "require": { @@ -1425,20 +1361,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-14T07:52:43+00:00" + "time": "2026-03-17T20:01:32+00:00" }, { "name": "filament/forms", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "1e2acdef45e7cb803e5bf7e415c10d35b2f178d1" + "reference": "1aec73dff14b049393e7c7c7f54c4a4b48c10e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/1e2acdef45e7cb803e5bf7e415c10d35b2f178d1", - "reference": "1e2acdef45e7cb803e5bf7e415c10d35b2f178d1", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/1aec73dff14b049393e7c7c7f54c4a4b48c10e3b", + "reference": "1aec73dff14b049393e7c7c7f54c4a4b48c10e3b", "shasum": "" }, "require": { @@ -1475,11 +1411,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-14T07:52:39+00:00" + "time": "2026-03-19T13:59:50+00:00" }, { "name": "filament/infolists", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", @@ -1524,7 +1460,7 @@ }, { "name": "filament/notifications", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", @@ -1571,16 +1507,16 @@ }, { "name": "filament/query-builder", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", - "reference": "3a8b722190ba8cf982cbd8123a2025f08e3f1136" + "reference": "441d187994b499f4da655651ed0df448bda74caf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/3a8b722190ba8cf982cbd8123a2025f08e3f1136", - "reference": "3a8b722190ba8cf982cbd8123a2025f08e3f1136", + "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/441d187994b499f4da655651ed0df448bda74caf", + "reference": "441d187994b499f4da655651ed0df448bda74caf", "shasum": "" }, "require": { @@ -1613,11 +1549,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-14T07:52:39+00:00" + "time": "2026-03-19T13:58:44+00:00" }, { "name": "filament/schemas", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", @@ -1662,32 +1598,31 @@ }, { "name": "filament/support", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "18b3730decc6670e7824314002f39cba742a796e" + "reference": "2a0d0b9ddd84fdcb7d2db566f4b5ad7187ada35c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/18b3730decc6670e7824314002f39cba742a796e", - "reference": "18b3730decc6670e7824314002f39cba742a796e", + "url": "https://api.github.com/repos/filamentphp/support/zipball/2a0d0b9ddd84fdcb7d2db566f4b5ad7187ada35c", + "reference": "2a0d0b9ddd84fdcb7d2db566f4b5ad7187ada35c", "shasum": "" }, "require": { "blade-ui-kit/blade-heroicons": "^2.5", "danharrin/livewire-rate-limiting": "^2.0", "ext-intl": "*", - "illuminate/contracts": "^11.28|^12.0", + "illuminate/contracts": "^11.28|^12.0|^13.0", "kirschbaum-development/eloquent-power-joins": "^4.0", "league/uri-components": "^7.0", "livewire/livewire": "^3.5", "nette/php-generator": "^4.0", "php": "^8.2", - "ryangjchandler/blade-capture-directive": "^1.0", "spatie/invade": "^2.0", "spatie/laravel-package-tools": "^1.9", - "symfony/console": "^7.0", + "symfony/console": "^7.0|^8.0", "symfony/html-sanitizer": "^7.0|^8.0" }, "type": "library", @@ -1716,20 +1651,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-12T11:48:52+00:00" + "time": "2026-03-19T13:57:32+00:00" }, { "name": "filament/tables", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "5242f724ff21222aecb47fbaea79f416372eefe7" + "reference": "4165bc5534fba20836b54ec128eee6027644e508" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/5242f724ff21222aecb47fbaea79f416372eefe7", - "reference": "5242f724ff21222aecb47fbaea79f416372eefe7", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/4165bc5534fba20836b54ec128eee6027644e508", + "reference": "4165bc5534fba20836b54ec128eee6027644e508", "shasum": "" }, "require": { @@ -1762,20 +1697,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-14T07:52:39+00:00" + "time": "2026-03-19T13:58:07+00:00" }, { "name": "filament/widgets", - "version": "v4.8.5", + "version": "v4.9.1", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "b4f124324a607fc43665a6947384a6664aaa7976" + "reference": "68ec754b0718f00481e964f06a8fdd6fc246980d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/b4f124324a607fc43665a6947384a6664aaa7976", - "reference": "b4f124324a607fc43665a6947384a6664aaa7976", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/68ec754b0718f00481e964f06a8fdd6fc246980d", + "reference": "68ec754b0718f00481e964f06a8fdd6fc246980d", "shasum": "" }, "require": { @@ -1806,7 +1741,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-03-12T11:50:53+00:00" + "time": "2026-03-17T20:01:12+00:00" }, { "name": "firebase/php-jwt", @@ -2482,28 +2417,28 @@ }, { "name": "kirschbaum-development/eloquent-power-joins", - "version": "4.2.11", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/kirschbaum-development/eloquent-power-joins.git", - "reference": "0e3e3372992e4bf82391b3c7b84b435c3db73588" + "reference": "dbf2dfaa1900152f2e3dc42b30b67f67a82e7c36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/0e3e3372992e4bf82391b3c7b84b435c3db73588", - "reference": "0e3e3372992e4bf82391b3c7b84b435c3db73588", + "url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/dbf2dfaa1900152f2e3dc42b30b67f67a82e7c36", + "reference": "dbf2dfaa1900152f2e3dc42b30b67f67a82e7c36", "shasum": "" }, "require": { - "illuminate/database": "^11.42|^12.0", - "illuminate/support": "^11.42|^12.0", + "illuminate/database": "^11.42|^12.0|^13.0", + "illuminate/support": "^11.42|^12.0|^13.0", "php": "^8.2" }, "require-dev": { "friendsofphp/php-cs-fixer": "dev-master", - "laravel/legacy-factories": "^1.0@dev", - "orchestra/testbench": "^9.0|^10.0", - "phpunit/phpunit": "^10.0|^11.0" + "laravel/legacy-factories": "^1.0@dev|dev-master", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0" }, "type": "library", "extra": { @@ -2539,22 +2474,22 @@ ], "support": { "issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues", - "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.2.11" + "source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.0" }, - "time": "2025-12-17T00:37:48+00:00" + "time": "2026-03-17T16:43:01+00:00" }, { "name": "laravel/framework", - "version": "v12.54.1", + "version": "v12.56.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868" + "reference": "dac16d424b59debb2273910dde88eb7050a2a709" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868", + "url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709", + "reference": "dac16d424b59debb2273910dde88eb7050a2a709", "shasum": "" }, "require": { @@ -2670,7 +2605,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -2763,24 +2698,24 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:25:56+00:00" + "time": "2026-03-26T14:51:54+00:00" }, { "name": "laravel/helpers", - "version": "v1.8.2", + "version": "v1.8.3", "source": { "type": "git", "url": "https://github.com/laravel/helpers.git", - "reference": "98499eea4c1cca76fb0fb37ed365a468773daf0a" + "reference": "5915be977c7cc05fe2498d561b8c026ee56567dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/helpers/zipball/98499eea4c1cca76fb0fb37ed365a468773daf0a", - "reference": "98499eea4c1cca76fb0fb37ed365a468773daf0a", + "url": "https://api.github.com/repos/laravel/helpers/zipball/5915be977c7cc05fe2498d561b8c026ee56567dd", + "reference": "5915be977c7cc05fe2498d561b8c026ee56567dd", "shasum": "" }, "require": { - "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^7.2.0|^8.0" }, "require-dev": { @@ -2818,22 +2753,22 @@ "laravel" ], "support": { - "source": "https://github.com/laravel/helpers/tree/v1.8.2" + "source": "https://github.com/laravel/helpers/tree/v1.8.3" }, - "time": "2025-11-25T14:46:28+00:00" + "time": "2026-03-17T16:40:11+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.14", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -2877,9 +2812,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.14" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-03-01T09:02:38+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", @@ -3007,16 +2942,16 @@ }, { "name": "laravel/socialite", - "version": "v5.25.0", + "version": "v5.26.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3" + "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/231f572e1a37c9ca1fb8085e9fb8608285beafb3", - "reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3", + "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0", + "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0", "shasum": "" }, "require": { @@ -3075,7 +3010,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-02-27T13:56:35+00:00" + "time": "2026-03-24T18:37:47+00:00" }, { "name": "laravel/tinker", @@ -3145,29 +3080,29 @@ }, { "name": "laravel/ui", - "version": "v4.6.2", + "version": "v4.6.3", "source": { "type": "git", "url": "https://github.com/laravel/ui.git", - "reference": "4acfa331aa073f169a22d87851dc51eb2f7ac6be" + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ui/zipball/4acfa331aa073f169a22d87851dc51eb2f7ac6be", - "reference": "4acfa331aa073f169a22d87851dc51eb2f7ac6be", + "url": "https://api.github.com/repos/laravel/ui/zipball/ff27db15416c1ed8ad9848f5692e47595dd5de27", + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27", "shasum": "" }, "require": { - "illuminate/console": "^9.21|^10.0|^11.0|^12.0", - "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", - "illuminate/validation": "^9.21|^10.0|^11.0|^12.0", + "illuminate/console": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^9.21|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0", - "phpunit/phpunit": "^9.3|^10.4|^11.5" + "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.3|^10.4|^11.5|^12.5|^13.0" }, "type": "library", "extra": { @@ -3202,9 +3137,9 @@ "ui" ], "support": { - "source": "https://github.com/laravel/ui/tree/v4.6.2" + "source": "https://github.com/laravel/ui/tree/v4.6.3" }, - "time": "2026-03-10T20:00:50+00:00" + "time": "2026-03-17T13:41:52+00:00" }, { "name": "lcobucci/jwt", @@ -3281,16 +3216,16 @@ }, { "name": "league/commonmark", - "version": "2.8.1", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "84b1ca48347efdbe775426f108622a42735a6579" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", - "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -3384,7 +3319,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T21:37:03+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -3561,16 +3496,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -3638,9 +3573,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -4264,16 +4199,16 @@ }, { "name": "livewire/livewire", - "version": "v3.7.11", + "version": "v3.7.12", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6" + "reference": "7fcb612d1274980d80703efb5658e58d6d37ada9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6", - "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6", + "url": "https://api.github.com/repos/livewire/livewire/zipball/7fcb612d1274980d80703efb5658e58d6d37ada9", + "reference": "7fcb612d1274980d80703efb5658e58d6d37ada9", "shasum": "" }, "require": { @@ -4328,7 +4263,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.11" + "source": "https://github.com/livewire/livewire/tree/v3.7.12" }, "funding": [ { @@ -4336,7 +4271,7 @@ "type": "github" } ], - "time": "2026-02-26T00:58:19+00:00" + "time": "2026-03-25T23:04:42+00:00" }, { "name": "masterminds/html5", @@ -5390,16 +5325,16 @@ }, { "name": "phiki/phiki", - "version": "v2.1.0", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/phikiphp/phiki.git", - "reference": "b16020573e9f4ad3c9d230c17ed4c84c15356e28" + "reference": "546c7d6fca490c6597fbeb2381de28a79c76643d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phikiphp/phiki/zipball/b16020573e9f4ad3c9d230c17ed4c84c15356e28", - "reference": "b16020573e9f4ad3c9d230c17ed4c84c15356e28", + "url": "https://api.github.com/repos/phikiphp/phiki/zipball/546c7d6fca490c6597fbeb2381de28a79c76643d", + "reference": "546c7d6fca490c6597fbeb2381de28a79c76643d", "shasum": "" }, "require": { @@ -5445,7 +5380,7 @@ "description": "Syntax highlighting using TextMate grammars in PHP.", "support": { "issues": "https://github.com/phikiphp/phiki/issues", - "source": "https://github.com/phikiphp/phiki/tree/v2.1.0" + "source": "https://github.com/phikiphp/phiki/tree/v2.1.1" }, "funding": [ { @@ -5457,7 +5392,7 @@ "type": "other" } ], - "time": "2026-01-20T21:26:48+00:00" + "time": "2026-03-18T20:23:32+00:00" }, { "name": "phpdocumentor/reflection", @@ -5586,16 +5521,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "5.6.7", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { @@ -5644,9 +5579,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:47:46+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -5783,16 +5718,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.49", + "version": "3.0.50", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", "shasum": "" }, "require": { @@ -5873,7 +5808,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" }, "funding": [ { @@ -5889,7 +5824,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-03-19T02:57:58+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -6533,16 +6468,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.21", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -6606,9 +6541,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-03-06T21:21:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -6808,84 +6743,6 @@ }, "time": "2025-12-14T04:43:48+00:00" }, - { - "name": "ryangjchandler/blade-capture-directive", - "version": "v1.1.0", - "source": { - "type": "git", - "url": "https://github.com/ryangjchandler/blade-capture-directive.git", - "reference": "bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ryangjchandler/blade-capture-directive/zipball/bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d", - "reference": "bbb1513dfd89eaec87a47fe0c449a7e3d4a1976d", - "shasum": "" - }, - "require": { - "illuminate/contracts": "^10.0|^11.0|^12.0", - "php": "^8.1", - "spatie/laravel-package-tools": "^1.9.2" - }, - "require-dev": { - "nunomaduro/collision": "^7.0|^8.0", - "nunomaduro/larastan": "^2.0|^3.0", - "orchestra/testbench": "^8.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.7", - "pestphp/pest-plugin-laravel": "^2.0|^3.1", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.0|^2.0", - "phpunit/phpunit": "^10.0|^11.5.3", - "spatie/laravel-ray": "^1.26" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "BladeCaptureDirective": "RyanChandler\\BladeCaptureDirective\\Facades\\BladeCaptureDirective" - }, - "providers": [ - "RyanChandler\\BladeCaptureDirective\\BladeCaptureDirectiveServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "RyanChandler\\BladeCaptureDirective\\": "src", - "RyanChandler\\BladeCaptureDirective\\Database\\Factories\\": "database/factories" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ryan Chandler", - "email": "support@ryangjchandler.co.uk", - "role": "Developer" - } - ], - "description": "Create inline partials in your Blade templates with ease.", - "homepage": "https://github.com/ryangjchandler/blade-capture-directive", - "keywords": [ - "blade-capture-directive", - "laravel", - "ryangjchandler" - ], - "support": { - "issues": "https://github.com/ryangjchandler/blade-capture-directive/issues", - "source": "https://github.com/ryangjchandler/blade-capture-directive/tree/v1.1.0" - }, - "funding": [ - { - "url": "https://github.com/ryangjchandler", - "type": "github" - } - ], - "time": "2025-02-25T09:09:36+00:00" - }, { "name": "s1lentium/iptools", "version": "v1.2.0", @@ -7021,25 +6878,25 @@ }, { "name": "secondnetwork/blade-tabler-icons", - "version": "v3.40.0", + "version": "v3.40.1", "source": { "type": "git", "url": "https://github.com/secondnetwork/blade-tabler-icons.git", - "reference": "035772d57e710d7569faf7917e5d3263e12dc61d" + "reference": "e834c9d26859596cab74a4cea56aaa39872e7adf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/secondnetwork/blade-tabler-icons/zipball/035772d57e710d7569faf7917e5d3263e12dc61d", - "reference": "035772d57e710d7569faf7917e5d3263e12dc61d", + "url": "https://api.github.com/repos/secondnetwork/blade-tabler-icons/zipball/e834c9d26859596cab74a4cea56aaa39872e7adf", + "reference": "e834c9d26859596cab74a4cea56aaa39872e7adf", "shasum": "" }, "require": { "blade-ui-kit/blade-icons": "^1.8", - "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0" }, "require-dev": { - "orchestra/testbench": "^6.0|^7.0|^9.0", + "orchestra/testbench": "^6.0|^7.0|^9.0|^10.0", "phpunit/phpunit": "^9.0|^10.0|^11.0|^12.0" }, "type": "library", @@ -7073,9 +6930,9 @@ ], "support": { "issues": "https://github.com/secondnetwork/blade-tabler-icons/issues", - "source": "https://github.com/secondnetwork/blade-tabler-icons/tree/v3.40.0" + "source": "https://github.com/secondnetwork/blade-tabler-icons/tree/v3.40.1" }, - "time": "2026-03-07T15:09:32+00:00" + "time": "2026-03-22T11:30:19+00:00" }, { "name": "socialiteproviders/authentik", @@ -7179,22 +7036,22 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.8.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", "shasum": "" }, "require": { - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", "laravel/socialite": "^5.5", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "mockery/mockery": "^1.2", @@ -7249,7 +7106,7 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2025-02-24T19:33:30+00:00" + "time": "2026-03-18T22:13:24+00:00" }, { "name": "socialiteproviders/steam", @@ -7501,16 +7358,16 @@ }, { "name": "spatie/laravel-data", - "version": "4.20.0", + "version": "4.20.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-data.git", - "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e" + "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/05b792ab0e059d26eca15d47d199ba6f4c96054e", - "reference": "05b792ab0e059d26eca15d47d199ba6f4c96054e", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad", + "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad", "shasum": "" }, "require": { @@ -7571,7 +7428,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.20.0" + "source": "https://github.com/spatie/laravel-data/tree/4.20.1" }, "funding": [ { @@ -7579,7 +7436,7 @@ "type": "github" } ], - "time": "2026-02-25T16:18:18+00:00" + "time": "2026-03-18T07:44:01+00:00" }, { "name": "spatie/laravel-fractal", @@ -7664,16 +7521,16 @@ }, { "name": "spatie/laravel-health", - "version": "1.39.0", + "version": "1.39.1", "source": { "type": "git", "url": "https://github.com/spatie/laravel-health.git", - "reference": "ef13e7249adee21e42d71b05f93c61ecf49326df" + "reference": "b2dd80bd9d3e02639ba40b788aabca5977d3208f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-health/zipball/ef13e7249adee21e42d71b05f93c61ecf49326df", - "reference": "ef13e7249adee21e42d71b05f93c61ecf49326df", + "url": "https://api.github.com/repos/spatie/laravel-health/zipball/b2dd80bd9d3e02639ba40b788aabca5977d3208f", + "reference": "b2dd80bd9d3e02639ba40b788aabca5977d3208f", "shasum": "" }, "require": { @@ -7744,7 +7601,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-health/tree/1.39.0" + "source": "https://github.com/spatie/laravel-health/tree/1.39.1" }, "funding": [ { @@ -7752,7 +7609,7 @@ "type": "github" } ], - "time": "2026-03-01T12:56:10+00:00" + "time": "2026-03-17T20:58:31+00:00" }, { "name": "spatie/laravel-package-tools", @@ -7817,30 +7674,31 @@ }, { "name": "spatie/laravel-permission", - "version": "6.24.1", + "version": "6.25.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-permission.git", - "reference": "eefc9d17eba80d023d6bff313f882cb2bcd691a3" + "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/eefc9d17eba80d023d6bff313f882cb2bcd691a3", - "reference": "eefc9d17eba80d023d6bff313f882cb2bcd691a3", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/d7d4cb0d58616722f1afc90e0484e4825155b9b3", + "reference": "d7d4cb0d58616722f1afc90e0484e4825155b9b3", "shasum": "" }, "require": { - "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0", - "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0", - "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0" }, "require-dev": { - "laravel/passport": "^11.0|^12.0", + "laravel/passport": "^11.0|^12.0|^13.0", "laravel/pint": "^1.0", - "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0", - "phpunit/phpunit": "^9.4|^10.1|^11.5" + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0|^4.0" }, "type": "library", "extra": { @@ -7888,7 +7746,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-permission/issues", - "source": "https://github.com/spatie/laravel-permission/tree/6.24.1" + "source": "https://github.com/spatie/laravel-permission/tree/6.25.0" }, "funding": [ { @@ -7896,7 +7754,7 @@ "type": "github" } ], - "time": "2026-02-09T21:10:03+00:00" + "time": "2026-03-17T22:46:46+00:00" }, { "name": "spatie/laravel-query-builder", @@ -11633,38 +11491,39 @@ "packages-dev": [ { "name": "barryvdh/laravel-ide-helper", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-ide-helper.git", - "reference": "b106f7ee85f263c4f103eca49e7bf3862c2e5e75" + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/b106f7ee85f263c4f103eca49e7bf3862c2e5e75", - "reference": "b106f7ee85f263c4f103eca49e7bf3862c2e5e75", + "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", + "reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a", "shasum": "" }, "require": { "barryvdh/reflection-docblock": "^2.4", "composer/class-map-generator": "^1.0", "ext-json": "*", - "illuminate/console": "^11.15 || ^12", - "illuminate/database": "^11.15 || ^12", - "illuminate/filesystem": "^11.15 || ^12", - "illuminate/support": "^11.15 || ^12", + "illuminate/console": "^11.15 || ^12 || ^13.0", + "illuminate/database": "^11.15 || ^12 || ^13.0", + "illuminate/filesystem": "^11.15 || ^12 || ^13.0", + "illuminate/support": "^11.15 || ^12 || ^13.0", "php": "^8.2" }, "require-dev": { "ext-pdo_sqlite": "*", "friendsofphp/php-cs-fixer": "^3", - "illuminate/config": "^11.15 || ^12", - "illuminate/view": "^11.15 || ^12", + "illuminate/config": "^11.15 || ^12 || ^13.0", + "illuminate/view": "^11.15 || ^12 || ^13.0", + "larastan/larastan": "^3.1", "mockery/mockery": "^1.4", - "orchestra/testbench": "^9.2 || ^10", - "phpunit/phpunit": "^10.5 || ^11.5.3", + "orchestra/testbench": "^9.2 || ^10 || ^11.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.12", "spatie/phpunit-snapshot-assertions": "^4 || ^5", - "vimeo/psalm": "^5.4", "vlucas/phpdotenv": "^5" }, "suggest": { @@ -11678,7 +11537,7 @@ ] }, "branch-alias": { - "dev-master": "3.5-dev" + "dev-master": "3.6-dev" } }, "autoload": { @@ -11711,7 +11570,7 @@ ], "support": { "issues": "https://github.com/barryvdh/laravel-ide-helper/issues", - "source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.6.1" + "source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.7.0" }, "funding": [ { @@ -11723,7 +11582,7 @@ "type": "github" } ], - "time": "2025-12-10T09:11:07+00:00" + "time": "2026-03-17T14:12:51+00:00" }, { "name": "barryvdh/reflection-docblock", @@ -12605,16 +12464,16 @@ }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.55.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/67dc1b72da4e066a2fb54c1c7582fd2f140ea191", + "reference": "67dc1b72da4e066a2fb54c1c7582fd2f140ea191", "shasum": "" }, "require": { @@ -12664,7 +12523,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-03-23T15:56:34+00:00" }, { "name": "mockery/mockery", @@ -13420,11 +13279,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -13469,7 +13328,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/http.php b/config/http.php index 69bf4b530..e76ec923d 100644 --- a/config/http.php +++ b/config/http.php @@ -13,9 +13,9 @@ return [ */ 'rate_limit' => [ 'client_period' => 1, - 'client' => env('APP_API_CLIENT_RATELIMIT', 120), + 'client' => env('APP_API_CLIENT_RATELIMIT', 256), 'application_period' => 1, - 'application' => env('APP_API_APPLICATION_RATELIMIT', 240), + 'application' => env('APP_API_APPLICATION_RATELIMIT', 256), ], ]; diff --git a/config/panel.php b/config/panel.php index 848b4761d..86d7e3382 100644 --- a/config/panel.php +++ b/config/panel.php @@ -18,6 +18,7 @@ return [ 'cdn' => [ 'cache_time' => 60, + 'egg_index_url' => env('PANEL_EGG_INDEX_URL', 'https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json'), ], 'client_features' => [ diff --git a/database/Factories/ServerTransferFactory.php b/database/Factories/ServerTransferFactory.php new file mode 100644 index 000000000..420c3c513 --- /dev/null +++ b/database/Factories/ServerTransferFactory.php @@ -0,0 +1,29 @@ + [], + 'new_additional_allocations' => [], + 'successful' => null, + 'archived' => false, + ]; + } +} diff --git a/database/migrations/2025_12_13_000000_convert_base64_images_to_files.php b/database/migrations/2025_12_13_000000_convert_base64_images_to_files.php index 235f74f3c..f6ca0edfe 100644 --- a/database/migrations/2025_12_13_000000_convert_base64_images_to_files.php +++ b/database/migrations/2025_12_13_000000_convert_base64_images_to_files.php @@ -18,14 +18,14 @@ return new class extends Migration $eggs = DB::table('eggs')->whereNotNull('image')->get(); foreach ($eggs as $egg) { if (!empty($egg->image) && str_starts_with($egg->image, 'data:')) { - $this->convertBase64ToFile($egg->image, $egg->uuid, Egg::ICON_STORAGE_PATH); + $this->convertBase64ToFile($egg->image, $egg->uuid, Egg::getIconStoragePath()); } } $servers = DB::table('servers')->whereNotNull('icon')->get(); foreach ($servers as $server) { if (!empty($server->icon) && str_starts_with($server->icon, 'data:')) { - $this->convertBase64ToFile($server->icon, $server->uuid, Server::ICON_STORAGE_PATH); + $this->convertBase64ToFile($server->icon, $server->uuid, Server::getIconStoragePath()); } } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f69d737a6..18e6f6e14 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -31,7 +31,7 @@ else echo "Generated app key written to .env file" else echo "APP_KEY exists in environment, using that." - echo "APP_KEY=$APP_KEY" > /pelican-data/.env + echo "APP_KEY=${APP_KEY}" > /pelican-data/.env fi # enable installer @@ -47,7 +47,7 @@ if [ "${APP_INSTALLED}" = "true" ]; then if [ "${DB_CONNECTION}" != "sqlite" ]; then # check for DB up before starting the panel echo "Checking database status." - until nc -z -v -w30 $DB_HOST $DB_PORT + until nc -z -v -w30 "${DB_HOST}" "${DB_PORT}" do echo "Waiting for database connection..." # wait for 1 seconds before check again @@ -59,6 +59,8 @@ if [ "${APP_INSTALLED}" = "true" ]; then # run migration php artisan migrate --force + + php artisan p:plugin:composer fi echo "Optimizing Filament" diff --git a/lang/en/activity.php b/lang/en/activity.php index 464854f00..4667ff5f8 100644 --- a/lang/en/activity.php +++ b/lang/en/activity.php @@ -120,6 +120,9 @@ return [ 'update' => 'Updated the subuser permissions for :email', 'delete' => 'Removed :email as a subuser', ], + 'mount' => [ + 'update' => 'Updated the mounts for the server', + ], 'crashed' => 'Server crashed', ], ]; diff --git a/lang/en/admin/egg.php b/lang/en/admin/egg.php index db5d69d51..d70886ff8 100644 --- a/lang/en/admin/egg.php +++ b/lang/en/admin/egg.php @@ -13,9 +13,8 @@ return [ 'import' => [ 'file' => 'File', 'url' => 'URL', - 'image_url' => 'Image URL', - 'image_error' => 'Could not fetch image', - 'image_too_large' => 'Image too large. Limit is 1024KB', + 'icon_url' => 'Icon URL', + 'icon_error' => 'Could not fetch icon', 'egg_help' => 'This should be the raw .json/.yaml file', 'url_help' => 'URLs must point directly to the raw .json/.yaml file', 'add_url' => 'New URL', @@ -26,15 +25,16 @@ return [ 'failed_import_eggs' => 'Failed: :eggs', 'github' => 'GitHub', 'refresh' => 'Refresh', - 'import_image' => 'Import Image', - 'delete_image' => 'Delete Image', + 'import_icon' => 'Import Icon', + 'delete_icon' => 'Delete Icon', 'no_local_ip' => 'Local IP Addresses are not allowed', 'unsupported_format' => 'Unsupported Format. Supported Formats: :formats', 'invalid_url' => 'The provided URL is invalid', - 'unknown_extension' => 'Unknown image extension', - 'image_deleted' => 'Image Deleted', - 'no_image' => 'No Image Provided', - 'image_updated' => 'Image Updated', + 'unknown_extension' => 'Unknown icon extension (:extension)', + 'could_not_write' => 'Could not write icon to disk', + 'icon_deleted' => 'Icon Deleted', + 'no_icon' => 'No Icon Provided', + 'icon_updated' => 'Icon Updated', ], 'export' => [ 'modal' => 'How would you like to export :egg ?', diff --git a/lang/en/admin/mount.php b/lang/en/admin/mount.php index 1f0c6f949..c0e0f826f 100644 --- a/lang/en/admin/mount.php +++ b/lang/en/admin/mount.php @@ -17,14 +17,19 @@ return [ 'no_mounts' => 'No Mounts', 'eggs' => 'Eggs', 'nodes' => 'Nodes', + 'user_mountable' => 'User Mountable?', + 'user_mountable_help' => 'Should users be able to toggle this mount on or off for their servers?', 'toggles' => [ 'writable' => 'Writable', 'read_only' => 'Read Only', + 'user_mountable' => 'User Mountable', + 'not_user_mountable' => 'Admin Only', ], 'table' => [ 'name' => 'Name', 'all_eggs' => 'All Eggs', 'all_nodes' => 'All Nodes', 'read_only' => 'Read Only', + 'user_mountable' => 'User Mountable', ], ]; diff --git a/lang/en/admin/server.php b/lang/en/admin/server.php index e0bb2beba..40f00f1ad 100644 --- a/lang/en/admin/server.php +++ b/lang/en/admin/server.php @@ -7,8 +7,8 @@ return [ 'no_servers' => 'No Servers', 'create' => 'Create Server', 'ip_address' => 'IP Address', - 'import_image' => 'Import Image', - 'delete_image' => 'Delete Image', + 'import_icon' => 'Import Icon', + 'delete_icon' => 'Delete Icon', 'ip_address_helper' => 'Usually your machine\'s public IP unless you are port forwarding.', 'port' => 'Port', 'ports' => 'Ports', diff --git a/lang/en/server/mount.php b/lang/en/server/mount.php new file mode 100644 index 000000000..42a191561 --- /dev/null +++ b/lang/en/server/mount.php @@ -0,0 +1,10 @@ + 'Mounts', + 'description' => 'Manage the mounts attached to your server:', + 'no_mounts' => 'There are no user-mountable mounts available for this server.', + 'notification_updated' => 'Mounts updated successfully', + 'notification_updated_body' => 'Restart your server to apply the new mounts', + 'notification_failed' => 'Failed to update mounts', +]; diff --git a/lang/en/server/user.php b/lang/en/server/user.php index a56c71961..5af6b4a6e 100644 --- a/lang/en/server/user.php +++ b/lang/en/server/user.php @@ -17,33 +17,61 @@ return [ 'notification_failed' => 'Failed to invite user!', 'permissions' => [ 'title' => 'Permissions', + + 'activity_title' => 'Activity', 'activity_desc' => 'Permissions that control a user\'s access to the server activity logs.', + + 'startup_title' => 'Startup', 'startup_desc' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', + + 'settings_title' => 'Settings', 'settings_desc' => 'Permissions that control a user\'s ability to modify this server\'s settings.', + + 'control_title' => 'Control', 'control_desc' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.', + + 'user_title' => 'User', 'user_desc' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.', + + 'file_title' => 'File', 'file_desc' => 'Permissions that control a user\'s ability to modify the filesystem for this server.', + + 'allocation_title' => 'Allocation', 'allocation_desc' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', + + 'database_title' => 'Database', 'database_desc' => 'Permissions that control a user\'s access to the database management for this server.', + + 'backup_title' => 'Backup', 'backup_desc' => 'Permissions that control a user\'s ability to generate and manage server backups.', + + 'schedule_title' => 'Schedule', 'schedule_desc' => 'Permissions that control a user\'s access to the schedule management for this server.', + 'startup_read' => 'Allows a user to view the startup variables for a server.', 'startup_update' => 'Allows a user to modify the startup variables for the server.', 'startup_docker_image' => 'Allows a user to modify the Docker image used when running the server.', - 'settings_reinstall' => 'Allows a user to trigger a reinstall of this server.', + 'settings_rename' => 'Allows a user to rename this server.', 'settings_description' => 'Allows a user to change the description of this server.', + 'settings_reinstall' => 'Allows a user to trigger a reinstall of this server.', + 'settings_change_icon' => 'Allows a user to change the icon of this server.', + 'activity_read' => 'Allows a user to view the activity logs for the server.', + 'websocket_connect' => 'Allows a user access to the websocket for this server.', + 'control_console' => 'Allows a user to send data to the server console.', 'control_start' => 'Allows a user to start the server instance.', 'control_stop' => 'Allows a user to stop the server instance.', 'control_restart' => 'Allows a user to restart the server instance.', 'control_kill' => 'Allows a user to kill the server instance.', + 'user_create' => 'Allows a user to create new user accounts for the server.', 'user_read' => 'Allows a user permission to view users associated with this server.', 'user_update' => 'Allows a user to modify other users associated with this server.', 'user_delete' => 'Allows a user to delete other users associated with this server.', + 'file_create' => 'Allows a user permission to create new files and directories.', 'file_read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.', 'file_read_content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.', @@ -51,23 +79,30 @@ return [ 'file_delete' => 'Allows a user to delete files and directories.', 'file_archive' => 'Allows a user to create file archives and decompress existing archives.', 'file_sftp' => 'Allows a user to perform the above file actions using a SFTP client.', + 'allocation_read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.', 'allocation_update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.', 'allocation_delete' => 'Allows a user to delete an allocation from the server.', 'allocation_create' => 'Allows a user to assign additional allocations to the server.', + 'database_create' => 'Allows a user permission to create a new database for the server.', 'database_read' => 'Allows a user permission to view the server databases.', 'database_update' => 'Allows a user permission to make modifications to a database. If the user does not have the "View Password" permission as well they will not be able to modify the password.', 'database_delete' => 'Allows a user permission to delete a database instance.', 'database_view_password' => 'Allows a user permission to view a database password in the system.', + 'schedule_create' => 'Allows a user to create a new schedule for the server.', 'schedule_read' => 'Allows a user permission to view schedules for a server.', 'schedule_update' => 'Allows a user permission to make modifications to an existing server schedule.', 'schedule_delete' => 'Allows a user to delete a schedule for the server.', + 'backup_create' => 'Allows a user to create new backups for this server.', 'backup_read' => 'Allows a user to view all backups that exist for this server.', 'backup_delete' => 'Allows a user to remove backups from the system.', 'backup_download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.', 'backup_restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.', + 'mount_desc' => 'Permissions that control a user\'s ability to manage mounts for this server.', + 'mount_read' => 'Allows a user to view the mounts page and see available mounts.', + 'mount_update' => 'Allows a user to toggle mounts on or off for the server.', ], ]; diff --git a/resources/views/livewire/server-entry-placeholder.blade.php b/resources/views/livewire/server-entry-placeholder.blade.php index 0f151bd91..41cebd225 100644 --- a/resources/views/livewire/server-entry-placeholder.blade.php +++ b/resources/views/livewire/server-entry-placeholder.blade.php @@ -1,5 +1,5 @@ @php - $backgroundImage = $server->icon ?? $server->egg->image; + $backgroundImage = $server->icon ?? $server->egg->icon; $serverEntryColumn = $column ?? \App\Filament\Components\Tables\Columns\ServerEntryColumn::make('server_entry'); @endphp diff --git a/resources/views/livewire/server-entry.blade.php b/resources/views/livewire/server-entry.blade.php index 218ca3749..5e183a3c3 100644 --- a/resources/views/livewire/server-entry.blade.php +++ b/resources/views/livewire/server-entry.blade.php @@ -1,6 +1,6 @@ @php $actiongroup = \App\Filament\App\Resources\Servers\Pages\ListServers::getPowerActionGroup()->record($server); - $backgroundImage = $server->icon ?? $server->egg->image; + $backgroundImage = $server->icon ?? $server->egg->icon; $serverEntryColumn = $column ?? \App\Filament\Components\Tables\Columns\ServerEntryColumn::make('server_entry'); $serverNodeStatistics = $server->node->statistics(); diff --git a/routes/api-client.php b/routes/api-client.php index 4365cfeb2..16f8f678e 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -23,7 +23,9 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function () Route::get('/', [Client\AccountController::class, 'index'])->name('api:client.account'); Route::put('/username', [Client\AccountController::class, 'updateUsername'])->name('api:client.account.update-username'); - Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email'); + Route::put('/email', [Client\AccountController::class, 'updateEmail']) + ->middleware('throttle') + ->name('api:client.account.update-email'); Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password'); Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity'); diff --git a/routes/api-remote.php b/routes/api-remote.php index 10b0c699b..52d7e37c3 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -15,8 +15,6 @@ Route::prefix('/servers/{server:uuid}')->group(function () { Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']); Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']); - Route::get('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']); - Route::get('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']); Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']); Route::post('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']); diff --git a/tests/Integration/Api/Client/AccountControllerTest.php b/tests/Integration/Api/Client/AccountControllerTest.php index b6ad33ed3..d052e374f 100644 --- a/tests/Integration/Api/Client/AccountControllerTest.php +++ b/tests/Integration/Api/Client/AccountControllerTest.php @@ -2,8 +2,12 @@ namespace App\Tests\Integration\Api\Client; +use App\Jobs\RevokeSftpAccessJob; +use App\Models\Subuser; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -44,16 +48,38 @@ class AccountControllerTest extends ClientApiIntegrationTestCase /** @var User $user */ $user = User::factory()->create(); - $response = $this->actingAs($user)->putJson('/api/client/account/email', [ - 'email' => $email = mb_strtolower(Str::random() . '@example.com'), - 'password' => 'password', - ]); - - $response->assertStatus(Response::HTTP_NO_CONTENT); + $this->actingAs($user) + ->putJson('/api/client/account/email', [ + 'email' => $email = mb_strtolower(Str::random() . '@example.com'), + 'password' => 'password', + ]) + ->assertStatus(Response::HTTP_NO_CONTENT); + $this->assertActivityFor('user:account.email-changed', $user, $user); $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => $email]); } + public function test_email_change_is_throttled(): void + { + /** @var Collection $users */ + $users = User::factory()->count(2)->create(); + + for ($i = 0; $i < 3; $i++) { + $this->actingAs($users[0]) + ->putJson('/api/client/account/email', ['email' => "foo+{$i}@example.com", 'password' => 'password']) + ->assertNoContent(); + } + + $this->putJson('/api/client/account/email', ['email' => 'bar@example.com', 'password' => 'password']) + ->assertTooManyRequests(); + + // The other user should still be able to update their email because the throttle + // is tied to the account, not to the IP address. + $this->actingAs($users[1]) + ->putJson('/api/client/account/email', ['email' => 'bar+1@example.com', 'password' => 'password']) + ->assertNoContent(); + } + /** * Tests that an email is not updated if the password provided in the request is not * valid for the account. @@ -109,13 +135,24 @@ class AccountControllerTest extends ClientApiIntegrationTestCase /** @var User $user */ $user = User::factory()->create(); + // Assign the user to two servers, one as the owner the other as a subuser, both + // on different nodes to ensure our logic fires off correctly and the user has their + // credentials revoked on both nodes. + $server = $this->createServerModel(['owner_id' => $user->id]); + $server2 = $this->createServerModel(); + Subuser::factory()->for($server2)->for($user)->create(); + $initialHash = $user->password; - $response = $this->actingAs($user)->putJson('/api/client/account/password', [ - 'current_password' => 'password', - 'password' => 'New_Password1', - 'password_confirmation' => 'New_Password1', - ]); + Bus::fake([RevokeSftpAccessJob::class]); + + $this->actingAs($user) + ->putJson('/api/client/account/password', [ + 'current_password' => 'password', + 'password' => 'New_Password1', + 'password_confirmation' => 'New_Password1', + ]) + ->assertNoContent(); $user = $user->refresh(); @@ -123,7 +160,12 @@ class AccountControllerTest extends ClientApiIntegrationTestCase $this->assertTrue(Hash::check('New_Password1', $user->password)); $this->assertFalse(Hash::check('password', $user->password)); - $response->assertStatus(Response::HTTP_NO_CONTENT); + $this->assertActivityFor('user:account.password-changed', $user, $user); + $this->assertNotEquals($server->node_id, $server2->node_id); + + Bus::assertDispatchedTimes(RevokeSftpAccessJob::class, 2); + Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server->node)); + Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server2->node)); } /** diff --git a/tests/Integration/Api/Client/Server/Subuser/DeleteSubuserTest.php b/tests/Integration/Api/Client/Server/Subuser/DeleteSubuserTest.php index 49d6a9166..998fa51e8 100644 --- a/tests/Integration/Api/Client/Server/Subuser/DeleteSubuserTest.php +++ b/tests/Integration/Api/Client/Server/Subuser/DeleteSubuserTest.php @@ -3,10 +3,11 @@ namespace App\Tests\Integration\Api\Client\Server\Subuser; use App\Enums\SubuserPermission; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Subuser; use App\Models\User; -use App\Repositories\Daemon\DaemonServerRepository; use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; +use Illuminate\Support\Facades\Bus; use Ramsey\Uuid\Uuid; class DeleteSubuserTest extends ClientApiIntegrationTestCase @@ -22,7 +23,7 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase */ public function test_correct_subuser_is_deleted_from_server(): void { - $this->swap(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class)); + Bus::fake([RevokeSftpAccessJob::class]); [$user, $server] = $this->generateTestAccount(); @@ -42,10 +43,12 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase 'permissions' => [SubuserPermission::WebsocketConnect], ]); - $mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined(); - $this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent(); + Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) { + return $job->user === $subuser->uuid && $job->target->is($server); + }); + // Try the same test, but this time with a UUID that if cast to an int (shouldn't) line up with // anything in the database. $uuid = '18180000' . substr(Uuid::uuid4()->toString(), 8); @@ -58,8 +61,10 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase 'permissions' => [SubuserPermission::WebsocketConnect], ]); - $mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined(); - $this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent(); + + Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) { + return $job->user === $subuser->uuid && $job->target->is($server); + }); } } diff --git a/tests/Integration/Api/Client/Server/Subuser/SubuserAuthorizationTest.php b/tests/Integration/Api/Client/Server/Subuser/SubuserAuthorizationTest.php index 9d9c85d4f..9a8a0c2d9 100644 --- a/tests/Integration/Api/Client/Server/Subuser/SubuserAuthorizationTest.php +++ b/tests/Integration/Api/Client/Server/Subuser/SubuserAuthorizationTest.php @@ -2,10 +2,11 @@ namespace App\Tests\Integration\Api\Client\Server\Subuser; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Subuser; use App\Models\User; -use App\Repositories\Daemon\DaemonServerRepository; use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; +use Illuminate\Support\Facades\Bus; use PHPUnit\Framework\Attributes\DataProvider; class SubuserAuthorizationTest extends ClientApiIntegrationTestCase @@ -16,6 +17,8 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase #[DataProvider('methodDataProvider')] public function test_user_cannot_access_resource_belonging_to_other_servers(string $method): void { + Bus::fake([RevokeSftpAccessJob::class]); + // Generic subuser, the specific resource we're trying to access. /** @var User $internal */ $internal = User::factory()->create(); @@ -35,11 +38,6 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase Subuser::factory()->create(['server_id' => $server2->id, 'user_id' => $internal->id]); Subuser::factory()->create(['server_id' => $server3->id, 'user_id' => $internal->id]); - $this->instance(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class)); - if ($method === 'DELETE') { - $mock->expects('setServer->deauthorize')->with($internal->uuid)->andReturnUndefined(); - } - // This route is acceptable since they're accessing a subuser on their own server. $this->actingAs($user)->json($method, $this->link($server1, '/users/' . $internal->uuid))->assertStatus($method === 'POST' ? 422 : ($method === 'DELETE' ? 204 : 200)); @@ -47,6 +45,14 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase // errors out with a 403 since $user does not have the right permissions for this. $this->actingAs($user)->json($method, $this->link($server2, '/users/' . $internal->uuid))->assertForbidden(); $this->actingAs($user)->json($method, $this->link($server3, '/users/' . $internal->uuid))->assertNotFound(); + + if ($method === 'DELETE') { + Bus::assertDispatchedTimes(function (RevokeSftpAccessJob $job) use ($server1, $internal) { + return $job->user === $internal->uuid && $job->target->is($server1); + }); + } else { + Bus::assertNotDispatched(RevokeSftpAccessJob::class); + } } public static function methodDataProvider(): array diff --git a/tests/Integration/Api/Client/Server/Subuser/UpdateSubuserTest.php b/tests/Integration/Api/Client/Server/Subuser/UpdateSubuserTest.php index 56c3b6123..48e71d52b 100644 --- a/tests/Integration/Api/Client/Server/Subuser/UpdateSubuserTest.php +++ b/tests/Integration/Api/Client/Server/Subuser/UpdateSubuserTest.php @@ -3,9 +3,11 @@ namespace App\Tests\Integration\Api\Client\Server\Subuser; use App\Enums\SubuserPermission; +use App\Jobs\RevokeSftpAccessJob; use App\Models\Subuser; use App\Models\User; use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Http; @@ -17,6 +19,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase */ public function test_correct_permissions_are_required_for_updating(): void { + Bus::fake([RevokeSftpAccessJob::class]); + [$user, $server] = $this->generateTestAccount(['user.read']); Http::fake(); @@ -54,6 +58,10 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase ]); $this->postJson($endpoint, $data)->assertOk(); + + Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($server, $subuser) { + return $job->user === $subuser->user->uuid && $job->target->is($server); + }); } /** @@ -62,6 +70,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase */ public function test_permissions_are_saved_to_account(): void { + Bus::fake([RevokeSftpAccessJob::class]); + [$user, $server] = $this->generateTestAccount(); /** @var Subuser $subuser */ @@ -91,6 +101,10 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase ['control.start', 'control.stop', 'websocket.connect'], $subuser->permissions ); + + Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($server, $subuser) { + return $job->user === $subuser->user->uuid && $job->target->is($server); + }); } /** @@ -99,6 +113,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase */ public function test_user_cannot_assign_permissions_they_do_not_have(): void { + Bus::fake([RevokeSftpAccessJob::class]); + [$user, $server] = $this->generateTestAccount([SubuserPermission::UserRead, SubuserPermission::UserUpdate]); $subuser = Subuser::factory() @@ -113,6 +129,8 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase ->assertForbidden(); $this->assertEqualsCanonicalizing(['foo.bar'], $subuser->refresh()->permissions); + + Bus::assertNothingDispatched(); } /** diff --git a/tests/Integration/Api/Remote/ServerTransferControllerTest.php b/tests/Integration/Api/Remote/ServerTransferControllerTest.php new file mode 100644 index 000000000..5d9c81229 --- /dev/null +++ b/tests/Integration/Api/Remote/ServerTransferControllerTest.php @@ -0,0 +1,108 @@ +createServerModel(); + + $new = Node::factory() + ->has(Allocation::factory()) + ->create(); + + $this->transfer = ServerTransfer::factory()->for($server)->create([ + 'old_allocation' => $server->allocation_id, + 'new_allocation' => $new->allocations->first()->id, + 'new_node' => $new->id, + 'old_node' => $server->node_id, + ]); + } + + public function test_success_status_update_can_be_sent_from_new_node(): void + { + $server = $this->transfer->server; + $newNode = $this->transfer->newNode; + + $this->withHeader('Authorization', "Bearer $newNode->daemon_token_id." . $newNode->daemon_token) + ->postJson("/api/remote/servers/{$server->uuid}/transfer/success") + ->assertNoContent(); + + $this->assertTrue($this->transfer->refresh()->successful); + } + + public function test_failure_status_update_can_be_sent_from_old_node(): void + { + $server = $this->transfer->server; + $oldNode = $this->transfer->oldNode; + + $this->withHeader('Authorization', "Bearer $oldNode->daemon_token_id." . $oldNode->daemon_token) + ->postJson("/api/remote/servers/{$server->uuid}/transfer/failure") + ->assertNoContent(); + + $this->assertFalse($this->transfer->refresh()->successful); + } + + public function test_failure_status_update_can_be_sent_from_new_node(): void + { + $server = $this->transfer->server; + $newNode = $this->transfer->newNode; + + $this->withHeader('Authorization', "Bearer $newNode->daemon_token_id." . $newNode->daemon_token) + ->postJson("/api/remote/servers/{$server->uuid}/transfer/failure") + ->assertNoContent(); + + $this->assertFalse($this->transfer->refresh()->successful); + } + + public function test_success_status_update_cannot_be_sent_from_old_node(): void + { + $server = $this->transfer->server; + $oldNode = $this->transfer->oldNode; + + $this->withHeader('Authorization', "Bearer $oldNode->daemon_token_id." . $oldNode->daemon_token) + ->postJson("/api/remote/servers/{$server->uuid}/transfer/success") + ->assertForbidden() + ->assertJsonPath('errors.0.code', 'HttpForbiddenException') + ->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.'); + + $this->assertNull($this->transfer->refresh()->successful); + } + + public function test_success_status_update_cannot_be_sent_from_unauthorized_node(): void + { + $server = $this->transfer->server; + $node = Node::factory()->create(); + + $this->withHeader('Authorization', "Bearer $node->daemon_token_id." . $node->daemon_token) + ->postJson("/api/remote/servers/$server->uuid/transfer/success") + ->assertForbidden() + ->assertJsonPath('errors.0.code', 'HttpForbiddenException') + ->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.'); + + $this->assertNull($this->transfer->refresh()->successful); + } + + public function test_failure_status_update_cannot_be_sent_from_unauthorized_node(): void + { + $server = $this->transfer->server; + $node = Node::factory()->create(); + + $this->withHeader('Authorization', "Bearer $node->daemon_token_id." . $node->daemon_token) + ->postJson("/api/remote/servers/$server->uuid/transfer/failure")->assertForbidden() + ->assertJsonPath('errors.0.code', 'HttpForbiddenException') + ->assertJsonPath('errors.0.detail', 'Requesting node does not have permission to access this server.'); + + $this->assertNull($this->transfer->refresh()->successful); + } +} diff --git a/tests/Integration/Jobs/RevokeSftpAccessJobTest.php b/tests/Integration/Jobs/RevokeSftpAccessJobTest.php new file mode 100644 index 000000000..716ad084e --- /dev/null +++ b/tests/Integration/Jobs/RevokeSftpAccessJobTest.php @@ -0,0 +1,68 @@ +make(['uuid' => 'uuid-1234']); + + $job = new RevokeSftpAccessJob('user-1', $model); + + $this->assertEquals( + "revoke-sftp:user-1:{$key}:uuid-1234", + $job->uniqueId() + ); + } + + public function test_job_releases_back_to_queue_on_failure(): void + { + $node = Node::factory()->make(['uuid' => 'uuid-1234']); + + $mock = $this->mock(DaemonServerRepository::class, function ($mock) { + $mock->expects('setNode')->andReturnSelf(); + $mock->expects('deauthorize')->andThrows(new ConnectionException()); + }); + + $job = \Mockery::mock(RevokeSftpAccessJob::class, ['user-1', $node])->makePartial(); + $job->expects('release')->with(10); + + $job->handle($mock); + } + + public function test_job_dispatches_for_node(): void + { + $node = Node::factory()->make(['uuid' => 'uuid-1234']); + + $mock = $this->mock(DaemonServerRepository::class, function ($mock) { + $mock->expects('setNode')->andReturnSelf(); + $mock->expects('deauthorize')->with('user-1')->andReturnUndefined(); + }); + + (new RevokeSftpAccessJob('user-1', $node))->handle($mock); + } + + public function test_job_dispatches_for_individual_server(): void + { + $node = Node::factory()->make(['uuid' => 'node-1234']); + $server = Server::factory()->make(['uuid' => 'server-1234'])->setRelation('node', $node); + + $mock = $this->mock(DaemonServerRepository::class, function ($mock) { + $mock->expects('setServer')->with(\Mockery::on(fn (Server $server) => $server->uuid === 'server-1234'))->andReturnSelf(); + $mock->expects('deauthorize')->with('user-1')->andReturnUndefined(); + }); + + (new RevokeSftpAccessJob('user-1', $server))->handle($mock); + } +} diff --git a/tests/Integration/Services/Users/UserDeletionServiceTest.php b/tests/Integration/Services/Users/UserDeletionServiceTest.php new file mode 100644 index 000000000..aa31d3ab9 --- /dev/null +++ b/tests/Integration/Services/Users/UserDeletionServiceTest.php @@ -0,0 +1,63 @@ +createServerModel(); + + $this->expectException(DisplayException::class); + $this->expectExceptionMessage(trans('exceptions.users.has_servers')); + + $server->user->delete(); + + $this->assertModelExists($server->user); + + Bus::assertNotDispatched(RevokeSftpAccessJob::class); + } + + public function test_user_is_deleted(): void + { + $user = User::factory()->create(); + + $user->delete(); + + $this->assertModelMissing($user); + + Bus::assertNotDispatched(RevokeSftpAccessJob::class); + } + + public function test_user_is_deleted_and_access_revoked(): void + { + $user = User::factory()->create(); + + $server1 = $this->createServerModel(); + $server2 = $this->createServerModel(['node_id' => $server1->node_id]); + + Subuser::factory()->for($server1)->for($user)->create(); + Subuser::factory()->for($server2)->for($user)->create(); + + $user->delete(); + + $this->assertModelMissing($user); + + Bus::assertDispatchedTimes(RevokeSftpAccessJob::class); + Bus::assertDispatched(fn (RevokeSftpAccessJob $job) => $job->user === $user->uuid && $job->target->is($server1->node)); + } +}