From 2e81dd47262ac0be43beb2908e9aef3e578be948 Mon Sep 17 00:00:00 2001 From: Naterfute Date: Fri, 9 Jan 2026 04:52:17 -0800 Subject: [PATCH] feat: add Pterodactyl Wings support alonside Elytra Support --- .github/docker/entrypoint.sh | 2 +- .gitignore | 1 + app/Console/Commands/Node/MakeNodeCommand.php | 6 +- app/Contracts/Daemon/Daemon.php | 11 + app/Enums/Captcha/Captchas.php | 34 ++ app/Enums/Daemon/Adapters.php | 52 +++ app/Enums/Daemon/DaemonType.php | 39 ++ app/Enums/Subdomain/Features.php | 55 +++ app/Enums/Subdomain/Providers.php | 68 +++ .../Admin/NodeAutoDeployController.php | 3 +- .../Admin/Nodes/NodeController.php | 57 ++- .../Admin/Nodes/NodeViewController.php | 9 +- .../Controllers/Admin/NodesController.php | 7 +- .../Admin/Servers/CreateServerController.php | 3 +- .../Admin/Servers/ServerController.php | 4 +- .../Admin/Servers/ServerViewController.php | 4 +- .../Admin/Settings/CaptchaController.php | 13 +- .../Admin/Settings/DomainsController.php | 23 +- .../Api/Client/ClientController.php | 1 + .../Api/Client/ServerController.php | 53 +++ .../Servers/Elytra/ActivityLogController.php | 54 +++ .../{ => Elytra}/BackupsController.php | 4 +- .../Servers/Elytra/CommandController.php | 54 +++ .../Servers/Elytra/DatabaseController.php | 102 +++++ .../{ => Elytra}/ElytraJobsController.php | 5 +- .../Client/Servers/Elytra/FileController.php | 265 ++++++++++++ .../Servers/Elytra/FileUploadController.php | 54 +++ .../NetworkAllocationController.php | 2 +- .../Client/Servers/Elytra/PowerController.php | 35 ++ .../Elytra/ResourceUtilizationController.php | 41 ++ .../{ => Elytra}/ScheduleController.php | 2 +- .../{ => Elytra}/ScheduleTaskController.php | 2 +- .../Servers/Elytra/ServerController.php | 35 ++ .../{ => Elytra}/SettingsController.php | 181 ++++---- .../{ => Elytra}/StartupController.php | 10 +- .../{ => Elytra}/SubdomainController.php | 20 +- .../Servers/Elytra/WebsocketController.php | 73 ++++ .../{ => Wings}/ActivityLogController.php | 2 +- .../Client/Servers/Wings/BackupController.php | 224 ++++++++++ .../Servers/{ => Wings}/CommandController.php | 2 +- .../{ => Wings}/DatabaseController.php | 2 +- .../Servers/{ => Wings}/FileController.php | 2 +- .../{ => Wings}/FileUploadController.php | 2 +- .../Wings/NetworkAllocationController.php | 140 ++++++ .../Servers/{ => Wings}/PowerController.php | 2 +- .../ResourceUtilizationController.php | 2 +- .../Servers/Wings/ScheduleController.php | 184 ++++++++ .../Servers/Wings/ScheduleTaskController.php | 175 ++++++++ .../Servers/{ => Wings}/ServerController.php | 2 +- .../Servers/Wings/SettingsController.php | 224 ++++++++++ .../Servers/Wings/StartupController.php | 99 +++++ .../{ => Wings}/WebsocketController.php | 2 +- .../Server/AuthenticateServerAccess.php | 4 +- .../Api/Client/Server/CheckDaemonType.php | 26 ++ .../Requests/Admin/Node/NodeFormRequest.php | 3 + .../Backups/BackupRemoteUploadController.php | 135 ++++++ .../Remote/Backups/BackupStatusController.php | 159 +++++++ app/Jobs/Server/ApplyEggChangeJob.php | 32 +- app/Models/Backup.php | 19 +- app/Models/Daemons/.info | 1 + app/Models/Daemons/Elytra.php | 93 ++++ app/Models/Daemons/Wings.php | 50 +++ app/Models/Node.php | 104 ++--- app/Models/Permission.php | 398 +++++++++--------- app/Models/Server.php | 30 +- .../Eloquent/BackupRepository.php | 45 ++ app/Repositories/Eloquent/NodeRepository.php | 12 + .../Elytra/DaemonCommandRepository.php | 37 ++ .../Elytra/DaemonConfigurationRepository.php | 50 +++ .../Elytra/DaemonFileRepository.php | 301 +++++++++++++ .../Elytra/DaemonPowerRepository.php | 35 ++ app/Repositories/Elytra/DaemonRepository.php | 69 +++ .../Elytra/DaemonServerRepository.php | 162 +++++++ .../Elytra/DaemonTransferRepository.php | 37 ++ .../Wings/DaemonBackupRepository.php | 97 +++++ .../Wings/DaemonFileRepository.php | 4 +- app/Repositories/Wings/DaemonRepository.php | 4 +- .../Wings/DaemonRevocationRepository.php | 28 ++ .../Backups/Wings/DeleteBackupService.php | 81 ++++ .../Backups/Wings/DownloadLinkService.php | 60 +++ .../Backups/Wings/InitiateBackupService.php | 134 ++++++ app/Services/Captcha/CaptchaManager.php | 5 +- .../Captcha/Providers/RecaptchaProvider.php | 8 +- app/Services/Elytra/Jobs/BackupJob.php | 11 +- .../Subdomain/SubdomainManagementService.php | 24 +- .../Api/Client/ServerTransformer.php | 244 +++++------ composer.json | 4 +- config/backups.php | 2 +- config/captcha.php | 2 +- config/javascript.php | 2 +- database/Factories/NodeFactory.php | 62 +-- .../2025_12_07_022523_wings_or_elytra.php | 28 ++ .../2026_01_02_044710_backup_disk.php | 28 ++ package.json | 5 +- pnpm-lock.yaml | 155 +++++++ .../themes/pterodactyl/js/admin/new-server.js | 38 +- .../pterodactyl/js/plugins/minecraft/eula.js | 12 +- resources/scripts/api/server/activity.ts | 4 +- .../scripts/api/server/applyEggChange.ts | 5 +- .../scripts/api/server/applyEggChangeSync.ts | 84 ++++ .../api/server/backups/createServerBackup.ts | 4 +- .../server/backups/deleteAllServerBackups.ts | 14 + .../api/server/backups/getBackupStatus.ts | 4 +- resources/scripts/api/server/backups/index.ts | 15 +- .../scripts/api/server/backups/retryBackup.ts | 4 +- .../server/databases/createServerDatabase.ts | 3 +- .../server/databases/deleteServerDatabase.ts | 3 +- .../server/databases/getServerDatabases.ts | 5 +- .../databases/rotateDatabasePassword.ts | 4 +- .../scripts/api/server/files/chmodFiles.ts | 3 +- .../scripts/api/server/files/compressFiles.ts | 3 +- .../scripts/api/server/files/copyFile.ts | 3 +- .../api/server/files/createDirectory.ts | 3 +- .../api/server/files/decompressFiles.ts | 3 +- .../scripts/api/server/files/deleteFiles.ts | 3 +- .../api/server/files/getFileContents.ts | 3 +- .../api/server/files/getFileDownloadUrl.ts | 3 +- .../api/server/files/getFileUploadUrl.ts | 3 +- .../scripts/api/server/files/loadDirectory.ts | 3 +- .../scripts/api/server/files/renameFiles.ts | 3 +- .../api/server/files/saveFileContents.ts | 3 +- resources/scripts/api/server/getServer.ts | 51 ++- .../api/server/getServerResourceUsage.ts | 4 +- .../scripts/api/server/getWebsocketToken.ts | 5 +- .../scripts/api/server/network/subdomain.ts | 1 + .../scripts/api/server/previewEggChange.ts | 4 +- .../api/server/processStartupCommand.ts | 4 +- .../scripts/api/server/reinstallServer.ts | 3 +- resources/scripts/api/server/renameServer.ts | 3 +- .../scripts/api/server/resetStartupCommand.ts | 3 +- .../scripts/api/server/revertDockerImage.ts | 5 +- .../schedules/createOrUpdateSchedule.ts | 24 +- .../schedules/createOrUpdateScheduleTask.ts | 3 +- .../api/server/schedules/deleteSchedule.ts | 3 +- .../server/schedules/deleteScheduleTask.ts | 3 +- .../api/server/schedules/getServerSchedule.ts | 3 +- .../server/schedules/getServerSchedules.ts | 3 +- .../schedules/triggerScheduleExecution.ts | 3 +- .../scripts/api/server/serverOperations.ts | 5 +- .../api/server/setSelectedDockerImage.ts | 5 +- .../api/server/updateStartupCommand.ts | 5 +- .../api/server/updateStartupVariable.ts | 6 +- .../api/server/users/createOrUpdateSubuser.ts | 3 +- .../scripts/api/server/users/deleteSubuser.ts | 3 +- .../api/server/users/getServerSubusers.ts | 3 +- .../scripts/api/swr/getServerAllocations.ts | 4 +- resources/scripts/api/swr/getServerBackups.ts | 4 +- resources/scripts/api/swr/getServerStartup.ts | 3 +- .../components/dashboard/ServerRow.tsx | 59 +-- .../server/backups/BackupContainer.tsx | 44 +- .../{ => elytra}/BackupContextMenu.tsx | 12 +- .../backups/{ => elytra}/BackupItem.tsx | 5 +- .../server/backups/useUnifiedBackups.ts | 6 +- .../components/server/console/StatBlock.tsx | 4 +- .../WingsOperationProgressModal.tsx | 260 ++++++++++++ .../server/shell/ShellContainer.tsx | 118 ++++-- resources/scripts/routers/ServerRouter.tsx | 8 +- resources/views/admin/eggs/new.blade.php | 2 +- resources/views/admin/index.blade.php | 5 +- resources/views/admin/nodes/index.blade.php | 28 +- resources/views/admin/nodes/new.blade.php | 64 ++- .../admin/nodes/view/configuration.blade.php | 7 +- .../views/admin/nodes/view/settings.blade.php | 84 +++- resources/views/admin/servers/index.blade.php | 4 +- resources/views/admin/servers/new.blade.php | 2 + .../admin/servers/view/startup.blade.php | 18 +- .../views/admin/settings/captcha.blade.php | 8 +- routes/api-application.php | 102 ++--- routes/api-client.php | 158 ++----- routes/api-remote.php | 27 +- routes/servers/elytra.php | 144 +++++++ routes/servers/wings.php | 119 ++++++ vite.config.ts | 35 +- 173 files changed, 5825 insertions(+), 1137 deletions(-) create mode 100644 app/Contracts/Daemon/Daemon.php create mode 100644 app/Enums/Captcha/Captchas.php create mode 100644 app/Enums/Daemon/Adapters.php create mode 100644 app/Enums/Daemon/DaemonType.php create mode 100644 app/Enums/Subdomain/Features.php create mode 100644 app/Enums/Subdomain/Providers.php create mode 100644 app/Http/Controllers/Api/Client/ServerController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/ActivityLogController.php rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/BackupsController.php (99%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/CommandController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/ElytraJobsController.php (98%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/FileController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/FileUploadController.php rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/NetworkAllocationController.php (98%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/PowerController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/ResourceUtilizationController.php rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/ScheduleController.php (99%) rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/ScheduleTaskController.php (99%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/ServerController.php rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/SettingsController.php (62%) rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/StartupController.php (98%) rename app/Http/Controllers/Api/Client/Servers/{ => Elytra}/SubdomainController.php (98%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Elytra/WebsocketController.php rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/ActivityLogController.php (97%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/BackupController.php rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/CommandController.php (96%) rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/DatabaseController.php (98%) rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/FileController.php (99%) rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/FileUploadController.php (95%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/NetworkAllocationController.php rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/PowerController.php (93%) rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/ResourceUtilizationController.php (95%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/ScheduleController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/ScheduleTaskController.php rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/ServerController.php (94%) create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/SettingsController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/Wings/StartupController.php rename app/Http/Controllers/Api/Client/Servers/{ => Wings}/WebsocketController.php (97%) create mode 100644 app/Http/Middleware/Api/Client/Server/CheckDaemonType.php create mode 100644 app/Http/Requests/Api/Remote/Backups/BackupRemoteUploadController.php create mode 100644 app/Http/Requests/Api/Remote/Backups/BackupStatusController.php create mode 100644 app/Models/Daemons/.info create mode 100644 app/Models/Daemons/Elytra.php create mode 100644 app/Models/Daemons/Wings.php create mode 100644 app/Repositories/Eloquent/BackupRepository.php create mode 100644 app/Repositories/Elytra/DaemonCommandRepository.php create mode 100644 app/Repositories/Elytra/DaemonConfigurationRepository.php create mode 100644 app/Repositories/Elytra/DaemonFileRepository.php create mode 100644 app/Repositories/Elytra/DaemonPowerRepository.php create mode 100644 app/Repositories/Elytra/DaemonRepository.php create mode 100644 app/Repositories/Elytra/DaemonServerRepository.php create mode 100644 app/Repositories/Elytra/DaemonTransferRepository.php create mode 100644 app/Repositories/Wings/DaemonBackupRepository.php create mode 100644 app/Repositories/Wings/DaemonRevocationRepository.php create mode 100644 app/Services/Backups/Wings/DeleteBackupService.php create mode 100644 app/Services/Backups/Wings/DownloadLinkService.php create mode 100644 app/Services/Backups/Wings/InitiateBackupService.php create mode 100644 database/migrations/2025_12_07_022523_wings_or_elytra.php create mode 100644 database/migrations/2026_01_02_044710_backup_disk.php create mode 100644 resources/scripts/api/server/applyEggChangeSync.ts create mode 100644 resources/scripts/api/server/backups/deleteAllServerBackups.ts rename resources/scripts/components/server/backups/{ => elytra}/BackupContextMenu.tsx (97%) rename resources/scripts/components/server/backups/{ => elytra}/BackupItem.tsx (98%) create mode 100644 resources/scripts/components/server/operations/WingsOperationProgressModal.tsx create mode 100644 routes/servers/elytra.php create mode 100644 routes/servers/wings.php diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index d7ced73a4..a3de9c14a 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -140,7 +140,7 @@ fi fi # Make a location and node for the panel php artisan p:location:make -n --short local --long Local - php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $ELYTRA_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0 + php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $ELYTRA_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0 --daemonType elytra echo "Adding dummy allocations..." if [ "$DB_CONNECTION" = "mysql" ] || [ "$DB_CONNECTION" = "mariadb" ]; then diff --git a/.gitignore b/.gitignore index 7cecd762a..22378573b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .git/ .vscode/ .env +.github/workflows/ci.yaml # Elytra binary diff --git a/app/Console/Commands/Node/MakeNodeCommand.php b/app/Console/Commands/Node/MakeNodeCommand.php index f3ba12223..aa0c1c09b 100644 --- a/app/Console/Commands/Node/MakeNodeCommand.php +++ b/app/Console/Commands/Node/MakeNodeCommand.php @@ -24,7 +24,9 @@ class MakeNodeCommand extends Command {--uploadSize= : Enter the maximum upload filesize.} {--daemonListeningPort= : Enter the wings listening port.} {--daemonSFTPPort= : Enter the wings SFTP listening port.} - {--daemonBase= : Enter the base folder.}'; + {--daemonBase= : Enter the base folder.} + {--daemonType= : Enter the daemon Backend.} + {--backupDisk= : Enter the Backup type}'; protected $description = 'Creates a new node on the system via the CLI.'; @@ -64,6 +66,8 @@ class MakeNodeCommand extends Command $data['daemonListen'] = $this->option('daemonListeningPort') ?? $this->ask('Enter the wings listening port', '8080'); $data['daemonSFTP'] = $this->option('daemonSFTPPort') ?? $this->ask('Enter the wings SFTP listening port', '2022'); $data['daemonBase'] = $this->option('daemonBase') ?? $this->ask('Enter the base folder', '/var/lib/pterodactyl/volumes'); + $data['daemonType'] = $this->option('daemonType') ?? $this->ask('Enter the daemon backend', 'elytra'); + $data['backupDisk'] = $this->option('backupDisk') ?? $this->ask('Enter the Backup Disk', 'rustic_local'); $node = $this->creationService->handle($data); $this->line('Successfully created a new node on the location ' . $data['location_id'] . ' with the name ' . $data['name'] . ' and has an id of ' . $node->id . '.'); diff --git a/app/Contracts/Daemon/Daemon.php b/app/Contracts/Daemon/Daemon.php new file mode 100644 index 000000000..6b860e7da --- /dev/null +++ b/app/Contracts/Daemon/Daemon.php @@ -0,0 +1,11 @@ +value => 'Disabled', + self::TURNSTILE->value => 'Cloudflare Turnstile', + self::HCAPTCHA->value => 'HCaptcha', + self::RECAPTCHA->value => 'Google ReCaptcha', + ]; + + + public static function all(): array + { + $result = []; + foreach (self::cases() as $case) { + $result[$case->value] = self::DESCRIPTION_MAP[$case->value]; + } + return $result; + } + + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Daemon/Adapters.php b/app/Enums/Daemon/Adapters.php new file mode 100644 index 000000000..52bd446ad --- /dev/null +++ b/app/Enums/Daemon/Adapters.php @@ -0,0 +1,52 @@ + self::all_elytra(), 'wings' => self::all_wings()]; + } + + + public static function all_elytra(): array + { + return array_column(self::ELYTRA, "value"); + } + + public static function all_wings(): array + { + return array_column(self::WINGS, "value"); + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Daemon/DaemonType.php b/app/Enums/Daemon/DaemonType.php new file mode 100644 index 000000000..54800dda8 --- /dev/null +++ b/app/Enums/Daemon/DaemonType.php @@ -0,0 +1,39 @@ +value => \Pterodactyl\Models\Daemons\Wings::class, + self::ELYTRA->value => \Pterodactyl\Models\Daemons\Elytra::class, + ]; + + private const RESOURCE_MAP = [ + self::WINGS->value => \Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra\ResourceUtilizationController::class, + self::ELYTRA->value => \Pterodactyl\Http\Controllers\Api\Client\Servers\Wings\ResourceUtilizationController::class, + ]; + + public static function all(): array + { + return array_column(self::cases(), 'value', 'value'); + } + + public static function allResources(): array + { + return self::RESOURCE_MAP; + } + + public static function allClass(): array + { + return self::CLASS_MAP; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Subdomain/Features.php b/app/Enums/Subdomain/Features.php new file mode 100644 index 000000000..d841dd63f --- /dev/null +++ b/app/Enums/Subdomain/Features.php @@ -0,0 +1,55 @@ +value => FactorioSubdomainFeature::class, + self::MINECRAFT->value => MinecraftSubdomainFeature::class, + self::RUST->value => RustSubdomainFeature::class, + self::SCPSL->value => ScpSlSubdomainFeature::class, + self::TEAMSPEAK->value => TeamSpeakSubdomainFeature::class, + self::VINTAGESTORY->value => VintageStorySubdomainFeature::class, + ]; + + public static function all(): array + { + $result = []; + foreach (self::cases() as $case) { + $result[$case->value] = $case->getClassName(); + } + return $result; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public function getClassName(): string + { + return self::CLASS_MAP[$this->value]; + } + + public static function getClass(string $provider): string + { + return self::from($provider)->getClassName(); + } +} diff --git a/app/Enums/Subdomain/Providers.php b/app/Enums/Subdomain/Providers.php new file mode 100644 index 000000000..e8280a3e0 --- /dev/null +++ b/app/Enums/Subdomain/Providers.php @@ -0,0 +1,68 @@ +value => CloudflareProvider::class, + self::HETZNER->value => HetznerProvider::class, + self::ROUTE53->value => Route53Provider::class, + ]; + + private const DESCRIPTION_MAP = [ + self::CLOUDFLARE->value => 'Cloudflare DNS service', + self::HETZNER->value => 'Hetzner DNS Console', + self::ROUTE53->value => 'AWS Route53 Dns Service', + ]; + + + public static function all(): array + { + $result = []; + foreach (self::cases() as $case) { + $result[$case->value] = self::getClass($case->value); + } + return $result; + } + + /** + * Returns providers with name and description + */ + public static function allWithDescriptions(): array + { + $result = []; + foreach (self::cases() as $case) { + $result[$case->value] = [ + "name" => $case->value, + "description" => self::DESCRIPTION_MAP[$case->value] + ]; + } + + return $result; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public function getClassName(): string + { + return self::CLASS_MAP[$this->value]; + } + + public static function getClass(string $provider): string + { + return self::from($provider)->getClassName(); + } +} diff --git a/app/Http/Controllers/Admin/NodeAutoDeployController.php b/app/Http/Controllers/Admin/NodeAutoDeployController.php index c53d8b9a4..2c38476e5 100644 --- a/app/Http/Controllers/Admin/NodeAutoDeployController.php +++ b/app/Http/Controllers/Admin/NodeAutoDeployController.php @@ -20,8 +20,7 @@ class NodeAutoDeployController extends Controller private ApiKeyRepository $repository, private Encrypter $encrypter, private KeyCreationService $keyCreationService, - ) { - } + ) {} /** * Generates a new API key for the logged-in user with only permission to read diff --git a/app/Http/Controllers/Admin/Nodes/NodeController.php b/app/Http/Controllers/Admin/Nodes/NodeController.php index c4ba0c61e..8209ec92f 100644 --- a/app/Http/Controllers/Admin/Nodes/NodeController.php +++ b/app/Http/Controllers/Admin/Nodes/NodeController.php @@ -8,28 +8,47 @@ use Pterodactyl\Models\Node; use Spatie\QueryBuilder\QueryBuilder; use Pterodactyl\Http\Controllers\Controller; use Illuminate\Contracts\View\Factory as ViewFactory; +use Pterodactyl\Repositories\Eloquent\NodeRepository; +use Illuminate\Support\Facades\Log; class NodeController extends Controller { - /** - * NodeController constructor. - */ - public function __construct(private ViewFactory $view) - { - } + /** + * NodeController constructor. + */ + public function __construct(private ViewFactory $view) {} - /** - * Returns a listing of nodes on the system. - */ - public function index(Request $request): View - { - $nodes = QueryBuilder::for( - Node::query()->with('location')->withCount('servers') - ) - ->allowedFilters(['uuid', 'name']) - ->allowedSorts(['id']) - ->paginate(25); + /** + * Returns a listing of nodes on the system. + */ + public function index(Request $request): View + { + $nodes = QueryBuilder::for( + Node::query()->with('location')->withCount('servers') + ) + ->allowedFilters(['uuid', 'name']) + ->allowedSorts(['id']) + ->paginate(25); - return $this->view->make('admin.nodes.index', ['nodes' => $nodes]); - } + foreach ($nodes as $node) { + $stats = app('Pterodactyl\Repositories\Eloquent\NodeRepository')->getUsageStatsRaw($node); + // NOTE: Pre-creating stats so we donn't do it in the blade + + $memoryPercent = ($stats['memory']['value'] / $stats['memory']['base_limit']) * 100; + $diskPercent = ($stats['disk']['value'] / $stats['disk']['base_limit']) * 100; + + $node->memory_percent = round($memoryPercent); + $node->memory_color = $memoryPercent < 50 ? '#50af51' : ($memoryPercent < 70 ? '#e0a800' : '#d9534f'); + $node->allocated_memory = humanizeSize($stats['memory']['value'] * 1024 * 1024); + $node->total_memory = humanizeSize($stats['memory']['max'] * 1024 * 1024); + + $node->disk_percent = round($diskPercent); + $node->disk_color = $diskPercent < 50 ? '#50af51' : ($diskPercent < 70 ? '#e0a800' : '#d9534f'); + $node->allocated_disk = humanizeSize($stats['disk']['value'] * 1024 * 1024); + $node->total_disk = humanizeSize($stats['disk']['max'] * 1024 * 1024); + } + + + return $this->view->make('admin.nodes.index', ['nodes' => $nodes]); + } } diff --git a/app/Http/Controllers/Admin/Nodes/NodeViewController.php b/app/Http/Controllers/Admin/Nodes/NodeViewController.php index acd44c2b7..4f6e7931f 100644 --- a/app/Http/Controllers/Admin/Nodes/NodeViewController.php +++ b/app/Http/Controllers/Admin/Nodes/NodeViewController.php @@ -16,6 +16,8 @@ use Pterodactyl\Services\Helpers\SoftwareVersionService; use Pterodactyl\Repositories\Eloquent\LocationRepository; use Pterodactyl\Repositories\Eloquent\AllocationRepository; use Illuminate\Support\Facades\DB; +use Pterodactyl\Enums\Daemon\DaemonType; +use Pterodactyl\Enums\Daemon\Adapters; class NodeViewController extends Controller { @@ -39,10 +41,11 @@ class NodeViewController extends Controller public function index(Request $request, Node $node): View { $node = $this->repository->loadLocationAndServerCount($node); + $stats = $this->repository->getUsageStats($node); return $this->view->make('admin.nodes.view.index', [ 'node' => $node, - 'stats' => $this->repository->getUsageStats($node), + 'stats' => $stats, 'version' => $this->versionService, ]); } @@ -55,6 +58,8 @@ class NodeViewController extends Controller return $this->view->make('admin.nodes.view.settings', [ 'node' => $node, 'locations' => $this->locationRepository->all(), + 'daemonTypes' => DaemonType::all(), + 'backupDisks' => Adapters::all_sorted(), ]); } @@ -76,7 +81,7 @@ class NodeViewController extends Controller $this->plainInject(['node' => Collection::wrap($node)->only(['id'])]); switch (DB::getPdo()->getAttribute(DB::getPdo()::ATTR_DRIVER_NAME)) { - case 'mysql': + default: return $this->view->make('admin.nodes.view.allocation', [ 'node' => $node, 'allocations' => Allocation::query()->where('node_id', $node->id) diff --git a/app/Http/Controllers/Admin/NodesController.php b/app/Http/Controllers/Admin/NodesController.php index b71493292..c19cfcb63 100644 --- a/app/Http/Controllers/Admin/NodesController.php +++ b/app/Http/Controllers/Admin/NodesController.php @@ -9,6 +9,8 @@ use Illuminate\Http\Response; use Pterodactyl\Models\Allocation; use Illuminate\Http\RedirectResponse; use Prologue\Alerts\AlertsMessageBag; +use Pterodactyl\Enums\Daemon\Adapters; +use Pterodactyl\Enums\Daemon\DaemonType; use Illuminate\View\Factory as ViewFactory; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Services\Nodes\NodeUpdateService; @@ -45,8 +47,7 @@ class NodesController extends Controller protected NodeUpdateService $updateService, protected SoftwareVersionService $versionService, protected ViewFactory $view, - ) { - } + ) {} /** * Displays create new node page. @@ -60,7 +61,7 @@ class NodesController extends Controller return redirect()->route('admin.locations'); } - return $this->view->make('admin.nodes.new', ['locations' => $locations]); + return $this->view->make('admin.nodes.new', ['locations' => $locations, 'daemonTypes' => DaemonType::all(), 'backupDisks' => Adapters::all_sorted()]); } /** diff --git a/app/Http/Controllers/Admin/Servers/CreateServerController.php b/app/Http/Controllers/Admin/Servers/CreateServerController.php index 0bf26df49..9c71e4198 100644 --- a/app/Http/Controllers/Admin/Servers/CreateServerController.php +++ b/app/Http/Controllers/Admin/Servers/CreateServerController.php @@ -25,8 +25,7 @@ class CreateServerController extends Controller private NodeRepository $nodeRepository, private ServerCreationService $creationService, private ViewFactory $view, - ) { - } + ) {} /** * Displays the create server page. diff --git a/app/Http/Controllers/Admin/Servers/ServerController.php b/app/Http/Controllers/Admin/Servers/ServerController.php index 430c3f2b9..b330c23be 100644 --- a/app/Http/Controllers/Admin/Servers/ServerController.php +++ b/app/Http/Controllers/Admin/Servers/ServerController.php @@ -16,9 +16,7 @@ class ServerController extends Controller /** * ServerController constructor. */ - public function __construct(private ViewFactory $view) - { - } + public function __construct(private ViewFactory $view) {} /** * Returns all the servers that exist on the system using a paginated result set. If diff --git a/app/Http/Controllers/Admin/Servers/ServerViewController.php b/app/Http/Controllers/Admin/Servers/ServerViewController.php index 1bf731cf1..2850d84c6 100644 --- a/app/Http/Controllers/Admin/Servers/ServerViewController.php +++ b/app/Http/Controllers/Admin/Servers/ServerViewController.php @@ -10,6 +10,7 @@ use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Services\Servers\EnvironmentService; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Support\Facades\Log; use Pterodactyl\Repositories\Eloquent\NestRepository; use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\MountRepository; @@ -34,8 +35,7 @@ class ServerViewController extends Controller private ServerRepository $repository, private EnvironmentService $environmentService, private ViewFactory $view, - ) { - } + ) {} /** * Returns the index view for a server. diff --git a/app/Http/Controllers/Admin/Settings/CaptchaController.php b/app/Http/Controllers/Admin/Settings/CaptchaController.php index 5072be2b5..289cd3352 100644 --- a/app/Http/Controllers/Admin/Settings/CaptchaController.php +++ b/app/Http/Controllers/Admin/Settings/CaptchaController.php @@ -12,6 +12,7 @@ use Pterodactyl\Services\Captcha\CaptchaManager; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface; use Pterodactyl\Http\Requests\Admin\Settings\CaptchaSettingsFormRequest; +use Pterodactyl\Enums\Captcha\Captchas; class CaptchaController extends Controller { @@ -32,13 +33,9 @@ class CaptchaController extends Controller */ public function index(): View { + return $this->view->make('admin.settings.captcha', [ - 'providers' => [ - 'none' => 'Disabled', - 'turnstile' => 'Cloudflare Turnstile', - 'hcaptcha' => 'hCaptcha', - 'recaptcha' => 'Google reCAPTCHA', - ], + 'providers' => Captchas::all(), ]); } @@ -51,13 +48,13 @@ class CaptchaController extends Controller public function update(CaptchaSettingsFormRequest $request): RedirectResponse { $values = $request->normalize(); - + foreach ($values as $key => $value) { // Encrypt secret keys before storing if (in_array($key, \Pterodactyl\Providers\SettingsServiceProvider::getEncryptedKeys()) && !empty($value)) { $value = $this->encrypter->encrypt($value); } - + $this->settings->set('settings::' . $key, $value); } diff --git a/app/Http/Controllers/Admin/Settings/DomainsController.php b/app/Http/Controllers/Admin/Settings/DomainsController.php index c292bdacb..8cf70ec3f 100644 --- a/app/Http/Controllers/Admin/Settings/DomainsController.php +++ b/app/Http/Controllers/Admin/Settings/DomainsController.php @@ -11,6 +11,7 @@ use Pterodactyl\Services\Subdomain\SubdomainManagementService; use Pterodactyl\Exceptions\Dns\DnsProviderException; use Illuminate\Contracts\View\Factory as ViewFactory; use Pterodactyl\Http\Requests\Admin\Settings\DomainFormRequest; +use Pterodactyl\Enums\Subdomain\Providers; class DomainsController extends Controller { @@ -220,20 +221,7 @@ class DomainsController extends Controller */ private function getAvailableProviders(): array { - return [ - 'cloudflare' => [ - 'name' => 'Cloudflare', - 'description' => 'Cloudflare DNS service', - ], - 'hetzner' => [ - 'name' => 'Hetzner', - 'description' => 'Hetzner DNS Console', - ], - 'route53' => [ - 'name' => 'Route53', - 'description' => 'AWS Route53 Dns Service', - ], - ]; + return Providers::allWithDescriptions(); } /** @@ -241,11 +229,7 @@ class DomainsController extends Controller */ private function getProviderClass(string $provider): string { - $providers = [ - 'cloudflare' => \Pterodactyl\Services\Dns\Providers\CloudflareProvider::class, - 'hetzner' => \Pterodactyl\Services\Dns\Providers\HetznerProvider::class, - 'route53' => \Pterodactyl\Services\Dns\Providers\Route53Provider::class, - ]; + $providers = Providers::all(); if (!isset($providers[$provider])) { throw new \Exception("Unsupported DNS provider: {$provider}"); @@ -254,4 +238,3 @@ class DomainsController extends Controller return $providers[$provider]; } } - diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 8a0364611..26a1007a7 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -37,6 +37,7 @@ class ClientController extends ClientApiController 'name', 'description', 'external_id', + 'daemonType', AllowedFilter::custom('*', new MultiFieldServerFilter()), ]); diff --git a/app/Http/Controllers/Api/Client/ServerController.php b/app/Http/Controllers/Api/Client/ServerController.php new file mode 100644 index 000000000..7a325558a --- /dev/null +++ b/app/Http/Controllers/Api/Client/ServerController.php @@ -0,0 +1,53 @@ +loadMissing('node'); + + $daemonType = $server->node?->daemonType; + return $this->fractal->item($server) + ->transformWith($this->getTransformer(ServerTransformer::class)) + ->addMeta([ + 'daemonType' => $daemonType, + 'is_server_owner' => $request->user()->id === $server->owner_id, + 'user_permissions' => $this->permissionsService->handle($server, $request->user()), + ]) + ->toArray(); + } + + public function resources(GetServerRequest $request, Server $server): array + { + $server->loadMissing('node'); + + $daemonType = $server->node?->daemonType ?? 'elytra'; + $controllers = DaemonType::allResources(); + $controllerClass = $controllers[$daemonType]; + + $controller = app($controllerClass); + return $controller->__invoke($request, $server); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/ActivityLogController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/ActivityLogController.php new file mode 100644 index 000000000..1d1581a5d --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/ActivityLogController.php @@ -0,0 +1,54 @@ +authorize(Permission::ACTION_ACTIVITY_READ, $server); + + $activity = QueryBuilder::for($server->activity()) + ->with('actor') + ->allowedSorts(['timestamp']) + ->allowedFilters([AllowedFilter::partial('event')]) + ->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS) + ->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) { + // We could do this with a query and a lot of joins, but that gets pretty + // painful so for now we'll execute a simpler query. + $subusers = $server->subusers()->pluck('user_id')->merge($server->owner_id); + + $builder->select('activity_logs.*') + ->leftJoin('users', function (JoinClause $join) { + $join->on('users.id', 'activity_logs.actor_id') + ->where('activity_logs.actor_type', (new User())->getMorphClass()); + }) + ->where(function (Builder $builder) use ($subusers) { + $builder->whereNull('users.id') + ->orWhere('users.root_admin', 0) + ->orWhereIn('users.id', $subusers); + }); + }) + ->paginate(min($request->query('per_page', 25), 100)) + ->appends($request->query()); + + return $this->fractal->collection($activity) + ->transformWith($this->getTransformer(ActivityLogTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/BackupsController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/BackupsController.php similarity index 99% rename from app/Http/Controllers/Api/Client/Servers/BackupsController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/BackupsController.php index de54afde9..65df79e8c 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupsController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/BackupsController.php @@ -1,6 +1,6 @@ count($backupUuids), ]); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/CommandController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/CommandController.php new file mode 100644 index 000000000..3432ff642 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/CommandController.php @@ -0,0 +1,54 @@ +repository->setServer($server)->send($request->input('command')); + } catch (DaemonConnectionException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof BadResponseException) { + if ( + $previous->getResponse() instanceof ResponseInterface + && $previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY + ) { + throw new HttpException(Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception); + } + } + + throw $exception; + } + + Activity::event('server:console.command')->property('command', $request->input('command'))->log(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php new file mode 100644 index 000000000..85bc3291e --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php @@ -0,0 +1,102 @@ +fractal->collection($server->databases) + ->transformWith($this->getTransformer(DatabaseTransformer::class)) + ->toArray(); + } + + /** + * Create a new database for the given server and return it. + * + * @throws \Throwable + * @throws \Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException + * @throws \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException + */ + public function store(StoreDatabaseRequest $request, Server $server): array + { + $database = $this->deployDatabaseService->handle($server, $request->validated()); + + Activity::event('server:database.create') + ->subject($database) + ->property('name', $database->database) + ->log(); + + return $this->fractal->item($database) + ->parseIncludes(['password']) + ->transformWith($this->getTransformer(DatabaseTransformer::class)) + ->toArray(); + } + + /** + * Rotates the password for the given server model and returns a fresh instance to + * the caller. + * + * @throws \Throwable + */ + public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array + { + $this->passwordService->handle($database); + $database->refresh(); + + Activity::event('server:database.rotate-password') + ->subject($database) + ->property('name', $database->database) + ->log(); + + return $this->fractal->item($database) + ->parseIncludes(['password']) + ->transformWith($this->getTransformer(DatabaseTransformer::class)) + ->toArray(); + } + + /** + * Removes a database from the server. + * + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function delete(DeleteDatabaseRequest $request, Server $server, Database $database): Response + { + $this->managementService->delete($database); + + Activity::event('server:database.delete') + ->subject($database) + ->property('name', $database->database) + ->log(); + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/ElytraJobsController.php similarity index 98% rename from app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/ElytraJobsController.php index dcb2da065..67e58bfed 100644 --- a/app/Http/Controllers/Api/Client/Servers/ElytraJobsController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/ElytraJobsController.php @@ -1,6 +1,6 @@ fileRepository + ->setServer($server) + ->getDirectory($request->get('directory') ?? '/'); + + return $this->fractal->collection($contents) + ->transformWith($this->getTransformer(FileObjectTransformer::class)) + ->toArray(); + } + + /** + * Return the contents of a specified file for the user. + * + * @throws \Throwable + */ + public function contents(GetFileContentsRequest $request, Server $server): Response + { + $response = $this->fileRepository->setServer($server)->getContent( + $request->get('file'), + config('pterodactyl.files.max_edit_size') + ); + + Activity::event('server:file.read')->property('file', $request->get('file'))->log(); + + return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + } + + /** + * Generates a one-time token with a link that the user can use to + * download a given file. + * + * @throws \Throwable + */ + public function download(GetFileContentsRequest $request, Server $server): array + { + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setUser($request->user()) + ->setClaims([ + 'file_path' => rawurldecode($request->get('file')), + 'server_uuid' => $server->uuid, + ]) + ->handle($server->node, $request->user()->id . $server->uuid); + + Activity::event('server:file.download')->property('file', $request->get('file'))->log(); + + return [ + 'object' => 'signed_url', + 'attributes' => [ + 'url' => sprintf( + '%s/download/file?token=%s', + $server->node->getConnectionAddress(), + $token->toString() + ), + ], + ]; + } + + /** + * Writes the contents of the specified file to the server. + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function write(WriteFileContentRequest $request, Server $server): JsonResponse + { + $this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); + + Activity::event('server:file.write')->property('file', $request->get('file'))->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Creates a new folder on the server. + * + * @throws \Throwable + */ + public function create(CreateFolderRequest $request, Server $server): JsonResponse + { + $this->fileRepository + ->setServer($server) + ->createDirectory($request->input('name'), $request->input('root', '/')); + + Activity::event('server:file.create-directory') + ->property('name', $request->input('name')) + ->property('directory', $request->input('root')) + ->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Renames a file on the remote machine. + * + * @throws \Throwable + */ + public function rename(RenameFileRequest $request, Server $server): JsonResponse + { + $this->fileRepository + ->setServer($server) + ->renameFiles($request->input('root'), $request->input('files')); + + Activity::event('server:file.rename') + ->property('directory', $request->input('root')) + ->property('files', $request->input('files')) + ->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Copies a file on the server. + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function copy(CopyFileRequest $request, Server $server): JsonResponse + { + $this->fileRepository + ->setServer($server) + ->copyFile($request->input('location')); + + Activity::event('server:file.copy')->property('file', $request->input('location'))->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function compress(CompressFilesRequest $request, Server $server): array + { + $file = $this->fileRepository->setServer($server)->compressFiles( + $request->input('root'), + $request->input('files') + ); + + Activity::event('server:file.compress') + ->property('directory', $request->input('root')) + ->property('files', $request->input('files')) + ->log(); + + return $this->fractal->item($file) + ->transformWith($this->getTransformer(FileObjectTransformer::class)) + ->toArray(); + } + + /** + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse + { + set_time_limit(300); + + $this->fileRepository->setServer($server)->decompressFile( + $request->input('root'), + $request->input('file') + ); + + Activity::event('server:file.decompress') + ->property('directory', $request->input('root')) + ->property('files', $request->input('file')) + ->log(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Deletes files or folders for the server in the given root directory. + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function delete(DeleteFileRequest $request, Server $server): JsonResponse + { + $this->fileRepository->setServer($server)->deleteFiles( + $request->input('root'), + $request->input('files') + ); + + Activity::event('server:file.delete') + ->property('directory', $request->input('root')) + ->property('files', $request->input('files')) + ->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Updates file permissions for file(s) in the given root directory. + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function chmod(ChmodFilesRequest $request, Server $server): JsonResponse + { + $this->fileRepository->setServer($server)->chmodFiles( + $request->input('root'), + $request->input('files') + ); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Requests that a file be downloaded from a remote location by Wings. + * + * @throws \Throwable + */ + public function pull(PullFileRequest $request, Server $server): JsonResponse + { + $this->fileRepository->setServer($server)->pull( + $request->input('url'), + $request->input('directory'), + $request->safe(['filename', 'use_header', 'foreground']) + ); + + Activity::event('server:file.pull') + ->property('directory', $request->input('directory')) + ->property('url', $request->input('url')) + ->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/FileUploadController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/FileUploadController.php new file mode 100644 index 000000000..f9530609f --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/FileUploadController.php @@ -0,0 +1,54 @@ + 'signed_url', + 'attributes' => [ + 'url' => $this->getUploadUrl($server, $request->user()), + ], + ]); + } + + /** + * Returns an url where files can be uploaded to. + */ + protected function getUploadUrl(Server $server, User $user): string + { + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setUser($user) + ->setClaims(['server_uuid' => $server->uuid]) + ->handle($server->node, $user->id . $server->uuid); + + return sprintf( + '%s/upload/file?token=%s', + $server->node->getConnectionAddress(), + $token->toString() + ); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/NetworkAllocationController.php similarity index 98% rename from app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/NetworkAllocationController.php index 11a573423..6808b645f 100644 --- a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/NetworkAllocationController.php @@ -1,6 +1,6 @@ repository->setServer($server)->send( + $request->input('signal') + ); + + Activity::event(strtolower("server:power.{$request->input('signal')}"))->log(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/ResourceUtilizationController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/ResourceUtilizationController.php new file mode 100644 index 000000000..42f15b312 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/ResourceUtilizationController.php @@ -0,0 +1,41 @@ +uuid"; + $stats = $this->cache->remember($key, Carbon::now()->addSeconds(20), function () use ($server) { + return $this->repository->setServer($server)->getDetails(); + }); + + return $this->fractal->item($stats) + ->transformWith($this->getTransformer(StatsTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/ScheduleController.php similarity index 99% rename from app/Http/Controllers/Api/Client/Servers/ScheduleController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/ScheduleController.php index bfbbf5123..525a60bc0 100644 --- a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/ScheduleController.php @@ -1,6 +1,6 @@ fractal->item($server) + ->transformWith($this->getTransformer(ServerTransformer::class)) + ->addMeta([ + 'is_server_owner' => $request->user()->id === $server->owner_id, + 'user_permissions' => $this->permissionsService->handle($server, $request->user()), + ]) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/SettingsController.php similarity index 62% rename from app/Http/Controllers/Api/Client/Servers/SettingsController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/SettingsController.php index 364192397..6be3f1691 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/SettingsController.php @@ -1,6 +1,6 @@ image; $defaultImage = $server->getDefaultDockerImage(); - + if (empty($defaultImage)) { throw new BadRequestHttpException('No default docker image available for this server\'s egg.'); } @@ -140,98 +140,95 @@ class SettingsController extends ClientApiController return new JsonResponse([], Response::HTTP_NO_CONTENT); } - public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse - { - try { - $eggId = $request->input('egg_id'); - $nestId = $request->input('nest_id'); - - $previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId); - - // Log the preview action - Activity::event('server:settings.egg-preview') - ->property([ - 'current_egg_id' => $server->egg_id, - 'preview_egg_id' => $eggId, - 'preview_nest_id' => $nestId, - ]) - ->log(); - - return new JsonResponse($previewData); - } catch (Exception $e) { - Log::error('Failed to preview egg change', [ - 'server_id' => $server->id, - 'egg_id' => $request->input('egg_id'), - 'nest_id' => $request->input('nest_id'), - 'error' => $e->getMessage(), - ]); - - throw $e; - } - } + public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse + { + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); - /** - * Apply egg configuration changes asynchronously. - * This dispatches a background job to handle the complete egg change process. - * - * @throws \Throwable - */ - public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse - { - try { - $eggId = $request->input('egg_id'); - $nestId = $request->input('nest_id'); - $dockerImage = $request->input('docker_image'); - $startupCommand = $request->input('startup_command'); - $environment = $request->input('environment', []); - $shouldBackup = $request->input('should_backup', false); - $shouldWipe = $request->input('should_wipe', false); - - $result = $this->eggChangeService->applyEggChangeAsync( - $server, - $request->user(), - $eggId, - $nestId, - $dockerImage, - $startupCommand, - $environment, - $shouldBackup, - $shouldWipe - ); - - Activity::event('server:software.change-queued') - ->property([ - 'operation_id' => $result['operation_id'], - 'from_egg' => $server->egg_id, - 'to_egg' => $eggId, - 'should_backup' => $shouldBackup, - 'should_wipe' => $shouldWipe, - ]) - ->log(); - - return new JsonResponse($result, Response::HTTP_ACCEPTED); - } catch (Exception $e) { - Log::error('Failed to apply egg change', [ - 'server_id' => $server->id, - 'error' => $e->getMessage(), - ]); - - throw $e; - } - } + $previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId); - public function getOperationStatus(Server $server, string $operationId): JsonResponse - { - $operation = $this->operationService->getOperation($server, $operationId); - return new JsonResponse($this->operationService->formatOperationResponse($operation)); - } + // Log the preview action + Activity::event('server:settings.egg-preview') + ->property([ + 'current_egg_id' => $server->egg_id, + 'preview_egg_id' => $eggId, + 'preview_nest_id' => $nestId, + ]) + ->log(); - public function getServerOperations(Server $server): JsonResponse - { - $operations = $this->operationService->getServerOperations($server); - return new JsonResponse(['operations' => $operations]); - } + return new JsonResponse($previewData); + } catch (Exception $e) { + Log::error('Failed to preview egg change', [ + 'server_id' => $server->id, + 'egg_id' => $request->input('egg_id'), + 'nest_id' => $request->input('nest_id'), + 'error' => $e->getMessage(), + ]); - + throw $e; + } + } + + /** + * Apply egg configuration changes asynchronously. + * This dispatches a background job to handle the complete egg change process. + * + * @throws \Throwable + */ + public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse + { + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + $dockerImage = $request->input('docker_image'); + $startupCommand = $request->input('startup_command'); + $environment = $request->input('environment', []); + $shouldBackup = $request->input('should_backup', false); + $shouldWipe = $request->input('should_wipe', false); + + $result = $this->eggChangeService->applyEggChangeAsync( + $server, + $request->user(), + $eggId, + $nestId, + $dockerImage, + $startupCommand, + $environment, + $shouldBackup, + $shouldWipe + ); + + Activity::event('server:software.change-queued') + ->property([ + 'operation_id' => $result['operation_id'], + 'from_egg' => $server->egg_id, + 'to_egg' => $eggId, + 'should_backup' => $shouldBackup, + 'should_wipe' => $shouldWipe, + ]) + ->log(); + + return new JsonResponse($result, Response::HTTP_ACCEPTED); + } catch (Exception $e) { + Log::error('Failed to apply egg change', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + public function getOperationStatus(Server $server, string $operationId): JsonResponse + { + $operation = $this->operationService->getOperation($server, $operationId); + return new JsonResponse($this->operationService->formatOperationResponse($operation)); + } + + public function getServerOperations(Server $server): JsonResponse + { + $operations = $this->operationService->getServerOperations($server); + return new JsonResponse(['operations' => $operations]); + } } - diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/StartupController.php similarity index 98% rename from app/Http/Controllers/Api/Client/Servers/StartupController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/StartupController.php index dfee51a52..cbceedc47 100644 --- a/app/Http/Controllers/Api/Client/Servers/StartupController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/StartupController.php @@ -1,6 +1,6 @@ input('command', $server->startup); - + // Temporarily update the server's startup command for processing $originalStartup = $server->startup; $server->startup = $command; - + $processedCommand = $this->startupCommandService->handle($server, false); - + // Restore original startup command $server->startup = $originalStartup; - + return [ 'processed_command' => $processedCommand, ]; diff --git a/app/Http/Controllers/Api/Client/Servers/SubdomainController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/SubdomainController.php similarity index 98% rename from app/Http/Controllers/Api/Client/Servers/SubdomainController.php rename to app/Http/Controllers/Api/Client/Servers/Elytra/SubdomainController.php index 2901cd8fb..294309982 100644 --- a/app/Http/Controllers/Api/Client/Servers/SubdomainController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/SubdomainController.php @@ -1,6 +1,6 @@ attributes->get('server'); - + $this->authorize(Permission::ACTION_ALLOCATION_READ, $server); try { @@ -73,15 +73,15 @@ class SubdomainController extends ClientApiController public function store(CreateSubdomainRequest $request): JsonResponse { $server = $request->attributes->get('server'); - + $this->authorize(Permission::ACTION_ALLOCATION_CREATE, $server); - + $data = $request->validated(); try { // Get ALL active subdomains for this server (more than one should be impossible, but PHP makes me angry) $existingSubdomains = $server->subdomains()->where('is_active', true)->get(); - + $domain = Domain::where('id', $data['domain_id']) ->where('is_active', true) ->first(); @@ -148,7 +148,7 @@ class SubdomainController extends ClientApiController public function destroy(Request $request): JsonResponse { $server = $request->attributes->get('server'); - + $this->authorize(Permission::ACTION_ALLOCATION_DELETE, $server); try { @@ -179,7 +179,6 @@ class SubdomainController extends ClientApiController return response()->json([ 'error' => 'Failed to delete subdomain(s).' ], 422); - } } @@ -189,9 +188,9 @@ class SubdomainController extends ClientApiController public function checkAvailability(Request $request): JsonResponse { $server = $request->attributes->get('server'); - + $this->authorize(Permission::ACTION_ALLOCATION_READ, $server); - + $request->validate([ 'subdomain' => 'required|string|min:1|max:63|regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/', 'domain_id' => 'required|integer|exists:domains,id', @@ -221,4 +220,5 @@ class SubdomainController extends ClientApiController ], 422); } } -} \ No newline at end of file +} + diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/WebsocketController.php new file mode 100644 index 000000000..114d9b963 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/WebsocketController.php @@ -0,0 +1,73 @@ +user(); + if ($user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $server)) { + throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.'); + } + + $permissions = $this->permissionsService->handle($server, $user); + + $node = $server->node; + if (!is_null($server->transfer)) { + // Check if the user has permissions to receive transfer logs. + if (!in_array('admin.websocket.transfer', $permissions)) { + throw new HttpForbiddenException('You do not have permission to view server transfer logs.'); + } + + // Redirect the websocket request to the new node if the server has been archived. + if ($server->transfer->archived) { + $node = $server->transfer->newNode; + } + } + + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(10)) + ->setUser($request->user()) + ->setClaims([ + 'server_uuid' => $server->uuid, + 'permissions' => $permissions, + ]) + ->handle($node, $user->id . $server->uuid); + + $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getBrowserConnectionAddress()); + + return new JsonResponse([ + 'data' => [ + 'token' => $token->toString(), + 'socket' => $socket . sprintf('/api/servers/%s/ws', $server->uuid), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php b/app/Http/Controllers/Api/Client/Servers/Wings/ActivityLogController.php similarity index 97% rename from app/Http/Controllers/Api/Client/Servers/ActivityLogController.php rename to app/Http/Controllers/Api/Client/Servers/Wings/ActivityLogController.php index f569c4122..33f2e1583 100644 --- a/app/Http/Controllers/Api/Client/Servers/ActivityLogController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/ActivityLogController.php @@ -1,6 +1,6 @@ user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + + $limit = min($request->query('per_page') ?? 20, 50); + + return $this->fractal->collection($server->backups()->paginate($limit)) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->addMeta([ + 'backup_count' => $this->repository->getNonFailedBackups($server)->count(), + ]) + ->toArray(); + } + + /** + * Starts the backup process for a server. + * + * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation + * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified + * @throws \Throwable + */ + public function store(StoreBackupRequest $request, Server $server): array + { + $action = $this->initiateBackupService + ->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? '')); + + // Only set the lock status if the user even has permission to delete backups, + // otherwise ignore this status. This gets a little funky since it isn't clear + // how best to allow a user to create a backup that is locked without also preventing + // them from just filling up a server with backups that can never be deleted? + if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + $action->setIsLocked((bool) $request->input('is_locked')); + } + + $backup = $action->handle($server, $request->input('name')); + + Activity::event('server:backup.start') + ->subject($backup) + ->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')]) + ->log(); + + return $this->fractal->item($backup) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); + } + + /** + * Toggles the lock status of a given backup for a server. + * + * @throws \Throwable + * @throws AuthorizationException + */ + public function toggleLock(Request $request, Server $server, Backup $backup): array + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock'; + + $backup->update(['is_locked' => !$backup->is_locked]); + + Activity::event($action)->subject($backup)->property('name', $backup->name)->log(); + + return $this->fractal->item($backup) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); + } + + /** + * Returns information about a single backup. + * + * @throws AuthorizationException + */ + public function view(Request $request, Server $server, Backup $backup): array + { + if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + + return $this->fractal->item($backup) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); + } + + /** + * Deletes a backup from the panel as well as the remote source where it is currently + * being stored. + * + * @throws \Throwable + */ + public function delete(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $this->deleteBackupService->handle($backup); + + Activity::event('server:backup.delete') + ->subject($backup) + ->property(['name' => $backup->name, 'failed' => !$backup->is_successful]) + ->log(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Download the backup for a given server instance. For daemon local files, the file + * will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated + * which the user is redirected to. + * + * @throws \Throwable + * @throws AuthorizationException + */ + public function download(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) { + throw new AuthorizationException(); + } + + if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) { + throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.'); + } + + $url = $this->downloadLinkService->handle($backup, $request->user()); + + Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log(); + + return new JsonResponse([ + 'object' => 'signed_url', + 'attributes' => ['url' => $url], + ]); + } + + /** + * Handles restoring a backup by making a request to the Wings instance telling it + * to begin the process of finding (or downloading) the backup and unpacking it + * over the server files. + * + * If the "truncate" flag is passed through in this request then all the + * files that currently exist on the server will be deleted before restoring. + * Otherwise, the archive will simply be unpacked over the existing files. + * + * @throws \Throwable + */ + public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse + { + // Cannot restore a backup unless a server is fully installed and not currently + // processing a different backup restoration request. + if (!is_null($server->status)) { + throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'); + } + + if (!$backup->is_successful && is_null($backup->completed_at)) { + throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'); + } + + $log = Activity::event('server:backup.restore') + ->subject($backup) + ->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]); + + $log->transaction(function () use ($backup, $server, $request) { + // If the backup is for an S3 file we need to generate a unique Download link for + // it that will allow Wings to actually access the file. + if ($backup->disk === Backup::ADAPTER_AWS_S3) { + $url = $this->downloadLinkService->handle($backup, $request->user()); + } + + // Update the status right away for the server so that we know not to allow certain + // actions against it via the Panel API. + $server->update(['status' => Server::STATUS_RESTORING_BACKUP]); + + $this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate')); + }); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/CommandController.php b/app/Http/Controllers/Api/Client/Servers/Wings/CommandController.php similarity index 96% rename from app/Http/Controllers/Api/Client/Servers/CommandController.php rename to app/Http/Controllers/Api/Client/Servers/Wings/CommandController.php index 55ac2131a..0e3b5145c 100644 --- a/app/Http/Controllers/Api/Client/Servers/CommandController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/CommandController.php @@ -1,6 +1,6 @@ fractal->collection($server->allocations) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Set the primary allocation for a server. + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array + { + $original = $allocation->notes; + + $allocation->forceFill(['notes' => $request->input('notes')])->save(); + + if ($original !== $allocation->notes) { + Activity::event('server:allocation.notes') + ->subject($allocation) + ->property(['allocation' => $allocation->toString(), 'old' => $original, 'new' => $allocation->notes]) + ->log(); + } + + return $this->fractal->item($allocation) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Set the primary allocation for a server. + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array + { + $this->serverRepository->update($server->id, ['allocation_id' => $allocation->id]); + + Activity::event('server:allocation.primary') + ->subject($allocation) + ->property('allocation', $allocation->toString()) + ->log(); + + return $this->fractal->item($allocation) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Set the notes for the allocation for a server. + *s. + * + * @throws DisplayException + */ + public function store(NewAllocationRequest $request, Server $server): array + { + if ($server->allocations()->count() >= $server->allocation_limit) { + throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'); + } + + $allocation = $this->assignableAllocationService->handle($server); + + Activity::event('server:allocation.create') + ->subject($allocation) + ->property('allocation', $allocation->toString()) + ->log(); + + return $this->fractal->item($allocation) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Delete an allocation from a server. + * + * @throws DisplayException + */ + public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation): JsonResponse + { + // Don't allow the deletion of allocations if the server does not have an + // allocation limit set. + if (empty($server->allocation_limit)) { + throw new DisplayException('You cannot delete allocations for this server: no allocation limit is set.'); + } + + if ($allocation->id === $server->allocation_id) { + throw new DisplayException('You cannot delete the primary allocation for this server.'); + } + + Allocation::query()->where('id', $allocation->id)->update([ + 'notes' => null, + 'server_id' => null, + ]); + + Activity::event('server:allocation.delete') + ->subject($allocation) + ->property('allocation', $allocation->toString()) + ->log(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/PowerController.php b/app/Http/Controllers/Api/Client/Servers/Wings/PowerController.php similarity index 93% rename from app/Http/Controllers/Api/Client/Servers/PowerController.php rename to app/Http/Controllers/Api/Client/Servers/Wings/PowerController.php index ca0575765..3d63d9dba 100644 --- a/app/Http/Controllers/Api/Client/Servers/PowerController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/PowerController.php @@ -1,6 +1,6 @@ schedules->loadMissing('tasks'); + + return $this->fractal->collection($schedules) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + + /** + * Store a new schedule for a server. + * + * @throws DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + public function store(StoreScheduleRequest $request, Server $server): array + { + /** @var Schedule $model */ + $model = $this->repository->create([ + 'server_id' => $server->id, + 'name' => $request->input('name'), + 'cron_day_of_week' => $request->input('day_of_week'), + 'cron_month' => $request->input('month'), + 'cron_day_of_month' => $request->input('day_of_month'), + 'cron_hour' => $request->input('hour'), + 'cron_minute' => $request->input('minute'), + 'is_active' => (bool) $request->input('is_active'), + 'only_when_online' => (bool) $request->input('only_when_online'), + 'next_run_at' => $this->getNextRunAt($request), + ]); + + Activity::event('server:schedule.create') + ->subject($model) + ->property('name', $model->name) + ->log(); + + return $this->fractal->item($model) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + + /** + * Returns a specific schedule for the server. + */ + public function view(ViewScheduleRequest $request, Server $server, Schedule $schedule): array + { + if ($schedule->server_id !== $server->id) { + throw new NotFoundHttpException(); + } + + $schedule->loadMissing('tasks'); + + return $this->fractal->item($schedule) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + + /** + * Updates a given schedule with the new data provided. + * + * @throws DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateScheduleRequest $request, Server $server, Schedule $schedule): array + { + $active = (bool) $request->input('is_active'); + + $data = [ + 'name' => $request->input('name'), + 'cron_day_of_week' => $request->input('day_of_week'), + 'cron_month' => $request->input('month'), + 'cron_day_of_month' => $request->input('day_of_month'), + 'cron_hour' => $request->input('hour'), + 'cron_minute' => $request->input('minute'), + 'is_active' => $active, + 'only_when_online' => (bool) $request->input('only_when_online'), + 'next_run_at' => $this->getNextRunAt($request), + ]; + + // Toggle the processing state of the scheduled task when it is enabled or disabled so that an + // invalid state can be reset without manual database intervention. + // + // @see https://github.com/pterodactyl/panel/issues/2425 + if ($schedule->is_active !== $active) { + $data['is_processing'] = false; + } + + $this->repository->update($schedule->id, $data); + + Activity::event('server:schedule.update') + ->subject($schedule) + ->property(['name' => $schedule->name, 'active' => $active]) + ->log(); + + return $this->fractal->item($schedule->refresh()) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + + /** + * Executes a given schedule immediately rather than waiting on it's normally scheduled time + * to pass. This does not care about the schedule state. + * + * @throws \Throwable + */ + public function execute(TriggerScheduleRequest $request, Server $server, Schedule $schedule): JsonResponse + { + $this->service->handle($schedule, true); + + Activity::event('server:schedule.execute')->subject($schedule)->property('name', $schedule->name)->log(); + + return new JsonResponse([], JsonResponse::HTTP_ACCEPTED); + } + + /** + * Deletes a schedule and it's associated tasks. + */ + public function delete(DeleteScheduleRequest $request, Server $server, Schedule $schedule): JsonResponse + { + $this->repository->delete($schedule->id); + + Activity::event('server:schedule.delete')->subject($schedule)->property('name', $schedule->name)->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Get the next run timestamp based on the cron data provided. + * + * @throws DisplayException + */ + protected function getNextRunAt(Request $request): Carbon + { + try { + return Utilities::getScheduleNextRunDate( + $request->input('minute'), + $request->input('hour'), + $request->input('day_of_month'), + $request->input('month'), + $request->input('day_of_week') + ); + } catch (\Exception $exception) { + throw new DisplayException('The cron data provided does not evaluate to a valid expression.'); + } + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Wings/ScheduleTaskController.php b/app/Http/Controllers/Api/Client/Servers/Wings/ScheduleTaskController.php new file mode 100644 index 000000000..18cc2cd3c --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Wings/ScheduleTaskController.php @@ -0,0 +1,175 @@ +tasks()->count() >= $limit) { + throw new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit."); + } + + if ($server->backup_limit === 0 && $request->action === 'backup') { + throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."); + } + + /** @var Task|null $lastTask */ + $lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first(); + + /** @var Task $task */ + $task = $this->connection->transaction(function () use ($request, $schedule, $lastTask) { + $sequenceId = ($lastTask->sequence_id ?? 0) + 1; + $requestSequenceId = $request->integer('sequence_id', $sequenceId); + + // Ensure that the sequence id is at least 1. + if ($requestSequenceId < 1) { + $requestSequenceId = 1; + } + + // If the sequence id from the request is greater than or equal to the next available + // sequence id, we don't need to do anything special. Otherwise, we need to update + // the sequence id of all tasks that are greater than or equal to the request sequence + // id to be one greater than the current value. + if ($requestSequenceId < $sequenceId) { + $schedule->tasks() + ->where('sequence_id', '>=', $requestSequenceId) + ->increment('sequence_id'); + $sequenceId = $requestSequenceId; + } + + return $this->repository->create([ + 'schedule_id' => $schedule->id, + 'sequence_id' => $sequenceId, + 'action' => $request->input('action'), + 'payload' => $request->input('payload') ?? '', + 'time_offset' => $request->input('time_offset'), + 'continue_on_failure' => $request->boolean('continue_on_failure'), + ]); + }); + + Activity::event('server:task.create') + ->subject($schedule, $task) + ->property(['name' => $schedule->name, 'action' => $task->action, 'payload' => $task->payload]) + ->log(); + + return $this->fractal->item($task) + ->transformWith($this->getTransformer(TaskTransformer::class)) + ->toArray(); + } + + /** + * Updates a given task for a server. + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(StoreTaskRequest $request, Server $server, Schedule $schedule, Task $task): array + { + if ($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id) { + throw new NotFoundHttpException(); + } + + if ($server->backup_limit === 0 && $request->action === 'backup') { + throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."); + } + + $this->connection->transaction(function () use ($request, $schedule, $task) { + $sequenceId = $request->integer('sequence_id', $task->sequence_id); + // Ensure that the sequence id is at least 1. + if ($sequenceId < 1) { + $sequenceId = 1; + } + + // Shift all other tasks in the schedule up or down to make room for the new task. + if ($sequenceId < $task->sequence_id) { + $schedule->tasks() + ->where('sequence_id', '>=', $sequenceId) + ->where('sequence_id', '<', $task->sequence_id) + ->increment('sequence_id'); + } elseif ($sequenceId > $task->sequence_id) { + $schedule->tasks() + ->where('sequence_id', '>', $task->sequence_id) + ->where('sequence_id', '<=', $sequenceId) + ->decrement('sequence_id'); + } + + $this->repository->update($task->id, [ + 'sequence_id' => $sequenceId, + 'action' => $request->input('action'), + 'payload' => $request->input('payload') ?? '', + 'time_offset' => $request->input('time_offset'), + 'continue_on_failure' => $request->boolean('continue_on_failure'), + ]); + }); + + Activity::event('server:task.update') + ->subject($schedule, $task) + ->property(['name' => $schedule->name, 'action' => $task->action, 'payload' => $task->payload]) + ->log(); + + return $this->fractal->item($task->refresh()) + ->transformWith($this->getTransformer(TaskTransformer::class)) + ->toArray(); + } + + /** + * Delete a given task for a schedule. If there are subsequent tasks stored in the database + * for this schedule their sequence IDs are decremented properly. + * + * @throws \Exception + */ + public function delete(ClientApiRequest $request, Server $server, Schedule $schedule, Task $task): JsonResponse + { + if ($task->schedule_id !== $schedule->id || $schedule->server_id !== $server->id) { + throw new NotFoundHttpException(); + } + + if (!$request->user()->can(Permission::ACTION_SCHEDULE_UPDATE, $server)) { + throw new HttpForbiddenException('You do not have permission to perform this action.'); + } + + $schedule->tasks() + ->where('sequence_id', '>', $task->sequence_id) + ->decrement('sequence_id'); + $task->delete(); + + Activity::event('server:task.delete')->subject($schedule, $task)->property('name', $schedule->name)->log(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/ServerController.php b/app/Http/Controllers/Api/Client/Servers/Wings/ServerController.php similarity index 94% rename from app/Http/Controllers/Api/Client/Servers/ServerController.php rename to app/Http/Controllers/Api/Client/Servers/Wings/ServerController.php index 63eb9b988..985bf57f9 100644 --- a/app/Http/Controllers/Api/Client/Servers/ServerController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/ServerController.php @@ -1,6 +1,6 @@ input('name'); + $description = $request->has('description') ? (string) $request->input('description') : $server->description; + $this->repository->update($server->id, [ + 'name' => $name, + 'description' => $description, + ]); + + if ($server->name !== $name) { + Activity::event('server:settings.rename') + ->property(['old' => $server->name, 'new' => $name]) + ->log(); + } + + if ($server->description !== $description) { + Activity::event('server:settings.description') + ->property(['old' => $server->description, 'new' => $description]) + ->log(); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Reinstalls the server on the daemon. + * + * @throws \Throwable + */ + public function reinstall(ReinstallServerRequest $request, Server $server): JsonResponse + { + $this->reinstallServerService->handle($server); + + Activity::event('server:reinstall')->log(); + + return new JsonResponse([], Response::HTTP_ACCEPTED); + } + + /** + * Changes the Docker image in use by the server. + * + * @throws \Throwable + */ + public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse + { + if (!in_array($request->input('docker_image'), array_values($server->egg->docker_images))) { + throw new BadRequestHttpException('The requested Docker image is not allowed for this server.'); + } + + $original = $server->image; + $server->forceFill(['image' => $request->input('docker_image')])->saveOrFail(); + + if ($original !== $server->image) { + Activity::event('server:startup.image') + ->property(['old' => $original, 'new' => $request->input('docker_image')]) + ->log(); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Reset Startup Command + */ + private function resetStartupCommand(Server $server): JsonResponse + { + $server->startup = $server->egg->startup; + $server->save(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + /** + * Changes the egg for a server. + * + * @throws \Throwable + */ + public function changeEgg(SetEggRequest $request, Server $server): JsonResponse + { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + $originalEggId = $server->egg_id; + $originalNestId = $server->nest_id; + + // Check if the new Egg and Nest IDs are different from the current ones + if ($originalEggId !== $eggId || $originalNestId !== $nestId) { + // Update the server's Egg and Nest IDs + $server->egg_id = $eggId; + $server->nest_id = $nestId; + $server->save(); + + // Log an activity event for the Egg change + Activity::event('server:settings.egg') + ->property(['original_egg_id' => $originalEggId, 'new_egg_id' => $eggId, 'original_nest_id' => $originalNestId, 'new_nest_id' => $nestId]) + ->log(); + + // Reset the server's startup command + $this->resetStartupCommand($server); + } + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + + public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse + { + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + + $previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId); + + // Log the preview action + Activity::event('server:settings.egg-preview') + ->property([ + 'current_egg_id' => $server->egg_id, + 'preview_egg_id' => $eggId, + 'preview_nest_id' => $nestId, + ]) + ->log(); + + return new JsonResponse($previewData); + } catch (Exception $e) { + Log::error('Failed to preview egg change', [ + 'server_id' => $server->id, + 'egg_id' => $request->input('egg_id'), + 'nest_id' => $request->input('nest_id'), + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Apply egg configuration changes asynchronously. + * This dispatches a background job to handle the complete egg change process. + * + * @throws \Throwable + */ + public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse + { + try { + $eggId = $request->input('egg_id'); + $nestId = $request->input('nest_id'); + $dockerImage = $request->input('docker_image'); + $startupCommand = $request->input('startup_command'); + $environment = $request->input('environment', []); + $shouldBackup = $request->input('should_backup', false); + $shouldWipe = $request->input('should_wipe', false); + + $result = $this->eggChangeService->applyEggChangeAsync( + $server, + $request->user(), + $eggId, + $nestId, + $dockerImage, + $startupCommand, + $environment, + $shouldBackup, + $shouldWipe + ); + + Activity::event('server:software.change-queued') + ->property([ + 'operation_id' => $result['operation_id'], + 'from_egg' => $server->egg_id, + 'to_egg' => $eggId, + 'should_backup' => $shouldBackup, + 'should_wipe' => $shouldWipe, + ]) + ->log(); + + return new JsonResponse($result, Response::HTTP_ACCEPTED); + } catch (Exception $e) { + Log::error('Failed to apply egg change', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/Wings/StartupController.php b/app/Http/Controllers/Api/Client/Servers/Wings/StartupController.php new file mode 100644 index 000000000..77ee966e0 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/Wings/StartupController.php @@ -0,0 +1,99 @@ +startupCommandService->handle($server); + + return $this->fractal->collection( + $server->variables()->where('user_viewable', true)->get() + ) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->addMeta([ + 'startup_command' => $startup, + 'docker_images' => $server->egg->docker_images, + 'raw_startup_command' => $server->startup, + ]) + ->toArray(); + } + + /** + * Updates a single variable for a server. + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateStartupVariableRequest $request, Server $server): array + { + /** @var \Pterodactyl\Models\EggVariable $variable */ + $variable = $server->variables()->where('env_variable', $request->input('key'))->first(); + $original = $variable->server_value; + + if (is_null($variable) || !$variable->user_viewable) { + throw new BadRequestHttpException('The environment variable you are trying to edit does not exist.'); + } elseif (!$variable->user_editable) { + throw new BadRequestHttpException('The environment variable you are trying to edit is read-only.'); + } + + // Revalidate the variable value using the egg variable specific validation rules for it. + $this->validate($request, ['value' => $variable->rules]); + + $this->repository->updateOrCreate([ + 'server_id' => $server->id, + 'variable_id' => $variable->id, + ], [ + 'variable_value' => $request->input('value') ?? '', + ]); + + $variable = $variable->refresh(); + $variable->server_value = $request->input('value'); + + $startup = $this->startupCommandService->handle($server); + + if ($variable->env_variable !== $request->input('value')) { + Activity::event('server:startup.edit') + ->subject($variable) + ->property([ + 'variable' => $variable->env_variable, + 'old' => $original, + 'new' => $request->input('value'), + ]) + ->log(); + } + + return $this->fractal->item($variable) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->addMeta([ + 'startup_command' => $startup, + 'raw_startup_command' => $server->startup, + ]) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/Wings/WebsocketController.php similarity index 97% rename from app/Http/Controllers/Api/Client/Servers/WebsocketController.php rename to app/Http/Controllers/Api/Client/Servers/Wings/WebsocketController.php index ff49c78ff..9170fd3bc 100644 --- a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/WebsocketController.php @@ -1,6 +1,6 @@ attributes->get('server'); + $daemonType = $server->node->daemonType; + + if (! $daemonType) { + abort(404); + } + + if ($daemonType !== $daemon) { + abort(400, "This endpoint requires daemon type '{$daemon}', but server is using '{$daemonType}'."); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Admin/Node/NodeFormRequest.php b/app/Http/Requests/Admin/Node/NodeFormRequest.php index 77d340199..e9cde0e38 100644 --- a/app/Http/Requests/Admin/Node/NodeFormRequest.php +++ b/app/Http/Requests/Admin/Node/NodeFormRequest.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Requests\Admin\Node; use Pterodactyl\Rules\Fqdn; use Pterodactyl\Models\Node; +use Illuminate\Support\Facades\Log; use Pterodactyl\Http\Requests\Admin\AdminFormRequest; class NodeFormRequest extends AdminFormRequest @@ -16,12 +17,14 @@ class NodeFormRequest extends AdminFormRequest if ($this->method() === 'PATCH') { $rules = Node::getRulesForUpdate($this->route()->parameter('node')); $rules['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')]; + return $rules; } $data = Node::getRules(); $data['fqdn'][] = Fqdn::make('scheme'); $data['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')]; + log::info("rules", [$data]); return $data; } diff --git a/app/Http/Requests/Api/Remote/Backups/BackupRemoteUploadController.php b/app/Http/Requests/Api/Remote/Backups/BackupRemoteUploadController.php new file mode 100644 index 000000000..38d8fa45c --- /dev/null +++ b/app/Http/Requests/Api/Remote/Backups/BackupRemoteUploadController.php @@ -0,0 +1,135 @@ +attributes->get('node'); + + // Get the size query parameter. + $size = (int) $request->query('size'); + if (empty($size)) { + throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.'); + } + + /** @var Backup $model */ + $model = Backup::query() + ->where('uuid', $backup) + ->firstOrFail(); + + // Check that the backup is "owned" by the node making the request. This avoids other nodes + // from messing with backups that they don't own. + /** @var \Pterodactyl\Models\Server $server */ + $server = $model->server; + if ($server->node_id !== $node->id) { + throw new HttpForbiddenException('You do not have permission to access that backup.'); + } + + // Prevent backups that have already been completed from trying to + // be uploaded again. + if (!is_null($model->completed_at)) { + throw new ConflictHttpException('This backup is already in a completed state.'); + } + + // Ensure we are using the S3 adapter. + $adapter = $this->backupManager->adapter(); + if (!$adapter instanceof S3Filesystem) { + throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.'); + } + + // The path where backup will be uploaded to + $path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid); + + // Get the S3 client + $client = $adapter->getClient(); + $expires = CarbonImmutable::now()->addMinutes((int) config('backups.presigned_url_lifespan', 60)); + + // Params for generating the presigned urls + $params = [ + 'Bucket' => $adapter->getBucket(), + 'Key' => $path, + 'ContentType' => 'application/x-gzip', + ]; + + $storageClass = config('backups.disks.s3.storage_class'); + if (!is_null($storageClass)) { + $params['StorageClass'] = $storageClass; + } + + // Execute the CreateMultipartUpload request + $result = $client->execute($client->getCommand('CreateMultipartUpload', $params)); + + // Get the UploadId from the CreateMultipartUpload request, this is needed to create + // the other presigned urls. + $params['UploadId'] = $result->get('UploadId'); + + // Retrieve configured part size + $maxPartSize = $this->getConfiguredMaxPartSize(); + + // Create as many UploadPart presigned urls as needed + $parts = []; + for ($i = 0; $i < ($size / $maxPartSize); ++$i) { + $parts[] = $client->createPresignedRequest( + $client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])), + $expires + )->getUri()->__toString(); + } + + // Set the upload_id on the backup in the database. + $model->update(['upload_id' => $params['UploadId']]); + + return new JsonResponse([ + 'parts' => $parts, + 'part_size' => $maxPartSize, + ]); + } + + /** + * Get the configured maximum size of a single part in the multipart upload. + * + * The function tries to retrieve a configured value from the configuration. + * If no value is specified, a fallback value will be used. + * + * Note if the received config cannot be converted to int (0), is zero or is negative, + * the fallback value will be used too. + * + * The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}. + */ + private function getConfiguredMaxPartSize(): int + { + $maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE); + if ($maxPartSize <= 0) { + $maxPartSize = self::DEFAULT_MAX_PART_SIZE; + } + + return $maxPartSize; + } +} diff --git a/app/Http/Requests/Api/Remote/Backups/BackupStatusController.php b/app/Http/Requests/Api/Remote/Backups/BackupStatusController.php new file mode 100644 index 000000000..501c0ddad --- /dev/null +++ b/app/Http/Requests/Api/Remote/Backups/BackupStatusController.php @@ -0,0 +1,159 @@ +attributes->get('node'); + + /** @var Backup $model */ + $model = Backup::query() + ->where('uuid', $backup) + ->firstOrFail(); + + // Check that the backup is "owned" by the node making the request. This avoids other nodes + // from messing with backups that they don't own. + /** @var \Pterodactyl\Models\Server $server */ + $server = $model->server; + if ($server->node_id !== $node->id) { + throw new HttpForbiddenException('You do not have permission to access that backup.'); + } + + if ($model->is_successful) { + throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.'); + } + + $action = $request->boolean('successful') ? 'server:backup.complete' : 'server:backup.fail'; + $log = Activity::event($action)->subject($model, $model->server)->property('name', $model->name); + + $log->transaction(function () use ($model, $request) { + $successful = $request->boolean('successful'); + + $model->fill([ + 'is_successful' => $successful, + // Change the lock state to unlocked if this was a failed backup so that it can be + // deleted easily. Also does not make sense to have a locked backup on the system + // that is failed. + 'is_locked' => $successful ? $model->is_locked : false, + 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, + 'bytes' => $successful ? $request->input('size') : 0, + 'completed_at' => CarbonImmutable::now(), + ])->save(); + + // Check if we are using the s3 backup adapter. If so, make sure we mark the backup as + // being completed in S3 correctly. + $adapter = $this->backupManager->adapter(); + if ($adapter instanceof S3Filesystem) { + $this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts')); + } + }); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Handles toggling the restoration status of a server. The server status field should be + * set back to null, even if the restoration failed. This is not an unsolvable state for + * the server, and the user can keep trying to restore, or just use the reinstall button. + * + * The only thing the successful field does is update the entry value for the audit logs + * table tracking for this restoration. + * + * @throws \Throwable + */ + public function restore(Request $request, string $backup): JsonResponse + { + /** @var Backup $model */ + $model = Backup::query()->where('uuid', $backup)->firstOrFail(); + + $model->server->update(['status' => null]); + + Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed') + ->subject($model, $model->server) + ->property('name', $model->name) + ->log(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Marks a multipart upload in a given S3-compatible instance as failed or successful for + * the given backup. + * + * @throws \Exception + * @throws DisplayException + */ + protected function completeMultipartUpload(Backup $backup, S3Filesystem $adapter, bool $successful, ?array $parts): void + { + // This should never really happen, but if it does don't let us fall victim to Amazon's + // wildly fun error messaging. Just stop the process right here. + if (empty($backup->upload_id)) { + // A failed backup doesn't need to error here, this can happen if the backup encounters + // an error before we even start the upload. AWS gives you tooling to clear these failed + // multipart uploads as needed too. + if (!$successful) { + return; + } + + throw new DisplayException('Cannot complete backup request: no upload_id present on model.'); + } + + $params = [ + 'Bucket' => $adapter->getBucket(), + 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), + 'UploadId' => $backup->upload_id, + ]; + + $client = $adapter->getClient(); + if (!$successful) { + $client->execute($client->getCommand('AbortMultipartUpload', $params)); + + return; + } + + // Otherwise send a CompleteMultipartUpload request. + $params['MultipartUpload'] = [ + 'Parts' => [], + ]; + + if (is_null($parts)) { + $params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts']; + } else { + foreach ($parts as $part) { + $params['MultipartUpload']['Parts'][] = [ + 'ETag' => $part['etag'], + 'PartNumber' => $part['part_number'], + ]; + } + } + + $client->execute($client->getCommand('CompleteMultipartUpload', $params)); + } +} diff --git a/app/Jobs/Server/ApplyEggChangeJob.php b/app/Jobs/Server/ApplyEggChangeJob.php index e211edfd4..acd8738f9 100644 --- a/app/Jobs/Server/ApplyEggChangeJob.php +++ b/app/Jobs/Server/ApplyEggChangeJob.php @@ -68,11 +68,11 @@ class ApplyEggChangeJob extends Job implements ShouldQueue SubdomainManagementService $subdomainService ): void { $operation = null; - + try { $operation = ServerOperation::where('operation_id', $this->operationId)->firstOrFail(); $operation->markAsStarted(); - + Activity::actor($this->user)->event('server:software.change-started') ->property([ 'operation_id' => $this->operationId, @@ -105,7 +105,6 @@ class ApplyEggChangeJob extends Job implements ShouldQueue $this->logSuccessfulChange(); $operation->markAsCompleted('Software configuration applied successfully. Server installation completed.'); - } catch (Exception $e) { $this->handleJobFailure($e, $operation); throw $e; @@ -121,18 +120,18 @@ class ApplyEggChangeJob extends Job implements ShouldQueue $currentEgg = $this->server->egg; $targetEgg = Egg::find($this->eggId); - + $backupName = sprintf( 'Software Change: %s → %s (%s)', $currentEgg->name ?? 'Unknown', $targetEgg->name ?? 'Unknown', now()->format('M j, g:i A') ); - + if (strlen($backupName) > 190) { $backupName = substr($backupName, 0, 187) . '...'; } - + try { $result = $elytraJobService->submitJob( $this->server, @@ -159,7 +158,6 @@ class ApplyEggChangeJob extends Job implements ShouldQueue $operation->updateProgress('Backup job submitted successfully'); return $result['job_id']; - } catch (\Exception $e) { throw new BackupFailedException('Failed to create backup before egg change: ' . $e->getMessage()); } @@ -211,12 +209,12 @@ class ApplyEggChangeJob extends Job implements ShouldQueue private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation): void { $operation->updateProgress('Wiping server files...'); - + try { $contents = $fileRepository->setServer($this->server)->getDirectory('/'); if (!empty($contents)) { - $filesToDelete = array_map(function($item) { + $filesToDelete = array_map(function ($item) { return $item['name']; }, $contents); @@ -273,7 +271,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue SubdomainManagementService $subdomainService ): void { $operation->updateProgress('Applying software configuration...'); - + DB::transaction(function () use ($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService) { // Check if we need to remove subdomain before changing egg $activeSubdomain = $this->server->activeSubdomain; @@ -282,14 +280,14 @@ class ApplyEggChangeJob extends Job implements ShouldQueue $tempServer = clone $this->server; $tempServer->egg = $egg; $tempServer->egg_id = $egg->id; - + // If new egg doesn't support subdomains, delete the existing subdomain if (!$tempServer->supportsSubdomains()) { $operation->updateProgress('Removing incompatible subdomain...'); - + try { $subdomainService->deleteSubdomain($activeSubdomain); - + Activity::actor($this->user)->event('server:subdomain.deleted-egg-change') ->property([ 'operation_id' => $this->operationId, @@ -305,13 +303,13 @@ class ApplyEggChangeJob extends Job implements ShouldQueue 'subdomain' => $activeSubdomain->full_domain, 'error' => $e->getMessage(), ]); - + // Continue with egg change even if subdomain deletion fails $operation->updateProgress('Warning: Could not fully remove subdomain, continuing with egg change...'); } } } - + if ($this->server->egg_id !== $this->eggId || $this->server->nest_id !== $this->nestId) { $this->server->update([ 'egg_id' => $this->eggId, @@ -331,7 +329,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue $operation->updateProgress('Reinstalling server...'); $reinstallServerService->handle($updatedServer); - + $operation->updateProgress('Finalizing installation...'); }); } @@ -398,4 +396,4 @@ class ApplyEggChangeJob extends Job implements ShouldQueue ]) ->log(); } -} \ No newline at end of file +} diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 73d538fca..3a8131e35 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Pterodactyl\Enums\Daemon\Adapters; /** * Backup model @@ -41,9 +42,13 @@ class Backup extends Model public const RESOURCE_NAME = 'backup'; // Backup adapters - public const ADAPTER_WINGS = 'wings'; - public const ADAPTER_ELYTRA = 'elytra'; // Preferred name for local backups public const ADAPTER_AWS_S3 = 's3'; + + // Wings Adapters + public const ADAPTER_WINGS = 'wings'; + + // Elytra Backups + public const ADAPTER_ELYTRA = 'elytra'; public const ADAPTER_RUSTIC_LOCAL = 'rustic_local'; public const ADAPTER_RUSTIC_S3 = 'rustic_s3'; @@ -82,6 +87,7 @@ class Backup extends Model return in_array($this->disk, [self::ADAPTER_RUSTIC_LOCAL, self::ADAPTER_RUSTIC_S3]); } + /** * Check if this backup is stored locally (not in cloud storage). */ @@ -95,7 +101,7 @@ class Backup extends Model */ public function getRepositoryType(): ?string { - return match($this->disk) { + return match ($this->disk) { self::ADAPTER_RUSTIC_LOCAL => 'local', self::ADAPTER_RUSTIC_S3 => 's3', default => null, @@ -161,9 +167,8 @@ class Backup extends Model */ public function getElytraAdapterType(): string { - return match($this->disk) { - self::ADAPTER_WINGS => 'elytra', // Legacy support: wings -> elytra - self::ADAPTER_ELYTRA => 'elytra', // Direct mapping for new backups + return match ($this->disk) { + self::ADAPTER_ELYTRA => 'elytra', self::ADAPTER_AWS_S3 => 's3', self::ADAPTER_RUSTIC_LOCAL => 'rustic_local', self::ADAPTER_RUSTIC_S3 => 'rustic_s3', @@ -219,4 +224,4 @@ class Backup extends Model { return $this->query()->where($field ?? $this->getRouteKeyName(), $value)->firstOrFail(); } -} \ No newline at end of file +} diff --git a/app/Models/Daemons/.info b/app/Models/Daemons/.info new file mode 100644 index 000000000..200f35001 --- /dev/null +++ b/app/Models/Daemons/.info @@ -0,0 +1 @@ +These files are used in app/Models/Node.php for it's configuration getting and such diff --git a/app/Models/Daemons/Elytra.php b/app/Models/Daemons/Elytra.php new file mode 100644 index 000000000..c590096b0 --- /dev/null +++ b/app/Models/Daemons/Elytra.php @@ -0,0 +1,93 @@ + false, + 'uuid' => $node->uuid, + 'token_id' => $node->daemon_token_id, + 'token' => Container::getInstance()->make(Encrypter::class)->decrypt($node->daemon_token), + 'api' => [ + 'host' => '0.0.0.0', + 'port' => $node->daemonListen, + 'ssl' => [ + 'enabled' => (!$node->behind_proxy && $node->scheme === 'https'), + 'cert' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/fullchain.pem', + 'key' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/privkey.pem', + ], + 'upload_limit' => $node->upload_size, + ], + 'system' => [ + 'data' => $node->daemonBase, + 'sftp' => [ + 'bind_port' => $node->daemonSFTP, + ], + 'backups' => [ + 'rustic' => $this->getBackupConfiguration(), + ], + ], + 'allowed_mounts' => $node->mounts->pluck('source')->toArray(), + 'remote' => route('index'), + 'allowed_origins' => [ + config('app.url'), + ], + ]; + } + + private function getBackupConfiguration() + { + $localConfig = config('backups.disks.rustic_local', []); + $s3Config = config('backups.disks.rustic_s3', []); + return [ + // Path to rustic binary + 'binary_path' => $localConfig['binary_path'] ?? 'rustic', + + // Repository version (optional, default handled by rustic) + 'repository_version' => $localConfig['repository_version'] ?? 2, + + // Pack size configuration for performance tuning + 'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4, + 'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32, + + // Local repository configuration + 'local' => [ + 'enabled' => !empty($localConfig), + 'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos', + 'use_cold_storage' => $localConfig['use_cold_storage'] ?? false, + 'hot_repository_path' => $localConfig['hot_repository_path'] ?? '', + ], + + // S3 repository configuration + 's3' => [ + 'enabled' => !empty($s3Config['bucket']), + 'endpoint' => $s3Config['endpoint'] ?? '', + 'region' => $s3Config['region'] ?? 'us-east-1', + 'bucket' => $s3Config['bucket'] ?? '', + 'use_cold_storage' => $s3Config['use_cold_storage'] ?? false, + 'hot_bucket' => $s3Config['hot_bucket'] ?? '', + 'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER', + 'force_path_style' => $s3Config['force_path_style'] ?? false, + 'disable_ssl' => $s3Config['disable_ssl'] ?? false, + 'ca_cert_path' => $s3Config['ca_cert_path'] ?? '', + ], + ]; + } + + public function getAutoDeploy(Node $node, string $token): string + { + $debugFlag = config('app.debug') ? ' --allow-insecure' : ''; + + return "cd /etc/elytra && sudo elytra configure --panel-url " . config('app.url') . " --token " . $token . " --node " . $node->id . $debugFlag . ""; + } +} diff --git a/app/Models/Daemons/Wings.php b/app/Models/Daemons/Wings.php new file mode 100644 index 000000000..46410a1a1 --- /dev/null +++ b/app/Models/Daemons/Wings.php @@ -0,0 +1,50 @@ + false, + 'uuid' => $node->uuid, + 'token_id' => $node->daemon_token_id, + 'token' => Container::getInstance()->make(Encrypter::class)->decrypt($node->daemon_token), + 'api' => [ + 'host' => '0.0.0.0', + 'port' => $node->daemonListen, + 'ssl' => [ + 'enabled' => (!$node->behind_proxy && $node->scheme === 'https'), + 'cert' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/fullchain.pem', + 'key' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/privkey.pem', + ], + 'upload_limit' => $node->upload_size, + ], + 'system' => [ + 'data' => $node->daemonBase, + 'sftp' => [ + 'bind_port' => $node->daemonSFTP, + ], + ], + 'allowed_mounts' => $node->mounts->pluck('source')->toArray(), + 'remote' => route('index'), + 'allowed_origins' => [ + config('app.url'), + ], + ]; + } + + public function getAutoDeploy(Node $node, string $token): string + { + $debugFlag = config('app.debug') ? ' --allow-insecure' : ''; + + return "cd /etc/pterodactyl && sudo wings configure --panel-url " . config('app.url') . " --token " . $token . " --node " . $node->id . $debugFlag . ""; + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index 07714a2d7..8f664ba38 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -2,15 +2,18 @@ namespace Pterodactyl\Models; -use Illuminate\Support\Str; use Symfony\Component\Yaml\Yaml; use Illuminate\Container\Container; +use Illuminate\Support\Facades\Log; +use Pterodactyl\Enums\Daemon\DaemonType; use Illuminate\Notifications\Notifiable; use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Pterodactyl\Contracts\Daemon\Daemon as DaemonInterface; +use Pterodactyl\Http\Controllers\Admin\NodeAutoDeployController; /** * @property int $id @@ -36,6 +39,8 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; * @property int $daemonListen * @property int $daemonSFTP * @property string $daemonBase + * @property string $daemonType + * @property string $backupDisk * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property Location $location @@ -43,6 +48,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations */ + class Node extends Model { /** @use HasFactory<\Database\Factories\NodeFactory> */ @@ -110,6 +116,8 @@ class Node extends Model 'daemon_token', 'description', 'maintenance_mode', + 'daemonType', + 'backupDisk' ]; public static array $validationRules = [ @@ -132,6 +140,8 @@ class Node extends Model 'daemonListen' => 'required|numeric|between:1,65535', 'maintenance_mode' => 'boolean', 'upload_size' => 'int|between:1,1024', + 'daemonType' => 'required|string', + 'backupDisk' => 'required|string' ]; /** @@ -150,6 +160,23 @@ class Node extends Model 'use_separate_fqdns' => false, ]; + + private function getDaemonImplementation(): DaemonInterface + { + $implementations = DaemonType::allClass(); + + $daemonType = strtolower($this->daemonType); + + if (!isset($implementations[$daemonType])) { + + return new \Pterodactyl\Models\Daemons\Elytra(); + } + + $implementationClass = $implementations[$daemonType]; + return new $implementationClass(); + } + + /** * Get the connection address to use when making calls to this node. * This will use the internal FQDN if separate FQDNs are enabled and internal_fqdn is set, @@ -188,80 +215,17 @@ class Node extends Model */ public function getConfiguration(): array { - return [ - 'debug' => false, - 'uuid' => $this->uuid, - 'token_id' => $this->daemon_token_id, - 'token' => Container::getInstance()->make(Encrypter::class)->decrypt($this->daemon_token), - 'api' => [ - 'host' => '0.0.0.0', - 'port' => $this->daemonListen, - 'ssl' => [ - 'enabled' => (!$this->behind_proxy && $this->scheme === 'https'), - 'cert' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/fullchain.pem', - 'key' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/privkey.pem', - ], - 'upload_limit' => $this->upload_size, - ], - 'system' => [ - 'data' => $this->daemonBase, - 'sftp' => [ - 'bind_port' => $this->daemonSFTP, - ], - 'backups' => [ - 'rustic' => $this->getRusticBackupConfiguration(), - ], - ], - 'allowed_mounts' => $this->mounts->pluck('source')->toArray(), - 'remote' => route('index'), - 'allowed_origins' => [ - config('app.url'), // note: I have no idea why this wasn't included by Pterodactyl upstream, this might need to be configurable later - ellie - ], - ]; + $daemon = $this->getDaemonImplementation(); + return $daemon->getConfiguration($this); } /** - * Get rustic backup configuration for Wings. - * Matches the exact structure expected by elytra rustic implementation. + * Returns the auto deploy command as a string. */ - private function getRusticBackupConfiguration(): array + public function getAutoDeploy(string $token): string { - $localConfig = config('backups.disks.rustic_local', []); - $s3Config = config('backups.disks.rustic_s3', []); - - return [ - // Path to rustic binary - 'binary_path' => $localConfig['binary_path'] ?? 'rustic', - - // Repository version (optional, default handled by rustic) - 'repository_version' => $localConfig['repository_version'] ?? 2, - - // Pack size configuration for performance tuning - 'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4, - 'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32, - - // Local repository configuration - 'local' => [ - 'enabled' => !empty($localConfig), - 'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos', - 'use_cold_storage' => $localConfig['use_cold_storage'] ?? false, - 'hot_repository_path' => $localConfig['hot_repository_path'] ?? '', - ], - - // S3 repository configuration - 's3' => [ - 'enabled' => !empty($s3Config['bucket']), - 'endpoint' => $s3Config['endpoint'] ?? '', - 'region' => $s3Config['region'] ?? 'us-east-1', - 'bucket' => $s3Config['bucket'] ?? '', - 'use_cold_storage' => $s3Config['use_cold_storage'] ?? false, - 'hot_bucket' => $s3Config['hot_bucket'] ?? '', - 'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER', - 'force_path_style' => $s3Config['force_path_style'] ?? false, - 'disable_ssl' => $s3Config['disable_ssl'] ?? false, - 'ca_cert_path' => $s3Config['ca_cert_path'] ?? '', - ], - ]; + $daemon = $this->getDaemonImplementation(); + return $daemon->getAutoDeploy($this, $token); } /** diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 6da9af1dd..0d114cea4 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -6,234 +6,234 @@ use Illuminate\Support\Collection; class Permission extends Model { - /** - * The resource name for this model when it is transformed into an - * API representation using fractal. - */ - public const RESOURCE_NAME = 'subuser_permission'; + /** + * The resource name for this model when it is transformed into an + * API representation using fractal. + */ + public const RESOURCE_NAME = 'subuser_permission'; - /** - * Constants defining different permissions available. - */ - public const ACTION_WEBSOCKET_CONNECT = 'websocket.connect'; - public const ACTION_CONTROL_CONSOLE = 'control.console'; - public const ACTION_CONTROL_START = 'control.start'; - public const ACTION_CONTROL_STOP = 'control.stop'; - public const ACTION_CONTROL_RESTART = 'control.restart'; + /** + * Constants defining different permissions available. + */ + public const ACTION_WEBSOCKET_CONNECT = 'websocket.connect'; + public const ACTION_CONTROL_CONSOLE = 'control.console'; + public const ACTION_CONTROL_START = 'control.start'; + public const ACTION_CONTROL_STOP = 'control.stop'; + public const ACTION_CONTROL_RESTART = 'control.restart'; - public const ACTION_DATABASE_READ = 'database.read'; - public const ACTION_DATABASE_CREATE = 'database.create'; - public const ACTION_DATABASE_UPDATE = 'database.update'; - public const ACTION_DATABASE_DELETE = 'database.delete'; - public const ACTION_DATABASE_VIEW_PASSWORD = 'database.view_password'; + public const ACTION_DATABASE_READ = 'database.read'; + public const ACTION_DATABASE_CREATE = 'database.create'; + public const ACTION_DATABASE_UPDATE = 'database.update'; + public const ACTION_DATABASE_DELETE = 'database.delete'; + public const ACTION_DATABASE_VIEW_PASSWORD = 'database.view_password'; - public const ACTION_SCHEDULE_READ = 'schedule.read'; - public const ACTION_SCHEDULE_CREATE = 'schedule.create'; - public const ACTION_SCHEDULE_UPDATE = 'schedule.update'; - public const ACTION_SCHEDULE_DELETE = 'schedule.delete'; + public const ACTION_SCHEDULE_READ = 'schedule.read'; + public const ACTION_SCHEDULE_CREATE = 'schedule.create'; + public const ACTION_SCHEDULE_UPDATE = 'schedule.update'; + public const ACTION_SCHEDULE_DELETE = 'schedule.delete'; - public const ACTION_USER_READ = 'user.read'; - public const ACTION_USER_CREATE = 'user.create'; - public const ACTION_USER_UPDATE = 'user.update'; - public const ACTION_USER_DELETE = 'user.delete'; + public const ACTION_USER_READ = 'user.read'; + public const ACTION_USER_CREATE = 'user.create'; + public const ACTION_USER_UPDATE = 'user.update'; + public const ACTION_USER_DELETE = 'user.delete'; - public const ACTION_BACKUP_READ = 'backup.read'; - public const ACTION_BACKUP_CREATE = 'backup.create'; - public const ACTION_BACKUP_DELETE = 'backup.delete'; - public const ACTION_BACKUP_DOWNLOAD = 'backup.download'; - public const ACTION_BACKUP_RESTORE = 'backup.restore'; + public const ACTION_BACKUP_READ = 'backup.read'; + public const ACTION_BACKUP_CREATE = 'backup.create'; + public const ACTION_BACKUP_DELETE = 'backup.delete'; + public const ACTION_BACKUP_DOWNLOAD = 'backup.download'; + public const ACTION_BACKUP_RESTORE = 'backup.restore'; - public const ACTION_ALLOCATION_READ = 'allocation.read'; - public const ACTION_ALLOCATION_CREATE = 'allocation.create'; - public const ACTION_ALLOCATION_UPDATE = 'allocation.update'; - public const ACTION_ALLOCATION_DELETE = 'allocation.delete'; + public const ACTION_ALLOCATION_READ = 'allocation.read'; + public const ACTION_ALLOCATION_CREATE = 'allocation.create'; + public const ACTION_ALLOCATION_UPDATE = 'allocation.update'; + public const ACTION_ALLOCATION_DELETE = 'allocation.delete'; - public const ACTION_FILE_READ = 'file.read'; - public const ACTION_FILE_READ_CONTENT = 'file.read-content'; - public const ACTION_FILE_CREATE = 'file.create'; - public const ACTION_FILE_UPDATE = 'file.update'; - public const ACTION_FILE_DELETE = 'file.delete'; - public const ACTION_FILE_ARCHIVE = 'file.archive'; - public const ACTION_FILE_SFTP = 'file.sftp'; + public const ACTION_FILE_READ = 'file.read'; + public const ACTION_FILE_READ_CONTENT = 'file.read-content'; + public const ACTION_FILE_CREATE = 'file.create'; + public const ACTION_FILE_UPDATE = 'file.update'; + public const ACTION_FILE_DELETE = 'file.delete'; + public const ACTION_FILE_ARCHIVE = 'file.archive'; + public const ACTION_FILE_SFTP = 'file.sftp'; - public const ACTION_STARTUP_READ = 'startup.read'; - public const ACTION_STARTUP_UPDATE = 'startup.update'; - public const ACTION_STARTUP_COMMAND = 'startup.command'; - public const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image'; + public const ACTION_STARTUP_READ = 'startup.read'; + public const ACTION_STARTUP_UPDATE = 'startup.update'; + public const ACTION_STARTUP_COMMAND = 'startup.command'; + public const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image'; - public const ACTION_STARTUP_SOFTWARE = 'startup.software'; + public const ACTION_STARTUP_SOFTWARE = 'startup.software'; - public const ACTION_SETTINGS_RENAME = 'settings.rename'; - public const ACTION_SETTINGS_MODRINTH = 'settings.modrinth'; - public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; + public const ACTION_SETTINGS_RENAME = 'settings.rename'; + public const ACTION_SETTINGS_MODR = 'settings.mod'; + public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; - public const ACTION_ACTIVITY_READ = 'activity.read'; + public const ACTION_ACTIVITY_READ = 'activity.read'; - public const ACTION_MODRINTH_DOWNLOAD = 'modrinth.download'; + public const ACTION_MOD_DOWNLOAD = 'mod.download'; - /** - * Should timestamps be used on this model. - */ - public $timestamps = false; + /** + * Should timestamps be used on this model. + */ + public $timestamps = false; - /** - * The table associated with the model. - */ - protected $table = 'permissions'; + /** + * The table associated with the model. + */ + protected $table = 'permissions'; - /** - * Fields that are not mass assignable. - */ - protected $guarded = ['id', 'created_at', 'updated_at']; + /** + * Fields that are not mass assignable. + */ + protected $guarded = ['id', 'created_at', 'updated_at']; - /** - * Cast values to correct type. - */ - protected $casts = [ - 'subuser_id' => 'integer', - ]; + /** + * Cast values to correct type. + */ + protected $casts = [ + 'subuser_id' => 'integer', + ]; - public static array $validationRules = [ - 'subuser_id' => 'required|numeric|min:1', - 'permission' => 'required|string', - ]; + public static array $validationRules = [ + 'subuser_id' => 'required|numeric|min:1', + 'permission' => 'required|string', + ]; - /** - * All the permissions available on the system. You should use self::permissions() - * to retrieve them, and not directly access this array as it is subject to change. - * - * @see \Pterodactyl\Models\Permission::permissions() - */ - protected static array $permissions = [ - 'websocket' => [ - 'description' => 'Allows the user to connect to the server websocket, giving them access to view console output and realtime server stats.', - 'keys' => [ - 'connect' => 'Allows a user to connect to the websocket instance for a server to stream the console.', - ], - ], + /** + * All the permissions available on the system. You should use self::permissions() + * to retrieve them, and not directly access this array as it is subject to change. + * + * @see \Pterodactyl\Models\Permission::permissions() + */ + protected static array $permissions = [ + 'websocket' => [ + 'description' => 'Allows the user to connect to the server websocket, giving them access to view console output and realtime server stats.', + 'keys' => [ + 'connect' => 'Allows a user to connect to the websocket instance for a server to stream the console.', + ], + ], - 'control' => [ - 'description' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.', - 'keys' => [ - 'console' => 'Allows a user to send commands to the server instance via the console.', - 'start' => 'Allows a user to start the server if it is stopped.', - 'stop' => 'Allows a user to stop a server if it is running.', - 'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.', - ], - ], + 'control' => [ + 'description' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.', + 'keys' => [ + 'console' => 'Allows a user to send commands to the server instance via the console.', + 'start' => 'Allows a user to start the server if it is stopped.', + 'stop' => 'Allows a user to stop a server if it is running.', + 'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.', + ], + ], - 'user' => [ - 'description' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.', - 'keys' => [ - 'create' => 'Allows a user to create new subusers for the server.', - 'read' => 'Allows the user to view subusers and their permissions for the server.', - 'update' => 'Allows a user to modify other subusers.', - 'delete' => 'Allows a user to delete a subuser from the server.', - ], - ], + 'user' => [ + 'description' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.', + 'keys' => [ + 'create' => 'Allows a user to create new subusers for the server.', + 'read' => 'Allows the user to view subusers and their permissions for the server.', + 'update' => 'Allows a user to modify other subusers.', + 'delete' => 'Allows a user to delete a subuser from the server.', + ], + ], - 'file' => [ - 'description' => 'Permissions that control a user\'s ability to modify the filesystem for this server.', - 'keys' => [ - 'create' => 'Allows a user to create additional files and folders via the Panel or direct upload.', - 'read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.', - 'read-content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.', - 'update' => 'Allows a user to update the contents of an existing file or directory.', - 'delete' => 'Allows a user to delete files or directories.', - 'archive' => 'Allows a user to archive the contents of a directory as well as decompress existing archives on the system.', - 'sftp' => 'Allows a user to connect to SFTP and manage server files using the other assigned file permissions.', - ], - ], + 'file' => [ + 'description' => 'Permissions that control a user\'s ability to modify the filesystem for this server.', + 'keys' => [ + 'create' => 'Allows a user to create additional files and folders via the Panel or direct upload.', + 'read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.', + 'read-content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.', + 'update' => 'Allows a user to update the contents of an existing file or directory.', + 'delete' => 'Allows a user to delete files or directories.', + 'archive' => 'Allows a user to archive the contents of a directory as well as decompress existing archives on the system.', + 'sftp' => 'Allows a user to connect to SFTP and manage server files using the other assigned file permissions.', + ], + ], - 'backup' => [ - 'description' => 'Permissions that control a user\'s ability to generate and manage server backups.', - 'keys' => [ - 'create' => 'Allows a user to create new backups for this server.', - 'read' => 'Allows a user to view all backups that exist for this server.', - 'delete' => 'Allows a user to remove backups from the system.', - 'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.', - 'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.', - ], - ], + 'backup' => [ + 'description' => 'Permissions that control a user\'s ability to generate and manage server backups.', + 'keys' => [ + 'create' => 'Allows a user to create new backups for this server.', + 'read' => 'Allows a user to view all backups that exist for this server.', + 'delete' => 'Allows a user to remove backups from the system.', + 'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.', + 'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.', + ], + ], - // Controls permissions for editing or viewing a server's allocations. - 'allocation' => [ - 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', - 'keys' => [ - 'read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.', - 'create' => 'Allows a user to assign additional allocations to the server.', - 'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.', - 'delete' => 'Allows a user to delete an allocation from the server.', - ], - ], + // Controls permissions for editing or viewing a server's allocations. + 'allocation' => [ + 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', + 'keys' => [ + 'read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.', + 'create' => 'Allows a user to assign additional allocations to the server.', + 'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.', + 'delete' => 'Allows a user to delete an allocation from the server.', + ], + ], - // Controls permissions for editing or viewing a server's startup parameters. - 'startup' => [ - 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', - 'keys' => [ - 'read' => 'Allows a user to view the startup variables for a server.', - 'update' => 'Allows a user to modify the startup variables for the server.', - 'command' => 'Allows a user to modify the startup command for the server.', - 'docker-image' => 'Allows a user to modify the Docker image used when running the server.', - 'software' => 'Allows a user to modify the game / software used for the server.', - ], - ], + // Controls permissions for editing or viewing a server's startup parameters. + 'startup' => [ + 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', + 'keys' => [ + 'read' => 'Allows a user to view the startup variables for a server.', + 'update' => 'Allows a user to modify the startup variables for the server.', + 'command' => 'Allows a user to modify the startup command for the server.', + 'docker-image' => 'Allows a user to modify the Docker image used when running the server.', + 'software' => 'Allows a user to modify the game / software used for the server.', + ], + ], - 'database' => [ - 'description' => 'Permissions that control a user\'s access to the database management for this server.', - 'keys' => [ - 'create' => 'Allows a user to create a new database for this server.', - 'read' => 'Allows a user to view the database associated with this server.', - 'update' => 'Allows a user to rotate the password on a database instance. If the user does not have the view_password permission they will not see the updated password.', - 'delete' => 'Allows a user to remove a database instance from this server.', - 'view_password' => 'Allows a user to view the password associated with a database instance for this server.', - ], - ], + 'database' => [ + 'description' => 'Permissions that control a user\'s access to the database management for this server.', + 'keys' => [ + 'create' => 'Allows a user to create a new database for this server.', + 'read' => 'Allows a user to view the database associated with this server.', + 'update' => 'Allows a user to rotate the password on a database instance. If the user does not have the view_password permission they will not see the updated password.', + 'delete' => 'Allows a user to remove a database instance from this server.', + 'view_password' => 'Allows a user to view the password associated with a database instance for this server.', + ], + ], - 'schedule' => [ - 'description' => 'Permissions that control a user\'s access to the schedule management for this server.', - 'keys' => [ - 'create' => 'Allows a user to create new schedules for this server.', // task.create-schedule - 'read' => 'Allows a user to view schedules and the tasks associated with them for this server.', // task.view-schedule, task.list-schedules - 'update' => 'Allows a user to update schedules and schedule tasks for this server.', // task.edit-schedule, task.queue-schedule, task.toggle-schedule - 'delete' => 'Allows a user to delete schedules for this server.', // task.delete-schedule - ], - ], + 'schedule' => [ + 'description' => 'Permissions that control a user\'s access to the schedule management for this server.', + 'keys' => [ + 'create' => 'Allows a user to create new schedules for this server.', // task.create-schedule + 'read' => 'Allows a user to view schedules and the tasks associated with them for this server.', // task.view-schedule, task.list-schedules + 'update' => 'Allows a user to update schedules and schedule tasks for this server.', // task.edit-schedule, task.queue-schedule, task.toggle-schedule + 'delete' => 'Allows a user to delete schedules for this server.', // task.delete-schedule + ], + ], - 'settings' => [ - 'description' => 'Permissions that control a user\'s access to the settings for this server.', - 'keys' => [ - 'rename' => 'Allows a user to rename this server and change the description of it.', - 'reinstall' => 'Allows a user to trigger a reinstall of this server.', + 'settings' => [ + 'description' => 'Permissions that control a user\'s access to the settings for this server.', + 'keys' => [ + 'rename' => 'Allows a user to rename this server and change the description of it.', + 'reinstall' => 'Allows a user to trigger a reinstall of this server.', - ], - ], + ], + ], - 'activity' => [ - 'description' => 'Permissions that control a user\'s access to the server activity logs.', - 'keys' => [ - 'read' => 'Allows a user to view the activity logs for the server.', - ], - ], + 'activity' => [ + 'description' => 'Permissions that control a user\'s access to the server activity logs.', + 'keys' => [ + 'read' => 'Allows a user to view the activity logs for the server.', + ], + ], - 'modrinth' => [ - 'description' => 'Permissions that control a user\'s access to downloading and updating mods.', - 'keys' => [ - 'version' => 'Allows a user to change what version to download for', - 'loader' => 'Allows a user to change what loader to download for', - 'download' => 'Allows a user to download mods to the server using modrinth', - 'resolver' => 'Allows a user to access the Dependency Resolver', - 'update' => 'Allows a user to update Currently installed mods', - ], - ], - ]; + 'mod' => [ + 'description' => 'Permissions that control a user\'s access to downloading and updating mods.', + 'keys' => [ + 'version' => 'Allows a user to change what version to download for', + 'loader' => 'Allows a user to change what loader to download for', + 'download' => 'Allows a user to download mods to the server', + 'resolver' => 'Allows a user to access the Dependency Resolver', + 'update' => 'Allows a user to update Currently installed mods', + ], + ], + ]; - /** - * Returns all the permissions available on the system for a user to - * have when controlling a server. - */ - public static function permissions(): Collection - { - return Collection::make(self::$permissions); - } + /** + * Returns all the permissions available on the system for a user to + * have when controlling a server. + */ + public static function permissions(): Collection + { + return Collection::make(self::$permissions); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 44d664747..25737c8c4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -239,7 +239,7 @@ class Server extends Model if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) { return false; } - + return !in_array($this->image, array_values($this->egg->docker_images)); } @@ -252,14 +252,14 @@ class Server extends Model if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) { throw new \RuntimeException('Server egg has no docker images configured.'); } - + $eggDockerImages = $this->egg->docker_images; $defaultImage = reset($eggDockerImages); - + if (empty($defaultImage)) { throw new \RuntimeException('Server egg has no valid default docker image.'); } - + return $defaultImage; } @@ -530,7 +530,27 @@ class Server extends Model if ( $this->isSuspended() || $this->node->isUnderMaintenance() - || !$this->isInstalled() + /* || !$this->isInstalled() */ + || $this->status === self::STATUS_RESTORING_BACKUP + || !is_null($this->transfer) + ) { + throw new ServerStateConflictException($this); + } + } + + /** + * Checks if the server is currently in a user-accessible state. If not, an + * exception is raised. This should be called whenever something needs to make + * sure the server is not in a weird state that should block user access. + * + * @throws ServerStateConflictException + */ + public function validateCurrentStateClient() + { + if ( + $this->isSuspended() + || $this->node->isUnderMaintenance() + /* || !$this->isInstalled() */ // NOTE: this causes issues with how users view servers with the new system || $this->status === self::STATUS_RESTORING_BACKUP || !is_null($this->transfer) ) { diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php new file mode 100644 index 000000000..bbc5d2cd9 --- /dev/null +++ b/app/Repositories/Eloquent/BackupRepository.php @@ -0,0 +1,45 @@ +getBuilder() + ->withTrashed() + ->where('server_id', $server) + ->where(function ($query) { + $query->whereNull('completed_at') + ->orWhere('is_successful', '=', true); + }) + ->where('created_at', '>=', Carbon::now()->subSeconds($seconds)->toDateTimeString()) + ->get() + ->toBase(); + } + + /** + * Returns a query filtering only non-failed backups for a specific server. + */ + public function getNonFailedBackups(Server $server): HasMany + { + return $server->backups()->where(function ($query) { + $query->whereNull('completed_at') + ->orWhere('is_successful', true); + }); + } +} diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index 370bd0068..be59ea42c 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -176,4 +176,16 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa return $instance->first(); } + + /** + * Returns a node with the given id with the Node's resource usage. + */ + public function getDaemonType(int $node_id): Node + { + $instance = $this->getBuilder() + ->select(['nodes.daemonType']) + ->where('nodes.id', $node_id); + + return $instance->first(); + } } diff --git a/app/Repositories/Elytra/DaemonCommandRepository.php b/app/Repositories/Elytra/DaemonCommandRepository.php new file mode 100644 index 000000000..858d41a77 --- /dev/null +++ b/app/Repositories/Elytra/DaemonCommandRepository.php @@ -0,0 +1,37 @@ +server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/commands', $this->server->uuid), + [ + 'json' => ['commands' => is_array($command) ? $command : [$command]], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Elytra/DaemonConfigurationRepository.php b/app/Repositories/Elytra/DaemonConfigurationRepository.php new file mode 100644 index 000000000..90a7ef21c --- /dev/null +++ b/app/Repositories/Elytra/DaemonConfigurationRepository.php @@ -0,0 +1,50 @@ +getHttpClient()->get('/api/system' . (!is_null($version) ? '?v=' . $version : '')); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * Updates the configuration information for a daemon. Updates the information for + * this instance using a passed-in model. This allows us to change plenty of information + * in the model, and still use the old, pre-update model to actually make the HTTP request. + * + * @throws DaemonConnectionException + */ + public function update(Node $node): ResponseInterface + { + try { + return $this->getHttpClient()->post( + '/api/update', + ['json' => $node->getConfiguration()] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Elytra/DaemonFileRepository.php b/app/Repositories/Elytra/DaemonFileRepository.php new file mode 100644 index 000000000..af8db9be6 --- /dev/null +++ b/app/Repositories/Elytra/DaemonFileRepository.php @@ -0,0 +1,301 @@ +server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/contents', $this->server->uuid), + [ + 'query' => ['file' => $path], + ] + ); + } catch (ClientException|TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + $length = (int) Arr::get($response->getHeader('Content-Length'), 0, 0); + if ($notLargerThan && $length > $notLargerThan) { + throw new FileSizeTooLargeException(); + } + + return $response->getBody()->__toString(); + } + + /** + * Save new contents to a given file. This works for both creating and updating + * a file. + * + * @throws DaemonConnectionException + */ + public function putContent(string $path, string $content): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/write', $this->server->uuid), + [ + 'query' => ['file' => $path], + 'body' => $content, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Return a directory listing for a given path. + * + * @throws DaemonConnectionException + */ + public function getDirectory(string $path): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), + [ + 'query' => ['directory' => $path], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody(), true); + } + + /** + * Creates a new directory for the server in the given $path. + * + * @throws DaemonConnectionException + */ + public function createDirectory(string $name, string $path): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), + [ + 'json' => [ + 'name' => $name, + 'path' => $path, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Renames or moves a file on the remote machine. + * + * @throws DaemonConnectionException + */ + public function renameFiles(?string $root, array $files): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->put( + sprintf('/api/servers/%s/files/rename', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Copy a given file and give it a unique name. + * + * @throws DaemonConnectionException + */ + public function copyFile(string $location): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/copy', $this->server->uuid), + [ + 'json' => [ + 'location' => $location, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Delete a file or folder for the server. + * + * @throws DaemonConnectionException + */ + public function deleteFiles(?string $root, array $files): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/delete', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Compress the given files or folders in the given root. + * + * @throws DaemonConnectionException + */ + public function compressFiles(?string $root, array $files): array + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/compress', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + // Wait for up to 15 minutes for the archive to be completed when calling this endpoint + // since it will likely take quite awhile for large directories. + 'timeout' => 60 * 15, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody(), true); + } + + /** + * Decompresses a given archive file. + * + * @throws DaemonConnectionException + */ + public function decompressFile(?string $root, string $file): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/decompress', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'file' => $file, + ], + // Wait for up to 15 minutes for the decompress to be completed when calling this endpoint + // since it will likely take quite awhile for large directories. + 'timeout' => 60 * 15, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Chmods the given files. + * + * @throws DaemonConnectionException + */ + public function chmodFiles(?string $root, array $files): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/chmod', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Pulls a file from the given URL and saves it to the disk. + * + * @throws DaemonConnectionException + */ + public function pull(string $url, ?string $directory, array $params = []): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + $attributes = [ + 'url' => $url, + 'root' => $directory ?? '/', + 'file_name' => $params['filename'] ?? null, + 'use_header' => $params['use_header'] ?? null, + 'foreground' => $params['foreground'] ?? null, + ]; + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/pull', $this->server->uuid), + [ + 'json' => array_filter($attributes, fn ($value) => !is_null($value)), + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Elytra/DaemonPowerRepository.php b/app/Repositories/Elytra/DaemonPowerRepository.php new file mode 100644 index 000000000..2b6f339a6 --- /dev/null +++ b/app/Repositories/Elytra/DaemonPowerRepository.php @@ -0,0 +1,35 @@ +server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/power', $this->server->uuid), + ['json' => ['action' => $action]] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Elytra/DaemonRepository.php b/app/Repositories/Elytra/DaemonRepository.php new file mode 100644 index 000000000..5803579c3 --- /dev/null +++ b/app/Repositories/Elytra/DaemonRepository.php @@ -0,0 +1,69 @@ +server = $server; + + $this->setNode($this->server->node); + + return $this; + } + + /** + * Set the node model this request is stemming from. + */ + public function setNode(Node $node): self + { + $this->node = $node; + + return $this; + } + + /** + * Return an instance of the Guzzle HTTP Client to be used for requests. + */ + public function getHttpClient(array $headers = []): Client + { + Assert::isInstanceOf($this->node, Node::class); + + return new Client([ + 'verify' => $this->app->environment('production'), + 'base_uri' => $this->node->getConnectionAddress(), + 'timeout' => config('pterodactyl.guzzle.timeout'), + 'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'), + 'headers' => array_merge($headers, [ + 'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(), + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]), + ]); + } +} diff --git a/app/Repositories/Elytra/DaemonServerRepository.php b/app/Repositories/Elytra/DaemonServerRepository.php new file mode 100644 index 000000000..d7ee51a81 --- /dev/null +++ b/app/Repositories/Elytra/DaemonServerRepository.php @@ -0,0 +1,162 @@ +server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s', $this->server->uuid) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception, false); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * Creates a new server on the Wings daemon. + * + * @throws DaemonConnectionException + */ + public function create(bool $startOnCompletion = true): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post('/api/servers', [ + 'json' => [ + 'uuid' => $this->server->uuid, + 'start_on_completion' => $startOnCompletion, + ], + ]); + } catch (GuzzleException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Triggers a server sync on Wings. + * + * @throws DaemonConnectionException + */ + public function sync(): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/sync"); + } catch (GuzzleException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Delete a server from the daemon, forcibly if passed. + * + * @throws DaemonConnectionException + */ + public function delete(): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->delete('/api/servers/' . $this->server->uuid); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Reinstall a server on the daemon. + * + * @throws DaemonConnectionException + */ + public function reinstall(): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post(sprintf( + '/api/servers/%s/reinstall', + $this->server->uuid + )); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Requests the daemon to create a full archive of the server. Once the daemon is finished + * they will send a POST request to "/api/remote/servers/{uuid}/archive" with a boolean. + * + * @throws DaemonConnectionException + */ + public function requestArchive(): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post(sprintf( + '/api/servers/%s/archive', + $this->server->uuid + )); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Revokes a single user's JTI by using their ID. This is simply a helper function to + * make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted + * correctly and avoids any costly mistakes in the codebase. + * + * @throws DaemonConnectionException + */ + public function revokeUserJTI(int $id): void + { + Assert::isInstanceOf($this->server, Server::class); + + $this->revokeJTIs([md5($id . $this->server->uuid)]); + } + + /** + * Revokes an array of JWT JTI's by marking any token generated before the current time on + * the Wings instance as being invalid. + * + * @throws DaemonConnectionException + */ + protected function revokeJTIs(array $jtis): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient() + ->post(sprintf('/api/servers/%s/ws/deny', $this->server->uuid), [ + 'json' => ['jtis' => $jtis], + ]); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Elytra/DaemonTransferRepository.php b/app/Repositories/Elytra/DaemonTransferRepository.php new file mode 100644 index 000000000..9a2cd2160 --- /dev/null +++ b/app/Repositories/Elytra/DaemonTransferRepository.php @@ -0,0 +1,37 @@ +getHttpClient()->post(sprintf('/api/servers/%s/transfer', $this->server->uuid), [ + 'json' => [ + 'server_id' => $this->server->uuid, + 'url' => $targetNode->getConnectionAddress() . '/api/transfers', + 'token' => 'Bearer ' . $token->toString(), + 'server' => [ + 'uuid' => $this->server->uuid, + 'start_on_completion' => false, + ], + ], + ]); + } catch (GuzzleException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Wings/DaemonBackupRepository.php b/app/Repositories/Wings/DaemonBackupRepository.php new file mode 100644 index 000000000..228784089 --- /dev/null +++ b/app/Repositories/Wings/DaemonBackupRepository.php @@ -0,0 +1,97 @@ +adapter = $adapter; + + return $this; + } + + /** + * Tells the remote Daemon to begin generating a backup for the server. + * + * @throws DaemonConnectionException + */ + public function backup(Backup $backup): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/backup', $this->server->uuid), + [ + 'json' => [ + 'adapter' => $this->adapter ?? config('backups.default'), + 'uuid' => $backup->uuid, + 'ignore' => implode("\n", $backup->ignored_files), + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Sends a request to Wings to begin restoring a backup for a server. + * + * @throws DaemonConnectionException + */ + public function restore(Backup $backup, ?string $url = null, bool $truncate = false): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid), + [ + 'json' => [ + 'adapter' => $backup->disk, + 'truncate_directory' => $truncate, + 'download_url' => $url ?? '', + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * Deletes a backup from the daemon. + * + * @throws DaemonConnectionException + */ + public function delete(Backup $backup): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->delete( + sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index af8db9be6..19eb43369 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -37,7 +37,7 @@ class DaemonFileRepository extends DaemonRepository 'query' => ['file' => $path], ] ); - } catch (ClientException|TransferException $exception) { + } catch (ClientException | TransferException $exception) { throw new DaemonConnectionException($exception); } @@ -291,7 +291,7 @@ class DaemonFileRepository extends DaemonRepository return $this->getHttpClient()->post( sprintf('/api/servers/%s/files/pull', $this->server->uuid), [ - 'json' => array_filter($attributes, fn ($value) => !is_null($value)), + 'json' => array_filter($attributes, fn($value) => !is_null($value)), ] ); } catch (TransferException $exception) { diff --git a/app/Repositories/Wings/DaemonRepository.php b/app/Repositories/Wings/DaemonRepository.php index 5803579c3..4164f64c1 100644 --- a/app/Repositories/Wings/DaemonRepository.php +++ b/app/Repositories/Wings/DaemonRepository.php @@ -21,9 +21,7 @@ abstract class DaemonRepository /** * DaemonRepository constructor. */ - public function __construct(protected Application $app) - { - } + public function __construct(protected Application $app) {} /** * Set the server model this request is stemming from. diff --git a/app/Repositories/Wings/DaemonRevocationRepository.php b/app/Repositories/Wings/DaemonRevocationRepository.php new file mode 100644 index 000000000..138b53b1d --- /dev/null +++ b/app/Repositories/Wings/DaemonRevocationRepository.php @@ -0,0 +1,28 @@ +getHttpClient()->post('/api/deauthorize-user', [ + 'json' => ['user' => $user, 'servers' => $servers], + ]); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Services/Backups/Wings/DeleteBackupService.php b/app/Services/Backups/Wings/DeleteBackupService.php new file mode 100644 index 000000000..3ab7af690 --- /dev/null +++ b/app/Services/Backups/Wings/DeleteBackupService.php @@ -0,0 +1,81 @@ +is_locked && ($backup->is_successful && !is_null($backup->completed_at))) { + throw new BackupLockedException(); + } + + if ($backup->disk === Backup::ADAPTER_AWS_S3) { + $this->deleteFromS3($backup); + + return; + } + + $this->connection->transaction(function () use ($backup) { + try { + $this->daemonBackupRepository->setServer($backup->server)->delete($backup); + } catch (DaemonConnectionException $exception) { + $previous = $exception->getPrevious(); + // Don't fail the request if the Daemon responds with a 404, just assume the backup + // doesn't actually exist and remove its reference from the Panel as well. + if (!$previous instanceof ClientException || $previous->getResponse()->getStatusCode() !== Response::HTTP_NOT_FOUND) { + throw $exception; + } + } + + $backup->delete(); + }); + } + + /** + * Deletes a backup from an S3 disk. + * + * @throws \Throwable + */ + protected function deleteFromS3(Backup $backup): void + { + $this->connection->transaction(function () use ($backup) { + $backup->delete(); + + /** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */ + $adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3); + + $adapter->getClient()->deleteObject([ + 'Bucket' => $adapter->getBucket(), + 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), + ]); + }); + } +} diff --git a/app/Services/Backups/Wings/DownloadLinkService.php b/app/Services/Backups/Wings/DownloadLinkService.php new file mode 100644 index 000000000..64b0733f7 --- /dev/null +++ b/app/Services/Backups/Wings/DownloadLinkService.php @@ -0,0 +1,60 @@ +disk === Backup::ADAPTER_AWS_S3) { + return $this->getS3BackupUrl($backup); + } + + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setUser($user) + ->setClaims([ + 'backup_uuid' => $backup->uuid, + 'server_uuid' => $backup->server->uuid, + ]) + ->handle($backup->server->node, $user->id . $backup->server->uuid); + + return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString()); + } + + /** + * Returns a signed URL that allows us to download a file directly out of a non-public + * S3 bucket by using a signed URL. + */ + protected function getS3BackupUrl(Backup $backup): string + { + /** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */ + $adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3); + + $request = $adapter->getClient()->createPresignedRequest( + $adapter->getClient()->getCommand('GetObject', [ + 'Bucket' => $adapter->getBucket(), + 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), + 'ContentType' => 'application/x-gzip', + ]), + CarbonImmutable::now()->addMinutes(5) + ); + + return $request->getUri()->__toString(); + } +} diff --git a/app/Services/Backups/Wings/InitiateBackupService.php b/app/Services/Backups/Wings/InitiateBackupService.php new file mode 100644 index 000000000..c89e2f5f4 --- /dev/null +++ b/app/Services/Backups/Wings/InitiateBackupService.php @@ -0,0 +1,134 @@ +isLocked = $isLocked; + + return $this; + } + + /** + * Sets the files to be ignored by this backup. + * + * @param string[]|null $ignored + */ + public function setIgnoredFiles(?array $ignored): self + { + if (is_array($ignored)) { + foreach ($ignored as $value) { + Assert::string($value); + } + } + + // Set the ignored files to be any values that are not empty in the array. Don't use + // the PHP empty function here incase anything that is "empty" by default (0, false, etc.) + // were passed as a file or folder name. + $this->ignoredFiles = is_null($ignored) ? [] : array_filter($ignored, function ($value) { + return strlen($value) > 0; + }); + + return $this; + } + + /** + * Initiates the backup process for a server on Wings. + * + * @throws \Throwable + * @throws TooManyBackupsException + * @throws TooManyRequestsHttpException + */ + public function handle(Server $server, ?string $name = null, bool $override = false): Backup + { + $limit = config('backups.throttles.limit'); + $period = config('backups.throttles.period'); + if ($period > 0) { + $previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period); + if ($previous->count() >= $limit) { + $message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period); + + throw new TooManyRequestsHttpException((int) CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message); + } + } + + // Check if the server has reached or exceeded its backup limit. + // completed_at == null will cover any ongoing backups, while is_successful == true will cover any completed backups. + $successful = $this->repository->getNonFailedBackups($server); + if ($server->backup_limit == null) { + $server->backup_limit = $successful->count() + 1; + }; + if (!$server->backup_limit || $successful->count() >= $server->backup_limit) { + // Do not allow the user to continue if this server is already at its limit and can't override. + if ($server->backup_limit == null) { + $server->backup_limit = 12; + }; + + if (!$override || $server->backup_limit <= 0) { + throw new TooManyBackupsException($server->backup_limit); + } + + // Get the oldest backup the server has that is not "locked" (indicating a backup that should + // never be automatically purged). If we find a backup we will delete it and then continue with + // this process. If no backup is found that can be used an exception is thrown. + /** @var Backup $oldest */ + $oldest = $successful->where('is_locked', false)->orderBy('created_at')->first(); + if (!$oldest) { + throw new TooManyBackupsException($server->backup_limit); + } + + $this->deleteBackupService->handle($oldest); + } + + return $this->connection->transaction(function () use ($server, $name) { + /** @var Backup $backup */ + $backup = $this->repository->create([ + 'server_id' => $server->id, + 'uuid' => Uuid::uuid4()->toString(), + 'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()), + 'ignored_files' => array_values($this->ignoredFiles ?? []), + 'disk' => $server->node->backupDisk, + 'is_locked' => $this->isLocked, + ], true, true); + + $this->daemonBackupRepository->setServer($server) + ->setBackupAdapter($server->node->backupDisk) + ->backup($backup); + + return $backup; + }); + } +} diff --git a/app/Services/Captcha/CaptchaManager.php b/app/Services/Captcha/CaptchaManager.php index e62a30848..e2ccde885 100644 --- a/app/Services/Captcha/CaptchaManager.php +++ b/app/Services/Captcha/CaptchaManager.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Services\Captcha; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Manager; use Pterodactyl\Services\Captcha\Providers\TurnstileProvider; use Pterodactyl\Services\Captcha\Providers\HCaptchaProvider; @@ -73,6 +74,8 @@ class CaptchaManager extends Manager */ public function getWidget(): string { + + if ($this->getDefaultDriver() === 'none') { return ''; } @@ -103,4 +106,4 @@ class CaptchaManager extends Manager return $this->driver()->getScriptIncludes(); } -} \ No newline at end of file +} diff --git a/app/Services/Captcha/Providers/RecaptchaProvider.php b/app/Services/Captcha/Providers/RecaptchaProvider.php index e787ed4b0..c436bfd07 100644 --- a/app/Services/Captcha/Providers/RecaptchaProvider.php +++ b/app/Services/Captcha/Providers/RecaptchaProvider.php @@ -9,10 +9,11 @@ use Illuminate\Support\Facades\Log; class RecaptchaProvider implements CaptchaProviderInterface { - private const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + /* private const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; */ protected string $siteKey; protected string $secretKey; + protected string $verifyUrl = 'https://www.google.com/recaptcha/api/siteverify'; public function __construct(array $config) { @@ -29,7 +30,7 @@ class RecaptchaProvider implements CaptchaProviderInterface try { $httpResponse = Http::timeout(10) ->asForm() - ->post(self::VERIFY_URL, [ + ->post($this->verifyUrl, [ 'secret' => $this->secretKey, 'response' => $response, 'remoteip' => $remoteIp, @@ -106,4 +107,5 @@ class RecaptchaProvider implements CaptchaProviderInterface { return !empty($this->siteKey) && !empty($this->secretKey); } -} \ No newline at end of file +} + diff --git a/app/Services/Elytra/Jobs/BackupJob.php b/app/Services/Elytra/Jobs/BackupJob.php index c6ec1a2a3..d0de38e88 100644 --- a/app/Services/Elytra/Jobs/BackupJob.php +++ b/app/Services/Elytra/Jobs/BackupJob.php @@ -372,13 +372,9 @@ class BackupJob implements Job } } - private function handleRestoreCompletion(ElytraJob $job, array $statusData): void - { - } + private function handleRestoreCompletion(ElytraJob $job, array $statusData): void {} - private function handleDownloadCompletion(ElytraJob $job, array $statusData): void - { - } + private function handleDownloadCompletion(ElytraJob $job, array $statusData): void {} private function submitDeleteAllJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string { @@ -576,4 +572,5 @@ class BackupJob implements Job { return 'Backup operation failed. Please contact an administrator for details.'; // todo: better sanitization - elllie } -} \ No newline at end of file +} + diff --git a/app/Services/Subdomain/SubdomainManagementService.php b/app/Services/Subdomain/SubdomainManagementService.php index 858fac14c..695b4961f 100644 --- a/app/Services/Subdomain/SubdomainManagementService.php +++ b/app/Services/Subdomain/SubdomainManagementService.php @@ -8,16 +8,10 @@ use Pterodactyl\Models\ServerSubdomain; use Pterodactyl\Contracts\Dns\DnsProviderInterface; use Pterodactyl\Contracts\Subdomain\SubdomainFeatureInterface; use Pterodactyl\Exceptions\Dns\DnsProviderException; -use Pterodactyl\Services\Subdomain\Features\FactorioSubdomainFeature; -use Pterodactyl\Services\Subdomain\Features\MinecraftSubdomainFeature; -use Pterodactyl\Services\Subdomain\Features\RustSubdomainFeature; -use Pterodactyl\Services\Subdomain\Features\ScpSlSubdomainFeature; -use Pterodactyl\Services\Subdomain\Features\TeamSpeakSubdomainFeature; -use Pterodactyl\Services\Dns\Providers\CloudflareProvider; -use Pterodactyl\Services\Dns\Providers\HetznerProvider; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Pterodactyl\Services\Dns\Providers\Route53Provider; +use Pterodactyl\Enums\Subdomain\Providers; +use Pterodactyl\Enums\Subdomain\Features; class SubdomainManagementService { @@ -27,20 +21,10 @@ class SubdomainManagementService public function __construct() { // Register DNS providers - $this->dnsProviders = [ - 'cloudflare' => CloudflareProvider::class, - 'hetzner' => HetznerProvider::class, - 'route53' => Route53Provider::class - ]; + $this->dnsProviders = Providers::all(); // Register subdomain features - $this->subdomainFeatures = [ - 'subdomain_factorio' => FactorioSubdomainFeature::class, - 'subdomain_minecraft' => MinecraftSubdomainFeature::class, - 'subdomain_rust' => RustSubdomainFeature::class, - 'subdomain_scpsl' => ScpSlSubdomainFeature::class, - 'subdomain_teamspeak' => TeamSpeakSubdomainFeature::class, - ]; + $this->subdomainFeatures = Features::all(); } /** diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 46578a0f7..2779534b9 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -16,136 +16,138 @@ use Pterodactyl\Services\Servers\StartupCommandService; class ServerTransformer extends BaseClientTransformer { - protected array $defaultIncludes = ['allocations', 'variables']; + protected array $defaultIncludes = ['allocations', 'variables']; - protected array $availableIncludes = ['egg', 'subusers']; + protected array $availableIncludes = ['egg', 'subusers']; - public function getResourceName(): string - { - return Server::RESOURCE_NAME; - } - - /** - * Transform a server model into a representation that can be returned - * to a client. - */ - public function transform(Server $server): array - { - /** @var StartupCommandService $service */ - $service = Container::getInstance()->make(StartupCommandService::class); - - $user = $this->request->user(); - - return [ - 'server_owner' => $user->id === $server->owner_id, - 'identifier' => $server->uuidShort, - 'internal_id' => $server->id, - 'uuid' => $server->uuid, - 'name' => $server->name, - 'node' => $server->node->name, - 'is_node_under_maintenance' => $server->node->isUnderMaintenance(), - 'sftp_details' => [ - 'ip' => $server->node->fqdn, - 'port' => $server->node->daemonSFTP, - ], - 'sftp_alias' => [ - 'ip' => $server->node->SFTPAliasAddress, - 'port' => $server->node->SFTPAliasPort - ], - 'description' => $server->description, - 'limits' => [ - 'memory' => $server->memory, - 'overhead_memory' => $server->overhead_memory, - 'swap' => $server->swap, - 'disk' => $server->disk, - 'io' => $server->io, - 'cpu' => $server->cpu, - 'threads' => $server->threads, - 'oom_disabled' => $server->oom_disabled, - ], - 'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)), - 'docker_image' => $server->image, - 'egg_features' => $server->egg->inherit_features, - 'egg' => $server->egg->uuid, - 'feature_limits' => [ - 'databases' => $server->database_limit, - 'allocations' => $server->allocation_limit, - 'backups' => $server->backup_limit, - 'backupStorageMb' => $server->backup_storage_limit, - ], - 'status' => $server->status, - // This field is deprecated, please use "status". - 'is_suspended' => $server->isSuspended(), - // This field is deprecated, please use "status". - 'is_installing' => !$server->isInstalled(), - 'is_transferring' => !is_null($server->transfer), - ]; - } - - /** - * Returns the allocations associated with this server. - * - * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException - */ - public function includeAllocations(Server $server): Collection - { - $transformer = $this->makeTransformer(AllocationTransformer::class); - - $user = $this->request->user(); - // While we include this permission, we do need to actually handle it slightly different here - // for the purpose of keeping things functionally working. If the user doesn't have read permissions - // for the allocations we'll only return the primary server allocation, and any notes associated - // with it will be hidden. - // - // This allows us to avoid too much permission regression, without also hiding information that - // is generally needed for the frontend to make sense when browsing or searching results. - if (!$user->can(Permission::ACTION_ALLOCATION_READ, $server)) { - $primary = clone $server->allocation; - $primary->notes = null; - - return $this->collection([$primary], $transformer, Allocation::RESOURCE_NAME); + public function getResourceName(): string + { + return Server::RESOURCE_NAME; } - return $this->collection($server->allocations, $transformer, Allocation::RESOURCE_NAME); - } + /** + * Transform a server model into a representation that can be returned + * to a client. + */ + public function transform(Server $server): array + { + /** @var StartupCommandService $service */ + $service = Container::getInstance()->make(StartupCommandService::class); - /** - * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException - */ - public function includeVariables(Server $server): Collection|NullResource - { - if (!$this->request->user()->can(Permission::ACTION_STARTUP_READ, $server)) { - return $this->null(); + $user = $this->request->user(); + + return [ + 'server_owner' => $user->id === $server->owner_id, + 'identifier' => $server->uuidShort, + 'internal_id' => $server->id, + 'uuid' => $server->uuid, + 'name' => $server->name, + 'node' => $server->node->name, + 'is_node_under_maintenance' => $server->node->isUnderMaintenance(), + 'sftp_details' => [ + 'ip' => $server->node->fqdn, + 'port' => $server->node->daemonSFTP, + ], + 'sftp_alias' => [ + 'ip' => $server->node->SFTPAliasAddress, + 'port' => $server->node->SFTPAliasPort + ], + 'description' => $server->description, + 'limits' => [ + 'memory' => $server->memory, + 'overhead_memory' => $server->overhead_memory, + 'swap' => $server->swap, + 'disk' => $server->disk, + 'io' => $server->io, + 'cpu' => $server->cpu, + 'threads' => $server->threads, + 'oom_disabled' => $server->oom_disabled, + ], + 'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)), + 'docker_image' => $server->image, + 'egg_features' => $server->egg->inherit_features, + 'egg' => $server->egg->uuid, + 'feature_limits' => [ + 'databases' => $server->database_limit, + 'allocations' => $server->allocation_limit, + 'backups' => $server->backup_limit, + 'backupStorageMb' => $server->backup_storage_limit, + ], + 'status' => $server->status, + // This field is deprecated, please use "status". + 'is_suspended' => $server->isSuspended(), + // This field is deprecated, please use "status". + 'is_installing' => !$server->isInstalled(), + 'is_transferring' => !is_null($server->transfer), + 'daemon_type' => $server->node->daemonType, + 'backup_disk' => $server->node->backupDisk, + ]; } - return $this->collection( - $server->variables->where('user_viewable', true), - $this->makeTransformer(EggVariableTransformer::class), - EggVariable::RESOURCE_NAME - ); - } + /** + * Returns the allocations associated with this server. + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeAllocations(Server $server): Collection + { + $transformer = $this->makeTransformer(AllocationTransformer::class); - /** - * Returns the egg associated with this server. - * - * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException - */ - public function includeEgg(Server $server): Item - { - return $this->item($server->egg, $this->makeTransformer(EggTransformer::class), Egg::RESOURCE_NAME); - } + $user = $this->request->user(); + // While we include this permission, we do need to actually handle it slightly different here + // for the purpose of keeping things functionally working. If the user doesn't have read permissions + // for the allocations we'll only return the primary server allocation, and any notes associated + // with it will be hidden. + // + // This allows us to avoid too much permission regression, without also hiding information that + // is generally needed for the frontend to make sense when browsing or searching results. + if (!$user->can(Permission::ACTION_ALLOCATION_READ, $server)) { + $primary = clone $server->allocation; + $primary->notes = null; - /** - * Returns the subusers associated with this server. - * - * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException - */ - public function includeSubusers(Server $server): Collection|NullResource - { - if (!$this->request->user()->can(Permission::ACTION_USER_READ, $server)) { - return $this->null(); + return $this->collection([$primary], $transformer, Allocation::RESOURCE_NAME); + } + + return $this->collection($server->allocations, $transformer, Allocation::RESOURCE_NAME); } - return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME); - } + /** + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeVariables(Server $server): Collection|NullResource + { + if (!$this->request->user()->can(Permission::ACTION_STARTUP_READ, $server)) { + return $this->null(); + } + + return $this->collection( + $server->variables->where('user_viewable', true), + $this->makeTransformer(EggVariableTransformer::class), + EggVariable::RESOURCE_NAME + ); + } + + /** + * Returns the egg associated with this server. + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeEgg(Server $server): Item + { + return $this->item($server->egg, $this->makeTransformer(EggTransformer::class), Egg::RESOURCE_NAME); + } + + /** + * Returns the subusers associated with this server. + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeSubusers(Server $server): Collection|NullResource + { + if (!$this->request->user()->can(Permission::ACTION_USER_READ, $server)) { + return $this->null(); + } + + return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME); + } } diff --git a/composer.json b/composer.json index 1e6ea9ab0..8615908d7 100644 --- a/composer.json +++ b/composer.json @@ -72,13 +72,15 @@ ], "psr-4": { "Pterodactyl\\": "app/", + "Pyrodactyl\\": "app/", "Database\\Factories\\": "database/Factories/", "Database\\Seeders\\": "database/Seeders/" } }, "autoload-dev": { "psr-4": { - "Pterodactyl\\Tests\\": "tests/" + "Pterodactyl\\Tests\\": "tests/", + "Pyrodactyl\\Tests\\": "tests/" } }, "scripts": { diff --git a/config/backups.php b/config/backups.php index f226a4644..c19f6664a 100644 --- a/config/backups.php +++ b/config/backups.php @@ -7,7 +7,7 @@ return [ // will be stored in this location by default. It is possible to change this once backups // have been made, without losing data. // Options: elytra, wings (legacy), s3, rustic_local, rustic_s3 - 'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL), + 'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_WINGS), // This value is used to determine the lifespan of UploadPart presigned urls that wings // uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour. diff --git a/config/captcha.php b/config/captcha.php index 29827c9df..729810c4b 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -1,5 +1,5 @@ env('CAPTCHA_ENABLED', false), + 'enabled' => env('CAPTCHA_ENABLED', true), ]; diff --git a/config/javascript.php b/config/javascript.php index 57504395d..ae21ee45c 100644 --- a/config/javascript.php +++ b/config/javascript.php @@ -26,5 +26,5 @@ return [ | That way, you can access vars, like "SomeNamespace.someVariable." | */ - 'js_namespace' => 'Pterodactyl', + 'js_namespace' => 'Pyrodactyl', ]; diff --git a/database/Factories/NodeFactory.php b/database/Factories/NodeFactory.php index 099ce4e1b..fa24b3b6c 100644 --- a/database/Factories/NodeFactory.php +++ b/database/Factories/NodeFactory.php @@ -10,35 +10,37 @@ use Illuminate\Database\Eloquent\Factories\Factory; class NodeFactory extends Factory { - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = Node::class; - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - 'uuid' => Uuid::uuid4()->toString(), - 'public' => true, - 'name' => 'FactoryNode_' . Str::random(10), - 'fqdn' => $this->faker->unique()->ipv4, - 'scheme' => 'http', - 'behind_proxy' => false, - 'memory' => 1024, - 'memory_overallocate' => 0, - 'disk' => 10240, - 'disk_overallocate' => 0, - 'upload_size' => 100, - 'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH), - 'daemon_token' => Crypt::encrypt(Str::random(Node::DAEMON_TOKEN_LENGTH)), - 'daemonListen' => 8080, - 'daemonSFTP' => 2022, - 'daemonBase' => '/var/lib/pterodactyl/volumes', - ]; - } + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = Node::class; + + /** + * Define the model's default state. + */ + public function definition(): array + { + return [ + 'uuid' => Uuid::uuid4()->toString(), + 'public' => true, + 'name' => 'FactoryNode_' . Str::random(10), + 'fqdn' => $this->faker->unique()->ipv4, + 'scheme' => 'http', + 'behind_proxy' => false, + 'memory' => 1024, + 'memory_overallocate' => 0, + 'disk' => 10240, + 'disk_overallocate' => 0, + 'upload_size' => 100, + 'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH), + 'daemon_token' => Crypt::encrypt(Str::random(Node::DAEMON_TOKEN_LENGTH)), + 'daemonListen' => 8080, + 'daemonSFTP' => 2022, + 'daemonBase' => '/var/lib/pterodactyl/volumes', + 'backupDisk' => 'local', + ]; + } } diff --git a/database/migrations/2025_12_07_022523_wings_or_elytra.php b/database/migrations/2025_12_07_022523_wings_or_elytra.php new file mode 100644 index 000000000..fd73769b1 --- /dev/null +++ b/database/migrations/2025_12_07_022523_wings_or_elytra.php @@ -0,0 +1,28 @@ +enum('daemonType', ['wings', 'elytra'])->default("elytra")->comment("What daemon Type this node uses"); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table("nodes", function (Blueprint $table) { + $table->dropColumn('daemonType'); + }); + } +}; diff --git a/database/migrations/2026_01_02_044710_backup_disk.php b/database/migrations/2026_01_02_044710_backup_disk.php new file mode 100644 index 000000000..bffa55236 --- /dev/null +++ b/database/migrations/2026_01_02_044710_backup_disk.php @@ -0,0 +1,28 @@ +string('backupDisk')->default("rustic_local")->comment("What Backup type this Node uses"); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table("nodes", function (Blueprint $table) { + $table->dropColumn('backupDisk'); + }); + } +}; diff --git a/package.json b/package.json index 4155a5b0b..1ae22e5f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pyrodactyl", - "version": "4.0.0-dev", + "version": "canary", "buildNumber": "30", "engines": { "node": ">=20.0" @@ -97,6 +97,7 @@ "postcss-import": "^16.1.1", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", + "rollup-plugin-visualizer": "^6.0.5", "tailwind-scrollbar": "^4.0.2", "ts-essentials": "^10.1.1", "tw-animate-css": "^1.3.6", @@ -123,4 +124,4 @@ "not dead" ], "packageManager": "pnpm@10.13.1" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7973ec5e6..41716d399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.14 version: 0.6.14(@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2))(prettier@3.6.2) + rollup-plugin-visualizer: + specifier: ^6.0.5 + version: 6.0.5(rollup@4.46.2) tailwind-scrollbar: specifier: ^4.0.2 version: 4.0.2(react@19.1.1)(tailwindcss@4.1.11) @@ -2120,6 +2123,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2232,6 +2239,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2331,6 +2342,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -2383,6 +2398,9 @@ packages: electron-to-chromium@1.5.180: resolution: {integrity: sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -2623,6 +2641,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2762,6 +2784,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2770,6 +2797,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -2830,6 +2861,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3092,6 +3127,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3369,6 +3408,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3386,6 +3429,19 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup-plugin-visualizer@6.0.5: + resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.46.2: resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3473,10 +3529,18 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -3496,6 +3560,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3799,6 +3867,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3806,6 +3882,14 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5821,6 +5905,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -5970,6 +6056,12 @@ snapshots: chownr@3.0.0: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): @@ -6065,6 +6157,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -6108,6 +6202,8 @@ snapshots: electron-to-chromium@1.5.180: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -6465,6 +6561,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6603,12 +6701,16 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -6670,6 +6772,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -6898,6 +7004,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7156,6 +7268,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve@1.22.10: @@ -7172,6 +7286,15 @@ snapshots: reusify@1.1.0: {} + rollup-plugin-visualizer@6.0.5(rollup@4.46.2): + dependencies: + open: 8.4.2 + picomatch: 4.0.3 + source-map: 0.7.6 + yargs: 17.7.2 + optionalDependencies: + rollup: 4.46.2 + rollup@4.46.2: dependencies: '@types/estree': 1.0.8 @@ -7296,11 +7419,19 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -7345,6 +7476,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@3.1.1: {} style-mod@4.1.2: {} @@ -7636,10 +7771,30 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yup@1.7.0: diff --git a/public/themes/pterodactyl/js/admin/new-server.js b/public/themes/pterodactyl/js/admin/new-server.js index 1437c04e2..cdcbc5fd7 100644 --- a/public/themes/pterodactyl/js/admin/new-server.js +++ b/public/themes/pterodactyl/js/admin/new-server.js @@ -17,6 +17,7 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. + $(document).ready(function() { $('#pNestId').select2({ placeholder: 'Select a Nest', @@ -44,7 +45,7 @@ $(document).ready(function() { }); let lastActiveBox = null; -$(document).on('click', function (event) { +$(document).on('click', function(event) { if (lastActiveBox !== null) { lastActiveBox.removeClass('box-primary'); } @@ -52,10 +53,10 @@ $(document).on('click', function (event) { lastActiveBox = $(event.target).closest('.box'); lastActiveBox.addClass('box-primary'); }); - -$('#pNodeId').on('change', function () { +$('#pNodeId').on('change', function() { currentNode = $(this).val(); - $.each(Pterodactyl.nodeData, function (i, v) { + + $.each(Pyrodactyl.nodeData, function(i, v) { if (v.id == currentNode) { $('#pAllocation').html('').select2({ data: v.allocations, @@ -67,9 +68,10 @@ $('#pNodeId').on('change', function () { }); }); -$('#pNestId').on('change', function (event) { +$('#pNestId').on('change', function(event) { + const nestId = $(this).val(); $('#pEggId').html('').select2({ - data: $.map(_.get(Pterodactyl.nests, $(this).val() + '.eggs', []), function (item) { + data: $.map(_.get(Pyrodactyl.nests, $(this).val() + '.eggs', []), function(item) { return { id: item.id, text: item.name, @@ -78,8 +80,8 @@ $('#pNestId').on('change', function (event) { }).change(); }); -$('#pEggId').on('change', function (event) { - let parentChain = _.get(Pterodactyl.nests, $('#pNestId').val(), null); +$('#pEggId').on('change', function(event) { + let parentChain = _.get(Pyrodactyl.nests, $('#pNestId').val(), null); let objectChain = _.get(parentChain, 'eggs.' + $(this).val(), null); const images = _.get(objectChain, 'docker_images', {}) @@ -100,7 +102,7 @@ $('#pEggId').on('change', function (event) { $('#pPackId').html('').select2({ data: [{ id: 0, text: 'No Service Pack' }].concat( - $.map(_.get(objectChain, 'packs', []), function (item, i) { + $.map(_.get(objectChain, 'packs', []), function(item, i) { return { id: item.id, text: item.name + ' (' + item.version + ')', @@ -117,7 +119,7 @@ $('#pEggId').on('change', function (event) { const variableIds = {}; $('#appendVariablesTo').html(''); - $.each(_.get(objectChain, 'variables', []), function (i, item) { + $.each(_.get(objectChain, 'variables', []), function(i, item) { variableIds[item.env_variable] = 'var_ref_' + item.id; let isRequired = (item.required === 1) ? 'Required ' : ''; @@ -138,7 +140,7 @@ $('#pEggId').on('change', function (event) { serviceVariablesUpdated($('#pEggId').val(), variableIds); }); -$('#pAllocation').on('change', function () { +$('#pAllocation').on('change', function() { updateAdditionalAllocations(); }); @@ -146,7 +148,7 @@ function updateAdditionalAllocations() { let currentAllocation = $('#pAllocation').val(); let currentNode = $('#pNodeId').val(); - $.each(Pterodactyl.nodeData, function (i, v) { + $.each(Pyrodactyl.nodeData, function(i, v) { if (v.id == currentNode) { let allocations = []; @@ -179,14 +181,14 @@ function initUserIdSelect(data) { dataType: 'json', delay: 250, - data: function (params) { + data: function(params) { return { filter: { email: params.term }, page: params.page, }; }, - processResults: function (data, params) { + processResults: function(data, params) { return { results: data }; }, @@ -194,20 +196,20 @@ function initUserIdSelect(data) { }, data: data, - escapeMarkup: function (markup) { return markup; }, + escapeMarkup: function(markup) { return markup; }, minimumInputLength: 2, - templateResult: function (data) { + templateResult: function(data) { if (data.loading) return escapeHtml(data.text); return '
\ User Image \ \ - ' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) +' \ + ' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) + ' \ \ ' + escapeHtml(data.email) + ' - ' + escapeHtml(data.username) + ' \
'; }, - templateSelection: function (data) { + templateSelection: function(data) { return '
\ \ User Image \ diff --git a/public/themes/pterodactyl/js/plugins/minecraft/eula.js b/public/themes/pterodactyl/js/plugins/minecraft/eula.js index f431cf9b6..61c32cf0e 100644 --- a/public/themes/pterodactyl/js/plugins/minecraft/eula.js +++ b/public/themes/pterodactyl/js/plugins/minecraft/eula.js @@ -1,3 +1,5 @@ +// TODO: Convert this to pure React + // Copyright (c) 2015 - 2017 Dane Everitt // // Permission is hereby granted, free of charge, to any person obtaining a copy @@ -17,8 +19,8 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -$(document).ready(function () { - Socket.on('console', function (data) { +$(document).ready(function() { + Socket.on('console', function(data) { if (typeof data === 'undefined' || typeof data.line === 'undefined') { return; } @@ -37,7 +39,7 @@ $(document).ready(function () { closeOnConfirm: false, showLoaderOnConfirm: true, }, - function () { + function() { $.ajax({ type: 'POST', url: Pterodactyl.meta.saveFile, @@ -47,7 +49,7 @@ $(document).ready(function () { contents: 'eula=true', }, }) - .done(function (data) { + .done(function(data) { $('[data-attr="power"][data-action="start"]').trigger('click'); swal({ type: 'success', @@ -55,7 +57,7 @@ $(document).ready(function () { text: 'The EULA for this server has been accepted, restarting server now.', }); }) - .fail(function (jqXHR) { + .fail(function(jqXHR) { console.error(jqXHR); swal({ title: 'Whoops!', diff --git a/resources/scripts/api/server/activity.ts b/resources/scripts/api/server/activity.ts index b59481c30..0fde0b20f 100644 --- a/resources/scripts/api/server/activity.ts +++ b/resources/scripts/api/server/activity.ts @@ -7,6 +7,7 @@ import useSWR from 'swr'; import type { PaginatedResult, QueryBuilderParams } from '@/api/http'; import http, { withQueryBuilderParams } from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { ServerContext } from '@/state/server'; @@ -20,12 +21,13 @@ const useActivityLogs = ( config?: SWRConfiguration, AxiosError>, ) => { const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid); + const daemonType = getGlobalDaemonType(); const key = useServerSWRKey(['activity', useFilteredObject(filters || {})]); return useSWR>( key, async () => { - const { data } = await http.get(`/api/client/servers/${uuid}/activity`, { + const { data } = await http.get(`/api/client/servers/${daemonType}/${uuid}/activity`, { params: { ...withQueryBuilderParams(filters), include: ['actor'], diff --git a/resources/scripts/api/server/applyEggChange.ts b/resources/scripts/api/server/applyEggChange.ts index 79bcecc20..e8fb45aa0 100644 --- a/resources/scripts/api/server/applyEggChange.ts +++ b/resources/scripts/api/server/applyEggChange.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface ApplyEggChangeRequest { egg_id: number; @@ -21,6 +22,8 @@ export interface ApplyEggChangeResponse { * This initiates a background operation to change the server's egg configuration. */ export default async (uuid: string, data: ApplyEggChangeRequest): Promise => { - const { data: response } = await http.post(`/api/client/servers/${uuid}/settings/egg/apply`, data); + const daemonType = getGlobalDaemonType(); + + const { data: response } = await http.post(`/api/client/servers/${daemonType}/${uuid}/settings/egg/apply`, data); return response; }; diff --git a/resources/scripts/api/server/applyEggChangeSync.ts b/resources/scripts/api/server/applyEggChangeSync.ts new file mode 100644 index 000000000..f78971915 --- /dev/null +++ b/resources/scripts/api/server/applyEggChangeSync.ts @@ -0,0 +1,84 @@ +import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; +// NOTE: This is Specific to wings, it should also work for elytra, but I haven't actually tested + +interface ApplyEggChangeData { + egg_id: number; + nest_id: number; + docker_image?: string; + startup_command?: string; + environment?: Record; + should_backup?: boolean; + should_wipe?: boolean; +} + +export default async (uuid: string, data: ApplyEggChangeData): Promise => { + const daemonType = getGlobalDaemonType(); + + if (daemonType?.toLowerCase() === 'elytra') { + return http.post(`/api/client/servers/${daemonType}/${uuid}/settings/egg/apply`, data); + } + + if (daemonType?.toLowerCase() === 'wings') { + const { + egg_id, + nest_id, + docker_image, + startup_command, + environment = {}, + should_backup = false, + should_wipe = false, + } = data; + + try { + await http.put(`/api/client/servers/${daemonType}/${uuid}/settings/egg`, { + egg_id, + nest_id, + }); + + if (docker_image) { + await http.put(`/api/client/servers/${daemonType}/${uuid}/settings/docker-image`, { + docker_image, + }); + } + + if (startup_command) { + console.warn('Custom startup command update not supported for Wings daemon - using egg default'); + } + + const envPromises = Object.entries(environment).map(([key, value]) => + http.put(`/api/client/servers/${daemonType}/${uuid}/startup/variable`, { + key, + value, + }), + ); + await Promise.all(envPromises); + + if (should_backup) { + await http.post(`/api/client/servers/${daemonType}/${uuid}/backups`, { + name: `Software Change Backup - ${new Date().toISOString()}`, + is_locked: false, + }); + } + + if (should_wipe) { + const filesResponse = await http.get( + `/api/client/servers/${daemonType}/${uuid}/files/list?directory=/`, + ); + const files = filesResponse.data?.data || []; + if (files.length > 0) { + const fileNames = files.map((file: any) => file.name); + await http.post(`/api/client/servers/${daemonType}/${uuid}/files/delete`, { + root: '/', + files: fileNames, + }); + } + } + + await http.post(`/api/client/servers/${daemonType}/${uuid}/settings/reinstall`); + } catch (error) { + console.error('Failed to apply egg change for Wings:', error); + throw error; + } + } +}; diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index 1ec923692..6e8b9d8e7 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { ServerBackup } from '@/api/server/types'; import { rawDataToServerBackup } from '@/api/transformers'; @@ -22,7 +23,8 @@ export default async ( uuid: string, params: RequestParameters, ): Promise<{ backup: ServerBackup; jobId: string; status: string; progress: number; message?: string }> => { - const response = await http.post(`/api/client/servers/${uuid}/backups`, { + const daemonType = getGlobalDaemonType(); + const response = await http.post(`/api/client/servers/${daemonType}/${uuid}/backups`, { name: params.name, ignored: params.ignored, is_locked: params.isLocked, diff --git a/resources/scripts/api/server/backups/deleteAllServerBackups.ts b/resources/scripts/api/server/backups/deleteAllServerBackups.ts new file mode 100644 index 000000000..1282dbe5b --- /dev/null +++ b/resources/scripts/api/server/backups/deleteAllServerBackups.ts @@ -0,0 +1,14 @@ +import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; + +export default async (uuid: string, password: string, twoFactor: any, totpCode: any): Promise => { + const daemonType = getGlobalDaemonType(); + const response = await http.delete(`/api/client/servers/${daemonType}/${uuid}/backups/delete-all`, { + data: { + password: password, + ...(twoFactor ? { totp_code: totpCode } : {}), + }, + }); + + return response.status; +}; diff --git a/resources/scripts/api/server/backups/getBackupStatus.ts b/resources/scripts/api/server/backups/getBackupStatus.ts index ee5b6296f..af0ccd09f 100644 --- a/resources/scripts/api/server/backups/getBackupStatus.ts +++ b/resources/scripts/api/server/backups/getBackupStatus.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface BackupJobStatus { job_id: string | null; @@ -15,7 +16,8 @@ export interface BackupJobStatus { } export default async (uuid: string, backupUuid: string): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/backups/${backupUuid}/status`); + const daemonType = getGlobalDaemonType(); + const { data } = await http.get(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/status`); return data; }; diff --git a/resources/scripts/api/server/backups/index.ts b/resources/scripts/api/server/backups/index.ts index 07c4f4861..16a0a3ca9 100644 --- a/resources/scripts/api/server/backups/index.ts +++ b/resources/scripts/api/server/backups/index.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; interface RestoreBackupResponse { job_id: string; @@ -10,11 +11,15 @@ export const restoreServerBackup = async ( uuid: string, backup: string, ): Promise<{ jobId: string; status: string; message: string }> => { - const response = await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, { - adapter: 'rustic_s3', - truncate_directory: true, - download_url: '', - }); + const daemonType = getGlobalDaemonType(); + const response = await http.post( + `/api/client/servers/${daemonType}/${uuid}/backups/${backup}/restore`, + { + adapter: 'rustic_s3', + truncate_directory: true, + download_url: '', + }, + ); return { jobId: response.data.job_id, diff --git a/resources/scripts/api/server/backups/retryBackup.ts b/resources/scripts/api/server/backups/retryBackup.ts index 8dcf3b7f1..bf72b8291 100644 --- a/resources/scripts/api/server/backups/retryBackup.ts +++ b/resources/scripts/api/server/backups/retryBackup.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface RetryBackupResponse { message: string; @@ -8,7 +9,8 @@ export interface RetryBackupResponse { } export default async (uuid: string, backupUuid: string): Promise => { - const { data } = await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/retry`); + const daemonType = getGlobalDaemonType(); + const { data } = await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/retry`); return data; }; diff --git a/resources/scripts/api/server/databases/createServerDatabase.ts b/resources/scripts/api/server/databases/createServerDatabase.ts index 435a385d4..8fcb62073 100644 --- a/resources/scripts/api/server/databases/createServerDatabase.ts +++ b/resources/scripts/api/server/databases/createServerDatabase.ts @@ -1,10 +1,11 @@ import http from '@/api/http'; import { ServerDatabase, rawDataToServerDatabase } from '@/api/server/databases/getServerDatabases'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise => { return new Promise((resolve, reject) => { http.post( - `/api/client/servers/${uuid}/databases`, + `/api/client/servers/${getGlobalDaemonType()}/${uuid}/databases`, { database: data.databaseName, remote: data.connectionsFrom, diff --git a/resources/scripts/api/server/databases/deleteServerDatabase.ts b/resources/scripts/api/server/databases/deleteServerDatabase.ts index 23275bd36..7f730a3f9 100644 --- a/resources/scripts/api/server/databases/deleteServerDatabase.ts +++ b/resources/scripts/api/server/databases/deleteServerDatabase.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, database: string): Promise => { return new Promise((resolve, reject) => { - http.delete(`/api/client/servers/${uuid}/databases/${database}`) + http.delete(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/databases/${database}`) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/databases/getServerDatabases.ts b/resources/scripts/api/server/databases/getServerDatabases.ts index b66ebcab4..a819858bb 100644 --- a/resources/scripts/api/server/databases/getServerDatabases.ts +++ b/resources/scripts/api/server/databases/getServerDatabases.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface ServerDatabase { id: string; @@ -19,8 +20,10 @@ export const rawDataToServerDatabase = (data: any): ServerDatabase => ({ }); export default (uuid: string, includePassword = true): Promise => { + const daemonType = getGlobalDaemonType(); + return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/databases`, { + http.get(`/api/client/servers/${daemonType}/${uuid}/databases`, { params: includePassword ? { include: 'password' } : undefined, }) .then((response) => diff --git a/resources/scripts/api/server/databases/rotateDatabasePassword.ts b/resources/scripts/api/server/databases/rotateDatabasePassword.ts index 25c2ea67b..3518e3012 100644 --- a/resources/scripts/api/server/databases/rotateDatabasePassword.ts +++ b/resources/scripts/api/server/databases/rotateDatabasePassword.ts @@ -1,9 +1,11 @@ import http from '@/api/http'; import { ServerDatabase, rawDataToServerDatabase } from '@/api/server/databases/getServerDatabases'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, database: string): Promise => { + const daemonType = getGlobalDaemonType(); return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/databases/${database}/rotate-password`) + http.post(`/api/client/${daemonType}/servers/${uuid}/databases/${database}/rotate-password`) .then((response) => resolve(rawDataToServerDatabase(response.data.attributes))) .catch(reject); }); diff --git a/resources/scripts/api/server/files/chmodFiles.ts b/resources/scripts/api/server/files/chmodFiles.ts index 8bafd6d6e..72282b9ee 100644 --- a/resources/scripts/api/server/files/chmodFiles.ts +++ b/resources/scripts/api/server/files/chmodFiles.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; interface Data { file: string; @@ -7,7 +8,7 @@ interface Data { export default (uuid: string, directory: string, files: Data[]): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/files/chmod`, { root: directory, files }) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/chmod`, { root: directory, files }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/files/compressFiles.ts b/resources/scripts/api/server/files/compressFiles.ts index faa9d7c13..bb6e8d4d2 100644 --- a/resources/scripts/api/server/files/compressFiles.ts +++ b/resources/scripts/api/server/files/compressFiles.ts @@ -1,10 +1,11 @@ import http from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { rawDataToFileObject } from '@/api/transformers'; export default async (uuid: string, directory: string, files: string[]): Promise => { const { data } = await http.post( - `/api/client/servers/${uuid}/files/compress`, + `/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/compress`, { root: directory, files }, { timeout: 60000, diff --git a/resources/scripts/api/server/files/copyFile.ts b/resources/scripts/api/server/files/copyFile.ts index b19525c40..c440f4b19 100644 --- a/resources/scripts/api/server/files/copyFile.ts +++ b/resources/scripts/api/server/files/copyFile.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, location: string): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/files/copy`, { location }) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/copy`, { location }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/files/createDirectory.ts b/resources/scripts/api/server/files/createDirectory.ts index 588868655..a97ea9699 100644 --- a/resources/scripts/api/server/files/createDirectory.ts +++ b/resources/scripts/api/server/files/createDirectory.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, root: string, name: string): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/files/create-folder`, { root, name }) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/create-folder`, { root, name }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/files/decompressFiles.ts b/resources/scripts/api/server/files/decompressFiles.ts index d3a3e45c2..0b36879f4 100644 --- a/resources/scripts/api/server/files/decompressFiles.ts +++ b/resources/scripts/api/server/files/decompressFiles.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string, directory: string, file: string): Promise => { await http.post( - `/api/client/servers/${uuid}/files/decompress`, + `/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/decompress`, { root: directory, file }, { timeout: 300000, diff --git a/resources/scripts/api/server/files/deleteFiles.ts b/resources/scripts/api/server/files/deleteFiles.ts index 1250463ed..4fc454935 100644 --- a/resources/scripts/api/server/files/deleteFiles.ts +++ b/resources/scripts/api/server/files/deleteFiles.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, directory: string, files: string[]): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/files/delete`, { root: directory, files }) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/delete`, { root: directory, files }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/files/getFileContents.ts b/resources/scripts/api/server/files/getFileContents.ts index 66df376d3..fc4794349 100644 --- a/resources/scripts/api/server/files/getFileContents.ts +++ b/resources/scripts/api/server/files/getFileContents.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (server: string, file: string): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${server}/files/contents`, { + http.get(`/api/client/servers/${getGlobalDaemonType()}/${server}/files/contents`, { params: { file }, transformResponse: (res) => res, responseType: 'text', diff --git a/resources/scripts/api/server/files/getFileDownloadUrl.ts b/resources/scripts/api/server/files/getFileDownloadUrl.ts index 39db97290..9a00a549a 100644 --- a/resources/scripts/api/server/files/getFileDownloadUrl.ts +++ b/resources/scripts/api/server/files/getFileDownloadUrl.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, file: string): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/files/download`, { params: { file } }) + http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/download`, { params: { file } }) .then(({ data }) => resolve(data.attributes.url)) .catch(reject); }); diff --git a/resources/scripts/api/server/files/getFileUploadUrl.ts b/resources/scripts/api/server/files/getFileUploadUrl.ts index 690e8587c..98bca1186 100644 --- a/resources/scripts/api/server/files/getFileUploadUrl.ts +++ b/resources/scripts/api/server/files/getFileUploadUrl.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/files/upload`) + http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/upload`) .then(({ data }) => resolve(data.attributes.url)) .catch(reject); }); diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 1e2e9d4fe..96775ded8 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { rawDataToFileObject } from '@/api/transformers'; export interface FileObject { @@ -17,7 +18,7 @@ export interface FileObject { } export default async (uuid: string, directory?: string): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, { + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/list`, { params: { directory: directory ?? '/' }, }); diff --git a/resources/scripts/api/server/files/renameFiles.ts b/resources/scripts/api/server/files/renameFiles.ts index 53f92c4c3..8e2d1a825 100644 --- a/resources/scripts/api/server/files/renameFiles.ts +++ b/resources/scripts/api/server/files/renameFiles.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; interface Data { to: string; @@ -7,7 +8,7 @@ interface Data { export default (uuid: string, directory: string, files: Data[]): Promise => { return new Promise((resolve, reject) => { - http.put(`/api/client/servers/${uuid}/files/rename`, { root: directory, files }) + http.put(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/rename`, { root: directory, files }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/files/saveFileContents.ts b/resources/scripts/api/server/files/saveFileContents.ts index b97e60a6b..4b89ca1e6 100644 --- a/resources/scripts/api/server/files/saveFileContents.ts +++ b/resources/scripts/api/server/files/saveFileContents.ts @@ -1,7 +1,8 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string, file: string, content: string): Promise => { - await http.post(`/api/client/servers/${uuid}/files/write`, content, { + await http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/files/write`, content, { params: { file }, headers: { 'Content-Type': 'text/plain', diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index c7e65907c..a329e2915 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -2,6 +2,10 @@ import http, { FractalResponseData, FractalResponseList } from '@/api/http'; import { ServerEggVariable, ServerStatus } from '@/api/server/types'; import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers'; +// import { getGlobalDaemonType } from '@/api/server/getServer'; + +let globalDaemonType: string | null = null; + export interface Allocation { id: number; ip: string; @@ -45,6 +49,7 @@ export interface Server { variables: ServerEggVariable[]; allocations: Allocation[]; egg: string; + daemonType: string; } export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ @@ -73,18 +78,42 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) rawDataToServerAllocation, ), egg: data.egg, + daemonType: data.daemonType, }); -export default (uuid: string): Promise<[Server, string[]]> => { - return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}`) - .then(({ data }) => - resolve([ - rawDataToServerObject(data), +export default async (uuid: string): Promise<[Server, string[]]> => { + let daemonType_api = 'elytra'; + return http + .get(`/api/client/servers/${uuid}`) + .then((response) => { + daemonType_api = response.data?.meta.daemonType; + const daemonType: string = response.data?.meta.daemonType; - data.meta?.is_server_owner ? ['*'] : data.meta?.user_permissions || [], - ]), - ) - .catch(reject); - }); + if (daemonType) { + globalDaemonType = daemonType; + } + + return http.get(`/api/client/servers/${daemonType}/${uuid}`); + }) + .then((response) => { + const payload = response.data; + + const server = rawDataToServerObject(payload); + server.daemonType = daemonType_api; + + const permissions = payload.meta?.is_server_owner + ? ['*'] + : (payload.meta?.user_permissions as string[] | undefined) || []; + + return [server, permissions] as [Server, string[]]; + }) + .catch((error) => { + console.error('Failed to load server:', error); + throw error; + }); +}; + +export const getGlobalDaemonType = (): string | null => globalDaemonType; +export const setGlobalDaemonType = (type: string): void => { + globalDaemonType = type; }; diff --git a/resources/scripts/api/server/getServerResourceUsage.ts b/resources/scripts/api/server/getServerResourceUsage.ts index dd1ad4307..8c5cc7331 100644 --- a/resources/scripts/api/server/getServerResourceUsage.ts +++ b/resources/scripts/api/server/getServerResourceUsage.ts @@ -1,10 +1,11 @@ import http from '@/api/http'; -export type ServerPowerState = 'offline' | 'starting' | 'running' | 'stopping'; +export type ServerPowerState = 'offline' | 'starting' | 'running' | 'stopping' | 'installing'; export interface ServerStats { status: ServerPowerState; isSuspended: boolean; + isInstalling: boolean; memoryUsageInBytes: number; cpuUsagePercent: number; diskUsageInBytes: number; @@ -20,6 +21,7 @@ export default (server: string): Promise => { resolve({ status: attributes.current_state, isSuspended: attributes.is_suspended, + isInstalling: attributes.is_installing, memoryUsageInBytes: attributes.resources.memory_bytes, cpuUsagePercent: attributes.resources.cpu_absolute, diskUsageInBytes: attributes.resources.disk_bytes, diff --git a/resources/scripts/api/server/getWebsocketToken.ts b/resources/scripts/api/server/getWebsocketToken.ts index 5b2994b22..50b861b7b 100644 --- a/resources/scripts/api/server/getWebsocketToken.ts +++ b/resources/scripts/api/server/getWebsocketToken.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; interface Response { token: string; @@ -6,8 +7,10 @@ interface Response { } export default (server: string): Promise => { + const daemonType = getGlobalDaemonType(); + return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${server}/websocket`) + http.get(`/api/client/servers/${daemonType}/${server}/websocket`) .then(({ data }) => resolve({ token: data.data.token, diff --git a/resources/scripts/api/server/network/subdomain.ts b/resources/scripts/api/server/network/subdomain.ts index e751a1f90..3dddce4f9 100644 --- a/resources/scripts/api/server/network/subdomain.ts +++ b/resources/scripts/api/server/network/subdomain.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface SubdomainInfo { supported: boolean; diff --git a/resources/scripts/api/server/previewEggChange.ts b/resources/scripts/api/server/previewEggChange.ts index 725ffcb18..d9b2f7cc9 100644 --- a/resources/scripts/api/server/previewEggChange.ts +++ b/resources/scripts/api/server/previewEggChange.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface EggPreview { egg: { @@ -31,7 +32,8 @@ export interface EggPreview { * Returns egg details, variables, and available Docker images. */ export default async (uuid: string, eggId: number, nestId: number): Promise => { - const { data } = await http.post(`/api/client/servers/${uuid}/settings/egg/preview`, { + const daemonType = getGlobalDaemonType(); + const { data } = await http.post(`/api/client/servers/${daemonType}/${uuid}/settings/egg/preview`, { egg_id: eggId, nest_id: nestId, }); diff --git a/resources/scripts/api/server/processStartupCommand.ts b/resources/scripts/api/server/processStartupCommand.ts index 78412e09f..7bec7f057 100644 --- a/resources/scripts/api/server/processStartupCommand.ts +++ b/resources/scripts/api/server/processStartupCommand.ts @@ -1,8 +1,10 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, command: string): Promise => { + const daemonType = getGlobalDaemonType(); return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/startup/command/process`, { command }) + http.post(`/api/client/servers/${daemonType}/${uuid}/startup/command/process`, { command }) .then(({ data }) => resolve(data.processed_command)) .catch(reject); }); diff --git a/resources/scripts/api/server/reinstallServer.ts b/resources/scripts/api/server/reinstallServer.ts index 5cb2ca5e7..3765d279d 100644 --- a/resources/scripts/api/server/reinstallServer.ts +++ b/resources/scripts/api/server/reinstallServer.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/settings/reinstall`) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/settings/reinstall`) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/renameServer.ts b/resources/scripts/api/server/renameServer.ts index 4622f9d4d..2de1f1652 100644 --- a/resources/scripts/api/server/renameServer.ts +++ b/resources/scripts/api/server/renameServer.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, name: string, description?: string): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/settings/rename`, { name, description }) + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/settings/rename`, { name, description }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/resetStartupCommand.ts b/resources/scripts/api/server/resetStartupCommand.ts index 1d684d05e..ca58e797a 100644 --- a/resources/scripts/api/server/resetStartupCommand.ts +++ b/resources/scripts/api/server/resetStartupCommand.ts @@ -1,7 +1,8 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/startup/command/default`); + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/startup/command/default`); return data.default_startup_command; }; diff --git a/resources/scripts/api/server/revertDockerImage.ts b/resources/scripts/api/server/revertDockerImage.ts index a1b692089..ea980c3fb 100644 --- a/resources/scripts/api/server/revertDockerImage.ts +++ b/resources/scripts/api/server/revertDockerImage.ts @@ -1,5 +1,8 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string): Promise => { - await http.post(`/api/client/servers/${uuid}/settings/docker-image/revert`, { confirm: true }); + await http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/settings/docker-image/revert`, { + confirm: true, + }); }; diff --git a/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts b/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts index aae29284c..6cbb62dff 100644 --- a/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts +++ b/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts @@ -1,19 +1,23 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { Schedule, rawDataToServerSchedule } from '@/api/server/schedules/getServerSchedules'; type Data = Pick & { id?: number }; export default async (uuid: string, schedule: Data): Promise => { - const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, { - is_active: schedule.isActive, - only_when_online: schedule.onlyWhenOnline, - name: schedule.name, - minute: schedule.cron.minute, - hour: schedule.cron.hour, - day_of_month: schedule.cron.dayOfMonth, - month: schedule.cron.month, - day_of_week: schedule.cron.dayOfWeek, - }); + const { data } = await http.post( + `/api/client/servers/${getGlobalDaemonType()}/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, + { + is_active: schedule.isActive, + only_when_online: schedule.onlyWhenOnline, + name: schedule.name, + minute: schedule.cron.minute, + hour: schedule.cron.hour, + day_of_month: schedule.cron.dayOfMonth, + month: schedule.cron.month, + day_of_week: schedule.cron.dayOfWeek, + }, + ); return rawDataToServerSchedule(data.attributes); }; diff --git a/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts b/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts index 398b0dfa3..72d50b36e 100644 --- a/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts +++ b/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { Task, rawDataToServerTask } from '@/api/server/schedules/getServerSchedules'; interface Data { @@ -10,7 +11,7 @@ interface Data { export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise => { const { data: response } = await http.post( - `/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, + `/api/client/servers/${getGlobalDaemonType()}/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, { action: data.action, payload: data.payload, diff --git a/resources/scripts/api/server/schedules/deleteSchedule.ts b/resources/scripts/api/server/schedules/deleteSchedule.ts index c3669988d..c5e1b5c0b 100644 --- a/resources/scripts/api/server/schedules/deleteSchedule.ts +++ b/resources/scripts/api/server/schedules/deleteSchedule.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, schedule: number): Promise => { return new Promise((resolve, reject) => { - http.delete(`/api/client/servers/${uuid}/schedules/${schedule}`) + http.delete(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/schedules/${schedule}`) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/schedules/deleteScheduleTask.ts b/resources/scripts/api/server/schedules/deleteScheduleTask.ts index 8867677b2..12fb3b0bd 100644 --- a/resources/scripts/api/server/schedules/deleteScheduleTask.ts +++ b/resources/scripts/api/server/schedules/deleteScheduleTask.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, scheduleId: number, taskId: number): Promise => { return new Promise((resolve, reject) => { - http.delete(`/api/client/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`) + http.delete(`/api/client/${getGlobalDaemonType()}/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/schedules/getServerSchedule.ts b/resources/scripts/api/server/schedules/getServerSchedule.ts index 148b1dd56..89c7809cf 100644 --- a/resources/scripts/api/server/schedules/getServerSchedule.ts +++ b/resources/scripts/api/server/schedules/getServerSchedule.ts @@ -1,9 +1,10 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { Schedule, rawDataToServerSchedule } from '@/api/server/schedules/getServerSchedules'; export default (uuid: string, schedule: number): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/schedules/${schedule}`, { + http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/schedules/${schedule}`, { params: { include: ['tasks'], }, diff --git a/resources/scripts/api/server/schedules/getServerSchedules.ts b/resources/scripts/api/server/schedules/getServerSchedules.ts index aef756a0e..f508e3b02 100644 --- a/resources/scripts/api/server/schedules/getServerSchedules.ts +++ b/resources/scripts/api/server/schedules/getServerSchedules.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export interface Schedule { id: number; @@ -67,7 +68,7 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({ }); export default async (uuid: string): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/schedules`, { + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/schedules`, { params: { include: ['tasks'], }, diff --git a/resources/scripts/api/server/schedules/triggerScheduleExecution.ts b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts index 92f7a589f..ccd12e1ff 100644 --- a/resources/scripts/api/server/schedules/triggerScheduleExecution.ts +++ b/resources/scripts/api/server/schedules/triggerScheduleExecution.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (server: string, schedule: number): Promise => - await http.post(`/api/client/servers/${server}/schedules/${schedule}/execute`); + await http.post(`/api/client/servers/${getGlobalDaemonType()}/${server}/schedules/${schedule}/execute`); diff --git a/resources/scripts/api/server/serverOperations.ts b/resources/scripts/api/server/serverOperations.ts index 30b1e2279..154563e32 100644 --- a/resources/scripts/api/server/serverOperations.ts +++ b/resources/scripts/api/server/serverOperations.ts @@ -1,6 +1,7 @@ import React from 'react'; import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; /** * Server operation status constants. @@ -50,7 +51,7 @@ export interface ApplyEggChangeAsyncResponse { * Get specific operation status by ID. */ export const getOperationStatus = async (uuid: string, operationId: string): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/operations/${operationId}`); + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/operations/${operationId}`); return data; }; @@ -58,7 +59,7 @@ export const getOperationStatus = async (uuid: string, operationId: string): Pro * Get all operations for a server. */ export const getServerOperations = async (uuid: string): Promise<{ operations: ServerOperation[] }> => { - const { data } = await http.get(`/api/client/servers/${uuid}/operations`); + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/operations`); return data; }; diff --git a/resources/scripts/api/server/setSelectedDockerImage.ts b/resources/scripts/api/server/setSelectedDockerImage.ts index 70042f3a6..337b59ccb 100644 --- a/resources/scripts/api/server/setSelectedDockerImage.ts +++ b/resources/scripts/api/server/setSelectedDockerImage.ts @@ -1,5 +1,8 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string, image: string): Promise => { - await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image }); + await http.put(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/settings/docker-image`, { + docker_image: image, + }); }; diff --git a/resources/scripts/api/server/updateStartupCommand.ts b/resources/scripts/api/server/updateStartupCommand.ts index 06c9c1070..cdf70a824 100644 --- a/resources/scripts/api/server/updateStartupCommand.ts +++ b/resources/scripts/api/server/updateStartupCommand.ts @@ -1,7 +1,10 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default async (uuid: string, startup: string): Promise => { - const { data } = await http.put(`/api/client/servers/${uuid}/startup/command`, { startup }); + const { data } = await http.put(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/startup/command`, { + startup, + }); return data.meta.startup_command; }; diff --git a/resources/scripts/api/server/updateStartupVariable.ts b/resources/scripts/api/server/updateStartupVariable.ts index 510217282..169b743ed 100644 --- a/resources/scripts/api/server/updateStartupVariable.ts +++ b/resources/scripts/api/server/updateStartupVariable.ts @@ -1,9 +1,13 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { ServerEggVariable } from '@/api/server/types'; import { rawDataToServerEggVariable } from '@/api/transformers'; export default async (uuid: string, key: string, value: string): Promise<[ServerEggVariable, string]> => { - const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value }); + const { data } = await http.put(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/startup/variable`, { + key, + value, + }); return [rawDataToServerEggVariable(data), data.meta.startup_command]; }; diff --git a/resources/scripts/api/server/users/createOrUpdateSubuser.ts b/resources/scripts/api/server/users/createOrUpdateSubuser.ts index 4afd9a7e6..d4975812e 100644 --- a/resources/scripts/api/server/users/createOrUpdateSubuser.ts +++ b/resources/scripts/api/server/users/createOrUpdateSubuser.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { rawDataToServerSubuser } from '@/api/server/users/getServerSubusers'; import { Subuser } from '@/state/server/subusers'; @@ -10,7 +11,7 @@ interface Params { export default (uuid: string, params: Params, subuser?: Subuser): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, { + http.post(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, { ...params, }) .then((data) => resolve(rawDataToServerSubuser(data.data))) diff --git a/resources/scripts/api/server/users/deleteSubuser.ts b/resources/scripts/api/server/users/deleteSubuser.ts index dccd98e69..c6d716340 100644 --- a/resources/scripts/api/server/users/deleteSubuser.ts +++ b/resources/scripts/api/server/users/deleteSubuser.ts @@ -1,8 +1,9 @@ import http from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export default (uuid: string, userId: string): Promise => { return new Promise((resolve, reject) => { - http.delete(`/api/client/servers/${uuid}/users/${userId}`) + http.delete(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/users/${userId}`) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/api/server/users/getServerSubusers.ts b/resources/scripts/api/server/users/getServerSubusers.ts index 26d908c02..06ad58210 100644 --- a/resources/scripts/api/server/users/getServerSubusers.ts +++ b/resources/scripts/api/server/users/getServerSubusers.ts @@ -1,4 +1,5 @@ import http, { FractalResponseData } from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { Subuser } from '@/state/server/subusers'; @@ -15,7 +16,7 @@ export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({ export default (uuid: string): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/users`) + http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/users`) .then(({ data }) => resolve((data.data || []).map(rawDataToServerSubuser))) .catch(reject); }); diff --git a/resources/scripts/api/swr/getServerAllocations.ts b/resources/scripts/api/swr/getServerAllocations.ts index fb59996d1..0ebc2f994 100644 --- a/resources/scripts/api/swr/getServerAllocations.ts +++ b/resources/scripts/api/swr/getServerAllocations.ts @@ -2,17 +2,19 @@ import useSWR from 'swr'; import http from '@/api/http'; import { Allocation } from '@/api/server/getServer'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import { rawDataToServerAllocation } from '@/api/transformers'; import { ServerContext } from '@/state/server'; export default () => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const daemonType = getGlobalDaemonType(); return useSWR( ['server:allocations', uuid], async () => { - const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`); + const { data } = await http.get(`/api/client/servers/${daemonType}/${uuid}/network/allocations`); return (data.data || []).map(rawDataToServerAllocation); }, diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts index 6dca09a8d..6898242d3 100644 --- a/resources/scripts/api/swr/getServerBackups.ts +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -3,6 +3,7 @@ import useSWR from 'swr'; import type { PaginatedResult } from '@/api/http'; import http, { getPaginationSet } from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import type { ServerBackup } from '@/api/server/types'; import { rawDataToServerBackup } from '@/api/transformers'; @@ -42,11 +43,12 @@ type BackupResponse = PaginatedResult & { export default () => { const { page } = useContext(Context); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const daemonType = getGlobalDaemonType(); return useSWR( ['server:backups', uuid, page], async () => { - const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }); + const { data } = await http.get(`/api/client/servers/${daemonType}/${uuid}/backups`, { params: { page } }); return { items: (data.data || []).map(rawDataToServerBackup), diff --git a/resources/scripts/api/swr/getServerStartup.ts b/resources/scripts/api/swr/getServerStartup.ts index 069bd6c1b..ee09e3deb 100644 --- a/resources/scripts/api/swr/getServerStartup.ts +++ b/resources/scripts/api/swr/getServerStartup.ts @@ -3,6 +3,7 @@ import type { SWRConfiguration } from 'swr'; import useSWR from 'swr'; import http, { FractalResponseList } from '@/api/http'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import type { ServerEggVariable } from '@/api/server/types'; import { rawDataToServerEggVariable } from '@/api/transformers'; @@ -17,7 +18,7 @@ export default (uuid: string, fallbackData?: Response, config?: SWRConfiguration useSWR( [uuid, '/startup'], async (): Promise => { - const { data } = await http.get(`/api/client/servers/${uuid}/startup`); + const { data } = await http.get(`/api/client/servers/${getGlobalDaemonType()}/${uuid}/startup`); const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 28bd58c75..0621f4a59 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -11,7 +11,7 @@ import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/ser // than the more faded default style. const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9; -const StatusIndicatorBox = styled.div<{ $status: ServerPowerState | undefined }>` +const StatusIndicatorBox = styled.div<{ $status: ServerPowerState }>` background: #ffffff11; border: 1px solid #ffffff12; transition: all 250ms ease-in-out; @@ -39,19 +39,30 @@ const StatusIndicatorBox = styled.div<{ $status: ServerPowerState | undefined }> border-radius: 9999px; transition: all 250ms ease-in-out; - box-shadow: ${({ $status }) => - !$status || $status === 'offline' - ? '0 0 12px 1px #C74343' - : $status === 'running' - ? '0 0 12px 1px #43C760' - : '0 0 12px 1px #c7aa43'}; + box-shadow: ${({ $status }) => { + console.log($status); + if (!$status || $status === 'offline') { + return '0 0 12px 1px #C74343'; + } else if ($status === 'running') { + return '0 0 12px 1px #43C760'; + } else if ($status === 'installing') { + return '0 0 12px 1px #4381c7'; // Blue color for installing + } else { + return '0 0 12px 1px #c7aa43'; // Default for other statuses + } + }}; - background: ${({ $status }) => - !$status || $status === 'offline' - ? `linear-gradient(180deg, #C74343 0%, #C74343 100%)` - : $status === 'running' - ? `linear-gradient(180deg, #91FFA9 0%, #43C760 100%)` - : `linear-gradient(180deg, #c7aa43 0%, #c7aa43 100%)`}; + background: ${({ $status }) => { + if (!$status || $status === 'offline') { + return 'linear-gradient(180deg, #C74343 0%, #C74343 100%)'; + } else if ($status === 'running') { + return 'linear-gradient(180deg, #91FFA9 0%, #43C760 100%)'; + } else if ($status === 'installing') { + return 'linear-gradient(180deg, #91c7ff 0%, #4381c7 100%)'; + } else { + return 'linear-gradient(180deg, #c7aa43 0%, #c7aa43 100%)'; // Default for other statuses + } + }} } `; @@ -60,6 +71,7 @@ type Timer = ReturnType; const ServerRow = ({ server, className }: { server: Server; className?: string }) => { const interval = useRef(null) as React.MutableRefObject; const [isSuspended, setIsSuspended] = useState(server.status === 'suspended'); + const [isInstalling, setIsInstalling] = useState(server.status === 'installing'); const [stats, setStats] = useState(null); const getStats = () => @@ -71,6 +83,10 @@ const ServerRow = ({ server, className }: { server: Server; className?: string } setIsSuspended(stats?.isSuspended || server.status === 'suspended'); }, [stats?.isSuspended, server.status]); + useEffect(() => { + setIsInstalling(stats?.isInstalling || server.status === 'installing'); + }, [stats?.isInstalling, server.status]); + useEffect(() => { // Don't waste a HTTP request if there is nothing important to show to the user because // the server is suspended. @@ -99,9 +115,6 @@ const ServerRow = ({ server, className }: { server: Server; className?: string } return (
- {/*
- -
*/}

{server.name}

{' '} @@ -116,8 +129,6 @@ const ServerRow = ({ server, className }: { server: Server; className?: string } ))}

- {/* I don't think servers will ever have descriptions normall so I'll vaporize it */} - {/* {!!server.description &&

{server.description}

} */}
diff --git a/resources/scripts/components/server/backups/useUnifiedBackups.ts b/resources/scripts/components/server/backups/useUnifiedBackups.ts index 4978f2b59..ff9108878 100644 --- a/resources/scripts/components/server/backups/useUnifiedBackups.ts +++ b/resources/scripts/components/server/backups/useUnifiedBackups.ts @@ -6,10 +6,12 @@ import { ServerContext } from '@/state/server'; import { LiveProgressContext } from './BackupContainer'; import { UnifiedBackup } from './BackupItem'; +import { getGlobalDaemonType } from '@/api/server/getServer'; export const useUnifiedBackups = () => { const { data: backups, error, isValidating, mutate } = getServerBackups(); const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const daemonType = getGlobalDaemonType(); const liveProgress = useContext(LiveProgressContext); @@ -55,7 +57,7 @@ export const useUnifiedBackups = () => { const renameBackup = useCallback( async (backupUuid: string, newName: string) => { const http = (await import('@/api/http')).default; - await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/rename`, { name: newName }); + await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/rename`, { name: newName }); mutate(); }, [uuid, mutate], @@ -64,7 +66,7 @@ export const useUnifiedBackups = () => { const toggleBackupLock = useCallback( async (backupUuid: string) => { const http = (await import('@/api/http')).default; - await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/lock`); + await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/lock`); mutate(); }, [uuid, mutate], diff --git a/resources/scripts/components/server/console/StatBlock.tsx b/resources/scripts/components/server/console/StatBlock.tsx index 50123d7fe..341657d86 100644 --- a/resources/scripts/components/server/console/StatBlock.tsx +++ b/resources/scripts/components/server/console/StatBlock.tsx @@ -16,11 +16,11 @@ const StatBlock = ({ title, copyOnClick, className, children }: StatBlockProps)
-
+

{title}

diff --git a/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx b/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx new file mode 100644 index 000000000..c1029247d --- /dev/null +++ b/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx @@ -0,0 +1,260 @@ +import { TriangleExclamation } from '@gravity-ui/icons'; +import React, { useEffect, useState } from 'react'; + +import ActionButton from '@/components/elements/ActionButton'; +import Spinner from '@/components/elements/Spinner'; +import { Dialog } from '@/components/elements/dialog'; + +import { + UI_CONFIG, + canCloseOperation, + formatOperationId, + getStatusIconType, + getStatusStyling, + isActiveStatus, + isCompletedStatus, + isFailedStatus, +} from '@/lib/server-operations'; + +import { ServerOperation, useOperationPolling } from '@/api/server/serverOperations'; + +import { ServerContext } from '@/state/server'; + +interface Props { + visible: boolean; + operationId: string | null; + operationType: string; + onClose: () => void; + onComplete?: (operation: ServerOperation) => void; + onError?: (error: Error) => void; +} + +/** + * Modal component for displaying server operation progress in real-time. + * Handles polling, auto-close, and status updates for long-running operations. + */ +const WingsOperationProgressModal: React.FC = ({ + visible, + operationId, + operationType, + onClose, + onComplete, + onError, +}) => { + const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); + const [operation, setOperation] = useState(null); + const [error, setError] = useState(null); + const [autoCloseTimer, setAutoCloseTimer] = useState(null); + const { startPolling, stopPolling } = useOperationPolling(); + + useEffect(() => { + if (!visible || !operationId) { + stopPolling(operationId || ''); + setOperation(null); + setError(null); + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + setAutoCloseTimer(null); + } + return; + } + + const handleUpdate = (op: ServerOperation) => { + setOperation(op); + }; + + const handleComplete = (op: ServerOperation) => { + setOperation(op); + stopPolling(operationId); + + if (onComplete) { + onComplete(op); + } + + if (op.is_completed) { + const timer = setTimeout(() => { + onClose(); + }, UI_CONFIG.AUTO_CLOSE_DELAY); + setAutoCloseTimer(timer); + } + }; + + const handleError = (err: Error) => { + setError(err.message); + stopPolling(operationId); + + if (onError) { + onError(err); + } + }; + + startPolling(uuid, operationId, handleUpdate, handleComplete, handleError); + + return () => { + stopPolling(operationId); + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + } + }; + }, [visible, operationId, uuid, startPolling, stopPolling, onComplete, onError, onClose, autoCloseTimer]); + + const renderStatusIcon = (status: string) => { + const iconType = getStatusIconType(status as any); + + switch (iconType) { + case 'spinner': + return ; + case 'success': + return ( +
+
+
+ ); + case 'error': + return ( + + ); + default: + return ; + } + }; + + const canClose = canCloseOperation(operation, error); + const statusStyling = operation ? getStatusStyling(operation.status) : null; + + const handleClose = () => { + if (autoCloseTimer) { + clearTimeout(autoCloseTimer); + setAutoCloseTimer(null); + } + onClose(); + }; + + return ( + { }} + preventExternalClose={!canClose} + hideCloseIcon={!canClose} + title={operationType} + > +
+ {/* Operation ID */} + {operationId && ( +
+
+

ID: {formatOperationId(operationId)}

+
+
+ )} + + {/* Error State */} + {error ? ( +
+
+ + Error +
+
+

{error}

+
+
+ ) : operation ? ( + /* Operation State */ +
+ {/* Status Header */} +
+ {renderStatusIcon(operation.status)} + + {operation.status} + +
+ + {/* Message Box */} +
+

{operation.message || 'Processing...'}

+
+ + {/* Progress Bar for Active Operations */} + {isActiveStatus(operation.status) && ( +
+
+
+
+

+ This window will close automatically when complete +

+
+ )} + + {/* Success State */} + {isCompletedStatus(operation.status) && ( +
+
+
+
+
+

+ Operation completed successfully +

+
+ {autoCloseTimer && ( +

+ Closing automatically in 3 seconds +

+ )} +
+ )} + + {/* Failed State */} + {isFailedStatus(operation.status) && ( +
+
+ +

Operation failed

+
+ {operation.message && ( +

{operation.message}

+ )} +
+ )} +
+ ) : ( + /* Loading State */ +
+ + Initializing... +
+ )} +
+ + {canClose && ( + + + Cancel + + + {operation?.is_completed ? 'Done' : 'Close'} + + + )} +
+ ); +}; + +export default WingsOperationProgressModal; diff --git a/resources/scripts/components/server/shell/ShellContainer.tsx b/resources/scripts/components/server/shell/ShellContainer.tsx index a1b08916e..77ca6d149 100644 --- a/resources/scripts/components/server/shell/ShellContainer.tsx +++ b/resources/scripts/components/server/shell/ShellContainer.tsx @@ -18,10 +18,13 @@ import Spinner from '@/components/elements/Spinner'; import { Switch } from '@/components/elements/SwitchV2'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import OperationProgressModal from '@/components/server/operations/OperationProgressModal'; +import WingsOperationProgressModal from '@/components/server/operations/WingsOperationProgressModal'; import { httpErrorToHuman } from '@/api/http'; import getNests from '@/api/nests/getNests'; import applyEggChange from '@/api/server/applyEggChange'; +import applyEggChangeSync from '@/api/server/applyEggChangeSync'; +import { getGlobalDaemonType } from '@/api/server/getServer'; import previewEggChange, { EggPreview } from '@/api/server/previewEggChange'; import { ServerOperation } from '@/api/server/serverOperations'; import getServerBackups from '@/api/swr/getServerBackups'; @@ -248,6 +251,7 @@ const validateEnvironmentVariables = (variables: any[], pendingVariables: Record const SoftwareContainer = () => { const serverData = ServerContext.useStoreState((state) => state.server.data); + const daemonType = getGlobalDaemonType(); const uuid = serverData?.uuid; const [nests, setNests] = useState(); //const eggs = nests?.reduce( @@ -270,6 +274,7 @@ const SoftwareContainer = () => { ?.attributes?.name; }, [nests, currentEgg]); const backupLimit = serverData?.featureLimits.backups; + const { data: backups } = getServerBackups(); const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState); @@ -507,8 +512,8 @@ const SoftwareContainer = () => { selectedDockerImage && eggPreview.docker_images ? eggPreview.docker_images[selectedDockerImage] : eggPreview.default_docker_image && eggPreview.docker_images - ? eggPreview.docker_images[eggPreview.default_docker_image] - : ''; + ? eggPreview.docker_images[eggPreview.default_docker_image] + : ''; // Filter out empty environment variables to prevent validation issues const filteredEnvironment: Record = {}; @@ -518,24 +523,34 @@ const SoftwareContainer = () => { } }); - // Start the async operation - const response = await applyEggChange(uuid, { - egg_id: selectedEgg.attributes.id, - nest_id: selectedNest.attributes.id, - docker_image: actualDockerImage, - startup_command: customStartup, - environment: filteredEnvironment, - should_backup: shouldBackup, - should_wipe: shouldWipe, - }); + if (daemonType?.toLowerCase() == 'elytra') { + const response = await applyEggChange(uuid, { + egg_id: selectedEgg.attributes.id, + nest_id: selectedNest.attributes.id, + docker_image: actualDockerImage, + startup_command: customStartup, + environment: filteredEnvironment, + should_backup: shouldBackup, + should_wipe: shouldWipe, + }); - // Operation started successfully - show progress modal - setCurrentOperationId(response.operation_id); - setShowOperationModal(true); + setCurrentOperationId(response.operation_id); + + setShowOperationModal(true); + } else if (daemonType?.toLowerCase() == 'wings') { + await applyEggChangeSync(uuid, { + egg_id: selectedEgg.attributes.id, + nest_id: selectedNest.attributes.id, + docker_image: actualDockerImage, + startup_command: customStartup, + environment: filteredEnvironment, + should_backup: shouldBackup, + should_wipe: shouldWipe, + }); + } toast.success('Software change operation started successfully'); - // Reset the configuration flow but keep the modal open resetFlow(); } catch (error) { console.error('Failed to start egg change operation:', error); @@ -888,11 +903,10 @@ const SoftwareContainer = () => { handleVariableChange(variable.env_variable, e.target.value) } placeholder={variable.default_value || 'Enter value...'} - className={`w-full px-3 py-2 bg-[#ffffff08] border rounded-lg text-sm text-neutral-200 placeholder:text-neutral-500 focus:outline-none transition-colors ${ - variableErrors[variable.env_variable] - ? 'border-red-500 focus:border-red-500' - : 'border-[#ffffff12] focus:border-brand' - }`} + className={`w-full px-3 py-2 bg-[#ffffff08] border rounded-lg text-sm text-neutral-200 placeholder:text-neutral-500 focus:outline-none transition-colors ${variableErrors[variable.env_variable] + ? 'border-red-500 focus:border-red-500' + : 'border-[#ffffff12] focus:border-brand' + }`} /> {variableErrors[variable.env_variable] && (

@@ -933,11 +947,11 @@ const SoftwareContainer = () => {

{backupLimit !== 0 && - (backupLimit === null || (backups?.backupCount || 0) < backupLimit) + (backupLimit === null || (backups?.backupCount || 0) < backupLimit) ? 'Automatically create a backup before applying changes' : backupLimit === 0 - ? 'Backups are disabled for this server' - : 'Backup limit reached'} + ? 'Backups are disabled for this server' + : 'Backup limit reached'}

@@ -1095,26 +1109,23 @@ const SoftwareContainer = () => { {eggPreview.warnings.map((warning, index) => (

{warning.type === 'subdomain_incompatible' ? 'Subdomain Will Be Deleted' @@ -1188,7 +1199,33 @@ const SoftwareContainer = () => { ); } - + function RenderOperationModal() { + if (daemonType == 'elytra') { + return ( + + ); + } + if (daemonType == 'wings') { + return ( + + ); + } + return
Could not find Operation Modal for this daemon: Using ${daemonType}
; + } return (
@@ -1277,14 +1314,7 @@ const SoftwareContainer = () => { {/* Operation Progress Modal */} - + {RenderOperationModal()} ); }; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 2d2b45f60..800d45378 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -410,9 +410,9 @@ const ServerRouter = () => {

Files

- {}} /> - {}} /> - {}} /> + { }} /> + { }} /> + { }} /> { className='relative inset-[1px] w-full h-full overflow-y-auto overflow-x-hidden rounded-md bg-[#08080875]' > {inConflictState && - (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`))) ? ( + (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`))) ? ( ) : ( diff --git a/resources/views/admin/eggs/new.blade.php b/resources/views/admin/eggs/new.blade.php index 7f77fec9a..df216c6d6 100644 --- a/resources/views/admin/eggs/new.blade.php +++ b/resources/views/admin/eggs/new.blade.php @@ -150,7 +150,7 @@ }); $('#pNestId').on('change', function (event) { $('#pConfigFrom').html('').select2({ - data: $.map(_.get(Pterodactyl.nests, $(this).val() + '.eggs', []), function (item) { + data: $.map(_.get(Pyrodactyl.nests, $(this).val() + '.eggs', []), function (item) { return { id: item.id, text: item.name + ' <' + item.author + '>', diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 2db778ba9..884d188ca 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -23,6 +23,7 @@
You are running Pyrodactyl panel version {{ config('app.version') }}.
+ - @php - $stats = app('Pterodactyl\Repositories\Eloquent\NodeRepository')->getUsageStatsRaw($node); - $memoryPercent = ($stats['memory']['value'] / $stats['memory']['base_limit']) * 100; - $diskPercent = ($stats['disk']['value'] / $stats['disk']['base_limit']) * 100; - - $memoryColor = $memoryPercent < 50 ? '#50af51' : ($memoryPercent < 70 ? '#e0a800' : '#d9534f'); - $diskColor = $diskPercent < 50 ? '#50af51' : ($diskPercent < 70 ? '#e0a800' : '#d9534f'); - - $allocatedMemory = humanizeSize($stats['memory']['value'] * 1024 * 1024); - $totalMemory = humanizeSize($stats['memory']['max'] * 1024 * 1024); - $allocatedDisk = humanizeSize($stats['disk']['value'] * 1024 * 1024); - $totalDisk = humanizeSize($stats['disk']['max'] * 1024 * 1024); - @endphp - {{ round($memoryPercent) }}% - {{ $allocatedMemory }} - {{ $totalMemory }} - {{ round($diskPercent) }}% - {{ $allocatedDisk }} - {{ $totalDisk }} + {{ $node->memory_percent }}% + {{ $node->allocated_memory }} + {{ $node->total_memory }} + {{ $node->disk_percent }}% + {{ $node->allocated_disk }} + {{ $node->total_disk }} {{ $node->servers_count }} + {{ $node->daemonType }} @endforeach diff --git a/resources/views/admin/nodes/new.blade.php b/resources/views/admin/nodes/new.blade.php index c90cfedf6..9bd33d0ad 100644 --- a/resources/views/admin/nodes/new.blade.php +++ b/resources/views/admin/nodes/new.blade.php @@ -39,6 +39,26 @@ @endforeach
+
+ + +
+
+ +
+ +
+
+ +
@@ -58,7 +78,7 @@

- Domain name that browsers will use to connect to Wings (e.g wings.example.com). + Domain name that browsers will use to connect to your Node (e.g node.example.com). An IP address may be used only if you are not using SSL for this node.

@@ -71,10 +91,10 @@ value="{{ old('internal_fqdn') }}" />

Optional: - Leave blank to use the Public FQDN for panel-to-Wings communication. - If specified, this internal domain name will be used for panel-to-Wings communication instead - (e.g wings-internal.example.com or 10.0.0.5). - Useful for internal networks where the panel needs to communicate with Wings using a + Leave blank to use the Public FQDN for panel-to-node communication. + If specified, this internal domain name will be used for panel-to-node communication instead + (e.g node-internal.example.com or 10.0.0.5). + Useful for internal networks where the panel needs to communicate with your node using a different address than what browsers use.

@@ -190,5 +210,39 @@ @parent @endsection diff --git a/resources/views/admin/nodes/view/configuration.blade.php b/resources/views/admin/nodes/view/configuration.blade.php index b8f0dc023..124e5bfbe 100644 --- a/resources/views/admin/nodes/view/configuration.blade.php +++ b/resources/views/admin/nodes/view/configuration.blade.php @@ -70,11 +70,14 @@ url: '{{ route('admin.nodes.view.configuration.token', $node->id) }}', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, }).done(function (data) { + + var commandTemplate = "{!! addslashes($node->getAutoDeploy("PLACEHOLDER_TOKEN")) !!}"; + var command = commandTemplate.replace('PLACEHOLDER_TOKEN', data.token); swal({ type: 'success', title: 'Token created.', - text: '

To auto-configure your node run the following command:

cd /etc/elytra && sudo elytra configure --panel-url {{ config('app.url') }} --token ' + data.token + ' --node ' + data.node + '{{ config('app.debug') ? ' --allow-insecure' : '' }}

', - html: true + text: "

To auto-configure your node run the following command:

" + command + "

", + html: true, }) }).fail(function () { swal({ diff --git a/resources/views/admin/nodes/view/settings.blade.php b/resources/views/admin/nodes/view/settings.blade.php index 81696b997..3c536ef1d 100644 --- a/resources/views/admin/nodes/view/settings.blade.php +++ b/resources/views/admin/nodes/view/settings.blade.php @@ -57,12 +57,18 @@
+ +
+
-
@@ -88,7 +94,7 @@

- Domain name that browsers will use to connect to Wings (e.g wings.example.com). + Domain name that browsers will use to connect to {{ $node->daemonType }} (e.g {{ $node->daemonType }}.example.com). An IP address may be used only if you are not using SSL for this node. Why? @@ -107,10 +113,10 @@

Optional: - Leave blank to use the Public FQDN for panel-to-Wings communication. - If specified, this internal domain name will be used for panel-to-Wings communication instead - (e.g wings-internal.example.com or 10.0.0.5). - Useful for internal networks where the panel needs to communicate with Wings using a + Leave blank to use the Public FQDN for panel-to-{{ $node->daemonType }} communication. + If specified, this internal domain name will be used for panel-to-{{ $node->daemonType }} communication instead + (e.g {{ $node->daemonType }}-internal.example.com or 10.0.0.5). + Useful for internal networks where the panel needs to communicate with {{ $node->daemonType }} using a different address than what browsers use.

@@ -266,6 +272,25 @@

+ +
+
+
+

Backup Config

+
+
+
+ +
+ +
+
+
+
+
+
@@ -294,11 +319,40 @@ @endsection @section('footer-scripts') - @parent - + @parent + @endsection diff --git a/resources/views/admin/servers/index.blade.php b/resources/views/admin/servers/index.blade.php index 4c475baf1..98b2b0a04 100644 --- a/resources/views/admin/servers/index.blade.php +++ b/resources/views/admin/servers/index.blade.php @@ -39,6 +39,7 @@ Owner Node Connection + @@ -51,6 +52,7 @@ {{ $server->allocation->alias }}:{{ $server->allocation->port }} + @if($server->isSuspended()) Suspended @@ -59,7 +61,7 @@ @else Active @endif - + @if($server->exclude_from_resource_calculation)
Excluded @endif diff --git a/resources/views/admin/servers/new.blade.php b/resources/views/admin/servers/new.blade.php index f04e622f2..1fbb87809 100644 --- a/resources/views/admin/servers/new.blade.php +++ b/resources/views/admin/servers/new.blade.php @@ -421,4 +421,6 @@ // END Persist 'Nest' select2 }); + + @endsection diff --git a/resources/views/admin/servers/view/startup.blade.php b/resources/views/admin/servers/view/startup.blade.php index f213b194a..ac7223483 100644 --- a/resources/views/admin/servers/view/startup.blade.php +++ b/resources/views/admin/servers/view/startup.blade.php @@ -116,7 +116,7 @@ $(document).ready(function () { $('#pEggId').select2({placeholder: 'Select a Nest Egg'}).on('change', function () { var selectedEgg = _.isNull($(this).val()) ? $(this).find('option').first().val() : $(this).val(); - var parentChain = _.get(Pterodactyl.nests, $("#pNestId").val()); + var parentChain = _.get(Pyrodactyl.nests, $("#pNestId").val()); var objectChain = _.get(parentChain, 'eggs.' + selectedEgg); const images = _.get(objectChain, 'docker_images', []) @@ -126,7 +126,7 @@ let opt = document.createElement('option'); opt.value = images[keys[i]]; opt.innerText = keys[i] + " (" + images[keys[i]] + ")"; - if (objectChain.id === parseInt(Pterodactyl.server.egg_id) && Pterodactyl.server.image == opt.value) { + if (objectChain.id === parseInt(Pyrodactyl.server.egg_id) && Pyrodactyl.server.image == opt.value) { opt.selected = true } $('#pDockerImage').append(opt); @@ -135,9 +135,9 @@ $('#pDockerImageCustom').val(''); }) - if (objectChain.id === parseInt(Pterodactyl.server.egg_id)) { - if ($('#pDockerImage').val() != Pterodactyl.server.image) { - $('#pDockerImageCustom').val(Pterodactyl.server.image); + if (objectChain.id === parseInt(Pyrodactyl.server.egg_id)) { + if ($('#pDockerImage').val() != Pyrodactyl.server.image) { + $('#pDockerImageCustom').val(Pyrodactyl.server.image); } } @@ -149,7 +149,7 @@ $('#appendVariablesTo').html(''); $.each(_.get(objectChain, 'variables', []), function (i, item) { - var setValue = _.get(Pterodactyl.server_variables, item.env_variable, item.default_value); + var setValue = _.get(Pyrodactyl.server_variables, item.env_variable, item.default_value); var isRequired = (item.required === 1) ? 'Required ' : ''; var dataAppend = ' \
\ @@ -173,7 +173,7 @@ $('#pNestId').select2({placeholder: 'Select a Nest'}).on('change', function () { $('#pEggId').html('').select2({ - data: $.map(_.get(Pterodactyl.nests, $(this).val() + '.eggs', []), function (item) { + data: $.map(_.get(Pyrodactyl.nests, $(this).val() + '.eggs', []), function (item) { return { id: item.id, text: item.name, @@ -181,8 +181,8 @@ }), }); - if (_.isObject(_.get(Pterodactyl.nests, $(this).val() + '.eggs.' + Pterodactyl.server.egg_id))) { - $('#pEggId').val(Pterodactyl.server.egg_id); + if (_.isObject(_.get(Pyrodactyl.nests, $(this).val() + '.eggs.' + Pyrodactyl.server.egg_id))) { + $('#pEggId').val(Pyrodactyl.server.egg_id); } $('#pEggId').change(); diff --git a/resources/views/admin/settings/captcha.blade.php b/resources/views/admin/settings/captcha.blade.php index 0148076a4..cfe26db2c 100644 --- a/resources/views/admin/settings/captcha.blade.php +++ b/resources/views/admin/settings/captcha.blade.php @@ -180,12 +180,12 @@ function toggleSettings() { const provider = providerSelect.value; - + // Hide all provider-specific settings first turnstileSettings.style.display = 'none'; hcaptchaSettings.style.display = 'none'; recaptchaSettings.style.display = 'none'; - + if (provider === 'turnstile') { turnstileSettings.style.display = 'block'; } else if (provider === 'hcaptcha') { @@ -196,9 +196,9 @@ } providerSelect.addEventListener('change', toggleSettings); - + // Initialize on page load with a small delay to ensure DOM is ready setTimeout(toggleSettings, 100); }); -@endsection \ No newline at end of file +@endsection diff --git a/routes/api-application.php b/routes/api-application.php index 428263f9d..2b7f6eb2c 100644 --- a/routes/api-application.php +++ b/routes/api-application.php @@ -16,7 +16,7 @@ use Pterodactyl\Http\Controllers\Base; */ Route::group(['prefix' => '/panel'], function () { - Route::get('/status', [Base\SystemStatusController::class, 'index']); + Route::get('/status', [Base\SystemStatusController::class, 'index']); }); @@ -31,14 +31,14 @@ Route::group(['prefix' => '/panel'], function () { Route::group(['prefix' => '/users'], function () { - Route::get('/', [Application\Users\UserController::class, 'index'])->name('api.application.users'); - Route::get('/{user:id}', [Application\Users\UserController::class, 'view'])->name('api.application.users.view'); - Route::get('/external/{external_id}', [Application\Users\ExternalUserController::class, 'index'])->name('api.application.users.external'); + Route::get('/', [Application\Users\UserController::class, 'index'])->name('api.application.users'); + Route::get('/{user:id}', [Application\Users\UserController::class, 'view'])->name('api.application.users.view'); + Route::get('/external/{external_id}', [Application\Users\ExternalUserController::class, 'index'])->name('api.application.users.external'); - Route::post('/', [Application\Users\UserController::class, 'store']); - Route::patch('/{user:id}', [Application\Users\UserController::class, 'update']); + Route::post('/', [Application\Users\UserController::class, 'store']); + Route::patch('/{user:id}', [Application\Users\UserController::class, 'update']); - Route::delete('/{user:id}', [Application\Users\UserController::class, 'delete']); + Route::delete('/{user:id}', [Application\Users\UserController::class, 'delete']); }); /* @@ -50,21 +50,21 @@ Route::group(['prefix' => '/users'], function () { | */ Route::group(['prefix' => '/nodes'], function () { - Route::get('/', [Application\Nodes\NodeController::class, 'index'])->name('api.application.nodes'); - Route::get('/deployable', Application\Nodes\NodeDeploymentController::class); - Route::get('/{node:id}', [Application\Nodes\NodeController::class, 'view'])->name('api.application.nodes.view'); - Route::get('/{node:id}/configuration', Application\Nodes\NodeConfigurationController::class); + Route::get('/', [Application\Nodes\NodeController::class, 'index'])->name('api.application.nodes'); + Route::get('/deployable', Application\Nodes\NodeDeploymentController::class); + Route::get('/{node:id}', [Application\Nodes\NodeController::class, 'view'])->name('api.application.nodes.view'); + Route::get('/{node:id}/configuration', Application\Nodes\NodeConfigurationController::class); - Route::post('/', [Application\Nodes\NodeController::class, 'store']); - Route::patch('/{node:id}', [Application\Nodes\NodeController::class, 'update']); + Route::post('/', [Application\Nodes\NodeController::class, 'store']); + Route::patch('/{node:id}', [Application\Nodes\NodeController::class, 'update']); - Route::delete('/{node:id}', [Application\Nodes\NodeController::class, 'delete']); + Route::delete('/{node:id}', [Application\Nodes\NodeController::class, 'delete']); - Route::group(['prefix' => '/{node:id}/allocations'], function () { - Route::get('/', [Application\Nodes\AllocationController::class, 'index'])->name('api.application.allocations'); - Route::post('/', [Application\Nodes\AllocationController::class, 'store']); - Route::delete('/{allocation:id}', [Application\Nodes\AllocationController::class, 'delete'])->name('api.application.allocations.view'); - }); + Route::group(['prefix' => '/{node:id}/allocations'], function () { + Route::get('/', [Application\Nodes\AllocationController::class, 'index'])->name('api.application.allocations'); + Route::post('/', [Application\Nodes\AllocationController::class, 'store']); + Route::delete('/{allocation:id}', [Application\Nodes\AllocationController::class, 'delete'])->name('api.application.allocations.view'); + }); }); /* @@ -76,13 +76,13 @@ Route::group(['prefix' => '/nodes'], function () { | */ Route::group(['prefix' => '/locations'], function () { - Route::get('/', [Application\Locations\LocationController::class, 'index'])->name('api.applications.locations'); - Route::get('/{location:id}', [Application\Locations\LocationController::class, 'view'])->name('api.application.locations.view'); + Route::get('/', [Application\Locations\LocationController::class, 'index'])->name('api.applications.locations'); + Route::get('/{location:id}', [Application\Locations\LocationController::class, 'view'])->name('api.application.locations.view'); - Route::post('/', [Application\Locations\LocationController::class, 'store']); - Route::patch('/{location:id}', [Application\Locations\LocationController::class, 'update']); + Route::post('/', [Application\Locations\LocationController::class, 'store']); + Route::patch('/{location:id}', [Application\Locations\LocationController::class, 'update']); - Route::delete('/{location:id}', [Application\Locations\LocationController::class, 'delete']); + Route::delete('/{location:id}', [Application\Locations\LocationController::class, 'delete']); }); /* @@ -94,32 +94,32 @@ Route::group(['prefix' => '/locations'], function () { | */ Route::group(['prefix' => '/servers'], function () { - Route::get('/', [Application\Servers\ServerController::class, 'index'])->name('api.application.servers'); - Route::get('/{server:id}', [Application\Servers\ServerController::class, 'view'])->name('api.application.servers.view'); - Route::get('/external/{external_id}', [Application\Servers\ExternalServerController::class, 'index'])->name('api.application.servers.external'); + Route::get('/', [Application\Servers\ServerController::class, 'index'])->name('api.application.servers'); + Route::get('/{server:id}', [Application\Servers\ServerController::class, 'view'])->name('api.application.servers.view'); + Route::get('/external/{external_id}', [Application\Servers\ExternalServerController::class, 'index'])->name('api.application.servers.external'); - Route::patch('/{server:id}/details', [Application\Servers\ServerDetailsController::class, 'details'])->name('api.application.servers.details'); - Route::patch('/{server:id}/build', [Application\Servers\ServerDetailsController::class, 'build'])->name('api.application.servers.build'); - Route::patch('/{server:id}/startup', [Application\Servers\StartupController::class, 'index'])->name('api.application.servers.startup'); + Route::patch('/{server:id}/details', [Application\Servers\ServerDetailsController::class, 'details'])->name('api.application.servers.details'); + Route::patch('/{server:id}/build', [Application\Servers\ServerDetailsController::class, 'build'])->name('api.application.servers.build'); + Route::patch('/{server:id}/startup', [Application\Servers\StartupController::class, 'index'])->name('api.application.servers.startup'); - Route::post('/', [Application\Servers\ServerController::class, 'store']); - Route::post('/{server:id}/suspend', [Application\Servers\ServerManagementController::class, 'suspend'])->name('api.application.servers.suspend'); - Route::post('/{server:id}/unsuspend', [Application\Servers\ServerManagementController::class, 'unsuspend'])->name('api.application.servers.unsuspend'); - Route::post('/{server:id}/reinstall', [Application\Servers\ServerManagementController::class, 'reinstall'])->name('api.application.servers.reinstall'); + Route::post('/', [Application\Servers\ServerController::class, 'store']); + Route::post('/{server:id}/suspend', [Application\Servers\ServerManagementController::class, 'suspend'])->name('api.application.servers.suspend'); + Route::post('/{server:id}/unsuspend', [Application\Servers\ServerManagementController::class, 'unsuspend'])->name('api.application.servers.unsuspend'); + Route::post('/{server:id}/reinstall', [Application\Servers\ServerManagementController::class, 'reinstall'])->name('api.application.servers.reinstall'); - Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']); - Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']); + Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']); + Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']); - // Database Management Endpoint - Route::group(['prefix' => '/{server:id}/databases'], 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'); + // Database Management Endpoint + Route::group(['prefix' => '/{server:id}/databases'], 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'); - Route::post('/', [Application\Servers\DatabaseController::class, 'store']); - Route::post('/{database:id}/reset-password', [Application\Servers\DatabaseController::class, 'resetPassword']); + Route::post('/', [Application\Servers\DatabaseController::class, 'store']); + Route::post('/{database:id}/reset-password', [Application\Servers\DatabaseController::class, 'resetPassword']); - Route::delete('/{database:id}', [Application\Servers\DatabaseController::class, 'delete']); - }); + Route::delete('/{database:id}', [Application\Servers\DatabaseController::class, 'delete']); + }); }); /* @@ -131,12 +131,12 @@ Route::group(['prefix' => '/servers'], function () { | */ Route::group(['prefix' => '/nests'], function () { - Route::get('/', [Application\Nests\NestController::class, 'index'])->name('api.application.nests'); - Route::get('/{nest:id}', [Application\Nests\NestController::class, 'view'])->name('api.application.nests.view'); + Route::get('/', [Application\Nests\NestController::class, 'index'])->name('api.application.nests'); + Route::get('/{nest:id}', [Application\Nests\NestController::class, 'view'])->name('api.application.nests.view'); - // Egg Management Endpoint - Route::group(['prefix' => '/{nest:id}/eggs'], function () { - Route::get('/', [Application\Nests\EggController::class, 'index'])->name('api.application.nests.eggs'); - Route::get('/{egg:id}', [Application\Nests\EggController::class, 'view'])->name('api.application.nests.eggs.view'); - }); + // Egg Management Endpoint + Route::group(['prefix' => '/{nest:id}/eggs'], function () { + Route::get('/', [Application\Nests\EggController::class, 'index'])->name('api.application.nests.eggs'); + Route::get('/{egg:id}', [Application\Nests\EggController::class, 'view'])->name('api.application.nests.eggs.view'); + }); }); diff --git a/routes/api-client.php b/routes/api-client.php index bfd6a4e83..d1d39d651 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -1,9 +1,11 @@ name('api:client.index'); Route::get('/permissions', [Client\ClientController::class, 'permissions']); Route::get('/version', function () { @@ -59,6 +62,7 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function () | Endpoint: /api/client/servers/{server} | */ + Route::group([ 'prefix' => '/servers/{server}', 'middleware' => [ @@ -67,126 +71,48 @@ Route::group([ ResourceBelongsToServer::class, ], ], function () { - Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view'); - Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws'); - Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources'); - Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity'); - - Route::post('/command', [Client\Servers\CommandController::class, 'index']); - Route::post('/power', [Client\Servers\PowerController::class, 'index']); - - Route::group(['prefix' => '/databases'], function () { - Route::get('/', [Client\Servers\DatabaseController::class, 'index']); - Route::post('/', [Client\Servers\DatabaseController::class, 'store']); - Route::post('/{database}/rotate-password', [Client\Servers\DatabaseController::class, 'rotatePassword']); - Route::delete('/{database}', [Client\Servers\DatabaseController::class, 'delete']); - }); - - Route::group(['prefix' => '/files'], function () { - Route::get('/list', [Client\Servers\FileController::class, 'directory']); - Route::get('/contents', [Client\Servers\FileController::class, 'contents']); - Route::get('/download', [Client\Servers\FileController::class, 'download']); - Route::put('/rename', [Client\Servers\FileController::class, 'rename']); - Route::post('/copy', [Client\Servers\FileController::class, 'copy']); - Route::post('/write', [Client\Servers\FileController::class, 'write']); - Route::post('/compress', [Client\Servers\FileController::class, 'compress']); - Route::post('/decompress', [Client\Servers\FileController::class, 'decompress']); - Route::post('/delete', [Client\Servers\FileController::class, 'delete']); - Route::post('/create-folder', [Client\Servers\FileController::class, 'create']); - Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']); - Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:10,5']); - Route::get('/upload', Client\Servers\FileUploadController::class); - }); - - Route::group(['prefix' => '/schedules'], function () { - Route::get('/', [Client\Servers\ScheduleController::class, 'index']); - Route::post('/', [Client\Servers\ScheduleController::class, 'store']); - Route::get('/{schedule}', [Client\Servers\ScheduleController::class, 'view']); - Route::post('/{schedule}', [Client\Servers\ScheduleController::class, 'update']); - Route::post('/{schedule}/execute', [Client\Servers\ScheduleController::class, 'execute']); - Route::delete('/{schedule}', [Client\Servers\ScheduleController::class, 'delete']); - - Route::post('/{schedule}/tasks', [Client\Servers\ScheduleTaskController::class, 'store']); - Route::post('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'update']); - Route::delete('/{schedule}/tasks/{task}', [Client\Servers\ScheduleTaskController::class, 'delete']); - }); - - Route::group(['prefix' => '/network'], function () { - Route::get('/allocations', [Client\Servers\NetworkAllocationController::class, 'index']); - Route::post('/allocations', [Client\Servers\NetworkAllocationController::class, 'store']); - Route::post('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'update']); - Route::post('/allocations/{allocation}/primary', [Client\Servers\NetworkAllocationController::class, 'setPrimary']); - Route::delete('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'delete']); - }); + Route::get('/', [Client\ServerController::class, 'index'])->name('api.client.servers.daemonType'); + Route::get('/resources', [Client\ServerController::class, 'resources'])->name('api.client.servers.resources'); Route::group(['prefix' => '/subdomain'], function () { - Route::get('/', [Client\Servers\SubdomainController::class, 'index']); - Route::post('/', [Client\Servers\SubdomainController::class, 'store']) + Route::get('/', [Elytra\SubdomainController::class, 'index']); + Route::post('/', [Elytra\SubdomainController::class, 'store']) ->middleware('throttle:5,1'); // Max 5 creates/replaces per minute - Route::delete('/', [Client\Servers\SubdomainController::class, 'destroy']) + Route::delete('/', [Elytra\SubdomainController::class, 'destroy']) ->middleware('throttle:5,1'); // Max 5 deletes per minute - Route::post('/check-availability', [Client\Servers\SubdomainController::class, 'checkAvailability']) + Route::post('/check-availability', [Elytra\SubdomainController::class, 'checkAvailability']) ->middleware('throttle:20,1'); // Max 20 availability checks per minute }); - - Route::group(['prefix' => '/users'], function () { - Route::get('/', [Client\Servers\SubuserController::class, 'index']); - Route::post('/', [Client\Servers\SubuserController::class, 'store']); - Route::get('/{user}', [Client\Servers\SubuserController::class, 'view']); - Route::post('/{user}', [Client\Servers\SubuserController::class, 'update']); - Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']); - }); - - // Elytra Jobs API - Route::group(['prefix' => '/jobs'], function () { - Route::get('/', [Client\Servers\ElytraJobsController::class, 'index']); - Route::post('/', [Client\Servers\ElytraJobsController::class, 'create']) - ->middleware('server.operation.rate-limit'); - Route::get('/{jobId}', [Client\Servers\ElytraJobsController::class, 'show']); - Route::delete('/{jobId}', [Client\Servers\ElytraJobsController::class, 'cancel']); - }); - - // Backups API - Route::group(['prefix' => '/backups'], function () { - Route::get('/', [Client\Servers\BackupsController::class, 'index']); - Route::post('/', [Client\Servers\BackupsController::class, 'store']) - ->middleware('server.operation.rate-limit'); - Route::delete('/delete-all', [Client\Servers\BackupsController::class, 'deleteAll']) - ->middleware('throttle:2,60'); - Route::post('/bulk-delete', [Client\Servers\BackupsController::class, 'bulkDelete']) - ->middleware('throttle:10,60'); - Route::get('/{backup}', [Client\Servers\BackupsController::class, 'show']); - Route::get('/{backup}/download', [Client\Servers\BackupsController::class, 'download']); - Route::post('/{backup}/restore', [Client\Servers\BackupsController::class, 'restore']) - ->middleware('server.operation.rate-limit'); - Route::post('/{backup}/rename', [Client\Servers\BackupsController::class, 'rename']); - Route::post('/{backup}/lock', [Client\Servers\BackupsController::class, 'toggleLock']); - Route::delete('/{backup}', [Client\Servers\BackupsController::class, 'destroy']); - }); - - Route::group(['prefix' => '/startup'], function () { - Route::get('/', [Client\Servers\StartupController::class, 'index']); - Route::put('/variable', [Client\Servers\StartupController::class, 'update']); - Route::put('/command', [Client\Servers\StartupController::class, 'updateCommand']); - Route::get('/command/default', [Client\Servers\StartupController::class, 'getDefaultCommand']); - Route::post('/command/process', [Client\Servers\StartupController::class, 'processCommand']); - }); - - Route::group(['prefix' => '/settings'], function () { - Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']); - Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall']) - ->middleware('server.operation.rate-limit'); - Route::put('/docker-image', [Client\Servers\SettingsController::class, 'dockerImage']); - Route::post('/docker-image/revert', [Client\Servers\SettingsController::class, 'revertDockerImage']); - Route::put('/egg', [Client\Servers\SettingsController::class, 'changeEgg']); - Route::post('/egg/preview', [Client\Servers\SettingsController::class, 'previewEggChange']) - ->middleware('server.operation.rate-limit'); - Route::post('/egg/apply', [Client\Servers\SettingsController::class, 'applyEggChange']) - ->middleware('server.operation.rate-limit'); - }); - - Route::group(['prefix' => '/operations'], function () { - Route::get('/', [Client\Servers\SettingsController::class, 'getServerOperations']); - Route::get('/{operationId}', [Client\Servers\SettingsController::class, 'getOperationStatus']); - }); +}); + + + +/* +|-------------------------------------------------------------------------- +| Client Control API(Wings) +|-------------------------------------------------------------------------- +| +| Endpoint: /api/client/servers/wings/{server} +| +*/ + +Route::group([ + 'prefix' => 'servers/wings/', +], function () { + require __DIR__ . '/servers/wings.php'; +}); + + +/* +|-------------------------------------------------------------------------- +| Client Control API(Elytra) +|-------------------------------------------------------------------------- +| +| Endpoint: /api/client/servers/elytra/{server} +| +*/ +Route::group([ + 'prefix' => 'servers/elytra/', +], function () { + require __DIR__ . '/servers/elytra.php'; }); diff --git a/routes/api-remote.php b/routes/api-remote.php index b1ea3c8e9..45809ac81 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -11,6 +11,7 @@ use Pterodactyl\Http\Controllers\Api\Remote\ElytraJobCompletionController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerDetailsController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerInstallController; use Pterodactyl\Http\Controllers\Api\Remote\Servers\ServerTransferController; +use Pterodactyl\Http\Controllers\Api\Remote\Backups; // Routes for the Wings daemon. Route::post('/sftp/auth', SftpAuthenticationController::class); @@ -20,24 +21,26 @@ Route::post('/servers/reset', [ServerDetailsController::class, 'resetState']); Route::post('/activity', ActivityProcessingController::class); Route::group(['prefix' => '/servers/{uuid}'], function () { - Route::get('/', ServerDetailsController::class); - Route::get('/install', [ServerInstallController::class, 'index']); - Route::post('/install', [ServerInstallController::class, 'store']); + Route::get('/', ServerDetailsController::class); + Route::get('/install', [ServerInstallController::class, 'index']); + Route::post('/install', [ServerInstallController::class, 'store']); - Route::get('/rustic-config', [RusticConfigController::class, 'show']); - Route::post('/backup-sizes', [BackupSizeController::class, 'update']); + Route::get('/rustic-config', [RusticConfigController::class, 'show']); + Route::post('/backup-sizes', [BackupSizeController::class, 'update']); - Route::get('/transfer/failure', [ServerTransferController::class, 'failure']); - Route::get('/transfer/success', [ServerTransferController::class, 'success']); - Route::post('/transfer/failure', [ServerTransferController::class, 'failure']); - Route::post('/transfer/success', [ServerTransferController::class, 'success']); + Route::get('/transfer/failure', [ServerTransferController::class, 'failure']); + Route::get('/transfer/success', [ServerTransferController::class, 'success']); + Route::post('/transfer/failure', [ServerTransferController::class, 'failure']); + Route::post('/transfer/success', [ServerTransferController::class, 'success']); }); Route::group(['prefix' => '/backups'], function () { - Route::get('/{backup}', BackupRemoteUploadController::class); - Route::delete('/{backup}', BackupDeleteController::class); + Route::get('/{backup}', BackupRemoteUploadController::class); + Route::delete('/{backup}', BackupDeleteController::class); + Route::post('/{backup}', [Backups\BackupStatusController::class, 'index']); // NOTE: These are wings only paths, I need to make them use the DaemonType middleware + Route::post('/{backup}/restore', [Backups\BackupStatusController::class, 'restore']); }); Route::group(['prefix' => '/elytra-jobs'], function () { - Route::put('/{jobId}', [ElytraJobCompletionController::class, 'update']); + Route::put('/{jobId}', [ElytraJobCompletionController::class, 'update']); }); diff --git a/routes/servers/elytra.php b/routes/servers/elytra.php new file mode 100644 index 000000000..f2ede6fa7 --- /dev/null +++ b/routes/servers/elytra.php @@ -0,0 +1,144 @@ + '/{server}', + 'middleware' => [ + ServerSubject::class, + AuthenticateServerAccess::class, + ResourceBelongsToServer::class, + CheckDaemonType::class . ':elytra', + ], +], function () { + Route::get('/', [Elytra\ServerController::class, 'index'])->name('api:client:server.elytra.view'); + Route::get('/websocket', Elytra\WebsocketController::class)->name('api:client:server.elytra.ws'); + Route::get('/resources', Elytra\ResourceUtilizationController::class)->name('api:client:server.elytra.resources'); + Route::get('/activity', Elytra\ActivityLogController::class)->name('api:client:server.elytra.activity'); + + Route::post('/command', [Elytra\CommandController::class, 'index']); + Route::post('/power', [Elytra\PowerController::class, 'index']); + + Route::group(['prefix' => '/databases'], function () { + Route::get('/', [Elytra\DatabaseController::class, 'index']); + Route::post('/', [Elytra\DatabaseController::class, 'store']); + Route::post('/{database}/rotate-password', [Elytra\DatabaseController::class, 'rotatePassword']); + Route::delete('/{database}', [Elytra\DatabaseController::class, 'delete']); + }); + + Route::group(['prefix' => '/files'], function () { + Route::get('/list', [Elytra\FileController::class, 'directory']); + Route::get('/contents', [Elytra\FileController::class, 'contents']); + Route::get('/download', [Elytra\FileController::class, 'download']); + Route::put('/rename', [Elytra\FileController::class, 'rename']); + Route::post('/copy', [Elytra\FileController::class, 'copy']); + Route::post('/write', [Elytra\FileController::class, 'write']); + Route::post('/compress', [Elytra\FileController::class, 'compress']); + Route::post('/decompress', [Elytra\FileController::class, 'decompress']); + Route::post('/delete', [Elytra\FileController::class, 'delete']); + Route::post('/create-folder', [Elytra\FileController::class, 'create']); + Route::post('/chmod', [Elytra\FileController::class, 'chmod']); + Route::post('/pull', [Elytra\FileController::class, 'pull'])->middleware(['throttle:10,5']); + Route::get('/upload', Elytra\FileUploadController::class); + }); + + Route::group(['prefix' => '/schedules'], function () { + Route::get('/', [Elytra\ScheduleController::class, 'index']); + Route::post('/', [Elytra\ScheduleController::class, 'store']); + Route::get('/{schedule}', [Elytra\ScheduleController::class, 'view']); + Route::post('/{schedule}', [Elytra\ScheduleController::class, 'update']); + Route::post('/{schedule}/execute', [Elytra\ScheduleController::class, 'execute']); + Route::delete('/{schedule}', [Elytra\ScheduleController::class, 'delete']); + + Route::post('/{schedule}/tasks', [Elytra\ScheduleTaskController::class, 'store']); + Route::post('/{schedule}/tasks/{task}', [Elytra\ScheduleTaskController::class, 'update']); + Route::delete('/{schedule}/tasks/{task}', [Elytra\ScheduleTaskController::class, 'delete']); + }); + + Route::group(['prefix' => '/network'], function () { + Route::get('/allocations', [Elytra\NetworkAllocationController::class, 'index']); + Route::post('/allocations', [Elytra\NetworkAllocationController::class, 'store']); + Route::post('/allocations/{allocation}', [Elytra\NetworkAllocationController::class, 'update']); + Route::post('/allocations/{allocation}/primary', [Elytra\NetworkAllocationController::class, 'setPrimary']); + Route::delete('/allocations/{allocation}', [Elytra\NetworkAllocationController::class, 'delete']); + }); + + Route::group(['prefix' => '/users'], function () { + Route::get('/', [Servers\SubuserController::class, 'index']); + Route::post('/', [Servers\SubuserController::class, 'store']); + Route::get('/{user}', [Servers\SubuserController::class, 'view']); + Route::post('/{user}', [Servers\SubuserController::class, 'update']); + Route::delete('/{user}', [Servers\SubuserController::class, 'delete']); + }); + + // Elytra Jobs API + Route::group(['prefix' => '/jobs'], function () { + Route::get('/', [Elytra\ElytraJobsController::class, 'index']); + Route::post('/', [Elytra\ElytraJobsController::class, 'create']) + ->middleware('server.operation.rate-limit'); + Route::get('/{jobId}', [Elytra\ElytraJobsController::class, 'show']); + Route::delete('/{jobId}', [Elytra\ElytraJobsController::class, 'cancel']); + }); + + // Backups API + Route::group(['prefix' => '/backups'], function () { + Route::get('/', [Elytra\BackupsController::class, 'index']); + Route::post('/', [Elytra\BackupsController::class, 'store']) + ->middleware('server.operation.rate-limit'); + Route::delete('/delete-all', [Elytra\BackupsController::class, 'deleteAll']) + ->middleware('throttle:2,60'); + Route::post('/bulk-delete', [Elytra\BackupsController::class, 'bulkDelete']) + ->middleware('throttle:10,60'); + Route::get('/{backup}', [Elytra\BackupsController::class, 'show']); + Route::get('/{backup}/download', [Elytra\BackupsController::class, 'download']); + Route::post('/{backup}/restore', [Elytra\BackupsController::class, 'restore']) + ->middleware('server.operation.rate-limit'); + Route::post('/{backup}/rename', [Elytra\BackupsController::class, 'rename']); + Route::post('/{backup}/lock', [Elytra\BackupsController::class, 'toggleLock']); + Route::delete('/{backup}', [Elytra\BackupsController::class, 'destroy']); + }); + + Route::group(['prefix' => '/startup'], function () { + Route::get('/', [Elytra\StartupController::class, 'index']); + Route::put('/variable', [Elytra\StartupController::class, 'update']); + Route::put('/command', [Elytra\StartupController::class, 'updateCommand']); + Route::get('/command/default', [Elytra\StartupController::class, 'getDefaultCommand']); + Route::post('/command/process', [Elytra\StartupController::class, 'processCommand']); + }); + + Route::group(['prefix' => '/settings'], function () { + Route::post('/rename', [Elytra\SettingsController::class, 'rename']); + Route::post('/reinstall', [Elytra\SettingsController::class, 'reinstall']) + ->middleware('server.operation.rate-limit'); + Route::put('/docker-image', [Elytra\SettingsController::class, 'dockerImage']); + Route::post('/docker-image/revert', [Elytra\SettingsController::class, 'revertDockerImage']); + Route::put('/egg', [Elytra\SettingsController::class, 'changeEgg']); + Route::post('/egg/preview', [Elytra\SettingsController::class, 'previewEggChange']) + ->middleware('server.operation.rate-limit'); + Route::post('/egg/apply', [Elytra\SettingsController::class, 'applyEggChange']) + ->middleware('server.operation.rate-limit'); + }); + + Route::group(['prefix' => '/operations'], function () { + Route::get('/', [Elytra\SettingsController::class, 'getServerOperations']); + Route::get('/{operationId}', [Elytra\SettingsController::class, 'getOperationStatus']); + }); +}); diff --git a/routes/servers/wings.php b/routes/servers/wings.php new file mode 100644 index 000000000..0071d9e0e --- /dev/null +++ b/routes/servers/wings.php @@ -0,0 +1,119 @@ + '/{server}', + 'middleware' => [ + ServerSubject::class, + AuthenticateServerAccess::class, + ResourceBelongsToServer::class, + CheckDaemonType::class . ':wings', + ], +], function () { + Route::get('/', [Wings\ServerController::class, 'index'])->name('api:client:server.wings.view'); + Route::get('/websocket', Wings\WebsocketController::class)->name('api:client:server.wings.ws'); + Route::get('/resources', Wings\ResourceUtilizationController::class)->name('api:client:server.wings.resources'); + Route::get('/activity', Wings\ActivityLogController::class)->name('api:client:server.wings.activity'); + + Route::post('/command', [Wings\CommandController::class, 'index']); + Route::post('/power', [Wings\PowerController::class, 'index']); + + Route::group(['prefix' => '/databases'], function () { + Route::get('/', [Wings\DatabaseController::class, 'index']); + Route::post('/', [Wings\DatabaseController::class, 'store']); + Route::post('/{database}/rotate-password', [Wings\DatabaseController::class, 'rotatePassword']); + Route::delete('/{database}', [Wings\DatabaseController::class, 'delete']); + }); + + Route::group(['prefix' => '/files'], function () { + Route::get('/list', [Wings\FileController::class, 'directory']); + Route::get('/contents', [Wings\FileController::class, 'contents']); + Route::get('/download', [Wings\FileController::class, 'download']); + Route::put('/rename', [Wings\FileController::class, 'rename']); + Route::post('/copy', [Wings\FileController::class, 'copy']); + Route::post('/write', [Wings\FileController::class, 'write']); + Route::post('/compress', [Wings\FileController::class, 'compress']); + Route::post('/decompress', [Wings\FileController::class, 'decompress']); + Route::post('/delete', [Wings\FileController::class, 'delete']); + Route::post('/create-folder', [Wings\FileController::class, 'create']); + Route::post('/chmod', [Wings\FileController::class, 'chmod']); + Route::post('/pull', [Wings\FileController::class, 'pull'])->middleware(['throttle:10,5']); + Route::get('/upload', Wings\FileUploadController::class); + }); + + Route::group(['prefix' => '/schedules'], function () { + Route::get('/', [Wings\ScheduleController::class, 'index']); + Route::post('/', [Wings\ScheduleController::class, 'store']); + Route::get('/{schedule}', [Wings\ScheduleController::class, 'view']); + Route::post('/{schedule}', [Wings\ScheduleController::class, 'update']); + Route::post('/{schedule}/execute', [Wings\ScheduleController::class, 'execute']); + Route::delete('/{schedule}', [Wings\ScheduleController::class, 'delete']); + + Route::post('/{schedule}/tasks', [Wings\ScheduleTaskController::class, 'store']); + Route::post('/{schedule}/tasks/{task}', [Wings\ScheduleTaskController::class, 'update']); + Route::delete('/{schedule}/tasks/{task}', [Wings\ScheduleTaskController::class, 'delete']); + }); + + Route::group(['prefix' => '/network'], function () { + Route::get('/allocations', [Wings\NetworkAllocationController::class, 'index']); + Route::post('/allocations', [Wings\NetworkAllocationController::class, 'store']); + Route::post('/allocations/{allocation}', [Wings\NetworkAllocationController::class, 'update']); + Route::post('/allocations/{allocation}/primary', [Wings\NetworkAllocationController::class, 'setPrimary']); + Route::delete('/allocations/{allocation}', [Wings\NetworkAllocationController::class, 'delete']); + }); + + Route::group(['prefix' => '/users'], function () { + Route::get('/', [Servers\SubuserController::class, 'index']); + Route::post('/', [Servers\SubuserController::class, 'store']); + Route::get('/{user}', [Servers\SubuserController::class, 'view']); + Route::post('/{user}', [Servers\SubuserController::class, 'update']); + Route::delete('/{user}', [Servers\SubuserController::class, 'delete']); + }); + + Route::group(['prefix' => '/backups'], function () { + Route::get('/', [Wings\BackupController::class, 'index']); + Route::post('/', [Wings\BackupController::class, 'store']); + Route::get('/{backup}', [Wings\BackupController::class, 'view']); + Route::get('/{backup}/download', [Wings\BackupController::class, 'download']); + Route::post('/{backup}/lock', [Wings\BackupController::class, 'toggleLock']); + Route::post('/{backup}/restore', [Wings\BackupController::class, 'restore']); + Route::delete('/{backup}', [Wings\BackupController::class, 'delete']); + }); + + Route::group(['prefix' => '/startup'], function () { + Route::get('/', [Wings\StartupController::class, 'index']); + Route::put('/variable', [Wings\StartupController::class, 'update']); + }); + + Route::group(['prefix' => '/settings'], function () { + Route::post('/rename', [Wings\SettingsController::class, 'rename']); + Route::post('/reinstall', [Wings\SettingsController::class, 'reinstall']); + Route::put('/docker-image', [Wings\SettingsController::class, 'dockerImage']); + Route::put('/egg', [Wings\SettingsController::class, 'changeEgg']); + Route::post('/egg/preview', [Wings\SettingsController::class, 'previewEggChange']) + ->middleware('server.operation.rate-limit'); + Route::post('/egg/apply', [Wings\SettingsController::class, 'applyEggChange']) + ->middleware('server.operation.rate-limit'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index bbb99aaa4..99c1becc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -83,28 +83,27 @@ export default defineConfig({ plugins: [ laravel('resources/scripts/index.tsx'), manifestSRI(), - [ - million.vite({ - auto: { - threshold: 0.01, - }, - telemetry: false, - }), - react({ - plugins: [ - [ - '@swc/plugin-styled-components', - { - pure: true, - namespace: 'pyrodactyl', - }, - ], + million.vite({ + auto: { + threshold: 0.01, + }, + telemetry: false, + }), + react({ + plugins: [ + [ + '@swc/plugin-styled-components', + { + pure: true, + namespace: 'pyrodactyl', + }, ], - }), - ], + ], + }), ], resolve: { + dedupe: ['react', 'react-dom', 'react/jsx-runtime'], alias: { '@': resolve(dirname(fileURLToPath(import.meta.url)), 'resources', 'scripts'), '@definitions': resolve(