mirror of
https://github.com/pelican-dev/panel.git
synced 2026-05-04 18:00:48 +03:00
Compare commits
25 Commits
charles/ex
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b884131ac | ||
|
|
9291bb4477 | ||
|
|
e8c80ae420 | ||
|
|
f1be003276 | ||
|
|
e532a9a180 | ||
|
|
41fdd7bc8e | ||
|
|
9b01203b7c | ||
|
|
ab4eadec32 | ||
|
|
789c4c7284 | ||
|
|
b1a39f1724 | ||
|
|
6a548c09a0 | ||
|
|
55bda569cc | ||
|
|
adf1249086 | ||
|
|
dbf77bf146 | ||
|
|
a34bf9fd49 | ||
|
|
7a9deba0e1 | ||
|
|
159bfe2210 | ||
|
|
a821db8aae | ||
|
|
1556f8efb8 | ||
|
|
57c2aa6f21 | ||
|
|
36de4c3786 | ||
|
|
26312e3897 | ||
|
|
a477c89025 | ||
|
|
93e81c26a9 | ||
|
|
23e91e8df3 |
@@ -12,6 +12,7 @@ enum CustomizationKey: string
|
||||
case DashboardLayout = 'dashboard_layout';
|
||||
|
||||
case ButtonStyle = 'button_style';
|
||||
case RedirectToAdmin = 'redirect_to_admin';
|
||||
|
||||
public function getDefaultValue(): string|int|bool
|
||||
{
|
||||
@@ -23,6 +24,7 @@ enum CustomizationKey: string
|
||||
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
|
||||
self::DashboardLayout => 'grid',
|
||||
self::ButtonStyle => true,
|
||||
self::RedirectToAdmin => false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use Filament\Forms\Components\ToggleButtons;
|
||||
use Filament\Resources\Pages\PageRegistration;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Fieldset;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@@ -113,12 +114,44 @@ class ApiKeyResource extends Resource
|
||||
*/
|
||||
public static function defaultForm(Schema $schema): Schema
|
||||
{
|
||||
$permissionList = ApiKey::getPermissionList();
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(trans('admin/apikey.permissions.all'))
|
||||
->description(trans('admin/apikey.permissions.all_description'))
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
ToggleButtons::make('permissions_all')
|
||||
->hiddenLabel()
|
||||
->inline()
|
||||
->options([
|
||||
0 => trans('admin/apikey.permissions.none'),
|
||||
1 => trans('admin/apikey.permissions.read'),
|
||||
3 => trans('admin/apikey.permissions.read_write'),
|
||||
])
|
||||
->icons([
|
||||
0 => TablerIcon::BookOff,
|
||||
1 => TablerIcon::Book,
|
||||
3 => TablerIcon::Writing,
|
||||
])
|
||||
->colors([
|
||||
0 => 'success',
|
||||
1 => 'warning',
|
||||
3 => 'danger',
|
||||
])
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, callable $set) use ($permissionList) {
|
||||
foreach ($permissionList as $resource) {
|
||||
$set('permissions_' . $resource, $state);
|
||||
}
|
||||
})
|
||||
->default(0),
|
||||
]),
|
||||
Fieldset::make('Permissions')
|
||||
->columnSpanFull()
|
||||
->schema(
|
||||
collect(ApiKey::getPermissionList())->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
|
||||
collect($permissionList)->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
|
||||
->label(str($resource)->replace('_', ' ')->title())->inline()
|
||||
->options([
|
||||
0 => trans('admin/apikey.permissions.none'),
|
||||
|
||||
@@ -450,17 +450,7 @@ class EditEgg extends EditRecord
|
||||
return [
|
||||
DeleteAction::make()
|
||||
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
|
||||
->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use'))
|
||||
->successNotification(fn (Egg $egg) => Notification::make()
|
||||
->success()
|
||||
->title(trans('admin/egg.delete_success'))
|
||||
->body(trans('admin/egg.deleted', ['egg' => $egg->name]))
|
||||
)
|
||||
->failureNotification(fn (Egg $egg) => Notification::make()
|
||||
->danger()
|
||||
->title(trans('admin/egg.delete_failed'))
|
||||
->body(trans('admin/egg.could_not_delete', ['egg' => $egg->name]))
|
||||
),
|
||||
->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use')),
|
||||
ExportEggAction::make(),
|
||||
ImportEggAction::make()
|
||||
->multiple(false),
|
||||
|
||||
@@ -82,9 +82,9 @@ class ListEggs extends ListRecords
|
||||
->successRedirectUrl(fn (Egg $replica) => EditEgg::getUrl(['record' => $replica])),
|
||||
])
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
ImportEggAction::make()
|
||||
->multiple(),
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function (Collection &$records) {
|
||||
|
||||
@@ -278,6 +278,14 @@ class CreateNode extends CreateRecord
|
||||
->default(256)
|
||||
->minValue(1)
|
||||
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
|
||||
TextInput::make('daemon_base')
|
||||
->label(trans('admin/node.daemon_base'))
|
||||
->placeholder('/var/lib/pelican/volumes')
|
||||
->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help'))
|
||||
->columnSpan(1)
|
||||
->required()
|
||||
->default('/var/lib/pelican/volumes')
|
||||
->rule('regex:/^([\/][\d\w.\-\/]+)$/'),
|
||||
TextInput::make('daemon_sftp')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.sftp_port'))
|
||||
@@ -287,7 +295,7 @@ class CreateNode extends CreateRecord
|
||||
->required()
|
||||
->integer(),
|
||||
TextInput::make('daemon_sftp_alias')
|
||||
->columnSpan(2)
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.sftp_alias'))
|
||||
->helperText(trans('admin/node.sftp_alias_help')),
|
||||
Grid::make()
|
||||
|
||||
@@ -314,7 +314,7 @@ class EditNode extends EditRecord
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 2,
|
||||
'lg' => 2,
|
||||
'lg' => 3,
|
||||
]),
|
||||
TextInput::make('upload_size')
|
||||
->columnSpan([
|
||||
@@ -329,12 +329,24 @@ class EditNode extends EditRecord
|
||||
->required()
|
||||
->minValue(1)
|
||||
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
|
||||
TextInput::make('daemon_base')
|
||||
->label(trans('admin/node.daemon_base'))
|
||||
->placeholder('/var/lib/pelican/volumes')
|
||||
->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help'))
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 2,
|
||||
'lg' => 2,
|
||||
])
|
||||
->required()
|
||||
->rule('regex:/^([\/][\d\w.\-\/]+)$/'),
|
||||
TextInput::make('daemon_sftp')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 3,
|
||||
'md' => 2,
|
||||
'lg' => 1,
|
||||
])
|
||||
->label(trans('admin/node.sftp_port'))
|
||||
->minValue(1)
|
||||
@@ -346,8 +358,8 @@ class EditNode extends EditRecord
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 3,
|
||||
'md' => 2,
|
||||
'lg' => 2,
|
||||
])
|
||||
->label(trans('admin/node.sftp_alias'))
|
||||
->helperText(trans('admin/node.sftp_alias_help')),
|
||||
@@ -356,7 +368,7 @@ class EditNode extends EditRecord
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 3,
|
||||
'lg' => 2,
|
||||
])
|
||||
->label(trans('admin/node.use_for_deploy'))
|
||||
->inline()
|
||||
@@ -374,7 +386,7 @@ class EditNode extends EditRecord
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 3,
|
||||
'lg' => 2,
|
||||
])
|
||||
->label(trans('admin/node.maintenance_mode'))
|
||||
->inline()
|
||||
@@ -572,7 +584,7 @@ class EditNode extends EditRecord
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Actions::make([
|
||||
Action::make('autoDeploy')
|
||||
Action::make('exclude_autoDeploy')
|
||||
->label(trans('admin/node.auto_deploy'))
|
||||
->color('primary')
|
||||
->modalHeading(trans('admin/node.auto_deploy'))
|
||||
@@ -610,7 +622,7 @@ class EditNode extends EditRecord
|
||||
}),
|
||||
])->fullWidth(),
|
||||
Actions::make([
|
||||
Action::make('resetKey')
|
||||
Action::make('exclude_resetKey')
|
||||
->label(trans('admin/node.reset_token'))
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
|
||||
@@ -91,7 +91,7 @@ class PluginResource extends Resource
|
||||
->url(fn (Plugin $plugin) => !$plugin->getReadme() ? $plugin->url : null, true)
|
||||
->slideOver(true)
|
||||
->modalHeading('Readme')
|
||||
->modalSubmitAction(fn (Plugin $plugin) => Action::make('visit_website')
|
||||
->modalSubmitAction(fn (Plugin $plugin) => Action::make('exclude_visit_website')
|
||||
->label(trans('admin/plugin.visit_website'))
|
||||
->visible(!is_null($plugin->url))
|
||||
->url($plugin->url, true)
|
||||
@@ -240,7 +240,7 @@ class PluginResource extends Resource
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->toolbarActions([
|
||||
->headerActions([
|
||||
Action::make('import_from_file')
|
||||
->hiddenLabel()
|
||||
->tooltip(trans('admin/plugin.import_from_file'))
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Enums\SuspendAction;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\Servers\ServerResource;
|
||||
use App\Filament\Components\Actions\DeleteServerIcon;
|
||||
use App\Filament\Components\Actions\ExportServerConfigAction;
|
||||
use App\Filament\Components\Actions\PreviewStartupAction;
|
||||
use App\Filament\Components\Forms\Fields\MonacoEditor;
|
||||
use App\Filament\Components\Forms\Fields\StartupVariable;
|
||||
@@ -1005,16 +1004,6 @@ class EditServer extends EditRecord
|
||||
->hiddenLabel()
|
||||
->hint(new HtmlString(trans('admin/server.transfer_help'))),
|
||||
]),
|
||||
Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
Actions::make([
|
||||
ExportServerConfigAction::make(),
|
||||
])->fullWidth(),
|
||||
ToggleButtons::make('export_help')
|
||||
->hiddenLabel()
|
||||
->hint(trans('admin/server.import_export.export_description')),
|
||||
]),
|
||||
Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\Servers\ServerResource;
|
||||
use App\Filament\Components\Actions\ImportServerConfigAction;
|
||||
use App\Filament\Server\Pages\Console;
|
||||
use App\Models\Server;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
@@ -100,7 +99,6 @@ class ListServers extends ListRecords
|
||||
])
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
ImportServerConfigAction::make(),
|
||||
])
|
||||
->searchable()
|
||||
->emptyStateIcon(TablerIcon::BrandDocker)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Actions;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\Servers\Sharing\ServerConfigExporterService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
|
||||
class ExportServerConfigAction extends Action
|
||||
{
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'export_config';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->label(trans('filament-actions::export.modal.actions.export.label'));
|
||||
|
||||
$this->iconSize(IconSize::ExtraLarge);
|
||||
|
||||
$this->authorize(fn () => user()?->can('view server'));
|
||||
|
||||
$this->modalHeading(fn (Server $server) => trans('admin/server.import_export.export_heading', ['name' => $server->name]));
|
||||
|
||||
$this->modalDescription(trans('admin/server.import_export.export_description'));
|
||||
|
||||
$this->modalFooterActionsAlignment(Alignment::Center);
|
||||
|
||||
$this->schema([
|
||||
Toggle::make('include_description')
|
||||
->label(trans('admin/server.import_export.include_description'))
|
||||
->helperText(trans('admin/server.import_export.include_description_help'))
|
||||
->default(true),
|
||||
Toggle::make('include_allocations')
|
||||
->label(trans('admin/server.import_export.include_allocations'))
|
||||
->helperText(trans('admin/server.import_export.include_allocations_help'))
|
||||
->default(true),
|
||||
Toggle::make('include_variable_values')
|
||||
->label(trans('admin/server.import_export.include_variables'))
|
||||
->helperText(trans('admin/server.import_export.include_variables_help'))
|
||||
->default(true),
|
||||
]);
|
||||
|
||||
$this->action(fn (ServerConfigExporterService $service, Server $server, array $data) => response()->streamDownload(
|
||||
function () use ($service, $server, $data) {
|
||||
echo $service->handle($server, $data);
|
||||
},
|
||||
'server-' . str($server->name)->kebab()->lower()->trim() . '.yaml',
|
||||
[
|
||||
'Content-Type' => 'application/x-yaml',
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Actions;
|
||||
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Services\Servers\Sharing\ServerConfigCreatorService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ImportServerConfigAction extends Action
|
||||
{
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'import_config';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->hiddenLabel();
|
||||
|
||||
$this->icon('tabler-file-import');
|
||||
|
||||
$this->tooltip(trans('admin/server.import_export.import_tooltip'));
|
||||
|
||||
$this->authorize(fn () => user()?->can('create server'));
|
||||
|
||||
$this->modalHeading(trans('admin/server.import_export.import_heading'));
|
||||
|
||||
$this->modalDescription(trans('admin/server.import_export.import_description'));
|
||||
|
||||
$this->schema([
|
||||
FileUpload::make('file')
|
||||
->label(trans('admin/server.import_export.config_file'))
|
||||
->hint(trans('admin/server.import_export.config_file_hint'))
|
||||
->acceptedFileTypes(['application/x-yaml', 'text/yaml', 'text/x-yaml', '.yaml', '.yml'])
|
||||
->preserveFilenames()
|
||||
->previewable(false)
|
||||
->storeFiles(false)
|
||||
->required()
|
||||
->maxSize(1024), // 1MB max
|
||||
Select::make('node_id')
|
||||
->label(trans('admin/server.import_export.node_select'))
|
||||
->hint(trans('admin/server.import_export.node_select_hint'))
|
||||
->options(fn () => user()?->accessibleNodes()->pluck('name', 'id') ?? [])
|
||||
->searchable()
|
||||
->required()
|
||||
->visible(fn () => (user()?->accessibleNodes()->count() ?? 0) > 1),
|
||||
]);
|
||||
|
||||
$this->action(function (ServerConfigCreatorService $createService, array $data): void {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $data['file'];
|
||||
$nodeId = $data['node_id'] ?? user()->accessibleNodes()->first()->id;
|
||||
|
||||
try {
|
||||
$server = $createService->fromFile($file, $nodeId);
|
||||
|
||||
Notification::make()
|
||||
->title(trans('admin/server.notifications.import_created'))
|
||||
->body(trans('admin/server.notifications.import_created_body', ['name' => $server->name]))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
redirect()->route('filament.admin.resources.servers.edit', ['record' => $server]);
|
||||
} catch (InvalidFileUploadException $exception) {
|
||||
Notification::make()
|
||||
->title(trans('admin/server.notifications.import_failed'))
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
} catch (\Exception $exception) {
|
||||
Notification::make()
|
||||
->title(trans('admin/server.notifications.import_failed'))
|
||||
->body(trans('admin/server.notifications.import_failed_body', ['error' => $exception->getMessage()]))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
report($exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ class EditProfile extends BaseEditProfile
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_api_key')
|
||||
Action::make('exclude_create_api_key')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('description')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
|
||||
@@ -343,7 +343,7 @@ class EditProfile extends BaseEditProfile
|
||||
->live(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_ssh_key')
|
||||
Action::make('exclude_create_ssh_key')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
|
||||
@@ -474,6 +474,17 @@ class EditProfile extends BaseEditProfile
|
||||
false => 'Icon Button',
|
||||
]),
|
||||
]),
|
||||
Section::make(trans('profile.admin'))
|
||||
->collapsible()
|
||||
->icon(TablerIcon::Shield)
|
||||
->visible(fn (User $user) => $user->isAdmin())
|
||||
->schema([
|
||||
ToggleButtons::make('redirect_to_admin')
|
||||
->label(trans('profile.redirect_to_admin'))
|
||||
->helperText(trans('profile.redirect_to_admin_help'))
|
||||
->inline()
|
||||
->boolean(),
|
||||
]),
|
||||
Section::make(trans('profile.console'))
|
||||
->collapsible()
|
||||
->icon(TablerIcon::Terminal2)
|
||||
@@ -599,6 +610,7 @@ class EditProfile extends BaseEditProfile
|
||||
'dashboard_layout' => $data['dashboard_layout'],
|
||||
'top_navigation' => $data['top_navigation'],
|
||||
'button_style' => $data['button_style'],
|
||||
'redirect_to_admin' => $data['redirect_to_admin'] ?? $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin),
|
||||
];
|
||||
|
||||
unset(
|
||||
@@ -608,6 +620,7 @@ class EditProfile extends BaseEditProfile
|
||||
$data['dashboard_layout'],
|
||||
$data['top_navigation'],
|
||||
$data['button_style'],
|
||||
$data['redirect_to_admin'],
|
||||
);
|
||||
|
||||
$data['customization'] = json_encode($customization);
|
||||
@@ -623,6 +636,7 @@ class EditProfile extends BaseEditProfile
|
||||
$data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
|
||||
$data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout);
|
||||
$data['button_style'] = $this->getUser()->getCustomization(CustomizationKey::ButtonStyle);
|
||||
$data['redirect_to_admin'] = $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin);
|
||||
|
||||
// Handle migration from boolean to string navigation types
|
||||
$topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation);
|
||||
|
||||
@@ -66,6 +66,14 @@ class Login extends BaseLogin
|
||||
->extraInputAttributes(['tabindex' => 1]);
|
||||
}
|
||||
|
||||
protected function getPasswordFormComponent(): Component
|
||||
{
|
||||
/** @var TextInput $component */
|
||||
$component = parent::getPasswordFormComponent();
|
||||
|
||||
return $component->extraInputAttributes(['tabindex' => 2]);
|
||||
}
|
||||
|
||||
protected function getOAuthFormComponent(): Component
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
@@ -78,11 +78,15 @@ class Console extends Page
|
||||
$feature = data_get($data, 'key');
|
||||
|
||||
$feature = $this->featureService->get($feature);
|
||||
if (!$feature || $this->getMountedAction()) {
|
||||
if (!$feature) {
|
||||
return;
|
||||
}
|
||||
$this->mountAction($feature->getId());
|
||||
sleep(2); // TODO find a better way
|
||||
|
||||
if ($this->getMountedAction()) {
|
||||
$this->replaceMountedAction($feature->getId());
|
||||
} else {
|
||||
$this->mountAction($feature->getId());
|
||||
}
|
||||
}
|
||||
|
||||
public function getWidgetData(): array
|
||||
|
||||
@@ -209,16 +209,18 @@ class ListFiles extends ListRecords
|
||||
->required()
|
||||
->live(),
|
||||
TextEntry::make('new_location')
|
||||
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, $get('location') ?? '/', $file->name))),
|
||||
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, str_ends_with($get('location') ?? '/', '/') ? join_paths($get('location') ?? '/', $file->name) : $get('location') ?? '/'))),
|
||||
])
|
||||
->action(function ($data, File $file) {
|
||||
$location = $data['location'];
|
||||
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
|
||||
$endsWithSlash = str_ends_with($location, '/');
|
||||
$to = $endsWithSlash ? join_paths($location, $file->name) : $location;
|
||||
$files = [['to' => $to, 'from' => $file->name]];
|
||||
|
||||
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
|
||||
|
||||
$oldLocation = join_paths($this->path, $file->name);
|
||||
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
|
||||
$newLocation = resolve_path(join_paths($this->path, $to));
|
||||
|
||||
Activity::event('server:file.rename')
|
||||
->property('directory', $this->path)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Application\Plugins;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Exceptions\PanelException;
|
||||
use App\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
use App\Http\Requests\Api\Application\Plugins\ImportFilePluginRequest;
|
||||
use App\Http\Requests\Api\Application\Plugins\ReadPluginRequest;
|
||||
use App\Http\Requests\Api\Application\Plugins\UninstallPluginRequest;
|
||||
use App\Http\Requests\Api\Application\Plugins\WritePluginRequest;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Helpers\PluginService;
|
||||
use App\Transformers\Api\Application\PluginTransformer;
|
||||
use Exception;
|
||||
use Illuminate\Http\Response;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
|
||||
class PluginController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* PluginController constructor.
|
||||
*/
|
||||
public function __construct(private readonly PluginService $pluginService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* List plugins
|
||||
*
|
||||
* Return all plugins on the Panel.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function index(ReadPluginRequest $request): array
|
||||
{
|
||||
$plugins = QueryBuilder::for(Plugin::class)
|
||||
->allowedFilters(['id', 'name', 'author', 'category'])
|
||||
->allowedSorts(['id', 'name', 'author', 'category'])
|
||||
->paginate($request->query('per_page') ?? 10);
|
||||
|
||||
return $this->fractal->collection($plugins)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* View plugin
|
||||
*
|
||||
* Return a single plugin.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function view(ReadPluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import plugin (file)
|
||||
*
|
||||
* Imports a new plugin file.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function importFile(WritePluginRequest $request): Response
|
||||
{
|
||||
if (!$request->hasFile('plugin')) {
|
||||
throw new PanelException("No 'plugin' file in request");
|
||||
}
|
||||
|
||||
$this->pluginService->downloadPluginFromFile($request->file('plugin'));
|
||||
|
||||
return new Response('', Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import plugin (url)
|
||||
*
|
||||
* Imports a new plugin from an url.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function importUrl(ImportFilePluginRequest $request): Response
|
||||
{
|
||||
$this->pluginService->downloadPluginFromUrl($request->input('url'));
|
||||
|
||||
return new Response('', Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install plugin
|
||||
*
|
||||
* Installs and enables a plugin.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function install(WritePluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
if ($plugin->status !== PluginStatus::NotInstalled) {
|
||||
throw new PanelException('Plugin is already installed');
|
||||
}
|
||||
|
||||
$this->pluginService->installPlugin($plugin);
|
||||
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plugin
|
||||
*
|
||||
* Downloads and installs an update for a plugin. Will throw if no update is available.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(WritePluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
if (!$plugin->isUpdateAvailable()) {
|
||||
throw new PanelException("Plugin doesn't need updating");
|
||||
}
|
||||
|
||||
$this->pluginService->updatePlugin($plugin);
|
||||
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall plugin
|
||||
*
|
||||
* Uninstalls a plugin. Optionally it will delete the plugin folder too.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function uninstall(UninstallPluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
if ($plugin->status === PluginStatus::NotInstalled) {
|
||||
throw new PanelException('Plugin is not installed');
|
||||
}
|
||||
|
||||
$this->pluginService->uninstallPlugin($plugin, $request->boolean('delete'));
|
||||
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable plugin
|
||||
*
|
||||
* Enables a plugin.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function enable(WritePluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
if (!$plugin->canEnable()) {
|
||||
throw new PanelException("Plugin can't be enabled");
|
||||
}
|
||||
|
||||
$this->pluginService->enablePlugin($plugin);
|
||||
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable plugin
|
||||
*
|
||||
* Disables a plugin.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function disable(WritePluginRequest $request, Plugin $plugin): array
|
||||
{
|
||||
if (!$plugin->canDisable()) {
|
||||
throw new PanelException("Plugin can't be disabled");
|
||||
}
|
||||
|
||||
$this->pluginService->disablePlugin($plugin);
|
||||
|
||||
return $this->fractal->item($plugin)
|
||||
->transformWith($this->getTransformer(PluginTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Application\Servers;
|
||||
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
use App\Http\Requests\Api\Application\Servers\GetServerRequest;
|
||||
use App\Models\Server;
|
||||
use App\Services\Servers\Sharing\ServerConfigCreatorService;
|
||||
use App\Services\Servers\Sharing\ServerConfigExporterService;
|
||||
use App\Transformers\Api\Application\ServerTransformer;
|
||||
use Dedoc\Scramble\Attributes\Group;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
#[Group('Server Config', weight: 1)]
|
||||
class ServerConfigController extends ApplicationApiController
|
||||
{
|
||||
public function __construct(
|
||||
private ServerConfigExporterService $exporterService,
|
||||
private ServerConfigCreatorService $creatorService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export server configuration
|
||||
*
|
||||
* Export a server's configuration to YAML format. Returns the configuration as a
|
||||
* downloadable YAML file containing settings, limits, allocations, and variable values.
|
||||
*/
|
||||
public function export(GetServerRequest $request, Server $server): Response
|
||||
{
|
||||
$options = [
|
||||
'include_description' => $request->boolean('include_description', true),
|
||||
'include_allocations' => $request->boolean('include_allocations', true),
|
||||
'include_variable_values' => $request->boolean('include_variable_values', true),
|
||||
];
|
||||
|
||||
$yaml = $this->exporterService->handle($server, $options);
|
||||
|
||||
$filename = 'server-config-' . str($server->name)->kebab()->lower()->trim() . '.yaml';
|
||||
|
||||
return response($yaml, 200, [
|
||||
'Content-Type' => 'application/x-yaml',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create server from configuration
|
||||
*
|
||||
* Create a new server from a YAML configuration file. The configuration must
|
||||
* include a valid egg UUID that exists in the system. Optionally specify a
|
||||
* node_id to create the server on a specific node.
|
||||
*
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:yaml,yml|max:1024',
|
||||
'node_id' => 'required|integer|exists:nodes,id',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$nodeId = $request->input('node_id');
|
||||
|
||||
$server = $this->creatorService->fromFile($file, $nodeId);
|
||||
|
||||
return $this->fractal->item($server)
|
||||
->transformWith($this->getTransformer(ServerTransformer::class))
|
||||
->respond(201);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ class OAuthController extends Controller
|
||||
$email = $oauthUser->getEmail();
|
||||
|
||||
if (!$email) {
|
||||
return $this->errorRedirect();
|
||||
return $this->errorRedirect('No email was linked to your account on the OAuth provider.');
|
||||
}
|
||||
|
||||
$user = User::whereEmail($email)->first();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Application\Plugins;
|
||||
|
||||
class ImportFilePluginRequest extends WritePluginRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => 'required|string',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Application\Plugins;
|
||||
|
||||
use App\Http\Requests\Api\Application\ApplicationApiRequest;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Acl\Api\AdminAcl;
|
||||
|
||||
class ReadPluginRequest extends ApplicationApiRequest
|
||||
{
|
||||
protected ?string $resource = Plugin::RESOURCE_NAME;
|
||||
|
||||
protected int $permission = AdminAcl::READ;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Application\Plugins;
|
||||
|
||||
class UninstallPluginRequest extends WritePluginRequest
|
||||
{
|
||||
/**
|
||||
* @param array<array-key, string|string[]>|null $rules
|
||||
* @return array<array-key, string|string[]>
|
||||
*/
|
||||
public function rules(?array $rules = null): array
|
||||
{
|
||||
return [
|
||||
'delete' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\Application\Plugins;
|
||||
|
||||
use App\Http\Requests\Api\Application\ApplicationApiRequest;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\Acl\Api\AdminAcl;
|
||||
|
||||
class WritePluginRequest extends ApplicationApiRequest
|
||||
{
|
||||
protected ?string $resource = Plugin::RESOURCE_NAME;
|
||||
|
||||
protected int $permission = AdminAcl::WRITE;
|
||||
}
|
||||
25
app/Http/Responses/LoginResponse.php
Normal file
25
app/Http/Responses/LoginResponse.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Enums\CustomizationKey;
|
||||
use App\Models\User;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|Redirector
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
if ($user?->getCustomization(CustomizationKey::RedirectToAdmin) && $user->canAccessPanel(Filament::getPanel('admin'))) {
|
||||
return redirect()->intended(Filament::getPanel('admin')->getUrl());
|
||||
}
|
||||
|
||||
return redirect()->intended(Filament::getUrl());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ class ProcessWebhook implements ShouldQueue
|
||||
$data = reset($data);
|
||||
}
|
||||
|
||||
if (is_object($data)) {
|
||||
$data = get_object_vars($data);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
$data = Arr::wrap(json_decode($data, true) ?? []);
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ class ApiKey extends PersonalAccessToken
|
||||
Database::RESOURCE_NAME,
|
||||
Mount::RESOURCE_NAME,
|
||||
Role::RESOURCE_NAME,
|
||||
Plugin::RESOURCE_NAME,
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
|
||||
@@ -358,7 +358,7 @@ class Node extends Model implements Validatable
|
||||
'disk_used' => 0,
|
||||
];
|
||||
|
||||
return cache()->remember("nodes.$this->id.statistics", now()->addSeconds(360), function () use ($default) {
|
||||
return cache()->flexible("nodes.$this->id.statistics", [5, 30], function () use ($default) {
|
||||
try {
|
||||
|
||||
$data = Http::daemon($this)
|
||||
|
||||
@@ -38,6 +38,8 @@ class Plugin extends Model implements HasPluginSettings
|
||||
{
|
||||
use Sushi;
|
||||
|
||||
public const RESOURCE_NAME = 'plugin';
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
@@ -198,7 +198,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
|
||||
});
|
||||
|
||||
static::saving(function (self $user) {
|
||||
$user->email = mb_strtolower($user->email);
|
||||
$user->username = str($user->username)->lower()->toString();
|
||||
$user->email = str($user->email)->lower()->toString();
|
||||
});
|
||||
|
||||
static::deleting(function (self $user) {
|
||||
|
||||
@@ -23,14 +23,16 @@ class AccountCreated extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
$message = (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '!')
|
||||
->line('You are receiving this email because an account has been created for you on ' . config('app.name') . '.')
|
||||
->line('Username: ' . $notifiable->username)
|
||||
->line('Email: ' . $notifiable->email);
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.account_created.body', ['app' => config('app.name')], $locale))
|
||||
->line(trans('mail.account_created.username', ['username' => $notifiable->username], $locale))
|
||||
->line(trans('mail.account_created.email', ['email' => $notifiable->email], $locale));
|
||||
|
||||
if (!is_null($this->token)) {
|
||||
return $message->action('Setup Your Account', Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
|
||||
return $message->action(trans('mail.account_created.action', locale: $locale), Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
|
||||
}
|
||||
|
||||
return $message;
|
||||
|
||||
@@ -24,10 +24,12 @@ class AddedToServer extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '!')
|
||||
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Visit Server', Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.added_to_server.body', locale: $locale))
|
||||
->line(trans('mail.added_to_server.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.added_to_server.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ class MailTested extends Notification
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$locale = $this->user->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->subject('Panel Test Message')
|
||||
->greeting('Hello ' . $this->user->username . '!')
|
||||
->line('This is a test of the Panel mail system. You\'re good to go!');
|
||||
->subject(trans('mail.mail_tested.subject', locale: $locale))
|
||||
->greeting(trans('mail.greeting', ['name' => $this->user->username], $locale))
|
||||
->line(trans('mail.mail_tested.body', locale: $locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ class RemovedFromServer extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->error()
|
||||
->greeting('Hello ' . $notifiable->username . '.')
|
||||
->line('You have been removed as a subuser for the following server.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Visit Panel', url(''));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.removed_from_server.body', locale: $locale))
|
||||
->line(trans('mail.removed_from_server.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.removed_from_server.action', locale: $locale), url(''));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ class ServerInstalled extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '.')
|
||||
->line('Your server has finished installing and is now ready for you to use.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Login and Begin Using', Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.server_installed.body', locale: $locale))
|
||||
->line(trans('mail.server_installed.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.server_installed.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Services\Helpers\SoftwareVersionService;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -125,6 +126,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(LoginResponseContract::class, \App\Http\Responses\LoginResponse::class);
|
||||
|
||||
Scramble::ignoreDefaultRoutes();
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
|
||||
@@ -92,7 +92,7 @@ class EggExporterService
|
||||
return $this->yamlExport($decoded);
|
||||
}
|
||||
|
||||
return str_replace(["\r\n", '\\r\\n', '\\n'], "\n", $data);
|
||||
return str_replace("\r\n", "\n", $data);
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
|
||||
@@ -31,13 +31,22 @@ class GetUserPermissionsService
|
||||
'admin.websocket.transfer',
|
||||
];
|
||||
|
||||
if ($isAdmin) {
|
||||
return $isOwner || $user->can('update', $server) ? array_merge(['*'], $adminPermissions) : array_merge([SubuserPermission::WebsocketConnect->value], $adminPermissions);
|
||||
if ($isAdmin && ($isOwner || $user->can('update', $server))) {
|
||||
return array_merge(['*'], $adminPermissions);
|
||||
}
|
||||
|
||||
/** @var Subuser|null $subuser */
|
||||
$subuser = $server->subusers()->where('user_id', $user->id)->first();
|
||||
$subuserPermissions = $subuser !== null ? $subuser->permissions : [];
|
||||
|
||||
return $subuser->permissions ?? [];
|
||||
if ($isAdmin) {
|
||||
return array_unique(array_merge(
|
||||
[SubuserPermission::WebsocketConnect->value],
|
||||
$adminPermissions,
|
||||
$subuserPermissions,
|
||||
));
|
||||
}
|
||||
|
||||
return $subuserPermissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Servers;
|
||||
|
||||
use App\Extensions\Features\FeatureService;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Mount;
|
||||
use App\Models\Server;
|
||||
|
||||
@@ -39,6 +38,7 @@ class ServerConfigurationStructureService
|
||||
* Returns the data format used for the daemon.
|
||||
*
|
||||
* @return array{
|
||||
* id: int,
|
||||
* uuid: string,
|
||||
* meta: array{name: string, description: string},
|
||||
* suspended: bool,
|
||||
@@ -70,6 +70,7 @@ class ServerConfigurationStructureService
|
||||
protected function returnFormat(Server $server): array
|
||||
{
|
||||
$response = [
|
||||
'id' => $server->id,
|
||||
'uuid' => $server->uuid,
|
||||
'meta' => [
|
||||
'name' => $server->name,
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Servers\Sharing;
|
||||
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Egg;
|
||||
use App\Models\EggVariable;
|
||||
use App\Models\Node;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerVariable;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServerConfigCreatorService
|
||||
{
|
||||
/**
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
public function fromFile(UploadedFile $file, ?int $nodeId = null): Server
|
||||
{
|
||||
if ($file->getError() !== UPLOAD_ERR_OK) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.file_error'));
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = Yaml::parse($file->getContent());
|
||||
} catch (\Exception $exception) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.parse_error_desc', ['error' => $exception->getMessage()]));
|
||||
}
|
||||
|
||||
return $this->createServer($parsed, $nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server from configuration array.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
protected function createServer(array $config, ?int $nodeId = null): Server
|
||||
{
|
||||
$eggUuid = Arr::get($config, 'egg.uuid');
|
||||
$eggName = Arr::get($config, 'egg.name');
|
||||
|
||||
if (!$eggUuid) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.egg_uuid_required'));
|
||||
}
|
||||
|
||||
$egg = Egg::where('uuid', $eggUuid)->first();
|
||||
|
||||
if (!$egg) {
|
||||
throw new InvalidFileUploadException(
|
||||
trans('admin/server.import_errors.egg_not_found_desc', [
|
||||
'uuid' => $eggUuid,
|
||||
'name' => $eggName ?: trans('admin/server.none'),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if ($nodeId) {
|
||||
$node = Node::whereIn('id', user()?->accessibleNodes()->pluck('id'))
|
||||
->where('id', $nodeId)
|
||||
->first();
|
||||
|
||||
if (!$node) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.node_not_accessible'));
|
||||
}
|
||||
} else {
|
||||
$node = Node::whereIn('id', user()?->accessibleNodes()->pluck('id'))->first();
|
||||
|
||||
if (!$node) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.no_nodes'));
|
||||
}
|
||||
}
|
||||
|
||||
$allocations = Arr::get($config, 'allocations', []);
|
||||
$primaryAllocation = null;
|
||||
$createdAllocations = [];
|
||||
|
||||
if (!empty($allocations)) {
|
||||
foreach ($allocations as $allocationData) {
|
||||
$ip = Arr::get($allocationData, 'ip');
|
||||
$port = Arr::get($allocationData, 'port');
|
||||
$isPrimary = Arr::get($allocationData, 'is_primary', false);
|
||||
|
||||
$allocation = Allocation::where('node_id', $node->id)
|
||||
->where('ip', $ip)
|
||||
->where('port', $port)
|
||||
->whereNull('server_id')
|
||||
->first();
|
||||
|
||||
if (!$allocation) {
|
||||
$existingAllocation = Allocation::where('node_id', $node->id)
|
||||
->where('ip', $ip)
|
||||
->where('port', $port)
|
||||
->first();
|
||||
|
||||
if ($existingAllocation) {
|
||||
$port = $this->findNextAvailablePort($node->id, $ip, $port);
|
||||
}
|
||||
|
||||
$allocation = Allocation::create([
|
||||
'node_id' => $node->id,
|
||||
'ip' => $ip,
|
||||
'port' => $port,
|
||||
]);
|
||||
}
|
||||
|
||||
$createdAllocations[] = $allocation;
|
||||
|
||||
if ($isPrimary && !$primaryAllocation) {
|
||||
$primaryAllocation = $allocation;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$primaryAllocation && !empty($createdAllocations)) {
|
||||
$primaryAllocation = $createdAllocations[0];
|
||||
}
|
||||
}
|
||||
|
||||
$owner = user();
|
||||
|
||||
if (!$owner) {
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.no_user'));
|
||||
}
|
||||
|
||||
$serverName = Arr::get($config, 'name', 'Imported Server');
|
||||
|
||||
$startupCommand = Arr::get($config, 'settings.startup');
|
||||
if ($startupCommand === null) {
|
||||
$startupCommand = array_values($egg->startup_commands)[0];
|
||||
}
|
||||
|
||||
$dockerImage = Arr::get($config, 'settings.image');
|
||||
if ($dockerImage === null) {
|
||||
$dockerImage = array_values($egg->docker_images)[0];
|
||||
}
|
||||
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
$server = Server::create([
|
||||
'uuid' => $uuid,
|
||||
'uuid_short' => substr($uuid, 0, 8),
|
||||
'name' => $serverName,
|
||||
'description' => Arr::get($config, 'description', ''),
|
||||
'owner_id' => $owner->id,
|
||||
'node_id' => $node->id,
|
||||
'allocation_id' => $primaryAllocation?->id,
|
||||
'egg_id' => $egg->id,
|
||||
'startup' => $startupCommand,
|
||||
'image' => $dockerImage,
|
||||
'skip_scripts' => Arr::get($config, 'settings.skip_scripts', false),
|
||||
'memory' => Arr::get($config, 'limits.memory', 512),
|
||||
'swap' => Arr::get($config, 'limits.swap', 0),
|
||||
'disk' => Arr::get($config, 'limits.disk', 1024),
|
||||
'io' => Arr::get($config, 'limits.io', 500),
|
||||
'cpu' => Arr::get($config, 'limits.cpu', 0),
|
||||
'threads' => Arr::get($config, 'limits.threads'),
|
||||
'oom_killer' => Arr::get($config, 'limits.oom_killer', false),
|
||||
'database_limit' => Arr::get($config, 'feature_limits.databases', 0),
|
||||
'allocation_limit' => Arr::get($config, 'feature_limits.allocations', 0),
|
||||
'backup_limit' => Arr::get($config, 'feature_limits.backups', 0),
|
||||
]);
|
||||
|
||||
if ($primaryAllocation) {
|
||||
$primaryAllocation->update(['server_id' => $server->id]);
|
||||
}
|
||||
|
||||
foreach ($createdAllocations as $allocation) {
|
||||
if ($allocation->id !== $primaryAllocation?->id) {
|
||||
$allocation->update(['server_id' => $server->id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['variables'])) {
|
||||
$this->importVariables($server, $config['variables']);
|
||||
}
|
||||
|
||||
if (isset($config['icon'])) {
|
||||
$this->importServerIcon($server, $config['icon']);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import server icon from base64 encoded data.
|
||||
*
|
||||
* @param array<string, string> $iconData
|
||||
*/
|
||||
protected function importServerIcon(Server $server, array $iconData): void
|
||||
{
|
||||
$base64Data = Arr::get($iconData, 'data');
|
||||
$extension = Arr::get($iconData, 'extension');
|
||||
|
||||
if (!$base64Data || !$extension) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$imageData = base64_decode($base64Data, true);
|
||||
|
||||
if ($imageData === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = Server::ICON_STORAGE_PATH . "/{$server->uuid}.{$extension}";
|
||||
Storage::disk('public')->put($path, $imageData);
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but do not fail the entire import process
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{env_variable: string, value: string|null}> $variables
|
||||
*/
|
||||
protected function importVariables(Server $server, array $variables): void
|
||||
{
|
||||
foreach ($variables as $variable) {
|
||||
$envVariable = Arr::get($variable, 'env_variable');
|
||||
$value = Arr::get($variable, 'value');
|
||||
|
||||
/** @var EggVariable $eggVariable */
|
||||
$eggVariable = $server->egg->variables()->where('env_variable', $envVariable)->first();
|
||||
|
||||
ServerVariable::create([
|
||||
'server_id' => $server->id,
|
||||
'variable_id' => $eggVariable->id,
|
||||
'variable_value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
protected function findNextAvailablePort(int $nodeId, string $ip, int $startPort): int
|
||||
{
|
||||
$port = $startPort + 1;
|
||||
$maxPort = 65535;
|
||||
|
||||
while ($port <= $maxPort) {
|
||||
$exists = Allocation::where('node_id', $nodeId)
|
||||
->where('ip', $ip)
|
||||
->where('port', $port)
|
||||
->exists();
|
||||
|
||||
if (!$exists) {
|
||||
return $port;
|
||||
}
|
||||
|
||||
$port++;
|
||||
}
|
||||
|
||||
throw new InvalidFileUploadException(trans('admin/server.import_errors.port_exhausted_desc', ['ip' => $ip, 'port' => $startPort]));
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Servers\Sharing;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServerConfigExporterService
|
||||
{
|
||||
/**
|
||||
* @param array<string, bool> $options
|
||||
*/
|
||||
public function handle(Server|int $server, array $options = []): string
|
||||
{
|
||||
if (!$server instanceof Server) {
|
||||
$server = Server::with(['egg', 'allocations', 'serverVariables.variable'])->findOrFail($server);
|
||||
}
|
||||
|
||||
$includeDescription = $options['include_description'] ?? true;
|
||||
$includeAllocations = $options['include_allocations'] ?? true;
|
||||
$includeVariableValues = $options['include_variable_values'] ?? true;
|
||||
|
||||
$data = [
|
||||
'name' => $server->name,
|
||||
'egg' => [
|
||||
'uuid' => $server->egg->uuid,
|
||||
'name' => $server->egg->name,
|
||||
],
|
||||
'settings' => [
|
||||
'startup' => $server->startup,
|
||||
'image' => $server->image,
|
||||
'skip_scripts' => $server->skip_scripts,
|
||||
],
|
||||
'limits' => [
|
||||
'memory' => $server->memory,
|
||||
'swap' => $server->swap,
|
||||
'disk' => $server->disk,
|
||||
'io' => $server->io,
|
||||
'cpu' => $server->cpu,
|
||||
'threads' => $server->threads,
|
||||
'oom_killer' => $server->oom_killer,
|
||||
],
|
||||
'feature_limits' => [
|
||||
'databases' => $server->database_limit,
|
||||
'allocations' => $server->allocation_limit,
|
||||
'backups' => $server->backup_limit,
|
||||
],
|
||||
];
|
||||
|
||||
if ($includeDescription && !empty($server->description)) {
|
||||
$data['description'] = $server->description;
|
||||
}
|
||||
|
||||
// Export server icon if exists
|
||||
$iconData = $this->exportServerIcon($server);
|
||||
if ($iconData) {
|
||||
$data['icon'] = $iconData;
|
||||
}
|
||||
|
||||
if ($includeAllocations && $server->allocations->isNotEmpty()) {
|
||||
$data['allocations'] = $server->allocations->map(function ($allocation) use ($server) {
|
||||
return [
|
||||
'ip' => $allocation->ip,
|
||||
'port' => $allocation->port,
|
||||
'is_primary' => $allocation->id === $server->allocation_id,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
if ($includeVariableValues && $server->serverVariables->isNotEmpty()) {
|
||||
$data['variables'] = $server->serverVariables->map(function ($serverVar) {
|
||||
return [
|
||||
'env_variable' => $serverVar->variable->env_variable,
|
||||
'value' => $serverVar->variable_value,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
return Yaml::dump($data, 4, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export server icon as base64 encoded string with mime type.
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
protected function exportServerIcon(Server $server): ?array
|
||||
{
|
||||
foreach (array_keys(Server::IMAGE_FORMATS) as $ext) {
|
||||
$path = Server::ICON_STORAGE_PATH . "/{$server->uuid}.{$ext}";
|
||||
if (Storage::disk('public')->exists($path)) {
|
||||
$contents = Storage::disk('public')->get($path);
|
||||
$mimeType = Server::IMAGE_FORMATS[$ext];
|
||||
|
||||
return [
|
||||
'data' => base64_encode($contents),
|
||||
'mime_type' => $mimeType,
|
||||
'extension' => $ext,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Servers\Sharing;
|
||||
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Egg;
|
||||
use App\Models\EggVariable;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerVariable;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServerConfigImporterService
|
||||
{
|
||||
/**
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
public function fromFile(UploadedFile $file, Server $server): void
|
||||
{
|
||||
if ($file->getError() !== UPLOAD_ERR_OK) {
|
||||
throw new InvalidFileUploadException('The selected file was not uploaded successfully');
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = Yaml::parse($file->getContent());
|
||||
} catch (\Exception $exception) {
|
||||
throw new InvalidFileUploadException('Could not parse YAML file: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
$this->applyConfiguration($server, $parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* egg: array{uuid: string, name?: string},
|
||||
* settings?: array<string, mixed>,
|
||||
* limits?: array<string, mixed>,
|
||||
* feature_limits?: array<string, mixed>,
|
||||
* description?: string,
|
||||
* variables?: array<int, array<string, mixed>>,
|
||||
* allocations?: array<int, array<string, mixed>>
|
||||
* } $config
|
||||
*
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
public function applyConfiguration(Server $server, array $config): void
|
||||
{
|
||||
$eggUuid = Arr::get($config, 'egg.uuid');
|
||||
$eggName = Arr::get($config, 'egg.name');
|
||||
|
||||
if (!$eggUuid) {
|
||||
throw new InvalidFileUploadException('Egg UUID is required in the configuration file');
|
||||
}
|
||||
|
||||
$egg = Egg::where('uuid', $eggUuid)->first();
|
||||
|
||||
if (!$egg) {
|
||||
throw new InvalidFileUploadException(
|
||||
"Egg with UUID '{$eggUuid}'" .
|
||||
($eggName ? " (name: {$eggName})" : '') .
|
||||
' does not exist in the system'
|
||||
);
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'egg_id' => $egg->id,
|
||||
'startup' => Arr::get($config, 'settings.startup', $server->startup),
|
||||
'image' => Arr::get($config, 'settings.image', $server->image),
|
||||
'skip_scripts' => Arr::get($config, 'settings.skip_scripts', $server->skip_scripts),
|
||||
'memory' => Arr::get($config, 'limits.memory', $server->memory),
|
||||
'swap' => Arr::get($config, 'limits.swap', $server->swap),
|
||||
'disk' => Arr::get($config, 'limits.disk', $server->disk),
|
||||
'io' => Arr::get($config, 'limits.io', $server->io),
|
||||
'cpu' => Arr::get($config, 'limits.cpu', $server->cpu),
|
||||
'threads' => Arr::get($config, 'limits.threads', $server->threads),
|
||||
'oom_killer' => Arr::get($config, 'limits.oom_killer', $server->oom_killer),
|
||||
'database_limit' => Arr::get($config, 'feature_limits.databases', $server->database_limit),
|
||||
'allocation_limit' => Arr::get($config, 'feature_limits.allocations', $server->allocation_limit),
|
||||
'backup_limit' => Arr::get($config, 'feature_limits.backups', $server->backup_limit),
|
||||
]);
|
||||
|
||||
if (isset($config['description'])) {
|
||||
$server->update(['description' => $config['description']]);
|
||||
}
|
||||
|
||||
if (isset($config['variables'])) {
|
||||
$this->importVariables($server, $config['variables']);
|
||||
}
|
||||
|
||||
if (isset($config['allocations'])) {
|
||||
$this->importAllocations($server, $config['allocations']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{env_variable: string, value: string|null}> $variables
|
||||
*/
|
||||
protected function importVariables(Server $server, array $variables): void
|
||||
{
|
||||
foreach ($variables as $variable) {
|
||||
$envVariable = Arr::get($variable, 'env_variable');
|
||||
$value = Arr::get($variable, 'value');
|
||||
|
||||
$eggVariable = EggVariable::where('egg_id', $server->egg_id)
|
||||
->where('env_variable', $envVariable)
|
||||
->first();
|
||||
|
||||
if ($eggVariable) {
|
||||
ServerVariable::updateOrCreate(
|
||||
[
|
||||
'server_id' => $server->id,
|
||||
'variable_id' => $eggVariable->id,
|
||||
],
|
||||
[
|
||||
'variable_value' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $allocations
|
||||
*
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
protected function importAllocations(Server $server, array $allocations): void
|
||||
{
|
||||
$nodeId = $server->node_id;
|
||||
$primaryAllocationSet = false;
|
||||
|
||||
foreach ($allocations as $allocationData) {
|
||||
$ip = Arr::get($allocationData, 'ip');
|
||||
$port = Arr::get($allocationData, 'port');
|
||||
$isPrimary = Arr::get($allocationData, 'is_primary', false);
|
||||
|
||||
$allocation = Allocation::where('node_id', $nodeId)
|
||||
->where('ip', $ip)
|
||||
->where('port', $port)
|
||||
->first();
|
||||
|
||||
if (!$allocation) {
|
||||
$allocation = Allocation::create([
|
||||
'node_id' => $nodeId,
|
||||
'ip' => $ip,
|
||||
'port' => $port,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} elseif ($allocation->server_id && $allocation->server_id !== $server->id) {
|
||||
$newPort = $this->findNextAvailablePort($nodeId, $ip, $port);
|
||||
|
||||
$allocation = Allocation::create([
|
||||
'node_id' => $nodeId,
|
||||
'ip' => $ip,
|
||||
'port' => $newPort,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} elseif (!$allocation->server_id) {
|
||||
$allocation->update(['server_id' => $server->id]);
|
||||
}
|
||||
|
||||
if ($isPrimary && !$primaryAllocationSet) {
|
||||
$server->update(['allocation_id' => $allocation->id]);
|
||||
$primaryAllocationSet = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidFileUploadException
|
||||
*/
|
||||
protected function findNextAvailablePort(int $nodeId, string $ip, int $startPort): int
|
||||
{
|
||||
$port = $startPort + 1;
|
||||
$maxPort = 65535;
|
||||
|
||||
while ($port <= $maxPort) {
|
||||
$exists = Allocation::where('node_id', $nodeId)
|
||||
->where('ip', $ip)
|
||||
->where('port', $port)
|
||||
->exists();
|
||||
|
||||
if (!$exists) {
|
||||
return $port;
|
||||
}
|
||||
|
||||
$port++;
|
||||
}
|
||||
|
||||
throw new InvalidFileUploadException("Could not find an available port for IP {$ip} starting from port {$startPort}");
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,6 @@ class UserCreationService
|
||||
$data['username'] = str($data['email'])->before('@')->toString() . Str::random(3);
|
||||
}
|
||||
|
||||
$data['username'] = str($data['username'])
|
||||
->replace(['.', '-'], '')
|
||||
->ascii()
|
||||
->substr(0, 64)
|
||||
->toString();
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->forceCreate(array_merge($data, [
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
|
||||
47
app/Transformers/Api/Application/PluginTransformer.php
Normal file
47
app/Transformers/Api/Application/PluginTransformer.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Transformers\Api\Application;
|
||||
|
||||
use App\Models\Plugin;
|
||||
|
||||
class PluginTransformer extends BaseTransformer
|
||||
{
|
||||
/**
|
||||
* Return the resource name for the JSONAPI output.
|
||||
*/
|
||||
public function getResourceName(): string
|
||||
{
|
||||
return Plugin::RESOURCE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Plugin $model
|
||||
*/
|
||||
public function transform($model): array
|
||||
{
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'name' => $model->name,
|
||||
'author' => $model->author,
|
||||
'version' => $model->version,
|
||||
'description' => $model->description,
|
||||
'category' => $model->category,
|
||||
'url' => $model->url,
|
||||
'update_url' => $model->update_url,
|
||||
'namespace' => $model->namespace,
|
||||
'class' => $model->class,
|
||||
'panels' => $model->panels ? explode(',', $model->panels) : null,
|
||||
'panel_version' => $model->panel_version,
|
||||
'composer_packages' => $model->composer_packages ? json_decode($model->composer_packages, true, 512, JSON_THROW_ON_ERROR) : null,
|
||||
'meta' => [
|
||||
'status' => $model->status,
|
||||
'status_message' => $model->status_message,
|
||||
'load_order' => $model->load_order,
|
||||
'is_compatible' => $model->isCompatible(),
|
||||
'update_available' => $model->isUpdateAvailable(),
|
||||
'can_enable' => $model->canEnable(),
|
||||
'can_disable' => $model->canDisable(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
493
composer.lock
generated
493
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ return [
|
||||
'logo' => env('APP_LOGO'),
|
||||
'favicon' => env('APP_FAVICON', '/pelican.ico'),
|
||||
|
||||
'version' => 'canary',
|
||||
'version' => '1.0.0-beta32',
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ return [
|
||||
'never_used' => 'Never Used',
|
||||
],
|
||||
'permissions' => [
|
||||
'all' => 'Set All Permissions',
|
||||
'all_description' => 'Quickly set all permissions below to the same level.',
|
||||
'none' => 'None',
|
||||
'read' => 'Read',
|
||||
'read_write' => 'Read & Write',
|
||||
|
||||
@@ -115,10 +115,6 @@ return [
|
||||
'no_update_url' => 'The following eggs do not have a working update URL set: :eggs',
|
||||
'cannot_delete' => 'Cannot delete :count egg(s)',
|
||||
'eggs_have_servers' => 'The following eggs have servers and cannot be deleted: :eggs',
|
||||
'delete_success' => 'Egg deleted successfully',
|
||||
'deleted' => 'Deleted: :egg',
|
||||
'delete_failed' => 'Failed to delete egg',
|
||||
'could_not_delete' => 'Could not delete: :egg',
|
||||
'updated_from' => 'Successfully updated from: :url',
|
||||
'update_error' => 'Error: :error',
|
||||
'updated_eggs' => 'Updated: :eggs',
|
||||
|
||||
@@ -65,6 +65,8 @@ return [
|
||||
'sftp_port' => 'SFTP Port',
|
||||
'sftp_alias' => 'SFTP Alias',
|
||||
'sftp_alias_help' => 'Display alias for the SFTP address. Leave empty to use the Node FQDN.',
|
||||
'daemon_base' => 'Daemon Base Directory',
|
||||
'daemon_base_help' => 'The directory where server data will be stored.',
|
||||
'use_for_deploy' => 'Use for Deployments?',
|
||||
'maintenance_mode' => 'Maintenance Mode',
|
||||
'maintenance_mode_help' => 'If the node is marked \'Under Maintenance\' users won\'t be able to access servers that are on that node',
|
||||
|
||||
@@ -147,44 +147,8 @@ return [
|
||||
'transfer_failed' => 'Transfer failed',
|
||||
'already_transfering' => 'Server is currently being transferred.',
|
||||
'backup_transfer_failed' => 'Backup Transfer Failed',
|
||||
'import_created' => 'Server Created',
|
||||
'import_created_body' => 'Server \':name\' has been successfully created from configuration.',
|
||||
'import_failed' => 'Import Failed',
|
||||
'import_failed_body' => 'An unexpected error occurred: :error',
|
||||
],
|
||||
'notes' => 'Notes',
|
||||
'no_notes' => 'No Notes',
|
||||
'none' => 'None',
|
||||
'import_export' => [
|
||||
'import_tooltip' => 'Import server configuration from YAML file',
|
||||
'import_heading' => 'Import Server Configuration',
|
||||
'import_description' => 'Import server configuration from a YAML file to create a new server.',
|
||||
'export_tooltip' => 'Export server configuration to YAML file',
|
||||
'export_heading' => 'Export Configuration: :name',
|
||||
'export_description' => 'Export the server configuration, settings, limits, allocations, and variable values to a YAML file.',
|
||||
'config_file' => 'Configuration File',
|
||||
'config_file_hint' => 'Upload a YAML file exported from another panel',
|
||||
'node_select' => 'Node',
|
||||
'node_select_hint' => 'Select the node where the server will be created',
|
||||
'include_description' => 'Include Description?',
|
||||
'include_description_help' => 'Export the server description',
|
||||
'include_allocations' => 'Include Allocations?',
|
||||
'include_allocations_help' => 'Export IP addresses and ports assigned to the server',
|
||||
'include_variables' => 'Include Variable Values?',
|
||||
'include_variables_help' => 'Export environment variable values',
|
||||
],
|
||||
'import_errors' => [
|
||||
'file_error' => 'The selected file was not uploaded successfully',
|
||||
'file_error_desc' => 'Please check the file and try again',
|
||||
'parse_error' => 'Could not parse YAML file',
|
||||
'parse_error_desc' => 'The uploaded file is not valid YAML: :error',
|
||||
'egg_uuid_required' => 'Egg UUID is required in the configuration file',
|
||||
'egg_not_found' => 'Egg does not exist in the panel',
|
||||
'egg_not_found_desc' => 'Egg with UUID \':uuid\' (name: :name) does not exist in the panel',
|
||||
'node_not_accessible' => 'Selected node is not accessible or does not exist',
|
||||
'no_nodes' => 'No accessible nodes found',
|
||||
'no_user' => 'No authenticated user found',
|
||||
'port_exhausted' => 'Could not find an available port',
|
||||
'port_exhausted_desc' => 'Could not find an available port for IP :ip starting from port :port',
|
||||
],
|
||||
];
|
||||
|
||||
35
lang/en/mail.php
Normal file
35
lang/en/mail.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'greeting' => 'Hello :name!',
|
||||
|
||||
'account_created' => [
|
||||
'body' => 'You are receiving this email because an account has been created for you on :app.',
|
||||
'username' => 'Username: :username',
|
||||
'email' => 'Email: :email',
|
||||
'action' => 'Setup Your Account',
|
||||
],
|
||||
|
||||
'added_to_server' => [
|
||||
'body' => 'You have been added as a subuser for the following server, allowing you certain control over the server.',
|
||||
'server_name' => 'Server Name: :name',
|
||||
'action' => 'Visit Server',
|
||||
],
|
||||
|
||||
'removed_from_server' => [
|
||||
'body' => 'You have been removed as a subuser for the following server.',
|
||||
'server_name' => 'Server Name: :name',
|
||||
'action' => 'Visit Panel',
|
||||
],
|
||||
|
||||
'server_installed' => [
|
||||
'body' => 'Your server has finished installing and is now ready for you to use.',
|
||||
'server_name' => 'Server Name: :name',
|
||||
'action' => 'Login and Begin Using',
|
||||
],
|
||||
|
||||
'mail_tested' => [
|
||||
'subject' => 'Panel Test Message',
|
||||
'body' => 'This is a test of the Panel mail system. You\'re good to go!',
|
||||
],
|
||||
];
|
||||
@@ -64,6 +64,8 @@ return [
|
||||
'sidebar' => 'Sidebar',
|
||||
'topbar' => 'Topbar',
|
||||
'mixed' => 'Mixed',
|
||||
'redirect_to_admin' => 'Redirect to Admin on Login',
|
||||
'redirect_to_admin_help' => 'When enabled, you will be redirected to the admin area after logging in instead of the server list.',
|
||||
'no_oauth' => 'No Accounts Linked',
|
||||
'no_api_keys' => 'No API Keys',
|
||||
'no_ssh_keys' => 'No SSH Keys',
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle X-Forwarded-Proto Header
|
||||
RewriteCond %{HTTP:X-Forwarded-Proto} =https [NC]
|
||||
RewriteRule .* - [E=HTTPS:on]
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex)})},syncActionModals(t){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}if(this.actionNestingIndex!==null&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
|
||||
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};
|
||||
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
function h({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{h as default};
|
||||
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
|
||||
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:b,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let a=this.getTabs();a.includes(this.tab)||(this.tab=a[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),b=t.map(i=>{let a=i.querySelector(".fi-tabs-item-label"),s=i.querySelector(".fi-badge"),o=Math.ceil(a.clientWidth),l=s?Math.ceil(s.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let a=u.slice(0,i+1).reduce((p,y)=>p+y,0),s=i*n,o=b.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(p=>p.total)):0,D=l?r+W+d+h+n:0;if(a+s+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(s=>s.style.display);n.forEach(s=>s.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),a=this.findOverflowIndex(n,r,h,b,i,u);n.forEach((s,o)=>s.style.display=d[o]),a!==-1&&(this.withinDropdownIndex=a),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
|
||||
|
||||
@@ -1 +1 @@
|
||||
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};
|
||||
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!s&&e>this.getStepIndex(this.step)||(this.step=t,this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};
|
||||
|
||||
@@ -1 +1 @@
|
||||
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};
|
||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:d,respond:u})=>{n(({snapshot:f,effect:h})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{o as default};
|
||||
function o({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:r,succeed:a,fail:u,respond:d})=>{a(({snapshot:h,effect:l})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
||||
|
||||
@@ -1 +1 @@
|
||||
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};
|
||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,16 @@
|
||||
<x-filament-panels::page>
|
||||
@once
|
||||
<style>
|
||||
.fi-ta-header-ctn {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
<div
|
||||
x-data="
|
||||
{
|
||||
|
||||
@@ -75,12 +75,10 @@ Route::prefix('/servers')->group(function () {
|
||||
Route::post('/{server:id}/transfer', [Application\Servers\ServerManagementController::class, 'startTransfer'])->name('api.application.servers.transfer');
|
||||
Route::post('/{server:id}/transfer/cancel', [Application\Servers\ServerManagementController::class, 'cancelTransfer'])->name('api.application.servers.transfer.cancel');
|
||||
|
||||
Route::get('/{server:id}/config/export', [Application\Servers\ServerConfigController::class, 'export'])->name('api.application.servers.config.export');
|
||||
Route::post('/config/create', [Application\Servers\ServerConfigController::class, 'create'])->name('api.application.servers.config.create');
|
||||
|
||||
Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']);
|
||||
Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']);
|
||||
|
||||
// Database Management Endpoint
|
||||
Route::prefix('/{server:id}/databases')->group(function () {
|
||||
Route::get('/', [Application\Servers\DatabaseController::class, 'index'])->name('api.application.servers.databases');
|
||||
Route::get('/{database:id}', [Application\Servers\DatabaseController::class, 'view'])->name('api.application.servers.databases.view');
|
||||
@@ -174,3 +172,26 @@ Route::prefix('/roles')->group(function () {
|
||||
|
||||
Route::delete('/{role:id}', [Application\Roles\RoleController::class, 'delete']);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Plugin Controller Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Endpoint: /api/application/plugins
|
||||
|
|
||||
*/
|
||||
Route::prefix('/plugins')->group(function () {
|
||||
Route::get('/', [Application\Plugins\PluginController::class, 'index'])->name('api.application.plugins');
|
||||
Route::get('/{plugin:id}', [Application\Plugins\PluginController::class, 'view'])->name('api.application.plugins.view');
|
||||
|
||||
Route::post('/import/file', [Application\Plugins\PluginController::class, 'importFile']);
|
||||
Route::post('/import/url', [Application\Plugins\PluginController::class, 'importUrl']);
|
||||
|
||||
Route::post('/{plugin:id}/install', [Application\Plugins\PluginController::class, 'install']);
|
||||
Route::post('/{plugin:id}/update', [Application\Plugins\PluginController::class, 'update']);
|
||||
Route::post('/{plugin:id}/uninstall', [Application\Plugins\PluginController::class, 'uninstall']);
|
||||
|
||||
Route::post('/{plugin:id}/enable', [Application\Plugins\PluginController::class, 'enable']);
|
||||
Route::post('/{plugin:id}/disable', [Application\Plugins\PluginController::class, 'disable']);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\User;
|
||||
use App\Models\UserSSHKey;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
class SftpAuthenticationControllerTest extends IntegrationTestCase
|
||||
{
|
||||
@@ -195,6 +196,39 @@ class SftpAuthenticationControllerTest extends IntegrationTestCase
|
||||
$this->post('/api/remote/sftp/auth', $data)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_subuser_sftp_works_when_user_has_view_only_role(): void
|
||||
{
|
||||
[$user, $server] = $this->generateTestAccount([SubuserPermission::FileRead, SubuserPermission::FileSftp]);
|
||||
|
||||
$user->update(['password' => password_hash('foobar', PASSWORD_DEFAULT)]);
|
||||
|
||||
$this->setAuthorization($server->node);
|
||||
|
||||
$data = [
|
||||
'username' => $user->username . '.' . $server->uuid_short,
|
||||
'password' => 'foobar',
|
||||
];
|
||||
|
||||
// SFTP works as a plain subuser
|
||||
$this->postJson('/api/remote/sftp/auth', $data)
|
||||
->assertOk()
|
||||
->assertJsonPath('permissions', [SubuserPermission::FileRead->value, SubuserPermission::FileSftp->value]);
|
||||
|
||||
// Assign a role with only "view server" permission
|
||||
$role = Role::findOrCreate('view-only-test', 'web');
|
||||
$permission = Permission::findOrCreate('view server', 'web');
|
||||
$role->givePermissionTo($permission);
|
||||
$user->syncRoles($role);
|
||||
|
||||
// SFTP should still work — subuser permissions must be merged with admin permissions
|
||||
$response = $this->postJson('/api/remote/sftp/auth', $data)
|
||||
->assertOk();
|
||||
|
||||
$permissions = $response->json('permissions');
|
||||
$this->assertContains(SubuserPermission::FileSftp->value, $permissions);
|
||||
$this->assertContains(SubuserPermission::FileRead->value, $permissions);
|
||||
}
|
||||
|
||||
public static function authorizationTypeDataProvider(): array
|
||||
{
|
||||
return [
|
||||
|
||||
66
tests/Unit/Services/Eggs/EggExporterServiceTest.php
Normal file
66
tests/Unit/Services/Eggs/EggExporterServiceTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Unit\Services\Eggs;
|
||||
|
||||
use App\Services\Eggs\Sharing\EggExporterService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EggExporterServiceTest extends TestCase
|
||||
{
|
||||
private EggExporterService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = new EggExporterService();
|
||||
}
|
||||
|
||||
public function test_yaml_export_preserves_literal_backslash_n_in_scripts(): void
|
||||
{
|
||||
$script = <<<'BASH'
|
||||
if [[ "${STEAM_USER}" == "" ]] || [[ "${STEAM_PASS}" == "" ]]; then
|
||||
echo -e "steam user is not set.\n"
|
||||
echo -e "Using anonymous user.\n"
|
||||
STEAM_USER=anonymous
|
||||
STEAM_PASS=""
|
||||
STEAM_AUTH=""
|
||||
else
|
||||
echo -e "user set to ${STEAM_USER}"
|
||||
fi
|
||||
BASH;
|
||||
|
||||
$result = $this->callYamlExport($script);
|
||||
|
||||
$this->assertStringContainsString('echo -e "steam user is not set.\n"', $result);
|
||||
$this->assertStringContainsString('echo -e "Using anonymous user.\n"', $result);
|
||||
}
|
||||
|
||||
public function test_yaml_export_preserves_literal_backslash_r_backslash_n(): void
|
||||
{
|
||||
$script = 'echo -e "line ending\\r\\n"';
|
||||
|
||||
$result = $this->callYamlExport($script);
|
||||
|
||||
$this->assertSame($script, $result);
|
||||
}
|
||||
|
||||
public function test_yaml_export_normalizes_real_crlf_to_lf(): void
|
||||
{
|
||||
$script = "line one\r\nline two\r\nline three";
|
||||
|
||||
$result = $this->callYamlExport($script);
|
||||
|
||||
$this->assertSame("line one\nline two\nline three", $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the protected yamlExport method via reflection.
|
||||
*/
|
||||
private function callYamlExport(mixed $data): mixed
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->service, 'yamlExport');
|
||||
|
||||
return $reflection->invoke($this->service, $data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user