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