Refactor egg & server icon uploading (#2281)

This commit is contained in:
Boy132
2026-04-20 17:23:41 +02:00
committed by GitHub
parent 06c662988a
commit 2e48095379
19 changed files with 393 additions and 695 deletions

View File

@@ -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 [];

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class DeleteIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
protected ?string $iconStoragePath = null;
public static function getDefaultName(): ?string
{
return 'delete_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->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;
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class DeleteServerIcon extends Action
{
public static function getDefaultName(): ?string
{
return 'delete_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->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();
});
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class UploadIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
public static function getDefaultName(): ?string
{
return 'upload_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -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');

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Traits;
use App\Models\Model;
use Exception;
use Illuminate\Support\Facades\Storage;
/**
* @mixin Model
*/
trait HasIcon
{
/**
* Supported icon formats: file extension => MIME type
*
* @var array<string, string>
*/
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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
/**

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -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 ?',

View File

@@ -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',

View File

@@ -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

View File

@@ -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();