mirror of
https://github.com/pelican-dev/panel.git
synced 2026-05-04 18:00:48 +03:00
Compare commits
2 Commits
boy132/rep
...
shift-2026
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ab49f7ab | ||
|
|
fde131db62 |
@@ -13,7 +13,7 @@ class UpdateEggIndexCommand extends Command
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$data = Http::timeout(5)->connectTimeout(1)->get(config('panel.cdn.egg_index_url'))->throw()->json();
|
||||
$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();
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
|
||||
@@ -41,8 +41,17 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
public function getColor(bool $hex = false): string
|
||||
{
|
||||
if ($hex) {
|
||||
return match ($this) {
|
||||
self::Created, self::Restarting => '#2563EB',
|
||||
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
|
||||
self::Running => '#22C55E',
|
||||
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
|
||||
};
|
||||
}
|
||||
|
||||
return match ($this) {
|
||||
self::Created => 'primary',
|
||||
self::Starting => 'warning',
|
||||
|
||||
@@ -25,8 +25,16 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
public function getColor(bool $hex = false): string
|
||||
{
|
||||
if ($hex) {
|
||||
return match ($this) {
|
||||
self::Installing, self::RestoringBackup => '#2563EB',
|
||||
self::Suspended => '#D97706',
|
||||
self::InstallFailed, self::ReinstallFailed => '#EF4444',
|
||||
};
|
||||
}
|
||||
|
||||
return match ($this) {
|
||||
self::Installing => 'primary',
|
||||
self::InstallFailed => 'danger',
|
||||
|
||||
@@ -50,9 +50,6 @@ 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';
|
||||
@@ -60,7 +57,6 @@ 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
|
||||
@@ -88,7 +84,6 @@ enum SubuserPermission: string
|
||||
'schedule' => TablerIcon::Clock,
|
||||
'settings' => TablerIcon::Settings,
|
||||
'activity' => TablerIcon::Stack,
|
||||
'mount' => TablerIcon::LayersLinked,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\User;
|
||||
|
||||
use App\Events\Event;
|
||||
use App\Models\User;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class Deleting extends Event
|
||||
{
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public User $user) {}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
final class PasswordChanged
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public readonly User $user) {}
|
||||
}
|
||||
@@ -5,10 +5,8 @@ namespace App\Filament\Admin\Resources\Eggs\Pages;
|
||||
use App\Enums\EditorLanguages;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\Eggs\EggResource;
|
||||
use App\Filament\Components\Actions\DeleteIcon;
|
||||
use App\Filament\Components\Actions\ExportEggAction;
|
||||
use App\Filament\Components\Actions\ImportEggAction;
|
||||
use App\Filament\Components\Actions\UploadIcon;
|
||||
use App\Filament\Components\Forms\Fields\CopyFrom;
|
||||
use App\Filament\Components\Forms\Fields\MonacoEditor;
|
||||
use App\Models\Egg;
|
||||
@@ -16,10 +14,12 @@ use App\Models\EggVariable;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use App\Traits\Filament\CanCustomizeTabs;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
@@ -28,8 +28,11 @@ use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Schemas\Components\Fieldset;
|
||||
use Filament\Schemas\Components\Flex;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Image;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
@@ -37,7 +40,10 @@ use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class EditEgg extends EditRecord
|
||||
{
|
||||
@@ -68,17 +74,163 @@ class EditEgg extends EditRecord
|
||||
->icon(TablerIcon::Egg)
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->columnStart(1)
|
||||
->columnSpan(1)
|
||||
->schema([
|
||||
Image::make('', 'icon')
|
||||
->hidden(fn ($record) => !$record->icon)
|
||||
->url(fn ($record) => $record->icon)
|
||||
Image::make('', '')
|
||||
->hidden(fn ($record) => !$record->image)
|
||||
->url(fn ($record) => $record->image)
|
||||
->alt('')
|
||||
->alignJustify()
|
||||
->imageSize(150)
|
||||
->columnSpanFull()
|
||||
->alignJustify(),
|
||||
UploadIcon::make(),
|
||||
DeleteIcon::make()
|
||||
->iconStoragePath(Egg::getIconStoragePath()),
|
||||
->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();
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/egg.name'))
|
||||
@@ -317,6 +469,39 @@ 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 [];
|
||||
|
||||
@@ -38,8 +38,6 @@ 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)
|
||||
@@ -47,11 +45,13 @@ class ListEggs extends ListRecords
|
||||
TextColumn::make('id')
|
||||
->label('Id')
|
||||
->hidden(),
|
||||
ImageColumn::make('icon')
|
||||
ImageColumn::make('image')
|
||||
->label('')
|
||||
->alignCenter()
|
||||
->circular()
|
||||
->getStateUsing(fn (Egg $record) => $record->icon ?: $defaultEggIcon),
|
||||
->getStateUsing(fn ($record) => $record->image
|
||||
? $record->image
|
||||
: 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))),
|
||||
TextColumn::make('name')
|
||||
->label(trans('admin/egg.name'))
|
||||
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
|
||||
|
||||
@@ -95,12 +95,6 @@ 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()
|
||||
@@ -130,8 +124,7 @@ class MountResource extends Resource
|
||||
->label(trans('admin/mount.name'))
|
||||
->required()
|
||||
->helperText(trans('admin/mount.name_help'))
|
||||
->maxLength(64)
|
||||
->columnSpanFull(),
|
||||
->maxLength(64),
|
||||
ToggleButtons::make('read_only')
|
||||
->label(trans('admin/mount.read_only'))
|
||||
->helperText(trans('admin/mount.read_only_help'))
|
||||
@@ -150,24 +143,6 @@ 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()
|
||||
|
||||
@@ -42,6 +42,7 @@ class CreateMount extends CreateRecord
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
$data['uuid'] ??= Str::uuid()->toString();
|
||||
$data['user_mountable'] = 1;
|
||||
|
||||
return parent::handleRecordCreation($data);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,9 @@ class CreateNode extends CreateRecord
|
||||
->icon(TablerIcon::Server)
|
||||
->columnSpanFull()
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'md' => 2,
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 4,
|
||||
])
|
||||
->schema([
|
||||
@@ -75,83 +76,81 @@ class CreateNode extends CreateRecord
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rules(Node::getRulesForField('fqdn'))
|
||||
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
|
||||
->validationMessages([
|
||||
'prohibited' => trans('admin/node.dns_error'),
|
||||
])
|
||||
->prohibited(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $get('ip');
|
||||
|
||||
return !is_ip($ip);
|
||||
})
|
||||
->hintColor(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
->helperText(function ($state) {
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return 'warning';
|
||||
return trans('admin/node.fqdn_help');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? 'success' : 'danger';
|
||||
return '';
|
||||
}
|
||||
|
||||
return null;
|
||||
return trans('admin/node.error');
|
||||
})
|
||||
->hint(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
|
||||
}
|
||||
|
||||
return null;
|
||||
return '';
|
||||
})
|
||||
->afterStateUpdated(function (Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
if (!$state) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!is_ip($state)) {
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if (is_ip($ip)) {
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('ip', null);
|
||||
}
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if ($ip) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('dns', false);
|
||||
}
|
||||
})
|
||||
->maxLength(255),
|
||||
Hidden::make('ip')
|
||||
->saved(false),
|
||||
|
||||
TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
|
||||
ToggleButtons::make('dns')
|
||||
->label(trans('admin/node.dns'))
|
||||
->helperText(trans('admin/node.dns_help'))
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->options([
|
||||
true => trans('admin/node.valid'),
|
||||
false => trans('admin/node.invalid'),
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
])
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
]),
|
||||
|
||||
TextInput::make('daemon_connect')
|
||||
->columnSpan(1)
|
||||
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
|
||||
@@ -161,16 +160,7 @@ class CreateNode extends CreateRecord
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/node.display_name'))
|
||||
->columnSpan([
|
||||
@@ -181,16 +171,27 @@ class CreateNode extends CreateRecord
|
||||
])
|
||||
->required()
|
||||
->maxLength(100),
|
||||
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
|
||||
ToggleButtons::make('connection')
|
||||
->label(trans('admin/node.ssl'))
|
||||
->columnSpan(2)
|
||||
->columnSpan(1)
|
||||
->inline()
|
||||
->helperText(function () {
|
||||
->helperText(function (Get $get) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.panel_on_ssl');
|
||||
return new HtmlString(trans('admin/node.panel_on_ssl'));
|
||||
}
|
||||
|
||||
return null;
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return trans('admin/node.ssl_help');
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
@@ -218,10 +219,17 @@ class CreateNode extends CreateRecord
|
||||
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
|
||||
$set('daemon_listen', 8080);
|
||||
}),
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
]),
|
||||
Step::make('advanced')
|
||||
->label(trans('admin/node.tabs.advanced_settings'))
|
||||
|
||||
@@ -137,83 +137,74 @@ class EditNode extends EditRecord
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rules(Node::getRulesForField('fqdn'))
|
||||
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
|
||||
->validationMessages([
|
||||
'prohibited' => trans('admin/node.dns_error'),
|
||||
])
|
||||
->prohibited(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $get('ip');
|
||||
|
||||
return !is_ip($ip);
|
||||
})
|
||||
->hintColor(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
->helperText(function ($state) {
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return 'warning';
|
||||
return trans('admin/node.fqdn_help');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? 'success' : 'danger';
|
||||
return '';
|
||||
}
|
||||
|
||||
return null;
|
||||
return trans('admin/node.error');
|
||||
})
|
||||
->hint(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
|
||||
}
|
||||
|
||||
return null;
|
||||
return '';
|
||||
})
|
||||
->afterStateUpdated(function (Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
if (!$state) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!is_ip($state)) {
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if (is_ip($ip)) {
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('ip', null);
|
||||
}
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if ($ip) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('dns', false);
|
||||
}
|
||||
})
|
||||
->maxLength(255),
|
||||
Hidden::make('ip')
|
||||
->saved(false),
|
||||
TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
ToggleButtons::make('dns')
|
||||
->label(trans('admin/node.dns'))
|
||||
->helperText(trans('admin/node.dns_help'))
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->stateCast(new BooleanStateCast(false, true))
|
||||
->options([
|
||||
1 => trans('admin/node.valid'),
|
||||
0 => trans('admin/node.invalid'),
|
||||
])
|
||||
->colors([
|
||||
1 => 'success',
|
||||
0 => 'danger',
|
||||
])
|
||||
->columnSpan(1),
|
||||
TextInput::make('daemon_connect')
|
||||
->columnSpan(1)
|
||||
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
|
||||
@@ -223,16 +214,6 @@ class EditNode extends EditRecord
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/node.display_name'))
|
||||
->columnSpan([
|
||||
@@ -243,16 +224,22 @@ class EditNode extends EditRecord
|
||||
])
|
||||
->required()
|
||||
->maxLength(100),
|
||||
Hidden::make('scheme'),
|
||||
Hidden::make('behind_proxy'),
|
||||
ToggleButtons::make('connection')
|
||||
->label(trans('admin/node.ssl'))
|
||||
->columnSpan(2)
|
||||
->columnSpan(1)
|
||||
->inline()
|
||||
->helperText(function () {
|
||||
->helperText(function (Get $get) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.panel_on_ssl');
|
||||
return new HtmlString(trans('admin/node.panel_on_ssl'));
|
||||
}
|
||||
|
||||
return null;
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return trans('admin/node.ssl_help');
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
@@ -280,10 +267,16 @@ class EditNode extends EditRecord
|
||||
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
|
||||
$set('daemon_listen', 8080);
|
||||
}),
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
]),
|
||||
Tab::make('advanced_settings')
|
||||
->label(trans('admin/node.tabs.advanced_settings'))
|
||||
|
||||
@@ -5,9 +5,8 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
|
||||
use App\Enums\SuspendAction;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\Servers\ServerResource;
|
||||
use App\Filament\Components\Actions\DeleteIcon;
|
||||
use App\Filament\Components\Actions\DeleteServerIcon;
|
||||
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,6 +31,7 @@ use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
@@ -41,6 +41,7 @@ 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;
|
||||
@@ -59,7 +60,9 @@ 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;
|
||||
|
||||
@@ -108,18 +111,141 @@ 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->icon)
|
||||
->url(fn ($record) => $record->icon ?: $record->egg->icon)
|
||||
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
|
||||
->url(fn ($record) => $record->icon ?: $record->egg->image)
|
||||
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
|
||||
->imageSize(150)
|
||||
->columnSpanFull()
|
||||
->columnSpan(2)
|
||||
->alignJustify(),
|
||||
UploadIcon::make(),
|
||||
DeleteIcon::make()
|
||||
->iconStoragePath(Server::getIconStoragePath()),
|
||||
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(),
|
||||
]),
|
||||
Grid::make()
|
||||
->columns(3)
|
||||
@@ -1076,4 +1202,37 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class ListServers extends ListRecords
|
||||
ImageColumn::make('icon')
|
||||
->label('')
|
||||
->imageSize(46)
|
||||
->state(fn (Server $server) => $server->icon ?: $server->egg->icon),
|
||||
->state(fn (Server $server) => $server->icon ?: $server->egg->image),
|
||||
TextColumn::make('condition')
|
||||
->label(trans('server/dashboard.status'))
|
||||
->badge()
|
||||
@@ -81,8 +81,7 @@ class ListServers extends ListRecords
|
||||
->label(trans('server/dashboard.title'))
|
||||
->description(fn (Server $server) => $server->description)
|
||||
->grow()
|
||||
->searchable()
|
||||
->sortable(),
|
||||
->searchable(),
|
||||
TextColumn::make('allocation.address')
|
||||
->label('')
|
||||
->badge()
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Actions;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Models\Traits\HasIcon;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeleteIcon extends Action
|
||||
{
|
||||
/** @var string[] */
|
||||
protected ?array $iconFormats = null;
|
||||
|
||||
protected ?string $iconStoragePath = null;
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'delete_icon';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->visible(fn ($record) => $record->icon);
|
||||
|
||||
$this->hiddenLabel();
|
||||
|
||||
$this->tooltip(trans('admin/egg.import.delete_icon'));
|
||||
|
||||
$this->icon(TablerIcon::Trash);
|
||||
|
||||
$this->color('danger');
|
||||
|
||||
$this->action(function ($record) {
|
||||
foreach ($this->getIconFormats() as $ext) {
|
||||
$path = $this->getIconStoragePath() . "/$record->uuid.$ext";
|
||||
if (Storage::disk('public')->exists($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(trans('admin/egg.import.icon_deleted'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$record->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/** @param string[] $iconFormats */
|
||||
public function iconFormats(?array $iconFormats): static
|
||||
{
|
||||
$this->iconFormats = $iconFormats;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function iconStoragePath(?string $iconStoragePath): static
|
||||
{
|
||||
$this->iconStoragePath = $iconStoragePath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getIconFormats(): array
|
||||
{
|
||||
return $this->iconFormats ?? array_keys(HasIcon::$iconFormats);
|
||||
}
|
||||
|
||||
public function getIconStoragePath(): ?string
|
||||
{
|
||||
return $this->iconStoragePath;
|
||||
}
|
||||
}
|
||||
48
app/Filament/Components/Actions/DeleteServerIcon.php
Normal file
48
app/Filament/Components/Actions/DeleteServerIcon.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Actions;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Models\Server;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeleteServerIcon extends Action
|
||||
{
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'delete_icon';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->visible(fn ($record) => $record->icon);
|
||||
|
||||
$this->hiddenLabel();
|
||||
|
||||
$this->tooltip(trans('admin/server.import_image'));
|
||||
|
||||
$this->icon(TablerIcon::Trash);
|
||||
|
||||
$this->color('danger');
|
||||
|
||||
$this->action(function ($record) {
|
||||
foreach (array_keys(Server::IMAGE_FORMATS) as $ext) {
|
||||
$path = Server::ICON_STORAGE_PATH . "/$record->uuid.$ext";
|
||||
if (Storage::disk('public')->exists($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(trans('server/setting.server_info.icon.deleted'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$record->refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Actions;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Models\Traits\HasIcon;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Image;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
|
||||
class UploadIcon extends Action
|
||||
{
|
||||
/** @var string[] */
|
||||
protected ?array $iconFormats = null;
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'upload_icon';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->hiddenLabel();
|
||||
|
||||
$this->tooltip(trans('admin/egg.import.import_icon'));
|
||||
|
||||
$this->icon(TablerIcon::PhotoUp);
|
||||
|
||||
$this->modal();
|
||||
|
||||
$this->modalHeading('');
|
||||
|
||||
$this->modalSubmitActionLabel(trans('admin/egg.import.import_icon'));
|
||||
|
||||
$this->schema([
|
||||
Tabs::make()
|
||||
->contained(false)
|
||||
->tabs([
|
||||
Tab::make(trans('admin/egg.import.url'))
|
||||
->schema([
|
||||
TextInput::make('icon_url')
|
||||
->label(trans('admin/egg.import.icon_url'))
|
||||
->reactive()
|
||||
->autocomplete(false)
|
||||
->debounce(500)
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if (!$state) {
|
||||
$set('icon_url_error', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateIconUrl($state);
|
||||
|
||||
$set('icon_url_error', null);
|
||||
} catch (Exception $exception) {
|
||||
$set('icon_url_error', $exception->getMessage());
|
||||
}
|
||||
}),
|
||||
TextEntry::make('icon_url_error')
|
||||
->hiddenLabel()
|
||||
->visible(fn (Get $get) => $get('icon_url_error') !== null)
|
||||
->afterStateHydrated(fn (Get $get) => $get('icon_url_error')),
|
||||
Image::make(fn (Get $get) => $get('icon_url'), '')
|
||||
->imageSize(150)
|
||||
->visible(fn (Get $get) => $get('icon_url') && !$get('icon_url_error'))
|
||||
->alignCenter(),
|
||||
]),
|
||||
Tab::make(trans('admin/egg.import.file'))
|
||||
->schema([
|
||||
FileUpload::make('icon')
|
||||
->hiddenLabel()
|
||||
->previewable()
|
||||
->openable(false)
|
||||
->downloadable(false)
|
||||
->maxSize(256)
|
||||
->maxFiles(1)
|
||||
->columnSpanFull()
|
||||
->alignCenter()
|
||||
->imageEditor()
|
||||
->image()
|
||||
->acceptedFileTypes(fn () => $this->getIconFormats())
|
||||
->saveUploadedFileUsing(fn (TemporaryUploadedFile $file, $record) => $record->writeIcon($file->getClientOriginalExtension(), $file->getContent())),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->action(function (array $data, $record) {
|
||||
if (!empty($data['icon_url'])) {
|
||||
$this->validateIconUrl($data['icon_url']);
|
||||
|
||||
$content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body();
|
||||
|
||||
if (empty($content)) {
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
|
||||
|
||||
$record->writeIcon($extension, $content);
|
||||
|
||||
Notification::make()
|
||||
->title(trans('admin/egg.import.icon_updated'))
|
||||
->success()
|
||||
->send();
|
||||
} elseif (!empty($data['icon'])) {
|
||||
Notification::make()
|
||||
->title(trans('admin/egg.import.icon_updated'))
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(trans('admin/egg.import.no_icon'))
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function validateIconUrl(string $url): void
|
||||
{
|
||||
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$ip = gethostbyname($host);
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
throw new Exception(trans('admin/egg.import.no_local_ip'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param string[] $iconFormats */
|
||||
public function iconFormats(?array $iconFormats): static
|
||||
{
|
||||
$this->iconFormats = $iconFormats;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getIconFormats(): array
|
||||
{
|
||||
return $this->iconFormats ?? array_values(HasIcon::$iconFormats);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
$this->helperText(fn (StartupVariable $component) => !$component->getVariableDesc() ? '—' : $component->getVariableDesc());
|
||||
|
||||
$this->rules(fn (StartupVariable $component) => $component->getVariableRules());
|
||||
|
||||
@@ -70,7 +70,7 @@ class StartupVariable extends Field
|
||||
],
|
||||
StartupVariableType::Toggle => [
|
||||
...parent::getDefaultStateCasts(),
|
||||
new BooleanStateCast(false, true),
|
||||
new BooleanStateCast(false),
|
||||
],
|
||||
default => parent::getDefaultStateCasts()
|
||||
};
|
||||
|
||||
@@ -21,9 +21,9 @@ class TagsFilter extends BaseFilter
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'] ?? null, fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
|
||||
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'], fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
|
||||
|
||||
$this->indicateUsing(fn (array $data) => ($data['tag'] ?? null) ? 'Tag: ' . $data['tag'] : null);
|
||||
$this->indicateUsing(fn (array $data) => $data['tag'] ? 'Tag: ' . $data['tag'] : null);
|
||||
|
||||
$this->resetState(['tag' => null]);
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Server\Pages;
|
||||
|
||||
use App\Enums\SubuserPermission;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Facades\Activity;
|
||||
use App\Models\Mount;
|
||||
use App\Models\Server;
|
||||
use BackedEnum;
|
||||
use Exception;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class Mounts extends ServerFormPage
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
|
||||
|
||||
protected static ?int $navigationSort = 9;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return parent::canAccess() && user()?->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 ? '<br>' . 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');
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ namespace App\Filament\Server\Pages;
|
||||
use App\Enums\SubuserPermission;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Facades\Activity;
|
||||
use App\Filament\Components\Actions\DeleteIcon;
|
||||
use App\Filament\Components\Actions\UploadIcon;
|
||||
use App\Filament\Components\Actions\DeleteServerIcon;
|
||||
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;
|
||||
@@ -20,14 +21,20 @@ 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 = 11;
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
@@ -72,20 +79,140 @@ 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->icon)
|
||||
->url(fn ($record) => $record->icon ?: $record->egg->icon)
|
||||
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
|
||||
->url(fn ($record) => $record->icon ?: $record->egg->image)
|
||||
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
|
||||
->imageSize(150)
|
||||
->columnSpanFull()
|
||||
->columnSpan(2)
|
||||
->alignJustify(),
|
||||
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)),
|
||||
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(),
|
||||
]),
|
||||
TextInput::make('uuid')
|
||||
->label(trans('server/setting.server_info.uuid'))
|
||||
@@ -319,6 +446,39 @@ 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');
|
||||
|
||||
@@ -28,7 +28,7 @@ class Startup extends ServerFormPage
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = TablerIcon::PlayerPlay;
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
protected static ?int $navigationSort = 9;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
@@ -149,16 +149,12 @@ class Startup extends ServerFormPage
|
||||
return parent::canAccess() && user()?->can(SubuserPermission::StartupRead, Filament::getTenant());
|
||||
}
|
||||
|
||||
public function update(null|string|bool $state, ServerVariable $serverVariable): void
|
||||
public function update(?string $state, ServerVariable $serverVariable): void
|
||||
{
|
||||
if (!$serverVariable->variable->user_editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_bool($state)) {
|
||||
$state = $state ? '1' : '0';
|
||||
}
|
||||
|
||||
$original = $serverVariable->variable_value;
|
||||
|
||||
try {
|
||||
|
||||
@@ -79,15 +79,15 @@ class SubuserResource extends Resource
|
||||
|
||||
foreach ($data['permissions'] as $permission) {
|
||||
$options[$permission] = str($permission)->headline();
|
||||
$descriptions[$permission] = trans($data['translation_prefix']. '.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
|
||||
$descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
|
||||
$permissionsArray[$data['name']][] = $permission;
|
||||
}
|
||||
|
||||
$tabs[] = Tab::make($data['name'])
|
||||
->label(trans($data['translation_prefix']. '.' . $data['name'] . '_title'))
|
||||
->label(str($data['name'])->headline())
|
||||
->schema([
|
||||
Section::make()
|
||||
->description(trans($data['translation_prefix']. '.' . $data['name'] . '_desc'))
|
||||
->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
|
||||
->icon($data['icon'])
|
||||
->contained(false)
|
||||
->schema([
|
||||
|
||||
@@ -13,17 +13,10 @@ 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.
|
||||
*/
|
||||
@@ -70,22 +63,10 @@ class AccountController extends ClientApiController
|
||||
*/
|
||||
public function updateEmail(UpdateEmailRequest $request): JsonResponse
|
||||
{
|
||||
$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());
|
||||
$original = $request->user()->email;
|
||||
$this->updateService->handle($request->user(), $request->validated());
|
||||
|
||||
if ($original !== $request->input('email')) {
|
||||
Activity::event('user:account.email-changed')
|
||||
->property(['old' => $original, 'new' => $request->input('email')])
|
||||
->log();
|
||||
@@ -104,9 +85,7 @@ class AccountController extends ClientApiController
|
||||
*/
|
||||
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
|
||||
{
|
||||
$user = Activity::event('user:account.password-changed')->transaction(function () use ($request) {
|
||||
return $this->updateService->handle($request->user(), $request->validated());
|
||||
});
|
||||
$user = $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
|
||||
@@ -119,6 +98,8 @@ class AccountController extends ClientApiController
|
||||
$guard->logoutOtherDevices($request->input('password'));
|
||||
}
|
||||
|
||||
Activity::event('user:account.password-changed')->log();
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ class BackupController extends ClientApiController
|
||||
}
|
||||
|
||||
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
|
||||
$server->backups()->lockForUpdate()->count();
|
||||
$server->backups()->lockForUpdate();
|
||||
|
||||
$backup = $action->handle($server, $request->input('name'));
|
||||
|
||||
|
||||
@@ -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()->count();
|
||||
$server->databases()->lockForUpdate();
|
||||
|
||||
$database = $this->deployDatabaseService->handle($server, $request->validated());
|
||||
|
||||
@@ -87,12 +87,15 @@ 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)
|
||||
->transaction(fn () => $this->managementService->rotatePassword($database));
|
||||
->log();
|
||||
|
||||
return $this->fractal->item($database->refresh())
|
||||
return $this->fractal->item($database)
|
||||
->parseIncludes(['password'])
|
||||
->transformWith($this->getTransformer(DatabaseTransformer::class))
|
||||
->toArray();
|
||||
|
||||
@@ -87,13 +87,13 @@ class StartupController extends ClientApiController
|
||||
|
||||
$startup = $this->startupCommandService->handle($server);
|
||||
|
||||
if ($original !== $request->input('value')) {
|
||||
if ($variable->env_variable !== $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();
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class BackupRemoteUploadController extends Controller
|
||||
/** @var Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
// Prevent backups that have already been completed from trying to
|
||||
|
||||
@@ -47,7 +47,7 @@ class BackupStatusController extends Controller
|
||||
/** @var Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
if ($model->is_successful) {
|
||||
@@ -97,11 +97,6 @@ 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')
|
||||
|
||||
@@ -3,23 +3,18 @@
|
||||
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(Request $request, Server $server): JsonResponse
|
||||
public function status(ServerRequest $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());
|
||||
|
||||
@@ -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,7 +17,6 @@ use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class ServerDetailsController extends Controller
|
||||
{
|
||||
@@ -34,21 +33,8 @@ 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(Request $request, Server $server): JsonResponse
|
||||
public function __invoke(ServerRequest $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),
|
||||
|
||||
@@ -4,13 +4,12 @@ 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
|
||||
@@ -18,18 +17,12 @@ class ServerInstallController extends Controller
|
||||
/**
|
||||
* Returns installation information for a server.
|
||||
*/
|
||||
public function index(Request $request, Server $server): JsonResponse
|
||||
public function index(ServerRequest $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' => $egg->copy_script_container,
|
||||
'entrypoint' => $egg->copy_script_entry,
|
||||
'script' => $egg->copy_script_install,
|
||||
'container_image' => $server->egg->copy_script_container,
|
||||
'entrypoint' => $server->egg->copy_script_entry,
|
||||
'script' => $server->egg->copy_script_install,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,10 +35,6 @@ 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
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
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
|
||||
{
|
||||
@@ -32,22 +29,13 @@ class ServerTransferController extends Controller
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function failure(Request $request, Server $server): JsonResponse
|
||||
public function failure(ServerRequest $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();
|
||||
|
||||
@@ -65,22 +53,13 @@ class ServerTransferController extends Controller
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function success(Request $request, Server $server): JsonResponse
|
||||
public function success(ServerRequest $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 = [];
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SetSecurityHeaders
|
||||
{
|
||||
/**
|
||||
* Ideally we move away from X-Frame-Options/X-XSS-Protection and implement a
|
||||
* proper standard CSP, but I can guarantee that will break for a lot of folks
|
||||
* using custom plugins and who knows what image embeds.
|
||||
*
|
||||
* We'll circle back to that at a later date when it can be more fully controlled
|
||||
* by the admin to support those cases without too much trouble.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\Api\Remote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InstallationDataRequest extends FormRequest
|
||||
class InstallationDataRequest extends ServerRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|string[]>
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Api\Remote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ReportBackupCompleteRequest extends FormRequest
|
||||
{
|
||||
@@ -10,13 +11,37 @@ class ReportBackupCompleteRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'successful' => 'required|boolean',
|
||||
'checksum' => 'nullable|string|required_if:successful,true',
|
||||
'checksum_type' => 'nullable|string|required_if:successful,true',
|
||||
'size' => 'nullable|numeric|required_if:successful,true',
|
||||
'parts' => 'nullable|array',
|
||||
'parts.*.etag' => 'required|string',
|
||||
'parts.*.part_number' => 'required|numeric',
|
||||
'successful' => [
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
'checksum' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::requiredIf(fn () => $this->boolean('successful')),
|
||||
],
|
||||
'checksum_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::requiredIf(fn () => $this->boolean('successful')),
|
||||
],
|
||||
'size' => [
|
||||
'nullable',
|
||||
'numeric',
|
||||
Rule::requiredIf(fn () => $this->boolean('successful')),
|
||||
],
|
||||
'parts' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'parts.*.etag' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
'parts.*.part_number' => [
|
||||
'required',
|
||||
'numeric',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
29
app/Http/Requests/Api/Remote/ServerRequest.php
Normal file
29
app/Http/Requests/Api/Remote/ServerRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Remote;
|
||||
|
||||
use App\Models\Node;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ServerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
/** @var Node $node */
|
||||
$node = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Api\Remote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SftpAuthenticationFormRequest extends FormRequest
|
||||
{
|
||||
@@ -22,7 +23,10 @@ class SftpAuthenticationFormRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['nullable', 'in:password,public_key'],
|
||||
'type' => [
|
||||
'nullable',
|
||||
Rule::in(['password', 'public_key']),
|
||||
],
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
|
||||
21
app/Jobs/Job.php
Normal file
21
app/Jobs/Job.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
||||
abstract class Job
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queueable Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This job base class provides a central location to place any logic that
|
||||
| is shared across all of your jobs. The trait included with the class
|
||||
| provides access to the "onQueue" and "delay" queue helper methods.
|
||||
|
|
||||
*/
|
||||
|
||||
use Queueable;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Node;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
|
||||
use Illuminate\Queue\Attributes\WithoutRelations;
|
||||
|
||||
/**
|
||||
* Revokes all SFTP access for a user on a given node or for a specific server.
|
||||
*/
|
||||
#[DeleteWhenMissingModels]
|
||||
class RevokeSftpAccessJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $maxExceptions = 1;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $user,
|
||||
#[WithoutRelations]
|
||||
public readonly Server|Node $target,
|
||||
) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
$target = $this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -15,10 +14,9 @@ use Illuminate\Queue\SerializesModels;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
class RunTaskJob implements ShouldQueue
|
||||
class RunTaskJob extends Job implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\User\Deleting;
|
||||
use App\Events\User\PasswordChanged;
|
||||
use App\Jobs\RevokeSftpAccessJob;
|
||||
use App\Models\Node;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class RevocationListener
|
||||
{
|
||||
public function handle(Deleting|PasswordChanged $event): void
|
||||
{
|
||||
$user = $event->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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -14,6 +13,7 @@ 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 $icon
|
||||
* @property-read string|null $image
|
||||
* @property-read string|null $inherit_config_files
|
||||
* @property-read string|null $inherit_config_logs
|
||||
* @property-read string|null $inherit_config_startup
|
||||
@@ -94,7 +94,6 @@ use Illuminate\Support\Str;
|
||||
class Egg extends Model implements Validatable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasIcon;
|
||||
use HasValidation;
|
||||
|
||||
/**
|
||||
@@ -108,6 +107,22 @@ 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.
|
||||
*/
|
||||
@@ -362,4 +377,16 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ 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;
|
||||
@@ -31,6 +29,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -130,7 +129,6 @@ use Psr\Http\Message\ResponseInterface;
|
||||
class Server extends Model implements HasAvatar, Validatable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasIcon;
|
||||
use HasValidation;
|
||||
use Notifiable;
|
||||
|
||||
@@ -140,6 +138,22 @@ 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.
|
||||
@@ -513,8 +527,20 @@ 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->icon;
|
||||
return $this->icon ?? $this->egg->image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -45,7 +44,6 @@ use Illuminate\Support\Carbon;
|
||||
*/
|
||||
class ServerTransfer extends Model implements Validatable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasValidation;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -45,21 +44,17 @@ class Subuser extends Model implements Validatable
|
||||
*/
|
||||
public const RESOURCE_NAME = 'server_subuser';
|
||||
|
||||
/** @var array<string, array{name: string, hidden: ?bool, icon: null|string|BackedEnum, translation_prefix: ?string, permissions: string[]}> */
|
||||
/** @var array<string, array{name: string, hidden: ?bool, icon: ?string, permissions: string[]}> */
|
||||
protected static array $customPermissions = [];
|
||||
|
||||
/** @param string[] $permissions */
|
||||
public static function registerCustomPermissions(string $name, array $permissions, ?string $translationPrefix = null, null|string|BackedEnum $icon = null, ?bool $hidden = null): void
|
||||
public static function registerCustomPermissions(string $name, array $permissions, ?string $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;
|
||||
}
|
||||
@@ -109,7 +104,7 @@ class Subuser extends Model implements Validatable
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return array<array{name: string, hidden: bool, icon: null|string|BackedEnum, translation_prefix: string, permissions: string[]}> */
|
||||
/** @return array<array{name: string, hidden: bool, icon: string, permissions: string[]}> */
|
||||
public static function allPermissionData(): array
|
||||
{
|
||||
$allPermissions = [];
|
||||
@@ -122,7 +117,6 @@ class Subuser extends Model implements Validatable
|
||||
'hidden' => $subuserPermission->isHidden(),
|
||||
'icon' => $subuserPermission->getIcon(),
|
||||
'permissions' => array_merge($allPermissions[$group]['permissions'] ?? [], [$permission]),
|
||||
'translation_prefix' => 'server/user.permissions',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -136,7 +130,6 @@ 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;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Model;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @mixin Model
|
||||
*/
|
||||
trait HasIcon
|
||||
{
|
||||
/**
|
||||
* Supported icon formats: file extension => MIME type
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public static array $iconFormats = [
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'webp' => 'image/webp',
|
||||
];
|
||||
|
||||
public static function getIconStoragePath(): string
|
||||
{
|
||||
return 'icons/' . static::RESOURCE_NAME;
|
||||
}
|
||||
|
||||
public function getIconAttribute(): ?string
|
||||
{
|
||||
foreach (array_keys(static::$iconFormats) as $ext) {
|
||||
$path = $this->getIconStoragePath() . "/$this->uuid.$ext";
|
||||
if (Storage::disk('public')->exists($path)) {
|
||||
return Storage::disk('public')->url($path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function writeIcon(string $extension, string $data): string
|
||||
{
|
||||
$normalizedExtension = match (strtolower($extension)) {
|
||||
'jpeg', 'jpg' => 'jpg',
|
||||
'png' => 'png',
|
||||
'webp' => 'webp',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_null($normalizedExtension)) {
|
||||
throw new Exception(trans('admin/egg.import.unknown_extension', ['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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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;
|
||||
@@ -226,8 +225,6 @@ 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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -71,7 +70,6 @@ abstract class PanelProvider extends BasePanelProvider
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
LanguageMiddleware::class,
|
||||
SetSecurityHeaders::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
|
||||
@@ -147,7 +147,7 @@ class DaemonServerRepository extends DaemonRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server (or all servers of a node).
|
||||
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
@@ -156,7 +156,7 @@ class DaemonServerRepository extends DaemonRepository
|
||||
$this->getHttpClient()->post('/api/deauthorize-user', [
|
||||
'json' => [
|
||||
'user' => $user,
|
||||
'servers' => $this->server ? [$this->server->uuid] : [],
|
||||
'servers' => [$this->server->uuid],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ 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);
|
||||
@@ -126,7 +125,6 @@ class FindAssignableAllocationService
|
||||
|
||||
/** @var Allocation $allocation */
|
||||
$allocation = Allocation::withoutGlobalScopes()
|
||||
->lockForUpdate()
|
||||
->where('node_id', $server->node_id)
|
||||
->where('ip', $server->allocation->ip)
|
||||
->where('port', $port)
|
||||
|
||||
@@ -18,7 +18,7 @@ class EggExporterService
|
||||
public function handle(int $egg, EggFormat $format): string
|
||||
{
|
||||
$egg = Egg::with(['scriptFrom', 'configFrom', 'variables'])->findOrFail($egg);
|
||||
$iconBase64 = $this->getEggIconAsBase64($egg);
|
||||
$imageBase64 = $this->getEggImageAsBase64($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,
|
||||
'icon' => $iconBase64,
|
||||
'image' => $imageBase64,
|
||||
'tags' => $egg->tags,
|
||||
'features' => $egg->features,
|
||||
'docker_images' => $egg->docker_images,
|
||||
@@ -63,14 +63,16 @@ class EggExporterService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the egg icon as base64 for export.
|
||||
* Get the egg image as base64 for export.
|
||||
*/
|
||||
private function getEggIconAsBase64(Egg $egg): ?string
|
||||
private function getEggImageAsBase64(Egg $egg): ?string
|
||||
{
|
||||
foreach (Egg::$iconFormats as $ext => $mimeType) {
|
||||
$path = Egg::getIconStoragePath() . "/$egg->uuid.$ext";
|
||||
foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) {
|
||||
$path = Egg::ICON_STORAGE_PATH . "/$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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +223,6 @@ class EggImporterService
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) {
|
||||
$parsed['icon'] = $parsed['image'];
|
||||
unset($parsed['image']);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
@@ -236,9 +231,9 @@ class EggImporterService
|
||||
*/
|
||||
protected function fillFromParsed(Egg $model, array $parsed): Egg
|
||||
{
|
||||
// Handle icon data if present
|
||||
if (!empty($parsed['icon']) && str_starts_with($parsed['icon'], 'data:')) {
|
||||
$this->saveEggIconFromBase64($parsed['icon'], $model);
|
||||
// Handle image data if present
|
||||
if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) {
|
||||
$this->saveEggImageFromBase64($parsed['image'], $model);
|
||||
}
|
||||
|
||||
return $model->forceFill([
|
||||
@@ -261,24 +256,34 @@ class EggImporterService
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an egg icon from base64 data to a file.
|
||||
* Save an egg image from base64 data to a file.
|
||||
*/
|
||||
private function saveEggIconFromBase64(string $base64String, Egg $egg): void
|
||||
private function saveEggImageFromBase64(string $base64String, Egg $egg): void
|
||||
{
|
||||
if (!preg_match('/^data:image\/([\w+]+);base64,(.+)$/', $base64String, $matches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = strtolower($matches[1]);
|
||||
$data = base64_decode($matches[2]);
|
||||
$extension = strtolower($matches[1]);
|
||||
$data = base64_decode($matches[2]);
|
||||
|
||||
if ($data) {
|
||||
$egg->writeIcon($extension, $data);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
if (!$data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml', 'svg' => 'svg',
|
||||
'jpeg', 'jpg' => 'jpg',
|
||||
'png' => 'png',
|
||||
'webp' => 'webp',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_null($normalizedExtension)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,6 @@ 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;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -16,7 +17,7 @@ class DetailsModificationService
|
||||
/**
|
||||
* DetailsModificationService constructor.
|
||||
*/
|
||||
public function __construct(private ConnectionInterface $connection) {}
|
||||
public function __construct(private ConnectionInterface $connection, private DaemonServerRepository $serverRepository) {}
|
||||
|
||||
/**
|
||||
* Update the details for a single server instance.
|
||||
@@ -33,7 +34,7 @@ class DetailsModificationService
|
||||
public function handle(Server $server, array $data): Server
|
||||
{
|
||||
return $this->connection->transaction(function () use ($data, $server) {
|
||||
$oldOwner = $server->user;
|
||||
$owner = $server->owner_id;
|
||||
|
||||
$server->forceFill([
|
||||
'external_id' => Arr::get($data, 'external_id'),
|
||||
@@ -45,8 +46,14 @@ 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 !== $oldOwner->id) {
|
||||
RevokeSftpAccessJob::dispatch($oldOwner->uuid, $server);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
return $server;
|
||||
|
||||
@@ -4,12 +4,17 @@ 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')
|
||||
@@ -22,7 +27,14 @@ class SubuserDeletionService
|
||||
|
||||
event(new SubUserRemoved($subuser->server, $subuser->user));
|
||||
|
||||
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ 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
|
||||
*/
|
||||
@@ -37,10 +42,18 @@ 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 () use ($subuser, $cleanedPermissions, $server) {
|
||||
$log->transaction(function ($instance) use ($subuser, $cleanedPermissions, $server) {
|
||||
$subuser->update(['permissions' => $cleanedPermissions]);
|
||||
|
||||
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Services\Users;
|
||||
|
||||
use App\Events\User\PasswordChanged;
|
||||
use App\Models\User;
|
||||
use App\Traits\Services\HasUserLevels;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
@@ -31,10 +30,6 @@ class UserUpdateService
|
||||
|
||||
$user->forceFill($data)->saveOrFail();
|
||||
|
||||
if (isset($data['password'])) {
|
||||
PasswordChanged::dispatch($user);
|
||||
}
|
||||
|
||||
return $user->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ class EggTransformer extends BaseTransformer
|
||||
'name' => $model->name,
|
||||
'author' => $model->author,
|
||||
'description' => $model->description,
|
||||
'icon' => $model->icon,
|
||||
'image' => $model->image,
|
||||
'features' => $model->features,
|
||||
'tags' => $model->tags,
|
||||
'docker_image' => Arr::first($model->docker_images, default: ''), // deprecated, use docker_images
|
||||
'docker_image' => Arr::first($model->docker_images, default: ''), // docker_images, use startup_commands
|
||||
'docker_images' => $model->docker_images,
|
||||
'config' => [
|
||||
'files' => $files,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
@@ -29,10 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->redirectGuestsTo(fn () => route('filament.app.auth.login'));
|
||||
|
||||
$middleware->web([
|
||||
LanguageMiddleware::class,
|
||||
SetSecurityHeaders::class,
|
||||
]);
|
||||
$middleware->web(LanguageMiddleware::class);
|
||||
|
||||
$middleware->api([
|
||||
EnsureStatefulRequests::class,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"filament/filament": "^4.8",
|
||||
"gboquizosanchez/filament-log-viewer": "^2.2",
|
||||
"guzzlehttp/guzzle": "^7.10",
|
||||
"laravel/framework": "^12.56",
|
||||
"laravel/framework": "^12.54",
|
||||
"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.25",
|
||||
"spatie/laravel-permission": "^6.24",
|
||||
"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.7",
|
||||
"barryvdh/laravel-ide-helper": "^3.6",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/pail": "^1.2.6",
|
||||
@@ -97,4 +97,4 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
603
composer.lock
generated
603
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@ return [
|
||||
*/
|
||||
'rate_limit' => [
|
||||
'client_period' => 1,
|
||||
'client' => env('APP_API_CLIENT_RATELIMIT', 256),
|
||||
'client' => env('APP_API_CLIENT_RATELIMIT', 120),
|
||||
|
||||
'application_period' => 1,
|
||||
'application' => env('APP_API_APPLICATION_RATELIMIT', 256),
|
||||
'application' => env('APP_API_APPLICATION_RATELIMIT', 240),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -18,7 +18,6 @@ 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' => [
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ServerTransfer;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ServerTransferFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = ServerTransfer::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'old_additional_allocations' => [],
|
||||
'new_additional_allocations' => [],
|
||||
'successful' => null,
|
||||
'archived' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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::getIconStoragePath());
|
||||
$this->convertBase64ToFile($egg->image, $egg->uuid, Egg::ICON_STORAGE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
$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::getIconStoragePath());
|
||||
$this->convertBase64ToFile($server->icon, $server->uuid, Server::ICON_STORAGE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +59,6 @@ if [ "${APP_INSTALLED}" = "true" ]; then
|
||||
|
||||
# run migration
|
||||
php artisan migrate --force
|
||||
|
||||
php artisan p:plugin:composer
|
||||
fi
|
||||
|
||||
echo "Optimizing Filament"
|
||||
|
||||
@@ -120,9 +120,6 @@ return [
|
||||
'update' => 'Updated the subuser permissions for <b>:email</b>',
|
||||
'delete' => 'Removed <b>:email</b> as a subuser',
|
||||
],
|
||||
'mount' => [
|
||||
'update' => 'Updated the mounts for the server',
|
||||
],
|
||||
'crashed' => 'Server crashed',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -13,8 +13,9 @@ return [
|
||||
'import' => [
|
||||
'file' => 'File',
|
||||
'url' => 'URL',
|
||||
'icon_url' => 'Icon URL',
|
||||
'icon_error' => 'Could not fetch icon',
|
||||
'image_url' => 'Image URL',
|
||||
'image_error' => 'Could not fetch image',
|
||||
'image_too_large' => 'Image too large. Limit is 1024KB',
|
||||
'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',
|
||||
@@ -25,16 +26,15 @@ return [
|
||||
'failed_import_eggs' => 'Failed: :eggs',
|
||||
'github' => 'GitHub',
|
||||
'refresh' => 'Refresh',
|
||||
'import_icon' => 'Import Icon',
|
||||
'delete_icon' => 'Delete Icon',
|
||||
'import_image' => 'Import Image',
|
||||
'delete_image' => 'Delete Image',
|
||||
'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 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',
|
||||
'unknown_extension' => 'Unknown image extension',
|
||||
'image_deleted' => 'Image Deleted',
|
||||
'no_image' => 'No Image Provided',
|
||||
'image_updated' => 'Image Updated',
|
||||
],
|
||||
'export' => [
|
||||
'modal' => 'How would you like to export :egg ?',
|
||||
|
||||
@@ -17,19 +17,14 @@ 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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -42,11 +42,13 @@ return [
|
||||
'refresh' => 'Refresh',
|
||||
'custom_ip' => 'Enter Custom IP',
|
||||
'domain' => 'Domain Name',
|
||||
'ssl_ip' => 'Consider using a domain name instead of an ip address',
|
||||
'fqdn_ssl' => 'Your panel is currently secured via an SSL certificate and that means your nodes require one too.',
|
||||
'dns_error' => 'No valid DNS records were found for the provided domain name.',
|
||||
'valid' => 'Valid DNS',
|
||||
'invalid' => 'Invalid DNS',
|
||||
'ssl_ip' => 'You cannot connect to an IP Address over SSL',
|
||||
'error' => 'This is the domain name that points to your node\'s IP Address. If you\'ve already set up this, you can verify it by checking the next field!',
|
||||
'fqdn_help' => 'Your panel is currently secured via an SSL certificate and that means your nodes require one too. You must use a domain name, because you cannot get SSL certificates for IP Addresses.',
|
||||
'dns' => 'DNS Record Check',
|
||||
'dns_help' => 'This lets you know if your DNS record is pointing to the correct IP address.',
|
||||
'valid' => 'Valid',
|
||||
'invalid' => 'Invalid',
|
||||
'port' => 'Port',
|
||||
'ports' => 'Ports',
|
||||
'port_help' => 'If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.',
|
||||
@@ -56,7 +58,7 @@ return [
|
||||
'listen_port_help' => 'Wings will listen on this port.',
|
||||
'display_name' => 'Display Name',
|
||||
'ssl' => 'Communicate over SSL',
|
||||
'panel_on_ssl' => 'Your Panel is using a secure SSL connection, so your Daemon must too.',
|
||||
'panel_on_ssl' => 'Your Panel is using a secure SSL connection,<br>so your Daemon must too.',
|
||||
'ssl_help' => 'An IP address cannot use SSL.',
|
||||
|
||||
'tags' => 'Tags',
|
||||
|
||||
@@ -7,8 +7,8 @@ return [
|
||||
'no_servers' => 'No Servers',
|
||||
'create' => 'Create Server',
|
||||
'ip_address' => 'IP Address',
|
||||
'import_icon' => 'Import Icon',
|
||||
'delete_icon' => 'Delete Icon',
|
||||
'import_image' => 'Import Image',
|
||||
'delete_image' => 'Delete Image',
|
||||
'ip_address_helper' => 'Usually your machine\'s public IP unless you are port forwarding.',
|
||||
'port' => 'Port',
|
||||
'ports' => 'Ports',
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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',
|
||||
];
|
||||
@@ -17,61 +17,33 @@ 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.',
|
||||
@@ -79,30 +51,23 @@ 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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@php
|
||||
$backgroundImage = $server->icon ?? $server->egg->icon;
|
||||
$backgroundImage = $server->icon ?? $server->egg->image;
|
||||
$serverEntryColumn = $column ?? \App\Filament\Components\Tables\Columns\ServerEntryColumn::make('server_entry');
|
||||
@endphp
|
||||
|
||||
<div class="relative cursor-pointer"
|
||||
x-on:click="{{ $component->redirectUrl() }}"
|
||||
x-on:auxclick.prevent="if ($event.button === 1) {{ $component->redirectUrl(true) }}">
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg fi-color fi-color-warning fi-bg-color-600" style="background-color: var(--bg);"></div>
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg" style="background-color: #D97706;"></div>
|
||||
|
||||
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
|
||||
@if($backgroundImage)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@php
|
||||
$actiongroup = \App\Filament\App\Resources\Servers\Pages\ListServers::getPowerActionGroup()->record($server);
|
||||
$backgroundImage = $server->icon ?? $server->egg->icon;
|
||||
$backgroundImage = $server->icon ?? $server->egg->image;
|
||||
|
||||
$serverEntryColumn = $column ?? \App\Filament\Components\Tables\Columns\ServerEntryColumn::make('server_entry');
|
||||
$serverNodeStatistics = $server->node->statistics();
|
||||
@@ -14,7 +14,9 @@
|
||||
x-on:click="{{ $component->redirectUrl() }}"
|
||||
x-on:auxclick.prevent="if ($event.button === 1) {{ $component->redirectUrl(true) }}">
|
||||
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg fi-color fi-color-{{ $server->condition->getColor() }} fi-bg-color-600" style="background-color: var(--bg);"> </div>
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg"
|
||||
style="background-color: {{ $server->condition->getColor(true) }};">
|
||||
</div>
|
||||
|
||||
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
|
||||
@if($backgroundImage)
|
||||
|
||||
@@ -23,9 +23,7 @@ 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'])
|
||||
->middleware('throttle')
|
||||
->name('api:client.account.update-email');
|
||||
Route::put('/email', [Client\AccountController::class, 'updateEmail'])->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');
|
||||
|
||||
@@ -15,6 +15,8 @@ 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']);
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -48,38 +44,16 @@ class AccountControllerTest extends ClientApiIntegrationTestCase
|
||||
/** @var User $user */
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->putJson('/api/client/account/email', [
|
||||
'email' => $email = mb_strtolower(Str::random() . '@example.com'),
|
||||
'password' => 'password',
|
||||
])
|
||||
->assertStatus(Response::HTTP_NO_CONTENT);
|
||||
$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->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<int, User> $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.
|
||||
@@ -135,24 +109,13 @@ 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;
|
||||
|
||||
Bus::fake([RevokeSftpAccessJob::class]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->putJson('/api/client/account/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'New_Password1',
|
||||
'password_confirmation' => 'New_Password1',
|
||||
])
|
||||
->assertNoContent();
|
||||
$response = $this->actingAs($user)->putJson('/api/client/account/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'New_Password1',
|
||||
'password_confirmation' => 'New_Password1',
|
||||
]);
|
||||
|
||||
$user = $user->refresh();
|
||||
|
||||
@@ -160,12 +123,7 @@ class AccountControllerTest extends ClientApiIntegrationTestCase
|
||||
$this->assertTrue(Hash::check('New_Password1', $user->password));
|
||||
$this->assertFalse(Hash::check('password', $user->password));
|
||||
|
||||
$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));
|
||||
$response->assertStatus(Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
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
|
||||
@@ -23,7 +22,7 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
||||
*/
|
||||
public function test_correct_subuser_is_deleted_from_server(): void
|
||||
{
|
||||
Bus::fake([RevokeSftpAccessJob::class]);
|
||||
$this->swap(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
|
||||
|
||||
[$user, $server] = $this->generateTestAccount();
|
||||
|
||||
@@ -43,11 +42,9 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
||||
'permissions' => [SubuserPermission::WebsocketConnect],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
|
||||
|
||||
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) {
|
||||
return $job->user === $subuser->uuid && $job->target->is($server);
|
||||
});
|
||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||
|
||||
// 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.
|
||||
@@ -61,10 +58,8 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
|
||||
'permissions' => [SubuserPermission::WebsocketConnect],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
|
||||
|
||||
Bus::assertDispatched(function (RevokeSftpAccessJob $job) use ($subuser, $server) {
|
||||
return $job->user === $subuser->uuid && $job->target->is($server);
|
||||
});
|
||||
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
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
|
||||
@@ -17,8 +16,6 @@ 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();
|
||||
@@ -38,6 +35,11 @@ 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));
|
||||
|
||||
@@ -45,14 +47,6 @@ 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
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
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;
|
||||
|
||||
@@ -19,8 +17,6 @@ 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();
|
||||
@@ -58,10 +54,6 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,8 +62,6 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
||||
*/
|
||||
public function test_permissions_are_saved_to_account(): void
|
||||
{
|
||||
Bus::fake([RevokeSftpAccessJob::class]);
|
||||
|
||||
[$user, $server] = $this->generateTestAccount();
|
||||
|
||||
/** @var Subuser $subuser */
|
||||
@@ -101,10 +91,6 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +99,6 @@ 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()
|
||||
@@ -129,8 +113,6 @@ class UpdateSubuserTest extends ClientApiIntegrationTestCase
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertEqualsCanonicalizing(['foo.bar'], $subuser->refresh()->permissions);
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Integration\Api\Remote;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Node;
|
||||
use App\Models\ServerTransfer;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
|
||||
class ServerTransferControllerTest extends IntegrationTestCase
|
||||
{
|
||||
protected ServerTransfer $transfer;
|
||||
|
||||
protected function setup(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$server = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Integration\Jobs;
|
||||
|
||||
use App\Jobs\RevokeSftpAccessJob;
|
||||
use App\Models\Node;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
|
||||
class RevokeSftpAccessJobTest extends IntegrationTestCase
|
||||
{
|
||||
#[TestWith([Server::class, 'server'])]
|
||||
#[TestWith([Node::class, 'node'])]
|
||||
public function test_unique_id_based_on_model_type(string $class, string $key): void
|
||||
{
|
||||
$model = $class::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Integration\Services\Users;
|
||||
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Jobs\RevokeSftpAccessJob;
|
||||
use App\Models\Subuser;
|
||||
use App\Models\User;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
class UserDeletionServiceTest extends IntegrationTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Bus::fake([RevokeSftpAccessJob::class]);
|
||||
}
|
||||
|
||||
public function test_exception_returned_if_user_assigned_to_servers(): void
|
||||
{
|
||||
$server = $this->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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user