diff --git a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php index 5fec9f225..2cccba780 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php @@ -5,8 +5,10 @@ namespace App\Filament\Admin\Resources\Eggs\Pages; use App\Enums\EditorLanguages; use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Eggs\EggResource; +use App\Filament\Components\Actions\DeleteIcon; use App\Filament\Components\Actions\ExportEggAction; use App\Filament\Components\Actions\ImportEggAction; +use App\Filament\Components\Actions\UploadIcon; use App\Filament\Components\Forms\Fields\CopyFrom; use App\Filament\Components\Forms\Fields\MonacoEditor; use App\Models\Egg; @@ -14,12 +16,10 @@ use App\Models\EggVariable; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use Exception; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; use Filament\Forms\Components\Checkbox; -use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; @@ -28,11 +28,8 @@ use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Infolists\Components\TextEntry; -use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Fieldset; -use Filament\Schemas\Components\Flex; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Image; use Filament\Schemas\Components\Tabs; @@ -40,10 +37,7 @@ use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; -use Filament\Support\Enums\IconSize; -use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rules\Unique; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class EditEgg extends EditRecord { @@ -74,163 +68,17 @@ class EditEgg extends EditRecord ->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/Servers/Pages/EditServer.php b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php index d171287f1..fe92b1232 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php @@ -5,8 +5,9 @@ namespace App\Filament\Admin\Resources\Servers\Pages; use App\Enums\SuspendAction; use App\Enums\TablerIcon; 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; @@ -31,7 +32,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; @@ -41,7 +41,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; @@ -60,9 +59,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; @@ -111,141 +108,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) @@ -1202,37 +1076,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 bfd98f8b6..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() 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/Server/Pages/Settings.php b/app/Filament/Server/Pages/Settings.php index f7fab31b1..5cab330e5 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,14 +20,8 @@ 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 { @@ -79,140 +72,18 @@ 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(), + DeleteIcon::make() + ->iconStoragePath(Server::getIconStoragePath()), ]), TextInput::make('uuid') ->label(trans('server/setting.server_info.uuid')) @@ -446,39 +317,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/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/Traits/HasIcon.php b/app/Models/Traits/HasIcon.php new file mode 100644 index 000000000..293082249 --- /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')); + } + + $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/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 10e8cfa9c..68f0b9010 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -11,7 +11,6 @@ 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 +222,11 @@ class EggImporterService } } + if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) { + $parsed['icon'] = $parsed['image']; + unset($parsed['image']); + } + return $parsed; } @@ -231,9 +235,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,9 +260,9 @@ 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; @@ -267,23 +271,9 @@ class EggImporterService $extension = strtolower($matches[1]); $data = base64_decode($matches[2]); - if (!$data) { - return; + if ($data) { + $egg->writeIcon($extension, $data); } - - $normalizedExtension = match ($extension) { - 'svg+xml', 'svg' => 'svg', - 'jpeg', 'jpg' => 'jpg', - 'png' => 'png', - 'webp' => 'webp', - default => null, - }; - - if (is_null($normalizedExtension)) { - return; - } - - Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data); } /** 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/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/lang/en/admin/egg.php b/lang/en/admin/egg.php index db5d69d51..c135598c0 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', + '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/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/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();