From eaba70ba8ccc15ecf6601527aa0bcf2ed8c7afdd Mon Sep 17 00:00:00 2001 From: bimbab189 <79527589+bimbab189@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:32:16 +0200 Subject: [PATCH 01/54] Add Extension Framework with Minecraft Player Manager (#110) * Adding a playermanager and an extensions module for future extensions(other games and stuff) * Adding a playermanager and an extensions module for future extensions(other games and stuff) * fixed a minor issue from coupon db migrations * some more bug fixes * bug fixes * bug fixes * bug fixes * Final minecraft player manager update * MC player manager v1.0.1 yay * Items now properly render, enchants are listed properly, armor now shows up * mod support, and minor fixes * Better? * Minor player manager extension changes * some more fixes for the extensions/extension module permission changes * fix git typo * merge newer networking fixes * DiscordSrv helper extension, extension module improvements, server resource rendering bug fix * fixes * fork bugfix * DiscordSrv helper small ui change * floating windows: use theme colors for background and editor * Floating Windows yippe, extensions are bugged pls fix * idk * Custom domains yippe * Custom Domains support for Cname and srv records with multiple cf api keys, script that restarts services everytime you build the panel * Custom domain bug fixes * Small bug fix * Final custom domain fixes * Custom Domains now can be limited and show up in user billing * fixes * Unfinished wings-rs implementation,works overall only ssh activity logging is missing both in the panel and wings, minor bug found in custom domains not patched yet * Minor activity changes * Fixing stuff * Fixing stuff --------- Co-authored-by: macery12 Co-authored-by: macery12 <57544649+macery12@users.noreply.github.com> --- .gitignore | 1 + .../Billing/CustomDomainController.php | 217 +++ .../Application/Billing/ProductController.php | 2 + .../CustomDomains/SettingsController.php | 47 + .../Extensions/ExtensionsController.php | 222 +++ .../Nodes/NodeInformationController.php | 15 +- .../Nodes/NodeWingsRsController.php | 128 ++ .../Servers/ServerWingsRsController.php | 69 + .../Api/Client/Billing/CheckoutController.php | 7 +- .../Billing/CustomDomainOptionsController.php | 52 + .../Billing/MollieCheckoutController.php | 3 + .../Billing/PayPalCheckoutController.php | 3 + .../Client/Billing/PlanChangeController.php | 1 + .../Extensions/DiscordSrvHelperController.php | 346 ++++ .../Extensions/ExtensionsController.php | 91 + .../Extensions/PlayerManagerController.php | 1599 +++++++++++++++++ .../Client/Servers/CustomDomainController.php | 130 ++ .../Api/Client/Servers/FileController.php | 65 + .../Api/Client/Servers/ModsController.php | 111 +- .../Api/Client/Servers/WingsRsController.php | 264 +++ .../Remote/ActivityProcessingController.php | 101 ++ app/Http/Kernel.php | 1 + .../Extensions/EnsureExtensionAccess.php | 49 + .../Client/Server/ResourceBelongsToServer.php | 2 + .../DeleteCustomDomainRequest.php | 14 + .../CustomDomains/GetCustomDomainsRequest.php | 14 + .../StoreCustomDomainRequest.php | 32 + .../UpdateCustomDomainRequest.php | 7 + .../DeleteCustomDomainApiKeyRequest.php | 14 + .../GetCustomDomainApiKeysRequest.php | 14 + .../GetCustomDomainSettingsRequest.php | 14 + .../StoreCustomDomainApiKeyRequest.php | 23 + .../UpdateCustomDomainApiKeyRequest.php | 18 + .../UpdateCustomDomainSettingsRequest.php | 26 + .../Extensions/GetExtensionsRequest.php | 13 + .../Extensions/UpdateExtensionRequest.php | 20 + .../UpdateExtensionSettingsRequest.php | 16 + .../Servers/StoreServerRequest.php | 5 + .../Servers/UpdateServerRequest.php | 5 + .../DiscordSrvHelperChannelRequest.php | 33 + .../DiscordSrvHelperInstallRequest.php | 33 + .../DiscordSrvHelperOwnerRequest.php | 35 + .../DiscordSrvHelperStatusRequest.php | 29 + .../DiscordSrvHelperSubuserAccessRequest.php | 13 + .../DiscordSrvHelperTokenRequest.php | 34 + .../Extensions/GetServerExtensionsRequest.php | 19 + .../PlayerManager/AttributeRequest.php | 15 + .../Extensions/PlayerManager/BanIpRequest.php | 21 + .../Extensions/PlayerManager/BanRequest.php | 21 + .../PlayerManager/GetStatusRequest.php | 19 + .../Extensions/PlayerManager/IpRequest.php | 19 + .../Extensions/PlayerManager/KickRequest.php | 21 + .../PlayerManager/PlayerNamedRequest.php | 21 + .../PlayerManager/PlayerReadRequest.php | 19 + .../PlayerManager/PlayerRequest.php | 19 + .../PlayerManager/SetWhitelistRequest.php | 21 + .../PlayerManager/WhisperRequest.php | 21 + .../DeleteCustomDomainRequest.php | 14 + .../CustomDomains/GetCustomDomainsRequest.php | 14 + .../StoreCustomDomainRequest.php | 26 + .../SyncCustomDomainsRequest.php | 14 + app/Http/ViewComposers/EverestComposer.php | 32 + .../CleanupServerCustomDomainsJob.php | 36 + .../ProvisionCustomDomainRecordJob.php | 33 + .../ProvisionServerCustomDomainsJob.php | 35 + app/Models/Billing/Order.php | 4 + app/Models/Billing/Product.php | 5 +- app/Models/CustomDomain.php | 48 + app/Models/CustomDomainApiKey.php | 30 + app/Models/CustomDomainDnsLog.php | 30 + app/Models/ExtensionConfig.php | 132 ++ app/Models/ExtensionFileSnapshot.php | 44 + app/Models/Node.php | 19 + app/Models/Permission.php | 25 + app/Models/Server.php | 12 + app/Models/ServerCustomDomain.php | 55 + app/Models/Subuser.php | 1 + app/Observers/ServerObserver.php | 5 + app/Policies/ServerPolicy.php | 5 +- app/Providers/RouteServiceProvider.php | 20 + app/Providers/SettingsServiceProvider.php | 11 + .../Wings/DaemonFileRepository.php | 26 + .../Wings/DaemonWingsRsRepository.php | 493 +++++ .../Billing/BillingConfigImportService.php | 1 + .../Billing/BillingValidationService.php | 9 + app/Services/Billing/CreateOrderService.php | 1 + app/Services/Billing/CreateServerService.php | 1 + .../Billing/OrderProcessorService.php | 15 +- app/Services/Billing/PlanChangeService.php | 6 +- .../Billing/ServerFulfillmentService.php | 6 + .../CustomDomains/CloudflareDnsService.php | 301 ++++ .../CustomDomainProvisioningService.php | 630 +++++++ .../CustomDomains/SslProvisioningService.php | 30 + .../ExtensionFileSnapshotService.php | 43 + .../MinecraftPlayerManager/MinecraftPing.php | 167 ++ .../MinecraftPingException.php | 7 + .../MinecraftPlayerManager/MinecraftQuery.php | 187 ++ .../MinecraftQueryException.php | 7 + .../MinecraftPlayerManager/NbtParser.php | 821 +++++++++ app/Services/Nodes/WingsDetectionService.php | 110 ++ .../Servers/BuildModificationService.php | 19 +- .../Servers/GetUserPermissionsService.php | 3 +- .../Servers/ServerCreationService.php | 1 + .../Api/Application/ProductTransformer.php | 1 + .../Api/Application/ServerTransformer.php | 1 + .../Api/Application/SubuserTransformer.php | 3 +- .../Api/Client/ProductTransformer.php | 1 + .../Api/Client/ServerTransformer.php | 8 + .../Api/Client/SubuserTransformer.php | 3 +- config/modules/custom_domains.php | 26 + config/modules/extensions.php | 204 +++ ..._000000_create_extension_configs_table.php | 34 + ..._disabled_extensions_to_subusers_table.php | 28 + ..._create_extension_file_snapshots_table.php | 44 + ..._17_120000_create_custom_domains_table.php | 24 + ...100_create_server_custom_domains_table.php | 40 + ...00_create_custom_domain_dns_logs_table.php | 29 + ...300_add_domain_payload_to_orders_table.php | 21 + ...00_create_custom_domain_api_keys_table.php | 23 + ...ubdomain_limit_to_servers_and_products.php | 43 + ...tom_domains_for_api_keys_and_targeting.php | 41 + ...g_service_tags_to_custom_domains_table.php | 21 + ...rd_type_to_server_custom_domains_table.php | 21 + ...8_000001_add_wings_rs_columns_to_nodes.php | 42 + docs/wings-rs-integration.md | 132 ++ openapi.txt | 1 + package.json | 2 + pnpm-lock.yaml | 297 ++- resources/lang/en/activity.php | 8 + .../definitions/account/billing/models.d.ts | 1 + .../account/billing/transformers.ts | 1 + .../scripts/api/definitions/admin/models.d.ts | 1 + .../api/definitions/admin/transformers.ts | 1 + .../api/definitions/server/models.d.ts | 4 + .../api/definitions/server/transformers.ts | 62 +- .../routes/account/billing/customDomains.ts | 22 + .../routes/account/billing/orders/mollie.ts | 7 + .../routes/account/billing/orders/paypal.ts | 7 + .../routes/account/billing/orders/process.ts | 6 + .../routes/account/billing/orders/stripe.ts | 2 + .../routes/account/billing/orders/types.d.ts | 5 + .../api/routes/admin/billing/types.d.ts | 1 + .../scripts/api/routes/admin/customDomains.ts | 141 ++ .../api/routes/admin/extensions/index.ts | 90 + .../api/routes/admin/nodes/getNodes.ts | 6 + .../scripts/api/routes/admin/nodes/wingsRs.ts | 153 ++ resources/scripts/api/routes/admin/server.ts | 1 + .../api/routes/admin/servers/createServer.ts | 2 + .../api/routes/admin/servers/getServers.ts | 2 + .../api/routes/admin/servers/updateServer.ts | 2 + .../api/routes/admin/servers/wingsRs.ts | 63 + .../scripts/api/routes/server/billing.ts | 1 + .../api/routes/server/customDomains.ts | 75 + .../scripts/api/routes/server/wingsRs.ts | 139 ++ .../api/server/extensions/discordSrvHelper.ts | 60 + .../scripts/api/server/extensions/index.ts | 20 + .../api/server/extensions/playerManager.ts | 252 +++ .../account/billing/ProductsContainer.tsx | 11 + .../account/billing/order/BillingCycleBox.tsx | 41 +- .../account/billing/order/EggBox.tsx | 2 +- .../billing/order/MolliePaymentButton.tsx | 6 + .../account/billing/order/NodeBox.tsx | 2 +- .../account/billing/order/OrderContainer.tsx | 167 +- .../billing/order/PayPalPaymentButton.tsx | 6 + .../account/billing/order/PaymentButton.tsx | 6 + .../billing/order/PaymentMethodSelector.tsx | 8 + .../management/nodes/NodeLogsContainer.tsx | 153 ++ .../admin/management/nodes/NodeRouter.tsx | 8 +- .../management/nodes/NodeStatsContainer.tsx | 162 ++ .../management/nodes/NodeWingsRsContainer.tsx | 213 +++ .../management/servers/NewServerContainer.tsx | 8 + .../servers/ServerResourcesContainer.tsx | 1 + .../admin/management/servers/ServerRouter.tsx | 8 + .../servers/ServerSettingsContainer.tsx | 1 + .../servers/ServerWingsRsContainer.tsx | 102 ++ .../servers/settings/FeatureLimitsBox.tsx | 7 + .../servers/settings/NetworkingBox.tsx | 105 +- .../modules/billing/products/ProductForm.tsx | 9 + .../customDomains/CustomDomainsRouter.tsx | 40 + .../domains/DomainsContainer.tsx | 468 +++++ .../settings/SettingsContainer.tsx | 250 +++ .../extensions/EnableExtensionsContainer.tsx | 20 + .../modules/extensions/ExtensionCard.tsx | 535 ++++++ .../extensions/ExtensionsContainer.tsx | 80 + .../modules/extensions/ExtensionsRouter.tsx | 61 + .../modules/extensions/ExtensionsSvg.tsx | 40 + .../extensions/ToggleExtensionsButton.tsx | 36 + .../server/billing/ChangePlanContainer.tsx | 5 + .../server/domains/CustomDomainsContainer.tsx | 346 ++++ .../server/extensions/AttributeEditor.tsx | 500 ++++++ .../server/extensions/ExtensionsContainer.tsx | 149 ++ .../server/extensions/ExtensionsRouter.tsx | 32 + .../server/extensions/InventoryViewer.tsx | 642 +++++++ .../extensions/PlayerManagerContainer.tsx | 822 +++++++++ .../DiscordSrvHelperContainer.tsx | 369 ++++ .../components/server/extensions/registry.ts | 21 + .../server/files/CompressFormatDialog.tsx | 106 ++ .../server/files/FileDropdownMenu.tsx | 79 + .../server/files/FileEditContainer.tsx | 67 +- .../server/files/FileFingerprintDialog.tsx | 110 ++ .../server/files/FileManagerContainer.tsx | 19 +- .../server/files/FileObjectGrid.tsx | 28 +- .../server/files/FileObjectList.tsx | 28 +- .../server/files/FileSearchDialog.tsx | 169 ++ .../components/server/files/SshInfoPanel.tsx | 79 + .../server/floating/FloatingWindowsLayer.tsx | 430 +++++ .../components/server/mods/ModDetails.tsx | 9 +- .../components/server/mods/ModsContainer.tsx | 16 +- .../server/plugins/ContentTypeTabPanel.tsx | 7 +- .../server/plugins/InstalledAddonsList.tsx | 17 +- .../server/plugins/ModsAndPluginsPage.tsx | 6 +- .../server/wingsrs/WingsRsContainer.tsx | 252 +++ resources/scripts/elements/ErrorBoundary.tsx | 57 +- resources/scripts/elements/Modal.tsx | 3 +- .../elements/activity/ActivityLogEntry.tsx | 37 +- resources/scripts/i18n.ts | 50 + resources/scripts/index.tsx | 1 + resources/scripts/routers/ServerRouter.tsx | 102 +- resources/scripts/routers/routes/admin.ts | 14 + resources/scripts/routers/routes/server.ts | 16 + routes/api-application.php | 65 + routes/api-client.php | 53 + .../extensions/client/discordsrv_helper.php | 26 + .../client/minecraft_player_manager.php | 44 + scripts/reload-dev-services.sh | 125 ++ .../Api/Remote/SshActivityProcessingTest.php | 313 ++++ .../Client/Servers/WingsRsControllerTest.php | 49 + .../Nodes/WingsDetectionServiceTest.php | 108 ++ vite.config.ts | 2 +- 229 files changed, 17592 insertions(+), 270 deletions(-) create mode 100644 app/Http/Controllers/Api/Application/Billing/CustomDomainController.php create mode 100644 app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php create mode 100644 app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php create mode 100644 app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php create mode 100644 app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php create mode 100644 app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php create mode 100644 app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php create mode 100644 app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php create mode 100644 app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/CustomDomainController.php create mode 100644 app/Http/Controllers/Api/Client/Servers/WingsRsController.php create mode 100644 app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php create mode 100644 app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php create mode 100644 app/Http/Requests/Api/Application/Billing/CustomDomains/GetCustomDomainsRequest.php create mode 100644 app/Http/Requests/Api/Application/Billing/CustomDomains/StoreCustomDomainRequest.php create mode 100644 app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/DeleteCustomDomainApiKeyRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/GetCustomDomainApiKeysRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/GetCustomDomainSettingsRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/StoreCustomDomainApiKeyRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php create mode 100644 app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php create mode 100644 app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php create mode 100644 app/Http/Requests/Api/Application/Extensions/UpdateExtensionRequest.php create mode 100644 app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperChannelRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/AttributeRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/IpRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/KickRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/SetWhitelistRequest.php create mode 100644 app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/CustomDomains/GetCustomDomainsRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/CustomDomains/StoreCustomDomainRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php create mode 100644 app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php create mode 100644 app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php create mode 100644 app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php create mode 100644 app/Models/CustomDomain.php create mode 100644 app/Models/CustomDomainApiKey.php create mode 100644 app/Models/CustomDomainDnsLog.php create mode 100644 app/Models/ExtensionConfig.php create mode 100644 app/Models/ExtensionFileSnapshot.php create mode 100644 app/Models/ServerCustomDomain.php create mode 100644 app/Repositories/Wings/DaemonWingsRsRepository.php create mode 100644 app/Services/CustomDomains/CloudflareDnsService.php create mode 100644 app/Services/CustomDomains/CustomDomainProvisioningService.php create mode 100644 app/Services/CustomDomains/SslProvisioningService.php create mode 100644 app/Services/Extensions/ExtensionFileSnapshotService.php create mode 100644 app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php create mode 100644 app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php create mode 100644 app/Services/Extensions/MinecraftPlayerManager/MinecraftQuery.php create mode 100644 app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php create mode 100644 app/Services/Extensions/MinecraftPlayerManager/NbtParser.php create mode 100644 app/Services/Nodes/WingsDetectionService.php create mode 100644 config/modules/custom_domains.php create mode 100644 config/modules/extensions.php create mode 100644 database/migrations/2026_02_03_000000_create_extension_configs_table.php create mode 100644 database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php create mode 100644 database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php create mode 100644 database/migrations/2026_02_17_120000_create_custom_domains_table.php create mode 100644 database/migrations/2026_02_17_120100_create_server_custom_domains_table.php create mode 100644 database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php create mode 100644 database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php create mode 100644 database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php create mode 100644 database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php create mode 100644 database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php create mode 100644 database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php create mode 100644 database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php create mode 100644 database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php create mode 100644 docs/wings-rs-integration.md create mode 100644 openapi.txt create mode 100644 resources/scripts/api/routes/account/billing/customDomains.ts create mode 100644 resources/scripts/api/routes/admin/customDomains.ts create mode 100644 resources/scripts/api/routes/admin/extensions/index.ts create mode 100644 resources/scripts/api/routes/admin/nodes/wingsRs.ts create mode 100644 resources/scripts/api/routes/admin/servers/wingsRs.ts create mode 100644 resources/scripts/api/routes/server/customDomains.ts create mode 100644 resources/scripts/api/routes/server/wingsRs.ts create mode 100644 resources/scripts/api/server/extensions/discordSrvHelper.ts create mode 100644 resources/scripts/api/server/extensions/index.ts create mode 100644 resources/scripts/api/server/extensions/playerManager.ts create mode 100644 resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx create mode 100644 resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx create mode 100644 resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx create mode 100644 resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx create mode 100644 resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx create mode 100644 resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx create mode 100644 resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/EnableExtensionsContainer.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/ExtensionCard.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/ExtensionsContainer.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/ExtensionsRouter.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/ExtensionsSvg.tsx create mode 100644 resources/scripts/components/admin/modules/extensions/ToggleExtensionsButton.tsx create mode 100644 resources/scripts/components/server/domains/CustomDomainsContainer.tsx create mode 100644 resources/scripts/components/server/extensions/AttributeEditor.tsx create mode 100644 resources/scripts/components/server/extensions/ExtensionsContainer.tsx create mode 100644 resources/scripts/components/server/extensions/ExtensionsRouter.tsx create mode 100644 resources/scripts/components/server/extensions/InventoryViewer.tsx create mode 100644 resources/scripts/components/server/extensions/PlayerManagerContainer.tsx create mode 100644 resources/scripts/components/server/extensions/discordsrv_helper/DiscordSrvHelperContainer.tsx create mode 100644 resources/scripts/components/server/extensions/registry.ts create mode 100644 resources/scripts/components/server/files/CompressFormatDialog.tsx create mode 100644 resources/scripts/components/server/files/FileFingerprintDialog.tsx create mode 100644 resources/scripts/components/server/files/FileSearchDialog.tsx create mode 100644 resources/scripts/components/server/files/SshInfoPanel.tsx create mode 100644 resources/scripts/components/server/floating/FloatingWindowsLayer.tsx create mode 100644 resources/scripts/components/server/wingsrs/WingsRsContainer.tsx create mode 100644 resources/scripts/i18n.ts create mode 100644 routes/extensions/client/discordsrv_helper.php create mode 100644 routes/extensions/client/minecraft_player_manager.php create mode 100755 scripts/reload-dev-services.sh create mode 100644 tests/Integration/Api/Remote/SshActivityProcessingTest.php create mode 100644 tests/Unit/Http/Controllers/Api/Client/Servers/WingsRsControllerTest.php create mode 100644 tests/Unit/Services/Nodes/WingsDetectionServiceTest.php diff --git a/.gitignore b/.gitignore index 7e347647e6..a7cb960992 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ docker-compose.yaml Caddyfile *.pem package-lock.json +wings/wings-rs/ \ No newline at end of file diff --git a/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php b/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php new file mode 100644 index 0000000000..485b925393 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php @@ -0,0 +1,217 @@ +with('apiKey')->orderBy('domain')->get(); + + return response()->json([ + 'data' => $domains->map(function (CustomDomain $domain) { + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'cloudflare_zone_id' => $domain->cloudflare_zone_id, + 'api_key_id' => $domain->api_key_id, + 'api_key_name' => $domain->apiKey?->name, + 'allowed_nest_ids' => $domain->allowed_nest_ids ?? [], + 'allowed_egg_ids' => $domain->allowed_egg_ids ?? [], + 'service_tag' => $domain->service_tag, + 'egg_service_tags' => $domain->egg_service_tags ?? (object) [], + 'wildcard_enabled' => $domain->wildcard_enabled, + 'enabled' => $domain->enabled, + 'created_at' => $domain->created_at, + 'updated_at' => $domain->updated_at, + ]; + })->values(), + ]); + } + + public function store(StoreCustomDomainRequest $request): JsonResponse + { + $domain = CustomDomain::query()->create([ + 'domain' => strtolower($request->input('domain')), + 'cloudflare_zone_id' => $request->input('cloudflare_zone_id'), + 'api_key_id' => $request->integer('api_key_id') ?: null, + 'allowed_nest_ids' => array_values(array_unique(array_map('intval', (array) $request->input('allowed_nest_ids', [])))), + 'allowed_egg_ids' => array_values(array_unique(array_map('intval', (array) $request->input('allowed_egg_ids', [])))), + 'service_tag' => $request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null, + 'egg_service_tags' => $this->sanitizeEggServiceTags((array) $request->input('egg_service_tags', [])), + 'wildcard_enabled' => $request->boolean('wildcard_enabled', false), + 'enabled' => $request->boolean('enabled', true), + ]); + + return response()->json(['data' => $domain], Response::HTTP_CREATED); + } + + public function update(UpdateCustomDomainRequest $request, CustomDomain $customDomain): JsonResponse + { + $customDomain->update([ + 'domain' => strtolower($request->input('domain', $customDomain->domain)), + 'cloudflare_zone_id' => $request->input('cloudflare_zone_id', $customDomain->cloudflare_zone_id), + 'api_key_id' => $request->has('api_key_id') ? ($request->integer('api_key_id') ?: null) : $customDomain->api_key_id, + 'allowed_nest_ids' => $request->has('allowed_nest_ids') + ? array_values(array_unique(array_map('intval', (array) $request->input('allowed_nest_ids', [])))) + : ($customDomain->allowed_nest_ids ?? []), + 'allowed_egg_ids' => $request->has('allowed_egg_ids') + ? array_values(array_unique(array_map('intval', (array) $request->input('allowed_egg_ids', [])))) + : ($customDomain->allowed_egg_ids ?? []), + 'service_tag' => $request->has('service_tag') + ? ($request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null) + : $customDomain->service_tag, + 'egg_service_tags' => $request->has('egg_service_tags') + ? $this->sanitizeEggServiceTags((array) $request->input('egg_service_tags', [])) + : ($customDomain->egg_service_tags ?? (object) []), + 'wildcard_enabled' => $request->boolean('wildcard_enabled', $customDomain->wildcard_enabled), + 'enabled' => $request->boolean('enabled', $customDomain->enabled), + ]); + + return response()->json(['data' => $customDomain->fresh()]); + } + + public function destroy(DeleteCustomDomainRequest $request, CustomDomain $customDomain): Response + { + $customDomain->delete(); + + return $this->returnNoContent(); + } + + public function apiKeys(GetCustomDomainApiKeysRequest $request): JsonResponse + { + $keys = CustomDomainApiKey::query()->orderBy('name')->get()->map(function (CustomDomainApiKey $key) { + return [ + 'id' => $key->id, + 'name' => $key->name, + 'enabled' => $key->enabled, + 'created_at' => $key->created_at, + 'updated_at' => $key->updated_at, + ]; + })->values(); + + return response()->json(['data' => $keys]); + } + + public function storeApiKey(StoreCustomDomainApiKeyRequest $request): JsonResponse + { + $validated = $request->validated(); + + $key = CustomDomainApiKey::query()->create([ + 'name' => trim((string) $validated['name']), + 'token' => trim((string) $validated['token']), + 'enabled' => (bool) ($validated['enabled'] ?? true), + ]); + + return response()->json([ + 'data' => [ + 'id' => $key->id, + 'name' => $key->name, + 'enabled' => $key->enabled, + 'created_at' => $key->created_at, + 'updated_at' => $key->updated_at, + ], + ], Response::HTTP_CREATED); + } + + public function updateApiKey(UpdateCustomDomainApiKeyRequest $request, CustomDomainApiKey $apiKey): JsonResponse + { + $validated = $request->validated(); + + $payload = []; + if (array_key_exists('name', $validated)) { + $payload['name'] = trim((string) $validated['name']); + } + if (!empty($validated['token'])) { + $payload['token'] = trim((string) $validated['token']); + } + if (array_key_exists('enabled', $validated)) { + $payload['enabled'] = (bool) $validated['enabled']; + } + + if (!empty($payload)) { + $apiKey->update($payload); + } + + return response()->json([ + 'data' => [ + 'id' => $apiKey->id, + 'name' => $apiKey->name, + 'enabled' => $apiKey->enabled, + 'created_at' => $apiKey->created_at, + 'updated_at' => $apiKey->updated_at, + ], + ]); + } + + public function deleteApiKey(DeleteCustomDomainApiKeyRequest $request, CustomDomainApiKey $apiKey): Response + { + if (CustomDomain::query()->where('api_key_id', $apiKey->id)->exists()) { + abort(422, 'This API key is assigned to one or more custom domains.'); + } + + $apiKey->delete(); + + return $this->returnNoContent(); + } + + public function options(GetCustomDomainsRequest $request, CustomDomainProvisioningService $service): JsonResponse + { + $nests = Nest::query()->orderBy('name')->get(['id', 'uuid', 'name', 'description']); + $eggs = Egg::query()->with('nest:id,name')->orderBy('name')->get(['id', 'uuid', 'nest_id', 'name', 'description']); + + return response()->json([ + 'data' => [ + 'nests' => $nests, + 'eggs' => $eggs->map(function (Egg $egg) use ($service) { + return [ + 'id' => $egg->id, + 'uuid' => $egg->uuid, + 'nest_id' => $egg->nest_id, + 'nest_name' => $egg->nest?->name, + 'name' => $egg->name, + 'description' => $egg->description, + 'default_service_tag' => $service->getDefaultServiceTagForEgg($egg->name, $egg->nest?->name), + ]; + })->values(), + ], + ]); + } + + private function sanitizeEggServiceTags(array $eggServiceTags): array + { + $result = []; + foreach ($eggServiceTags as $eggId => $tag) { + $id = (int) $eggId; + if ($id < 1 || !is_string($tag)) { + continue; + } + + $normalized = strtolower(trim($tag)); + if ($normalized === '') { + continue; + } + + $result[(string) $id] = $normalized; + } + + return $result; + } +} diff --git a/app/Http/Controllers/Api/Application/Billing/ProductController.php b/app/Http/Controllers/Api/Application/Billing/ProductController.php index a090c56650..4b72fa5509 100644 --- a/app/Http/Controllers/Api/Application/Billing/ProductController.php +++ b/app/Http/Controllers/Api/Application/Billing/ProductController.php @@ -75,6 +75,7 @@ public function store(StoreBillingProductRequest $request, string $category): Js 'backup_limit' => $request['limits']['backup'], 'database_limit' => $request['limits']['database'], 'allocation_limit' => $request['limits']['allocation'], + 'subdomain_limit' => $request['limits']['subdomain'] ?? 1, ]); // Create default billing cycles if provided @@ -115,6 +116,7 @@ public function update(UpdateBillingProductRequest $request, string $category, s 'backup_limit' => $request['limits']['backup'], 'database_limit' => $request['limits']['database'], 'allocation_limit' => $request['limits']['allocation'], + 'subdomain_limit' => $request['limits']['subdomain'] ?? 1, ]); // Update billing cycles if provided diff --git a/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php b/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php new file mode 100644 index 0000000000..b4aa57ca84 --- /dev/null +++ b/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php @@ -0,0 +1,47 @@ +json([ + 'data' => [ + 'cloudflare_token' => (string) config('modules.custom_domains.cloudflare.token', ''), + 'allow_wildcard' => (bool) config('modules.custom_domains.security.allow_wildcard', false), + 'max_wildcards_per_user' => (int) config('modules.custom_domains.security.max_wildcards_per_user', 1), + 'rate_limit_create_per_minute' => (int) config('modules.custom_domains.rate_limits.create_per_minute', 10), + 'rate_limit_sync_per_minute' => (int) config('modules.custom_domains.rate_limits.sync_per_minute', 5), + 'rate_limit_billing_options_per_minute' => (int) config('modules.custom_domains.rate_limits.billing_options_per_minute', 20), + ], + ]); + } + + public function update(UpdateCustomDomainSettingsRequest $request): Response + { + if ($request->has('cloudflare_token')) { + Setting::set('settings::modules:custom_domains:cloudflare:token', (string) $request->input('cloudflare_token', '')); + } + + Setting::set('settings::modules:custom_domains:security:allow_wildcard', $request->boolean('allow_wildcard', false)); + Setting::set('settings::modules:custom_domains:security:max_wildcards_per_user', (int) $request->input('max_wildcards_per_user', 1)); + Setting::set('settings::modules:custom_domains:rate_limits:create_per_minute', (int) $request->input('rate_limit_create_per_minute', 10)); + Setting::set('settings::modules:custom_domains:rate_limits:sync_per_minute', (int) $request->input('rate_limit_sync_per_minute', 5)); + Setting::set('settings::modules:custom_domains:rate_limits:billing_options_per_minute', (int) $request->input('rate_limit_billing_options_per_minute', 20)); + + Activity::event('admin:custom-domains:update-settings') + ->description('Custom domain settings were updated') + ->log(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php b/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php new file mode 100644 index 0000000000..6c08c5bc97 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php @@ -0,0 +1,222 @@ + $extensionConfig) { + $dbConfig = ExtensionConfig::getByExtensionId($extensionId); + + $extensions[] = [ + 'id' => $extensionId, + 'name' => $extensionConfig['name'], + 'description' => $extensionConfig['description'], + 'version' => $extensionConfig['version'], + 'author' => $extensionConfig['author'], + 'icon' => $extensionConfig['icon'], + 'enabled' => $dbConfig ? $dbConfig->enabled : false, + 'allowedNests' => $dbConfig && $dbConfig->allowed_nests ? $dbConfig->allowed_nests : [], + 'allowedEggs' => $dbConfig && $dbConfig->allowed_eggs ? $dbConfig->allowed_eggs : [], + 'settings' => $dbConfig && $dbConfig->settings ? $dbConfig->settings : [], + 'settingsSchema' => $extensionConfig['settings_schema'] ?? [], + ]; + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $extensions, + ]); + } + + /** + * Get a single extension configuration. + */ + public function view(GetExtensionsRequest $request, string $extensionId): JsonResponse + { + $availableExtensions = config('modules.extensions.available', []); + + if (!isset($availableExtensions[$extensionId])) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + $extensionConfig = $availableExtensions[$extensionId]; + $dbConfig = ExtensionConfig::getByExtensionId($extensionId); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => [ + 'id' => $extensionId, + 'name' => $extensionConfig['name'], + 'description' => $extensionConfig['description'], + 'version' => $extensionConfig['version'], + 'author' => $extensionConfig['author'], + 'icon' => $extensionConfig['icon'], + 'enabled' => $dbConfig ? $dbConfig->enabled : false, + 'allowedNests' => $dbConfig && $dbConfig->allowed_nests ? $dbConfig->allowed_nests : [], + 'allowedEggs' => $dbConfig && $dbConfig->allowed_eggs ? $dbConfig->allowed_eggs : [], + 'settings' => $dbConfig && $dbConfig->settings ? $dbConfig->settings : [], + 'settingsSchema' => $extensionConfig['settings_schema'] ?? [], + ], + ]); + } + + /** + * Update an extension configuration. + */ + public function update(UpdateExtensionRequest $request, string $extensionId): JsonResponse + { + $availableExtensions = config('modules.extensions.available', []); + + if (!isset($availableExtensions[$extensionId])) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + $existing = ExtensionConfig::getByExtensionId($extensionId); + + $payload = [ + 'allowed_nests' => $request->input('allowed_nests', []), + 'allowed_eggs' => $request->input('allowed_eggs', []), + 'settings' => $request->input('settings', []), + ]; + + if ($request->has('enabled')) { + $payload['enabled'] = (bool) $request->input('enabled'); + } elseif ($existing) { + $payload['enabled'] = (bool) $existing->enabled; + } + + $config = ExtensionConfig::updateOrCreateConfig($extensionId, $payload); + + Activity::event('admin:extensions:update') + ->property('extension_id', $extensionId) + ->property('enabled', $config->enabled) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => [ + 'id' => $extensionId, + 'name' => $availableExtensions[$extensionId]['name'], + 'description' => $availableExtensions[$extensionId]['description'], + 'version' => $availableExtensions[$extensionId]['version'], + 'author' => $availableExtensions[$extensionId]['author'], + 'icon' => $availableExtensions[$extensionId]['icon'], + 'enabled' => $config->enabled, + 'allowedNests' => $config->allowed_nests ?? [], + 'allowedEggs' => $config->allowed_eggs ?? [], + 'settings' => $config->settings ?? [], + ], + ]); + } + + /** + * Toggle an extension's enabled state. + */ + public function toggle(UpdateExtensionRequest $request, string $extensionId): JsonResponse + { + $availableExtensions = config('modules.extensions.available', []); + + if (!isset($availableExtensions[$extensionId])) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + $dbConfig = ExtensionConfig::getByExtensionId($extensionId); + $newEnabled = $dbConfig ? !$dbConfig->enabled : true; + + $config = ExtensionConfig::updateOrCreateConfig($extensionId, [ + 'enabled' => $newEnabled, + ]); + + Activity::event('admin:extensions:toggle') + ->property('extension_id', $extensionId) + ->property('enabled', $config->enabled) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => [ + 'id' => $extensionId, + 'enabled' => $config->enabled, + ], + ]); + } + + /** + * Update the extensions module settings. + */ + public function settings(UpdateExtensionSettingsRequest $request): Response + { + Setting::set('settings::modules:extensions:' . $request->input('key'), $request->input('value')); + + Activity::event('admin:extensions:settings') + ->property('key', $request->input('key')) + ->property('value', $request->input('value')) + ->log(); + + return $this->returnNoContent(); + } + + /** + * Get available nests and eggs for extension configuration. + */ + public function getNestsAndEggs(GetExtensionsRequest $request): JsonResponse + { + $nests = Nest::with('eggs')->get(); + + $nestsData = $nests->map(function ($nest) { + return [ + 'id' => $nest->id, + 'uuid' => $nest->uuid, + 'name' => $nest->name, + 'description' => $nest->description, + ]; + }); + + $eggsData = []; + foreach ($nests as $nest) { + foreach ($nest->eggs as $egg) { + $eggsData[] = [ + 'id' => $egg->id, + 'uuid' => $egg->uuid, + 'name' => $egg->name, + 'description' => $egg->description, + 'nestId' => $nest->id, + 'nestName' => $nest->name, + ]; + } + } + + return new JsonResponse([ + 'nests' => $nestsData, + 'eggs' => $eggsData, + ]); + } +} diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php b/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php index 3e64466073..cfd82bd719 100644 --- a/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php @@ -5,6 +5,7 @@ use Everest\Models\Node; use Illuminate\Support\Str; use Illuminate\Http\JsonResponse; +use Everest\Services\Nodes\WingsDetectionService; use Everest\Repositories\Wings\DaemonConfigurationRepository; use Everest\Http\Controllers\Api\Application\ApplicationApiController; use Everest\Http\Requests\Api\Application\Nodes\GetNodeInformationRequest; @@ -14,7 +15,10 @@ class NodeInformationController extends ApplicationApiController /** * NodeInformationController constructor. */ - public function __construct(private DaemonConfigurationRepository $repository) + public function __construct( + private DaemonConfigurationRepository $repository, + private WingsDetectionService $detectionService + ) { parent::__construct(); } @@ -26,8 +30,15 @@ public function __construct(private DaemonConfigurationRepository $repository) */ public function information(GetNodeInformationRequest $request, Node $node): JsonResponse { + if (!$node->isSupercharged()) { + $this->detectionService->detect($node); + $node->refresh(); + } + $data = $this->repository->setNode($node)->getSystemInformation(); + $isSupercharged = $node->isSupercharged() || !empty($data['supercharged']); + return new JsonResponse([ 'version' => $data['version'] ?? null, 'system' => [ @@ -35,7 +46,7 @@ public function information(GetNodeInformationRequest $request, Node $node): Jso 'arch' => $data['architecture'] ?? null, 'release' => $data['kernel_version'] ?? null, 'cpus' => $data['cpu_count'] ?? null, - 'supercharged' => $data['supercharged'] ?? false, + 'supercharged' => $isSupercharged, ], ]); } diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php b/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php new file mode 100644 index 0000000000..09b780c9b2 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php @@ -0,0 +1,128 @@ +detectionService->detect($node); + $node->refresh(); + + return new JsonResponse([ + 'detected' => $isSupercharged, + 'supercharged' => $isSupercharged, + 'wings_type' => $node->wings_type, + 'wings_version' => $node->wings_version, + 'detected_at' => $node->wings_detected_at?->toIso8601String(), + ]); + } + + /** + * GET /api/application/nodes/{node}/overview — Wings-RS system overview. + */ + public function overview(Request $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemOverview(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/stats — Wings-RS real-time stats. + */ + public function stats(Request $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemStats(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/logs — List Wings-RS log files. + */ + public function logs(Request $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemLogs(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/logs/{file} — Read specific log file. + */ + public function logContents(Request $request, Node $node, string $file): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $lines = (int) $request->query('lines', 200); + $lines = max(1, min($lines, 5000)); + + $content = $this->wingsRsRepository->setNode($node)->getSystemLogContents($file, $lines); + + return new JsonResponse([ + 'file' => $file, + 'content' => $content, + ]); + } + + /** + * POST /api/application/nodes/{node}/upgrade — Trigger Wings-RS self-upgrade. + */ + public function upgrade(Request $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $request->validate([ + 'url' => 'required|url', + 'sha256' => 'required|string|size:64', + 'restart_command' => 'required|string', + 'restart_command_args' => 'array', + 'restart_command_args.*' => 'string', + 'headers' => 'array', + ]); + + $this->wingsRsRepository->setNode($node)->upgradeSystem( + $request->input('url'), + $request->input('headers', []), + $request->input('sha256'), + $request->input('restart_command'), + $request->input('restart_command_args', []) + ); + + return new JsonResponse(['success' => true], 202); + } +} diff --git a/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php b/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php new file mode 100644 index 0000000000..324b13cc0e --- /dev/null +++ b/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php @@ -0,0 +1,69 @@ +node; + + return new JsonResponse([ + 'supercharged' => $node->isSupercharged(), + 'wings_type' => $node->wings_type, + 'wings_version' => $node->wings_version, + ]); + } + + public function stats(Request $request, Server $server): JsonResponse + { + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'This server node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setServer($server)->getSystemStats(); + + return new JsonResponse($data); + } + + public function installLogs(Request $request, Server $server): JsonResponse + { + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'This server node is not running Wings-RS.'], 400); + } + + $lines = (int) $request->query('lines', 100); + $lines = max(1, min($lines, 5000)); + + try { + $content = $this->wingsRsRepository->setServer($server)->getInstallLogs($lines); + } catch (DaemonConnectionException $exception) { + if ($exception->getStatusCode() === 404) { + return new JsonResponse([ + 'content' => [], + 'missing' => true, + ]); + } + + throw $exception; + } + + return new JsonResponse([ + 'content' => $content, + 'missing' => false, + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Billing/CheckoutController.php b/app/Http/Controllers/Api/Client/Billing/CheckoutController.php index d0a530f9c6..44c3494c25 100644 --- a/app/Http/Controllers/Api/Client/Billing/CheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/CheckoutController.php @@ -95,6 +95,7 @@ public function processFree(Request $request): array // Process the order $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $result = $this->processorService->createServerOrder( $request, $user, @@ -105,7 +106,8 @@ public function processFree(Request $request): array $variables, null, // No payment intent ID for free orders $serverName, - $billingDays + $billingDays, + is_array($domainPayload) ? $domainPayload : [] ); return $this->fractal->item($result['server']) @@ -338,6 +340,8 @@ public function updateIntent(Request $request, ?int $id = null): Response $variables = $request->input('variables') ?? []; $metadata['variables'] = !empty($variables) ? json_encode($variables) : ''; + $domainPayload = $request->input('domain_payload') ?? []; + $metadata['domain_payload'] = !empty($domainPayload) ? json_encode($domainPayload) : ''; $intent->metadata = $metadata; $intent->save(); @@ -354,6 +358,7 @@ public function updateIntent(Request $request, ?int $id = null): Response [ 'billing_days' => $billingDays, 'server_id' => $request->input('server_id') ? (int) $request->input('server_id') : null, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ] ); diff --git a/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php b/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php new file mode 100644 index 0000000000..cb576cd279 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php @@ -0,0 +1,52 @@ +query('egg_id', 0); + $egg = $eggId > 0 ? Egg::query()->with('nest:id,name')->find($eggId) : null; + $recommendation = $this->service->getDnsRecommendationForEgg($egg?->name, $egg?->nest?->name); + + $domains = collect($this->service->getAvailableDomains())->map(function ($domain) use ($egg, $recommendation) { + $eggTag = null; + if ($egg) { + $eggServiceTags = (array) ($domain->egg_service_tags ?? []); + $eggTag = $eggServiceTags[(string) $egg->id] ?? null; + } + + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'wildcard_enabled' => $domain->wildcard_enabled, + 'default_service_tag' => $eggTag + ? strtolower((string) $eggTag) + : ($domain->service_tag + ? strtolower((string) $domain->service_tag) + : $this->service->getDefaultServiceTagForEgg($egg?->name, $egg?->nest?->name)), + 'recommended_record_type' => $recommendation['recommended_record_type'], + 'srv_supported' => $recommendation['srv_supported'], + 'allow_record_type_selection' => $recommendation['allow_record_type_selection'], + 'forced_record_type' => $recommendation['forced_record_type'], + 'dns_mode' => $recommendation['mode'], + 'recommendation_notice' => $recommendation['notice'], + 'connection_hint' => $recommendation['connection_hint'], + ]; + })->values(); + + return response()->json(['data' => $domains]); + } +} diff --git a/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php b/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php index c3e232d72c..f50d9b8b86 100644 --- a/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php @@ -98,6 +98,7 @@ public function createPayment(Request $request, int $id): JsonResponse 'server_id' => $isRenewal ? $serverId : null, 'billing_days' => $billingDays, 'variables' => [], + 'domain_payload' => [], ]; // Store the token mapping in an order record (pending state) @@ -179,6 +180,7 @@ public function updatePayment(Request $request, int $id): Response $orderType = $this->getOrderType($request); $couponId = $request->input('coupon_id') ? (int) $request->input('coupon_id') : null; $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $serverId = $request->input('server_id') ? (int) $request->input('server_id') : null; // Find the existing pending order and update it @@ -196,6 +198,7 @@ public function updatePayment(Request $request, int $id): Response 'coupon_id' => $couponId, 'billing_days' => $billingDays, 'variables' => $variables, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ]); return $this->returnNoContent(); diff --git a/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php b/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php index 151c8a3598..a686ca6d70 100644 --- a/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php @@ -96,6 +96,7 @@ public function createOrder(Request $request, int $id): JsonResponse 'server_id' => $isRenewal ? $serverId : null, 'billing_days' => $billingDays, 'variables' => [], + 'domain_payload' => [], ]; $this->orderService->create( @@ -184,6 +185,7 @@ public function updateOrder(Request $request, int $id): Response $orderType = $this->getOrderType($request); $couponId = $request->input('coupon_id') ? (int) $request->input('coupon_id') : null; $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $serverId = $request->input('server_id') ? (int) $request->input('server_id') : null; // Find the existing pending order and update it @@ -201,6 +203,7 @@ public function updateOrder(Request $request, int $id): Response 'coupon_id' => $couponId, 'billing_days' => $billingDays, 'variables' => $variables, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ]); Log::info('PayPal order updated successfully', [ diff --git a/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php b/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php index 78563ef0f7..69dea9dadb 100644 --- a/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php +++ b/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php @@ -127,6 +127,7 @@ public function changePlan(GetServerRequest $request, Server $server, int $produ 'database' => $updatedServer->database_limit, 'backup' => $updatedServer->backup_limit, 'allocation' => $updatedServer->allocation_limit, + 'subdomain' => $updatedServer->subdomain_limit ?? $updatedServer->product?->subdomain_limit, ], ], ]); diff --git a/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php b/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php new file mode 100644 index 0000000000..35b902c71c --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php @@ -0,0 +1,346 @@ +fileRepository->setServer($server)->getDirectory(self::PLUGINS_DIR); + + $pluginJar = null; + $hasDiscordSrvFolder = false; + + foreach ($plugins as $item) { + $name = (string) Arr::get($item, 'name', ''); + $isFile = (bool) Arr::get($item, 'file', true); + + if (!$isFile && $name === 'DiscordSRV') { + $hasDiscordSrvFolder = true; + } + + if ($isFile && str_ends_with(strtolower($name), '.jar') && str_contains($name, 'DiscordSRV')) { + $pluginJar = $name; + } + } + + $tokenPresent = false; + $configPresent = false; + + if ($hasDiscordSrvFolder) { + $discordSrvDir = $this->fileRepository->setServer($server)->getDirectory(self::DISCORDSRV_DIR); + foreach ($discordSrvDir as $item) { + $name = (string) Arr::get($item, 'name', ''); + $isFile = (bool) Arr::get($item, 'file', true); + + if ($isFile && $name === '.token') { + $tokenPresent = true; + } + if ($isFile && $name === 'config.yml') { + $configPresent = true; + } + } + } + + return new JsonResponse([ + 'installed' => !is_null($pluginJar), + 'plugin_jar' => $pluginJar, + 'plugin_folder_present' => $hasDiscordSrvFolder, + 'token_file_present' => $tokenPresent, + 'config_present' => $configPresent, + ]); + } + + public function install(DiscordSrvHelperInstallRequest $request, Server $server): JsonResponse + { + $jarUrl = $request->input('jar_url'); + if (!$jarUrl) { + $config = ExtensionConfig::getByExtensionId(self::EXTENSION_ID); + $jarUrl = is_array($config?->settings) ? Arr::get($config->settings, 'jar_url') : null; + } + if (!$jarUrl) { + $jarUrl = $this->getLatestDiscordSrvJarUrl(); + } + + $jarUrl = $this->resolveRedirectedUrl($jarUrl); + + $response = $this->fileRepository->setServer($server)->pull($jarUrl, self::PLUGINS_DIR, [ + 'filename' => self::JAR_FILENAME, + 'foreground' => true, + ]); + + $this->ensureDaemonSuccess($response, 'Failed to download DiscordSRV jar.'); + + return new JsonResponse([ + 'installed' => true, + 'jar' => self::JAR_FILENAME, + 'jar_url' => $jarUrl, + ]); + } + + private function ensureDaemonSuccess(ResponseInterface $response, string $message): void + { + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + return; + } + + $body = trim((string) $response->getBody()); + throw new \RuntimeException($message . ($body ? " Wings response: {$body}" : '')); + } + + public function setToken(DiscordSrvHelperTokenRequest $request, Server $server): JsonResponse + { + $token = trim((string) $request->input('token')); + + $this->ensureDirectory($server, self::PLUGINS_DIR, 'DiscordSRV'); + + $before = $this->safeGetContent($server, self::TOKEN_FILE); + if (!is_null($before)) { + $this->snapshotService->create($server, self::EXTENSION_ID, $request->user(), 'set-token', [ + self::TOKEN_FILE => $before, + ]); + } + + $this->fileRepository->setServer($server)->putContent(self::TOKEN_FILE, $token); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function setGlobalChannel(DiscordSrvHelperChannelRequest $request, Server $server): JsonResponse + { + $channelId = (string) $request->input('channel_id'); + + $before = $this->safeGetContent($server, self::CONFIG_FILE); + if (is_null($before)) { + return new JsonResponse([ + 'error' => 'DiscordSRV config.yml was not found. Start the server once to let DiscordSRV generate its config, then try again.', + ], 409); + } + + $this->snapshotService->create($server, self::EXTENSION_ID, $request->user(), 'set-global-channel', [ + self::CONFIG_FILE => $before, + ]); + + try { + $config = Yaml::parse($before); + } catch (\Throwable $exception) { + return new JsonResponse([ + 'error' => 'DiscordSRV config.yml could not be parsed as YAML. Use the revert feature or fix the file manually, then try again.', + ], 422); + } + if (!is_array($config)) { + $config = []; + } + + $channels = Arr::get($config, 'Channels', []); + if (!is_array($channels)) { + $channels = []; + } + + $channels['global'] = $channelId; + $config['Channels'] = $channels; + + $yaml = Yaml::dump($config, 20, 2); + if (!str_ends_with($yaml, "\n")) { + $yaml .= "\n"; + } + + $this->fileRepository->setServer($server)->putContent(self::CONFIG_FILE, $yaml); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function history(DiscordSrvHelperOwnerRequest $request, Server $server): JsonResponse + { + $snapshots = ExtensionFileSnapshot::query() + ->where('server_id', $server->id) + ->where('extension_id', self::EXTENSION_ID) + ->with('actor') + ->orderByDesc('id') + ->limit(25) + ->get(); + + $data = $snapshots->map(fn (ExtensionFileSnapshot $s) => [ + 'id' => $s->id, + 'action' => $s->action, + 'created_at' => $s->created_at, + 'actor' => $s->actor ? [ + 'id' => $s->actor->id, + 'email' => $s->actor->email, + ] : null, + ])->values(); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $data, + ]); + } + + public function revert(DiscordSrvHelperOwnerRequest $request, Server $server, int $snapshotId): JsonResponse + { + $snapshot = ExtensionFileSnapshot::query() + ->where('server_id', $server->id) + ->where('extension_id', self::EXTENSION_ID) + ->where('id', $snapshotId) + ->firstOrFail(); + + $files = $this->snapshotService->decryptFiles($snapshot); + foreach ($files as $path => $contents) { + $this->fileRepository->setServer($server)->putContent($path, $contents); + } + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function subusers(DiscordSrvHelperOwnerRequest $request, Server $server): JsonResponse + { + $subusers = Subuser::query() + ->with('user') + ->where('server_id', $server->id) + ->get(); + + $data = $subusers->map(fn (Subuser $s) => [ + 'uuid' => $s->user->uuid, + 'email' => $s->user->email, + 'username' => $s->user->username, + 'disabled' => in_array(self::EXTENSION_ID, $s->disabled_extensions ?? [], true), + ])->values(); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $data, + ]); + } + + public function setSubuserAccess(DiscordSrvHelperSubuserAccessRequest $request, Server $server, string $subuserUuid): JsonResponse + { + $enabled = (bool) $request->input('enabled'); + + $subuser = Subuser::query() + ->where('server_id', $server->id) + ->whereHas('user', fn ($q) => $q->where('uuid', $subuserUuid)) + ->firstOrFail(); + + $disabled = $subuser->disabled_extensions ?? []; + $disabled = array_values(array_unique(array_filter($disabled, 'is_string'))); + + if ($enabled) { + $disabled = array_values(array_filter($disabled, fn ($id) => $id !== self::EXTENSION_ID)); + } else { + if (!in_array(self::EXTENSION_ID, $disabled, true)) { + $disabled[] = self::EXTENSION_ID; + } + } + + $subuser->update(['disabled_extensions' => $disabled]); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + private function ensureDirectory(Server $server, string $path, string $name): void + { + try { + $this->fileRepository->setServer($server)->createDirectory($name, $path); + } catch (\Throwable) { + // Directory likely already exists; ignore. + } + } + + private function safeGetContent(Server $server, string $file): ?string + { + try { + return $this->fileRepository->setServer($server)->getContent($file); + } catch (\Throwable) { + return null; + } + } + + private function getLatestDiscordSrvJarUrl(): string + { + $response = Http::timeout(15) + ->withHeaders([ + 'Accept' => 'application/vnd.github+json', + ]) + ->get('https://api.github.com/repos/DiscordSRV/DiscordSRV/releases/latest'); + + $response->throw(); + $json = $response->json(); + + $assets = $json['assets'] ?? []; + foreach ($assets as $asset) { + $name = (string) ($asset['name'] ?? ''); + $url = (string) ($asset['browser_download_url'] ?? ''); + + if ($url && str_ends_with(strtolower($name), '.jar')) { + return $url; + } + } + + throw new \RuntimeException('Could not locate a .jar asset in the latest DiscordSRV release.'); + } + + private function resolveRedirectedUrl(string $url): string + { + try { + $response = Http::timeout(15) + ->withOptions([ + 'allow_redirects' => [ + 'track_redirects' => true, + ], + ]) + ->head($url); + + $headers = $response->headers(); + $history = $headers['X-Guzzle-Redirect-History'] ?? []; + + if (is_string($history)) { + $history = array_filter(array_map('trim', explode(',', $history))); + } + + if (is_array($history) && count($history) > 0) { + $last = (string) $history[count($history) - 1]; + if ($last !== '') { + return $last; + } + } + + return $url; + } catch (\Throwable) { + return $url; + } + } +} diff --git a/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php b/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php new file mode 100644 index 0000000000..64d2288289 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php @@ -0,0 +1,91 @@ +root_admin || $server->owner_id === $user->id) { + return false; + } + + $subuser = Subuser::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->first(); + + return $subuser && in_array($extensionId, $subuser->disabled_extensions ?? [], true); + } + + /** + * Get all enabled extensions for a server. + */ + public function index(GetServerExtensionsRequest $request, Server $server): JsonResponse + { + $enabledConfigs = ExtensionConfig::getEnabledForServer($server); + $availableExtensions = config('modules.extensions.available', []); + + $user = $request->user(); + + $extensions = []; + foreach ($enabledConfigs as $config) { + if ($this->isExtensionDisabledForUser($server, $user, $config->extension_id)) { + continue; + } + + $extensionDef = $availableExtensions[$config->extension_id] ?? null; + if ($extensionDef) { + $extensions[] = [ + 'id' => $config->extension_id, + 'name' => $extensionDef['name'], + 'description' => $extensionDef['description'], + 'icon' => $extensionDef['icon'], + 'version' => $extensionDef['version'] ?? '1.0.0', + 'route' => $extensionDef['route'] ?? $config->extension_id, + 'settings' => $config->settings ?? [], + ]; + } + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $extensions, + ]); + } + + /** + * Check if a specific extension is enabled for a server. + */ + public function check(GetServerExtensionsRequest $request, Server $server, string $extensionId): JsonResponse + { + if ($this->isExtensionDisabledForUser($server, $request->user(), $extensionId)) { + return new JsonResponse([ + 'enabled' => false, + ]); + } + + $config = ExtensionConfig::getByExtensionId($extensionId); + + if (!$config || !$config->isServerEligible($server)) { + return new JsonResponse([ + 'enabled' => false, + ]); + } + + $availableExtensions = config('modules.extensions.available', []); + $extensionDef = $availableExtensions[$extensionId] ?? null; + + return new JsonResponse([ + 'enabled' => true, + 'route' => $extensionDef['route'] ?? $extensionId, + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php b/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php new file mode 100644 index 0000000000..01517bba18 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php @@ -0,0 +1,1599 @@ + 16) { + throw new \InvalidArgumentException('Invalid player name format'); + } + + return $sanitized; + } + + /** + * Validate and sanitize IP address. + */ + private function sanitizeIpAddress(string $ip): string + { + // Validate as IPv4 or IPv6 + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + throw new \InvalidArgumentException('Invalid IP address format'); + } + + return $ip; + } + + /** + * Sanitize reason/message to prevent command injection. + */ + private function sanitizeMessage(string $message): string + { + // Remove newlines and limit length + $sanitized = str_replace(["\r", "\n", "\t"], ' ', $message); + return substr(trim($sanitized), 0, 255); + } + + /** + * Check if the extension is enabled for this server. + */ + private function checkExtensionEnabled(Server $server): void + { + $config = ExtensionConfig::getByExtensionId('minecraft_player_manager'); + + if (!$config || !$config->isServerEligible($server)) { + throw new \Exception('Minecraft Player Manager is not enabled for this server.'); + } + } + + private function queryApi(Server $server): array + { + return Cache::remember("minecraftserver:query:{$server->id}", 10, function () use ($server) { + if ($this->isQueryEnabled($server)) { + $query = new MinecraftQuery(); + $query->Connect($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + + $data = $query->GetInfo(); + + if (!$data) { + throw new \Exception('Failed to query server'); + } + + $players = []; + $rawPlayers = $query->GetPlayers(); + if ($rawPlayers) { + foreach ($rawPlayers as $player) { + $userData = $this->lookupUserName($player, $server); + + if ($userData) { + $uuid = $userData['uuid']; + } + + if (!$uuid) { + continue; + } + + $players[] = [ + 'id' => $uuid, + 'name' => $player, + ]; + } + } + + return [ + 'players' => [ + 'online' => $data['Players'], + 'max' => $data['MaxPlayers'], + 'list' => $players, + ], + ]; + } else { + $query = new MinecraftPing($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + $query->Connect(); + + $data = $query->Query(); + + if (!$data) { + throw new \Exception('Failed to query server'); + } + + return [ + 'players' => [ + 'online' => $data['players']['online'], + 'max' => $data['players']['max'], + 'list' => $data['players']['sample'] ?? [], + ], + ]; + } + }); + } + + private function userCache(Server $server): array + { + return Cache::remember("minecraftserver:username-cache:{$server->id}", 30, function () use ($server) { + try { + $cache = $this->fileRepository->setServer($server)->getContent('/usercache.json'); + return json_decode($cache, true) ?? []; + } catch (\Throwable $e) { + return []; + } + }); + } + + private function formatUuid(string $uuid): string + { + $uuid = str_replace('-', '', $uuid); + return substr($uuid, 0, 8) . '-' . substr($uuid, 8, 4) . '-' . substr($uuid, 12, 4) . '-' . substr($uuid, 16, 4) . '-' . substr($uuid, 20); + } + + private function lookupUser(string $uuid, Server $server): array|null + { + $name = config('app.name', 'Jexactyl'); + $uuid = str_replace('-', '', $uuid); + $cache = $this->userCache($server); + + foreach ($cache as $player) { + if ($player['uuid'] === $this->formatUuid($uuid)) { + return [ + 'uuid' => $this->formatUuid($player['uuid']), + 'name' => $player['name'], + ]; + } + } + + $data = Cache::remember("minecraftplayer:$uuid", 1000, function () use ($name, $uuid) { + try { + $req = Http::withUserAgent("Jexactyl Player Manager @ $name") + ->timeout(5) + ->retry(2, 100, throw: true) + ->get("https://sessionserver.mojang.com/session/minecraft/profile/$uuid"); + + return json_decode($req->getBody()->getContents(), true); + } catch (\Throwable $e) { + return null; + } + }); + + if (is_null($data)) { + return null; + } + + return [ + 'uuid' => $this->formatUuid($data['id']), + 'name' => $data['name'], + ]; + } + + private function lookupUserName(string $name, Server $server): array|null + { + $app = config('app.name', 'Jexactyl'); + $offline = $this->isOfflineMode($server); + $cache = $this->userCache($server); + + foreach ($cache as $player) { + if ($player['name'] === $name) { + return [ + 'uuid' => $this->formatUuid($player['uuid']), + 'name' => $player['name'], + ]; + } + } + + if ($offline) { + $uuid = $this->formatUuid(md5("OfflinePlayer:$name")); + return [ + 'uuid' => $uuid, + 'name' => $name, + ]; + } + + $data = Cache::remember("minecraftplayername:$name", 1000, function () use ($app, $name) { + try { + $req = Http::withUserAgent("Jexactyl Player Manager @ $app") + ->timeout(5) + ->retry(2, 100, throw: true) + ->get("https://api.mojang.com/users/profiles/minecraft/$name"); + + return json_decode($req->getBody()->getContents(), true); + } catch (\Throwable $e) { + return null; + } + }); + + if (is_null($data)) { + return null; + } + + return [ + 'uuid' => $this->formatUuid($data['id']), + 'name' => $data['name'], + ]; + } + + private function sortList(array $list): array + { + usort($list, function ($a, $b) { + return strcasecmp($a['name'] ?? $a['ip'], $b['name'] ?? $b['ip']); + }); + + return $list; + } + + private function getServerProperties(Server $server): array + { + return Cache::remember("minecraftserver:properties:{$server->id}", 10, function () use ($server) { + try { + $properties = $this->fileRepository->setServer($server)->getContent('/server.properties'); + $data = explode("\n", $properties); + + $result = []; + foreach ($data as $line) { + if (str_starts_with($line, '#')) { + continue; + } + + $parts = explode('=', $line, 2); + $result[$parts[0]] = $parts[1] ?? ''; + } + + return $result; + } catch (\Throwable $e) { + return []; + } + }); + } + + private function isQueryEnabled(Server $server): bool + { + $properties = $this->getServerProperties($server); + + if (array_key_exists('enable-query', $properties) && $properties['enable-query'] === 'true') { + return true; + } + + return false; + } + + private function isOfflineMode(Server $server): bool + { + $properties = $this->getServerProperties($server); + + if (array_key_exists('online-mode', $properties) && $properties['online-mode'] === 'false') { + return true; + } + + return false; + } + + private function isBukkitBased(Server $server): bool + { + return Cache::remember("minecraftserver:bukkit:{$server->id}", 30, function () use ($server) { + try { + $bukkitYml = $this->fileRepository->setServer($server)->getContent('/bukkit.yml'); + return !!$bukkitYml; + } catch (\Throwable $e) { + return false; + } + }); + } + + /** + * Get player manager status for server. + */ + public function index(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + $properties = $this->getServerProperties($server); + + $onlineMode = !$this->isOfflineMode($server); + $opped = []; + $whitelisted = []; + $whitelistEnabled = array_key_exists('white-list', $properties) && $properties['white-list'] === 'true'; + $banned = []; + $bannedIps = []; + + // Load ops.json + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + + foreach ($data as $op) { + $uuid = str_replace('-', '', $op['uuid']); + + $opped[] = [ + 'uuid' => $op['uuid'], + 'name' => $op['name'], + 'level' => $op['level'], + 'bypassesPlayerLimit' => $op['bypassesPlayerLimit'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load whitelist.json + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + + foreach ($data as $whitelist) { + $uuid = str_replace('-', '', $whitelist['uuid']); + + $whitelisted[] = [ + 'uuid' => $whitelist['uuid'], + 'name' => $whitelist['name'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load banned-players.json + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + + foreach ($data as $ban) { + $uuid = str_replace('-', '', $ban['uuid']); + + $banned[] = [ + 'uuid' => $ban['uuid'], + 'name' => $ban['name'], + 'reason' => $ban['reason'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load banned-ips.json + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + + foreach ($data as $ban) { + $bannedIps[] = [ + 'ip' => $ban['ip'], + 'reason' => $ban['reason'], + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Try to query online players + try { + $data = $this->queryApi($server); + + $players = []; + foreach ($data['players']['list'] ?? [] as $player) { + $uuid = str_replace('-', '', $player['id']); + + if (preg_match('/^0+$/', $uuid) || str_starts_with($uuid, '0000000000000000')) { + continue; + } + + $players[] = [ + 'uuid' => $player['id'], + 'name' => $player['name'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + + return new JsonResponse([ + 'server' => [ + 'online' => true, + 'players' => [ + 'online' => $data['players']['online'], + 'max' => $data['players']['max'], + 'list' => $this->sortList($players), + ], + 'version' => '', + 'motd' => '', + ], + 'operators' => $this->sortList($opped), + 'whitelist' => $this->sortList($whitelisted), + 'bannedPlayers' => $this->sortList($banned), + 'bannedIps' => $this->sortList($bannedIps), + 'whitelistEnabled' => $whitelistEnabled, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'server' => [ + 'online' => false, + 'players' => [ + 'online' => 0, + 'max' => 0, + 'list' => [], + ], + 'version' => '', + 'motd' => '', + ], + 'operators' => $this->sortList($opped), + 'whitelist' => $this->sortList($whitelisted), + 'bannedPlayers' => $this->sortList($banned), + 'bannedIps' => $this->sortList($bannedIps), + 'whitelistEnabled' => $whitelistEnabled, + ]); + } + } + + public function op(PlayerNamedRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $op) { + if ($op['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already an operator', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + 'level' => 4, + 'bypassesPlayerLimit' => true, + ]; + + $this->fileRepository->setServer($server)->putContent('/ops.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:op {$playerData['name']}" : "op {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.op') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function deop(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + } catch (\Throwable $e) { + $data = []; + } + + // Look up player by name from route parameter + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($op) use ($playerData) { + return $op['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/ops.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:deop {$playerData['name']}" : "deop {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.deop') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function setWhitelist(SetWhitelistRequest $request, Server $server): array + { + $this->checkExtensionEnabled($server); + + try { + $properties = $this->fileRepository->setServer($server)->getContent('/server.properties'); + $data = explode("\n", $properties); + } catch (\Throwable $e) { + $data = []; + } + + $whitelist = $request->input('enabled'); + + $data = array_map(function ($line) use ($whitelist) { + if (str_starts_with($line, 'white-list=')) { + return 'white-list=' . ($whitelist ? 'true' : 'false'); + } + return $line; + }, $data); + + if (!in_array('white-list=false', $data) && !in_array('white-list=true', $data)) { + $data[] = 'white-list=' . ($whitelist ? 'true' : 'false'); + } + + Cache::forget("minecraftserver:properties:{$server->id}"); + $this->fileRepository->setServer($server)->putContent('/server.properties', implode("\n", $data)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? 'minecraft:whitelist ' : 'whitelist '; + $this->commandRepository->setServer($server)->send($cmd . ($whitelist ? 'on' : 'off')); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.set') + ->property(['enabled' => $whitelist]) + ->log(); + + return ['success' => true]; + } + + public function addWhitelist(PlayerNamedRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $w) { + if ($w['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already whitelisted', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + ]; + + $this->fileRepository->setServer($server)->putContent('/whitelist.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:whitelist add {$playerData['name']}" : "whitelist add {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.add') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function removeWhitelist(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + } catch (\Throwable $e) { + $data = []; + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($w) use ($playerData) { + return $w['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/whitelist.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:whitelist remove {$playerData['name']}" : "whitelist remove {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.remove') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function ban(BanRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Banned by panel')); + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $ban) { + if ($ban['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already banned', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + 'source' => 'Panel', + 'created' => date('Y-m-d H:i:s O'), + 'expires' => 'forever', + 'reason' => $reason, + ]; + + $this->fileRepository->setServer($server)->putContent('/banned-players.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:ban {$playerData['name']} $reason" : "ban {$playerData['name']} $reason"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.ban') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name'], 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function unban(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($ban) use ($playerData) { + return $ban['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/banned-players.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:pardon {$playerData['name']}" : "pardon {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.unban') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function banIp(BanIpRequest $request, Server $server, string $ip): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $ip = $this->sanitizeIpAddress($ip); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Banned by panel')); + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $ban) { + if ($ban['ip'] === $ip) { + return new JsonResponse([ + 'success' => false, + 'error' => 'IP is already banned', + ], 400); + } + } + + $data[] = [ + 'ip' => $ip, + 'source' => 'Panel', + 'created' => date('Y-m-d H:i:s O'), + 'expires' => 'forever', + 'reason' => $reason, + ]; + + $this->fileRepository->setServer($server)->putContent('/banned-ips.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:ban-ip $ip $reason" : "ban-ip $ip $reason"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.ban-ip') + ->property(['ip' => $ip, 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function unbanIp(IpRequest $request, Server $server, string $ip): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $ip = $this->sanitizeIpAddress($ip); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + $data = array_filter($data, function ($ban) use ($ip) { + return $ban['ip'] !== $ip; + }); + + $this->fileRepository->setServer($server)->putContent('/banned-ips.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:pardon-ip $ip" : "pardon-ip $ip"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.unban-ip') + ->property(['ip' => $ip]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function kick(KickRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Kicked by panel')); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:kick $name $reason" : "kick $name $reason"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.kick') + ->property(['name' => $name, 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + public function whisper(WhisperRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $message = $this->sanitizeMessage($request->input('message')); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:tell $name $message" : "tell $name $message"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.whisper') + ->property(['name' => $name, 'message' => $message]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + public function kill(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:kill $name" : "kill $name"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.kill') + ->property(['name' => $name]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Get the Minecraft server version (cached for 5 minutes). + */ + public function getServerVersion(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + $version = Cache::remember("minecraftserver:version:{$server->id}", 300, function () use ($server) { + try { + $query = new MinecraftPing($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + $query->Connect(); + $data = $query->Query(); + + if (!$data || !isset($data['version']['name'])) { + return null; + } + + $versionString = $data['version']['name']; + + // Parse version number from string (e.g., "1.20.4", "Paper 1.20.4", "Spigot 1.19.2") + preg_match('/(\d+)\.(\d+)(?:\.(\d+))?/', $versionString, $matches); + + if (empty($matches)) { + return null; + } + + $major = (int) $matches[1]; + $minor = (int) $matches[2]; + $patch = (int) ($matches[3] ?? 0); + + return [ + 'raw' => $versionString, + 'major' => $major, + 'minor' => $minor, + 'patch' => $patch, + 'protocol' => $data['version']['protocol'] ?? 0, + 'supportsAttributes' => ($major >= 1 && $minor >= 16), + ]; + } catch (\Throwable $e) { + return null; + } + }); + + if (!$version) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to detect server version', + ], 400); + } + + return new JsonResponse([ + 'success' => true, + 'version' => $version, + ]); + } + + /** + * Get player data from NBT file (inventory, location, stats). + */ + public function getPlayerData(PlayerReadRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Look up player UUID + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $uuid = $playerData['uuid']; + + // Find the world directory + $worldDir = $this->getWorldDirectory($server); + if (!$worldDir) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Could not find world directory', + ], 400); + } + + // Try to get the player data file + $playerDataPath = "/{$worldDir}/playerdata/{$uuid}.dat"; + + try { + $datContent = $this->fileRepository->setServer($server)->getContent($playerDataPath); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player data file has not been created yet. Please rejoin the server and try again.', + ], 404); + } + + try { + // Write to temp file and parse + $tempFile = tempnam(sys_get_temp_dir(), 'nbt_'); + file_put_contents($tempFile, $datContent); + + $parser = new NbtParser(); + $nbt = $parser->parseFile($tempFile); + + unlink($tempFile); + + // Extract data + $inventory = NbtParser::extractInventory($nbt); + $armor = NbtParser::extractArmor($nbt); + $enderChest = NbtParser::extractEnderChest($nbt); + $location = NbtParser::extractLocation($nbt); + $stats = NbtParser::extractStats($nbt); + + // Debug: collect all slot numbers for troubleshooting + $allSlots = array_map(fn($item) => ['slot' => $item['slot'], 'id' => $item['id']], $inventory); + + // Debug: Get raw NBT keys to understand structure + $nbtData = $nbt['value'] ?? $nbt; + $nbtKeys = is_array($nbtData) ? array_keys($nbtData) : []; + + // Debug: Get equipment structure + $equipmentDebug = isset($nbtData['equipment']) ? $nbtData['equipment'] : null; + + // Sort inventory by slot + usort($inventory, fn($a, $b) => $a['slot'] <=> $b['slot']); + + // Filter out armor slots from main inventory (100-103) and offhand (-106, 45) + $mainInventory = array_values(array_filter($inventory, fn($item) => $item['slot'] >= 0 && $item['slot'] < 100)); + + // Offhand: check equipment field first (1.20.5+), then inventory slot + $offhand = null; + if (isset($nbtData['equipment']['offhand']) && is_array($nbtData['equipment']['offhand']) && !empty($nbtData['equipment']['offhand'])) { + $offhand = NbtParser::parseItemPublic($nbtData['equipment']['offhand']); + } else { + foreach ($inventory as $item) { + if ($item['slot'] === -106 || $item['slot'] === 45) { + $offhand = $item; + break; + } + } + } + + return new JsonResponse([ + 'success' => true, + 'player' => [ + 'uuid' => $uuid, + 'name' => $playerData['name'], + ], + 'inventory' => $mainInventory, + 'armor' => $armor, + 'offhand' => $offhand, + 'enderChest' => $enderChest, + 'location' => $location, + 'stats' => $stats, + 'debug' => [ + 'allSlots' => $allSlots, + 'nbtKeys' => $nbtKeys, + 'equipment' => $equipmentDebug, + ], + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to parse player data: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Get the world directory name. + */ + private function getWorldDirectory(Server $server): ?string + { + return Cache::remember("minecraftserver:worlddir:{$server->id}", 60, function () use ($server) { + $properties = $this->getServerProperties($server); + $levelName = $properties['level-name'] ?? 'world'; + + // Check if the directory exists + try { + $this->fileRepository->setServer($server)->getDirectory("/{$levelName}"); + return $levelName; + } catch (\Throwable $e) { + // Try common alternatives + $alternatives = ['world', 'server', 'minecraft']; + foreach ($alternatives as $alt) { + try { + $this->fileRepository->setServer($server)->getDirectory("/{$alt}"); + return $alt; + } catch (\Throwable $e) { + continue; + } + } + } + + return null; + }); + } + + /** + * Get a specific attribute for a player. + */ + public function getAttribute(PlayerReadRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + try { + // First check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + // Use data get to retrieve attribute value via data command + $cmd = "data get entity {$name} Attributes"; + $this->commandRepository->setServer($server)->send($cmd); + + // Since we can't read command output directly, we'll return the available attributes + return new JsonResponse([ + 'success' => true, + 'message' => 'Attribute command sent. Check server console for result.', + 'attribute' => $attribute, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Set an attribute value for a player. + */ + public function setAttribute(AttributeRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + $value = $request->input('value'); + if (!is_numeric($value)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Value must be a number', + ], 400); + } + + // Clamp value to reasonable range + $value = max(-1024, min(1024, (float) $value)); + + try { + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + $cmd = "attribute {$name} minecraft:{$attribute} base set {$value}"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.attribute.set') + ->property(['name' => $name, 'attribute' => $attribute, 'value' => $value]) + ->log(); + + return new JsonResponse([ + 'success' => true, + 'attribute' => $attribute, + 'value' => $value, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Reset an attribute to its default value. + */ + public function resetAttribute(PlayerRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + try { + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + // Use the attribute reset command (1.20+) or set to default + $defaultValue = $this->getAttributeDefault($attribute); + $cmd = "attribute {$name} minecraft:{$attribute} base set {$defaultValue}"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.attribute.reset') + ->property(['name' => $name, 'attribute' => $attribute]) + ->log(); + + return new JsonResponse([ + 'success' => true, + 'attribute' => $attribute, + 'defaultValue' => $defaultValue, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Get all available attributes with their metadata. + */ + public function getAttributes(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + return new JsonResponse([ + 'success' => true, + 'attributes' => $this->getAttributeList($version), + ]); + } + + /** + * Sanitize and validate attribute name. + */ + private function sanitizeAttributeName(string $attribute): ?string + { + // Remove minecraft: prefix if present + $attribute = str_replace('minecraft:', '', $attribute); + + // Only allow alphanumeric and underscores + if (!preg_match('/^[a-zA-Z0-9_]+$/', $attribute)) { + return null; + } + + // Validate against known player attributes (without generic. or player. prefixes) + // Note: flying_speed, follow_range, tempt_range, spawn_reinforcements are mob-only + $validAttributes = [ + // Base attributes (1.16+) + 'max_health', 'knockback_resistance', + 'movement_speed', 'attack_damage', + 'attack_knockback', 'attack_speed', 'armor', + 'armor_toughness', 'luck', + // Player specific (1.20.5+) + 'block_interaction_range', 'entity_interaction_range', + 'block_break_speed', 'mining_efficiency', 'sneaking_speed', + 'submerged_mining_speed', 'sweeping_damage_ratio', + // 1.21+ attributes + 'scale', 'step_height', 'gravity', + 'safe_fall_distance', 'fall_damage_multiplier', + 'jump_strength', 'oxygen_bonus', + 'burning_time', 'explosion_knockback_resistance', + 'water_movement_efficiency', + ]; + + if (!in_array($attribute, $validAttributes)) { + return null; + } + + return $attribute; + } + + /** + * Get default value for an attribute. + */ + private function getAttributeDefault(string $attribute): float + { + $defaults = [ + 'max_health' => 20.0, + 'knockback_resistance' => 0.0, + 'movement_speed' => 0.1, + 'attack_damage' => 1.0, + 'attack_knockback' => 0.0, + 'attack_speed' => 4.0, + 'armor' => 0.0, + 'armor_toughness' => 0.0, + 'luck' => 0.0, + 'scale' => 1.0, + 'step_height' => 0.6, + 'gravity' => 0.08, + 'safe_fall_distance' => 3.0, + 'fall_damage_multiplier' => 1.0, + 'jump_strength' => 0.42, + 'oxygen_bonus' => 0.0, + 'burning_time' => 1.0, + 'explosion_knockback_resistance' => 0.0, + 'water_movement_efficiency' => 0.0, + 'block_interaction_range' => 4.5, + 'entity_interaction_range' => 3.0, + 'block_break_speed' => 1.0, + 'mining_efficiency' => 0.0, + 'sneaking_speed' => 0.3, + 'submerged_mining_speed' => 0.2, + 'sweeping_damage_ratio' => 0.0, + ]; + + return $defaults[$attribute] ?? 0.0; + } + + /** + * Get list of all attributes with metadata. + */ + private function getAttributeList(?array $version): array + { + $minor = $version['minor'] ?? 20; + + $attributes = [ + [ + 'category' => 'Health & Defense', + 'attributes' => [ + ['id' => 'max_health', 'name' => 'Max Health', 'default' => 20.0, 'min' => 1, 'max' => 1024, 'description' => 'Maximum health points'], + ['id' => 'armor', 'name' => 'Armor', 'default' => 0.0, 'min' => 0, 'max' => 30, 'description' => 'Armor points'], + ['id' => 'armor_toughness', 'name' => 'Armor Toughness', 'default' => 0.0, 'min' => 0, 'max' => 20, 'description' => 'Reduces armor penetration'], + ['id' => 'knockback_resistance', 'name' => 'Knockback Resistance', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Chance to resist knockback (0-1)'], + ], + ], + [ + 'category' => 'Combat', + 'attributes' => [ + ['id' => 'attack_damage', 'name' => 'Attack Damage', 'default' => 1.0, 'min' => 0, 'max' => 2048, 'description' => 'Base melee damage'], + ['id' => 'attack_speed', 'name' => 'Attack Speed', 'default' => 4.0, 'min' => 0, 'max' => 1024, 'description' => 'Attack cooldown recovery speed'], + ['id' => 'attack_knockback', 'name' => 'Attack Knockback', 'default' => 0.0, 'min' => 0, 'max' => 5, 'description' => 'Knockback dealt on attack'], + ], + ], + [ + 'category' => 'Movement', + 'attributes' => [ + ['id' => 'movement_speed', 'name' => 'Movement Speed', 'default' => 0.1, 'min' => 0, 'max' => 1024, 'description' => 'Walking/running speed'], + ], + ], + [ + 'category' => 'Miscellaneous', + 'attributes' => [ + ['id' => 'luck', 'name' => 'Luck', 'default' => 0.0, 'min' => -1024, 'max' => 1024, 'description' => 'Affects loot table quality'], + ], + ], + ]; + + // Add 1.20.5+ attributes + if ($minor >= 20) { + $attributes[] = [ + 'category' => 'Player Reach (1.20.5+)', + 'attributes' => [ + ['id' => 'block_interaction_range', 'name' => 'Block Interaction Range', 'default' => 4.5, 'min' => 0, 'max' => 64, 'description' => 'How far you can interact with blocks'], + ['id' => 'entity_interaction_range', 'name' => 'Entity Interaction Range', 'default' => 3.0, 'min' => 0, 'max' => 64, 'description' => 'How far you can interact with entities'], + ['id' => 'block_break_speed', 'name' => 'Block Break Speed', 'default' => 1.0, 'min' => 0, 'max' => 1024, 'description' => 'Mining speed multiplier'], + ['id' => 'mining_efficiency', 'name' => 'Mining Efficiency', 'default' => 0.0, 'min' => 0, 'max' => 1024, 'description' => 'Additional mining speed'], + ['id' => 'sneaking_speed', 'name' => 'Sneaking Speed', 'default' => 0.3, 'min' => 0, 'max' => 1, 'description' => 'Speed while sneaking (0-1)'], + ['id' => 'submerged_mining_speed', 'name' => 'Underwater Mining Speed', 'default' => 0.2, 'min' => 0, 'max' => 20, 'description' => 'Mining speed multiplier underwater'], + ], + ]; + } + + // Add 1.21+ attributes + if ($minor >= 21) { + $attributes[] = [ + 'category' => 'Physics (1.21+)', + 'attributes' => [ + ['id' => 'scale', 'name' => 'Scale', 'default' => 1.0, 'min' => 0.0625, 'max' => 16, 'description' => 'Entity size multiplier'], + ['id' => 'step_height', 'name' => 'Step Height', 'default' => 0.6, 'min' => 0, 'max' => 10, 'description' => 'Max height that can be stepped up'], + ['id' => 'gravity', 'name' => 'Gravity', 'default' => 0.08, 'min' => -1, 'max' => 1, 'description' => 'Gravity strength'], + ['id' => 'safe_fall_distance', 'name' => 'Safe Fall Distance', 'default' => 3.0, 'min' => -1024, 'max' => 1024, 'description' => 'Distance before fall damage'], + ['id' => 'fall_damage_multiplier', 'name' => 'Fall Damage Multiplier', 'default' => 1.0, 'min' => 0, 'max' => 100, 'description' => 'Fall damage multiplier'], + ['id' => 'jump_strength', 'name' => 'Jump Strength', 'default' => 0.42, 'min' => 0, 'max' => 32, 'description' => 'Jump power'], + ['id' => 'oxygen_bonus', 'name' => 'Oxygen Bonus', 'default' => 0.0, 'min' => 0, 'max' => 1024, 'description' => 'Extra breath time underwater'], + ['id' => 'burning_time', 'name' => 'Burning Time', 'default' => 1.0, 'min' => 0, 'max' => 1024, 'description' => 'Fire damage duration multiplier'], + ['id' => 'explosion_knockback_resistance', 'name' => 'Explosion Knockback Resistance', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Resistance to explosion knockback (0-1)'], + ['id' => 'water_movement_efficiency', 'name' => 'Water Movement Efficiency', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Movement speed in water (0-1)'], + ], + ]; + } + + return $attributes; + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php b/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php new file mode 100644 index 0000000000..39b86f713d --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php @@ -0,0 +1,130 @@ +customDomains()->with('customDomain')->orderByDesc('id')->get()->map(function ($row) { + $dnsRecords = (array) ($row->dns_records ?? []); + $hasSrv = collect($dnsRecords)->contains(fn ($record) => ($record['kind'] ?? null) === 'srv'); + $hostType = collect($dnsRecords)->firstWhere('kind', 'host')['type'] ?? null; + + return [ + 'id' => $row->id, + 'domain_id' => $row->custom_domain_id, + 'domain' => $row->customDomain?->domain, + 'subdomain' => $row->subdomain, + 'full_domain' => $row->full_domain, + 'port' => $row->port, + 'protocol' => $row->protocol, + 'service_tag' => $row->service_tag, + 'record_type' => $hasSrv ? 'srv' : 'cname', + 'host_record_type' => $hostType, + 'status' => $row->status, + 'last_error' => $row->last_error, + 'last_synced_at' => $row->last_synced_at, + ]; + })->values(); + + return response()->json(['data' => $records]); + } + + public function store(StoreCustomDomainRequest $request, Server $server): JsonResponse + { + $domainId = (int) $request->input('domain_id'); + $subdomain = strtolower((string) $request->input('subdomain')); + $port = (int) $request->input('port'); + $protocol = (string) $request->input('protocol', 'both'); + $recordType = $request->filled('record_type') ? strtolower((string) $request->input('record_type')) : null; + $serviceTag = $request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null; + + $this->service->createFromPayload($server, [[ + 'domain_id' => $domainId, + 'subdomain' => $subdomain, + 'port' => $port, + 'protocol' => $protocol, + 'record_type' => $recordType, + 'service_tag' => $serviceTag, + ]]); + + $mapping = $server->customDomains() + ->where('custom_domain_id', $domainId) + ->where('subdomain', $subdomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->latest() + ->first(); + + if ($mapping) { + ProvisionCustomDomainRecordJob::dispatch($mapping->id); + } else { + ProvisionServerCustomDomainsJob::dispatch($server->id); + } + + return response()->json([], JsonResponse::HTTP_CREATED); + } + + public function options(GetCustomDomainsRequest $request, Server $server): JsonResponse + { + $recommendation = $this->service->getDnsRecommendationForServer($server); + + $domains = collect($this->service->getAvailableDomains($server))->map(function ($domain) use ($server) { + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'wildcard_enabled' => $domain->wildcard_enabled, + 'default_service_tag' => $this->service->resolveSuggestedServiceTag($server, $domain), + ]; + })->map(function (array $domain) use ($recommendation) { + return array_merge($domain, [ + 'recommended_record_type' => $recommendation['recommended_record_type'], + 'srv_supported' => $recommendation['srv_supported'], + 'allow_record_type_selection' => $recommendation['allow_record_type_selection'], + 'forced_record_type' => $recommendation['forced_record_type'], + 'dns_mode' => $recommendation['mode'], + 'recommendation_notice' => $recommendation['notice'], + 'connection_hint' => $recommendation['connection_hint'], + ]); + })->values(); + + return response()->json(['data' => $domains]); + } + + public function destroy(DeleteCustomDomainRequest $request, Server $server, ServerCustomDomain $customDomain): JsonResponse + { + if ($customDomain->server_id !== $server->id) { + abort(404); + } + + $this->service->cleanup($customDomain); + $customDomain->delete(); + + return response()->json([], JsonResponse::HTTP_NO_CONTENT); + } + + public function sync(SyncCustomDomainsRequest $request, Server $server): JsonResponse + { + ProvisionServerCustomDomainsJob::dispatch($server->id); + + return response()->json(['message' => 'Custom domain provisioning has been queued.']); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index d46162fd22..9336018115 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -2,6 +2,7 @@ namespace Everest\Http\Controllers\Api\Client\Servers; +use Everest\Exceptions\DisplayException; use Everest\Models\Server; use Carbon\CarbonImmutable; use Everest\Facades\Activity; @@ -27,6 +28,66 @@ class FileController extends ClientApiController { + private function isArchivePathSegment(string $segment): bool + { + $lower = strtolower($segment); + + foreach ([ + '.zip', + '.7z', + '.ddup', + '.tar', + '.tar.gz', + '.tgz', + '.tar.xz', + '.txz', + '.tar.zst', + '.tzst', + '.tar.lz4', + '.tlz4', + '.tar.bz2', + '.tbz2', + '.gz', + '.xz', + '.zst', + '.lz4', + '.bz2', + ] as $extension) { + if (str_ends_with($lower, $extension)) { + return true; + } + } + + return false; + } + + private function isArchiveReadOnlyPath(string $path): bool + { + $segments = array_values(array_filter(explode('/', str_replace('\\\\', '/', trim($path))), fn (string $segment) => $segment !== '')); + + if (count($segments) === 0) { + return false; + } + + foreach ($segments as $segment) { + if ($this->isArchivePathSegment($segment)) { + return true; + } + } + + return false; + } + + /** + * @throws \Everest\Exceptions\DisplayException + */ + private function guardArchiveWritePath(string $path): void + { + if ($this->isArchiveReadOnlyPath($path)) { + throw new DisplayException('You cannot write to a file inside an archive. Extract it first.'); + } + } + /** * FileController constructor. */ @@ -109,6 +170,8 @@ public function download(GetFileContentsRequest $request, Server $server): array */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { + $this->guardArchiveWritePath($request->get('file')); + $this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); Activity::event('server:file.write')->property('file', $request->get('file'))->log(); @@ -128,6 +191,8 @@ public function writeWithDiff(WriteFileWithDiffRequest $request, Server $server) $content = $request->input('content'); $originalContent = $request->input('original_content', ''); + $this->guardArchiveWritePath($file); + // Write the new content to the file $this->fileRepository->setServer($server)->putContent($file, $content); diff --git a/app/Http/Controllers/Api/Client/Servers/ModsController.php b/app/Http/Controllers/Api/Client/Servers/ModsController.php index ea41be704e..12eca4f94f 100644 --- a/app/Http/Controllers/Api/Client/Servers/ModsController.php +++ b/app/Http/Controllers/Api/Client/Servers/ModsController.php @@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -29,6 +30,20 @@ class ModsController extends ClientApiController { + /** + * Directories to skip when scanning for installed addons. + * These contain internal/remapped files that are not real user plugins. + */ + private const IGNORED_DIRECTORIES = [ + '.paper-remapped', + '.paper-remapped-cp', + ]; + + /** + * Cache TTL for installed addons scan (in seconds). + */ + private const INSTALLED_CACHE_TTL = 300; + /** * ModsController constructor. */ @@ -130,11 +145,14 @@ public function installed(GetInstalledAddonsRequest $request, Server $server): J $perPage = (int) $request->input('perPage', 50); $page = (int) $request->input('page', 1); - $items = $this->scanJarDirectory( - $server, - $type === 'plugins' ? '/plugins' : '/mods', - $type === 'plugins' ? 'plugin' : 'mod' - ); + $cacheKey = "server:{$server->uuid}:installed:{$type}"; + $items = Cache::remember($cacheKey, self::INSTALLED_CACHE_TTL, function () use ($server, $type) { + return $this->scanJarDirectory( + $server, + $type === 'plugins' ? '/plugins' : '/mods', + $type === 'plugins' ? 'plugin' : 'mod' + ); + }); $filtered = array_values(array_filter($items, function (array $item) use ($status, $search) { if ($status === 'enabled' && !$item['enabled']) { @@ -195,6 +213,9 @@ public function toggleInstalledAddon(ToggleInstalledAddonRequest $request, Serve $this->fileRepository->setServer($server)->renameFiles($root, [['from' => $from, 'to' => $target]]); } + // Invalidate cache after toggle. + $this->invalidateInstalledCache($server, $type); + $items = $this->scanJarDirectory($server, $basePath, $type === 'plugins' ? 'plugin' : 'mod'); $updatedPath = $this->joinPath($root, $target); $updated = collect($items)->firstWhere('path', $updatedPath); @@ -341,6 +362,9 @@ public function downloadMod(DownloadModRequest $request, Server $server, string $type = $resource === 'plugins' || in_array($source, ['spiget', 'spigot'], true) ? 'plugin' : 'mod'; $result = $this->pluginInstallService->installFromProvider($server, $source, $type, $modId, $fileId); + // Invalidate cache after successful download. + $this->invalidateInstalledCache($server, $type === 'plugin' ? 'plugins' : 'mods'); + return response()->json($result); } catch (ModsServiceException $e) { return response()->json([ @@ -714,6 +738,9 @@ public function downloadModpack(DownloadModRequest $request, Server $server, int // Clean up temporary files $this->deleteDirectory($tempDir); + // Invalidate cache after modpack install. + $this->invalidateInstalledCache($server, 'mods'); + return response()->json([ 'success' => true, 'message' => 'Modpack downloaded and installed successfully.', @@ -746,49 +773,39 @@ public function downloadModpack(DownloadModRequest $request, Server $server, int private function scanJarDirectory(Server $server, string $path, string $type): array { $results = []; - $queue = [$this->normalizePath($path)]; + $normalized = $this->normalizePath($path); + $entries = $this->listDirectorySafely($server, $normalized); - while (!empty($queue)) { - $current = array_shift($queue); - $entries = $this->listDirectorySafely($server, $current); + if ($entries === null) { + return $results; + } - if ($entries === null) { + foreach ($entries as $entry) { + $name = Arr::get($entry, 'name'); + if (!$name || $name === '.' || $name === '..') { continue; } - foreach ($entries as $entry) { - $name = Arr::get($entry, 'name'); - if (!$name || $name === '.' || $name === '..') { - continue; - } - - $isFile = (bool) Arr::get($entry, 'file', true); - $isSymlink = (bool) Arr::get($entry, 'symlink', false); - $fullPath = $this->joinPath($current, $name); - - if ($isFile && $this->isJarLike($name)) { - $friendlyName = $this->makeFriendlyName($name); - $isEnabled = !$this->isDisabledFile($name); - $results[] = [ - 'filename' => $name, - 'friendly_name' => $friendlyName, - 'path' => $fullPath, - 'size_bytes' => (int) Arr::get($entry, 'size', 0), - 'modified_at' => $this->formatTimestamp(Arr::get($entry, 'modified')), - 'type' => $type, - 'enabled' => $isEnabled, - // Legacy keys for backward compatibility (can be removed once frontend is migrated) - 'name' => $name, - 'display_name' => $this->stripDisabledSuffix($name), - 'size' => (int) Arr::get($entry, 'size', 0), - 'disabled' => !$isEnabled, - ]; - continue; - } - - if (!$isFile && !$isSymlink) { - $queue[] = $fullPath; - } + $isFile = (bool) Arr::get($entry, 'file', true); + + if ($isFile && $this->isJarLike($name)) { + $fullPath = $this->joinPath($normalized, $name); + $friendlyName = $this->makeFriendlyName($name); + $isEnabled = !$this->isDisabledFile($name); + $results[] = [ + 'filename' => $name, + 'friendly_name' => $friendlyName, + 'path' => $fullPath, + 'size_bytes' => (int) Arr::get($entry, 'size', 0), + 'modified_at' => $this->formatTimestamp(Arr::get($entry, 'modified')), + 'type' => $type, + 'enabled' => $isEnabled, + // Legacy keys for backward compatibility (can be removed once frontend is migrated) + 'name' => $name, + 'display_name' => $this->stripDisabledSuffix($name), + 'size' => (int) Arr::get($entry, 'size', 0), + 'disabled' => !$isEnabled, + ]; } } @@ -834,6 +851,14 @@ private function friendlyNameValue(array $item): string return $item['friendly_name'] ?: $item['filename']; } + /** + * Invalidate the installed addons cache for a server. + */ + private function invalidateInstalledCache(Server $server, string $type): void + { + Cache::forget("server:{$server->uuid}:installed:{$type}"); + } + private function listDirectorySafely(Server $server, string $path): ?array { try { diff --git a/app/Http/Controllers/Api/Client/Servers/WingsRsController.php b/app/Http/Controllers/Api/Client/Servers/WingsRsController.php new file mode 100644 index 0000000000..bdb60f8ee0 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/WingsRsController.php @@ -0,0 +1,264 @@ + $server->node->isSupercharged(), + 'wings_type' => $server->node->wings_type, + 'wings_version' => $server->node->wings_version, + ]); + } + + /** + * GET /api/client/servers/{server}/files/fingerprints — Get file checksums. + */ + public function fingerprints(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_READ, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'files' => 'required|array|min:1|max:100', + 'files.*' => 'required|string', + 'algorithm' => 'string|in:sha256,sha512,md5,blake3', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->getFingerprints( + $request->input('files'), + $request->input('algorithm', 'sha256') + ); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/files/search — Advanced file search. + */ + public function searchFiles(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_READ, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'root' => 'nullable|string', + 'per_page' => 'required|integer|min:1|max:500', + 'path_filter' => 'nullable|array', + 'path_filter.include' => 'required_with:path_filter|array', + 'path_filter.include.*' => 'string', + 'path_filter.exclude' => 'nullable|array', + 'path_filter.exclude.*' => 'string', + 'path_filter.case_insensitive' => 'nullable|boolean', + 'size_filter' => 'nullable|array', + 'size_filter.min' => 'nullable|integer|min:0', + 'size_filter.max' => 'required_with:size_filter|integer|min:0', + 'content_filter' => 'nullable|array', + 'content_filter.query' => 'required_with:content_filter|string', + 'content_filter.max_search_size' => 'required_with:content_filter|integer|min:0', + 'content_filter.include_unmatched' => 'nullable|boolean', + 'content_filter.case_insensitive' => 'nullable|boolean', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->searchFiles($request->only([ + 'root', 'per_page', 'path_filter', 'size_filter', 'content_filter', + ])); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/files/compress-advanced — Compress with format selection. + */ + public function compressAdvanced(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'root' => 'nullable|string', + 'files' => 'required|array|min:1', + 'files.*' => 'required|string', + 'format' => 'nullable|string|in:tar,tar_gz,tar_xz,tar_lzip,tar_bz2,tar_lz4,tar_zstd,zip,seven_zip', + 'name' => 'nullable|string|max:255', + 'foreground' => 'nullable|boolean', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->compressFiles( + $request->input('root'), + $request->input('files'), + $request->input('format'), + $request->input('name'), + $request->boolean('foreground', true) + ); + + return new JsonResponse($data, isset($data['identifier']) ? 202 : 200); + } + + /** + * DELETE /api/client/servers/{server}/files/operations/{operation} — Cancel operation. + */ + public function cancelOperation(Request $request, Server $server, string $operation): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_UPDATE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $this->wingsRsRepository->setServer($server)->cancelOperation($operation); + + return new JsonResponse(['success' => true]); + } + + /** + * POST /api/client/servers/{server}/script — Run async script. + */ + public function runScript(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_STARTUP_UPDATE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'container_image' => 'required|string', + 'entrypoint' => 'required|string', + 'script' => 'required|string', + 'environment' => 'nullable|array', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->runScript( + $request->input('container_image'), + $request->input('entrypoint'), + $request->input('script'), + $request->input('environment', []) + ); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/install/abort — Abort running installation. + */ + public function abortInstall(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $this->wingsRsRepository->setServer($server)->abortInstall(); + + return new JsonResponse(['success' => true], 202); + } + + /** + * GET /api/client/servers/{server}/logs/install — Get install logs. + */ + public function installLogs(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_CONTROL_CONSOLE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $lines = (int) $request->query('lines', 100); + $lines = max(1, min($lines, 5000)); + + try { + $content = $this->wingsRsRepository->setServer($server)->getInstallLogs($lines); + } catch (DaemonConnectionException $exception) { + if ($exception->getStatusCode() === 404) { + return new JsonResponse(['content' => []]); + } + + throw $exception; + } + + return new JsonResponse(['content' => $content]); + } + + /** + * GET /api/client/servers/{server}/ssh — Get SSH connection instructions. + */ + public function sshInfo(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_SFTP, $server)) { + throw new AuthorizationException(); + } + + $node = $server->node; + $user = $request->user(); + + return new JsonResponse([ + 'host' => $node->fqdn, + 'port' => $node->public_port_sftp, + 'username' => $user->username . '.' . $server->uuidShort, + 'command' => sprintf( + 'ssh %s.%s@%s -p %d', + $user->username, + $server->uuidShort, + $node->fqdn, + $node->public_port_sftp + ), + 'supercharged' => $node->isSupercharged(), + 'shell_available' => $node->isSupercharged(), + 'shell_help_command' => '.wings help', + ]); + } +} diff --git a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php index e5df408fc4..473b98363b 100644 --- a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php +++ b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php @@ -82,6 +82,8 @@ public function __invoke(ActivityEventRequest $request) foreach ($logs as $key => $data) { Assert::isInstanceOf($server = $servers->get($key), Server::class); + $data = $this->coalesceSftpEvents($data); + $batch = []; foreach ($data as $datum) { $id = ActivityLog::insertGetId($datum); @@ -95,4 +97,103 @@ public function __invoke(ActivityEventRequest $request) ActivityLogSubject::insert($batch); } } + + /** + * Coalesce rapid SFTP create+write+rename sequences into single upload events. + * + * SFTP clients typically upload a file by: creating a temp file, writing to it, + * then renaming it to the final name. This produces 3 activity log entries for + * what the user perceives as a single upload. We collapse these sequences into + * a single "sftp.create" event carrying the final filename. + */ + private function coalesceSftpEvents(array $events): array + { + // Group SFTP events by actor within a tight time window. + // Rename events referencing a temp-created file absorb the create+write. + $sftpCreate = []; + $sftpWrite = []; + $absorbed = []; + + foreach ($events as $idx => $event) { + $e = $event['event'] ?? ''; + $actorId = $event['actor_id'] ?? null; + + $props = is_string($event['properties'] ?? null) + ? json_decode($event['properties'], true) + : ($event['properties'] ?? []); + + $files = $props['files'] ?? []; + + if ($e === 'server:sftp.create' && $actorId !== null) { + foreach ((array) $files as $file) { + $name = is_array($file) ? ($file['to'] ?? $file[0] ?? '') : (string) $file; + if ($name !== '') { + $sftpCreate[$actorId . ':' . $name] = $idx; + } + } + } + + if ($e === 'server:sftp.write' && $actorId !== null) { + foreach ((array) $files as $file) { + $name = is_array($file) ? ($file['to'] ?? $file[0] ?? '') : (string) $file; + if ($name !== '') { + $sftpWrite[$actorId . ':' . $name] = $idx; + } + } + } + } + + // Now scan rename events: if a rename's "from" matches a created temp file, + // rewrite the create event with the final name and drop the write + rename. + foreach ($events as $idx => $event) { + $e = $event['event'] ?? ''; + $actorId = $event['actor_id'] ?? null; + + if ($e !== 'server:sftp.rename' || $actorId === null) { + continue; + } + + $props = is_string($event['properties'] ?? null) + ? json_decode($event['properties'], true) + : ($event['properties'] ?? []); + + $files = $props['files'] ?? []; + + foreach ((array) $files as $file) { + $from = is_array($file) ? ($file['from'] ?? '') : ''; + $to = is_array($file) ? ($file['to'] ?? '') : ''; + + if ($from === '' || $to === '') { + continue; + } + + $createKey = $actorId . ':' . $from; + $writeKey = $actorId . ':' . $from; + + if (isset($sftpCreate[$createKey])) { + $createIdx = $sftpCreate[$createKey]; + + // Rewrite the create event to reference the final filename. + $createProps = is_string($events[$createIdx]['properties'] ?? null) + ? json_decode($events[$createIdx]['properties'], true) + : ($events[$createIdx]['properties'] ?? []); + + $createProps['files'] = [$to]; + $events[$createIdx]['properties'] = json_encode($createProps); + + // Mark write and rename events for removal. + if (isset($sftpWrite[$writeKey])) { + $absorbed[$sftpWrite[$writeKey]] = true; + } + $absorbed[$idx] = true; + } + } + } + + if (empty($absorbed)) { + return $events; + } + + return array_values(array_filter($events, fn ($_, $i) => !isset($absorbed[$i]), ARRAY_FILTER_USE_BOTH)); + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 83d1e15aec..5930e8dfaf 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -103,5 +103,6 @@ class Kernel extends HttpKernel 'bindings' => SubstituteBindings::class, 'captcha' => VerifyTurnstile::class, 'node.maintenance' => MaintenanceMiddleware::class, + 'extensions.access' => \Everest\Http\Middleware\Api\Client\Extensions\EnsureExtensionAccess::class, ]; } diff --git a/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php b/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php new file mode 100644 index 0000000000..e8eecad526 --- /dev/null +++ b/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php @@ -0,0 +1,49 @@ +user(); + + $server = $request->route()?->parameter('server'); + if (!$server instanceof Server) { + return response('', 404); + } + + $config = ExtensionConfig::getByExtensionId($extensionId); + if (!$config || !$config->isServerEligible($server)) { + return response('', 404); + } + + if ($user->root_admin || $server->owner_id === $user->id) { + return $next($request); + } + + $subuser = Subuser::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->first(); + + if ($subuser && in_array($extensionId, $subuser->disabled_extensions ?? [], true)) { + return response()->json([ + 'error' => 'This extension has been disabled for your account by the server owner.', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php index 6c91af8d1f..58bac5374a 100644 --- a/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php +++ b/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php @@ -9,6 +9,7 @@ use Everest\Models\Subuser; use Everest\Models\Database; use Everest\Models\Schedule; +use Everest\Models\ServerCustomDomain; use Illuminate\Http\Request; use Everest\Models\Allocation; use Illuminate\Database\Eloquent\Model; @@ -52,6 +53,7 @@ public function handle(Request $request, \Closure $next): mixed case Database::class: case Schedule::class: case Subuser::class: + case ServerCustomDomain::class: if ($model->server_id !== $server->id) { throw $exception; } diff --git a/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php b/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php new file mode 100644 index 0000000000..0df6479e09 --- /dev/null +++ b/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php @@ -0,0 +1,14 @@ + ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/'], + 'cloudflare_zone_id' => 'nullable|string|max:191', + 'api_key_id' => 'nullable|integer|exists:custom_domain_api_keys,id', + 'allowed_nest_ids' => 'nullable|array', + 'allowed_nest_ids.*' => 'integer|exists:nests,id', + 'allowed_egg_ids' => 'nullable|array', + 'allowed_egg_ids.*' => 'integer|exists:eggs,id', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'egg_service_tags' => 'nullable|array', + 'egg_service_tags.*' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'wildcard_enabled' => 'sometimes|boolean', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php b/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php new file mode 100644 index 0000000000..2abb18d4d2 --- /dev/null +++ b/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php @@ -0,0 +1,7 @@ + 'required|string|max:191|unique:custom_domain_api_keys,name', + 'token' => 'required|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php new file mode 100644 index 0000000000..67795a292a --- /dev/null +++ b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php @@ -0,0 +1,18 @@ +route('apiKey'); + $id = $apiKey?->id ?? 'NULL'; + + return [ + 'name' => 'sometimes|required|string|max:191|unique:custom_domain_api_keys,name,' . $id, + 'token' => 'nullable|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php new file mode 100644 index 0000000000..d181102754 --- /dev/null +++ b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php @@ -0,0 +1,26 @@ + ['sometimes', 'nullable', 'string', 'min:20', 'max:500'], + 'allow_wildcard' => ['required', 'boolean'], + 'max_wildcards_per_user' => ['required', 'integer', 'min:1', 'max:100'], + 'rate_limit_create_per_minute' => ['required', 'integer', 'min:1', 'max:1000'], + 'rate_limit_sync_per_minute' => ['required', 'integer', 'min:1', 'max:1000'], + 'rate_limit_billing_options_per_minute' => ['required', 'integer', 'min:1', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php b/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php new file mode 100644 index 0000000000..02cb9b28e5 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php @@ -0,0 +1,13 @@ + 'sometimes|boolean', + 'allowed_nests' => 'sometimes|array', + 'allowed_nests.*' => 'integer|exists:nests,id', + 'allowed_eggs' => 'sometimes|array', + 'allowed_eggs.*' => 'integer|exists:eggs,id', + 'settings' => 'sometimes|array', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php new file mode 100644 index 0000000000..9f45344ebc --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php @@ -0,0 +1,16 @@ + 'required|string|max:191', + 'value' => 'present', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php index 298fd1cd29..49c787fe5e 100644 --- a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php @@ -34,6 +34,7 @@ public function rules(): array 'feature_limits.backups' => $rules['backup_limit'], 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.subusers' => $rules['subuser_limit'], + 'feature_limits.subdomains' => $rules['subdomain_limit'], 'allocation.default' => 'required|bail|integer|exists:allocations,id', 'allocation.additional.*' => 'integer|exists:allocations,id', @@ -87,6 +88,10 @@ public function validated($key = null, $default = null) 'start_on_completion' => array_get($data, 'start_on_completion', false), ]; + if (Arr::has($data, 'feature_limits.subdomains')) { + $response['subdomain_limit'] = array_get($data, 'feature_limits.subdomains'); + } + return is_null($key) ? $response : Arr::get($response, $key, $default); } diff --git a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php index 746c8fcfde..f1f702979d 100644 --- a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php @@ -33,6 +33,7 @@ public function rules(): array 'feature_limits.backups' => $rules['backup_limit'], 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.subusers' => $rules['subuser_limit'], + 'feature_limits.subdomains' => $rules['subdomain_limit'], 'renewal_date' => $rules['renewal_date'], 'billing_product_id' => $rules['billing_product_id'], @@ -83,6 +84,10 @@ public function validated($key = null, $default = null) 'remove_allocations' => array_get($data, 'remove_allocations'), ]; + if (Arr::has($data, 'feature_limits.subdomains')) { + $response['subdomain_limit'] = array_get($data, 'feature_limits.subdomains'); + } + return is_null($key) ? $response : Arr::get($response, $key, $default); } diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperChannelRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperChannelRequest.php new file mode 100644 index 0000000000..bf74bd6dc7 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperChannelRequest.php @@ -0,0 +1,33 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_UPDATE, $server) + && $this->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server); + } + + public function rules(): array + { + return [ + 'channel_id' => 'required|string|regex:/^\d{10,25}$/', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php new file mode 100644 index 0000000000..27f73b0cf3 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php @@ -0,0 +1,33 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_CREATE, $server) + && $this->user()->can(Permission::ACTION_FILE_UPDATE, $server); + } + + public function rules(): array + { + return [ + 'jar_url' => 'sometimes|nullable|url', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php new file mode 100644 index 0000000000..60c38c7f96 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php @@ -0,0 +1,35 @@ +route()->parameter('server'); + $user = $this->user(); + + if (!$server instanceof \Everest\Models\Server) { + return false; + } + + if (!$user->root_admin && $user->id !== $server->owner_id) { + return false; + } + + return parent::authorize(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php new file mode 100644 index 0000000000..fbac00232f --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php @@ -0,0 +1,29 @@ +route()->parameter('server'); + return $this->user()->can(Permission::ACTION_FILE_READ, $server); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php new file mode 100644 index 0000000000..7633b6b03e --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php @@ -0,0 +1,13 @@ + 'required|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php new file mode 100644 index 0000000000..a6437e6129 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php @@ -0,0 +1,34 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_CREATE, $server) + && $this->user()->can(Permission::ACTION_FILE_UPDATE, $server) + && $this->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server); + } + + public function rules(): array + { + return [ + 'token' => 'required|string|min:30|max:200', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php b/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php new file mode 100644 index 0000000000..7e2821a3ef --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php @@ -0,0 +1,19 @@ + 'required|numeric', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php new file mode 100644 index 0000000000..aef69d67f9 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:3|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php new file mode 100644 index 0000000000..c329c35900 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:3|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php new file mode 100644 index 0000000000..81d6ec5742 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php @@ -0,0 +1,19 @@ + 'sometimes|string|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php new file mode 100644 index 0000000000..11912bedfd --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php @@ -0,0 +1,21 @@ + 'sometimes|integer|min:1|max:4', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php new file mode 100644 index 0000000000..1ea410a2ab --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php @@ -0,0 +1,19 @@ + 'required|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php new file mode 100644 index 0000000000..048dd0a1b2 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:1|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php b/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php new file mode 100644 index 0000000000..35510db9d3 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php @@ -0,0 +1,14 @@ + 'required|integer|exists:custom_domains,id', + 'subdomain' => ['required', 'string', 'max:191', 'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i'], + 'port' => 'required|integer|min:1|max:65535', + 'protocol' => 'required|in:tcp,udp,both', + 'record_type' => 'nullable|in:srv,cname', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php b/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php new file mode 100644 index 0000000000..ea170a5f2f --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php @@ -0,0 +1,14 @@ + config('modules.mods.rate_limit.requests_per_hour', 1800), ], ], + 'extensions' => [ + 'enabled' => boolval(config('modules.extensions.enabled', false)), + 'available' => $this->getAvailableExtensions(), + ], ]); } + /** + * Get the list of available extensions with their enabled status. + */ + private function getAvailableExtensions(): array + { + $extensions = config('modules.extensions.available', []); + + if (!is_array($extensions)) { + return []; + } + + $availableExtensions = []; + + foreach ($extensions as $id => $extension) { + if (!is_array($extension)) { + continue; + } + $availableExtensions[$id] = [ + 'name' => $extension['name'] ?? $id, + 'description' => $extension['description'] ?? '', + 'icon' => $extension['icon'] ?? 'puzzle', + 'version' => $extension['version'] ?? '1.0.0', + ]; + } + + return $availableExtensions; + } + private function emailEnabled(): bool { return EmailManager::isDeliveryEnabled(); diff --git a/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php b/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php new file mode 100644 index 0000000000..774c28b077 --- /dev/null +++ b/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php @@ -0,0 +1,36 @@ +with('customDomain') + ->where('server_id', $this->serverId) + ->get(); + + foreach ($mappings as $mapping) { + $service->cleanup($mapping); + $mapping->delete(); + } + } +} diff --git a/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php b/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php new file mode 100644 index 0000000000..011859d043 --- /dev/null +++ b/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php @@ -0,0 +1,33 @@ +with(['customDomain', 'server.node', 'allocation'])->find($this->mappingId); + if (!$mapping) { + return; + } + + $service->provision($mapping); + } +} diff --git a/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php b/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php new file mode 100644 index 0000000000..05e174188b --- /dev/null +++ b/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php @@ -0,0 +1,35 @@ +with('customDomains.customDomain')->find($this->serverId); + if (!$server) { + return; + } + + foreach ($server->customDomains as $mapping) { + $service->provision($mapping); + } + } +} diff --git a/app/Models/Billing/Order.php b/app/Models/Billing/Order.php index 6fb26d1742..5549d8814b 100644 --- a/app/Models/Billing/Order.php +++ b/app/Models/Billing/Order.php @@ -22,6 +22,7 @@ * @property int|null $node_id * @property int|null $server_id * @property array|null $variables + * @property array|null $domain_payload * @property string $type * @property int $threat_index * @property string $payment_intent_id @@ -67,6 +68,7 @@ class Order extends Model 'name', 'user_id', 'description', 'payment_intent_id', 'payment_processor', 'mollie_payment_id', 'paypal_order_id', 'paypal_capture_id', 'paypal_payer_id', 'paypal_payer_email', 'paypal_status', 'paypal_amount', 'paypal_currency', 'paypal_captured_at', 'payment_token', 'total', 'status', 'product_id', 'billing_days', 'final_price', 'multiplier_used', 'node_multiplier_used', 'egg_id', 'node_id', 'server_id', 'variables', 'type', 'threat_index', + 'domain_payload', 'coupon_id', 'subtotal', 'discount', ]; @@ -85,6 +87,7 @@ class Order extends Model 'node_id' => 'int', 'server_id' => 'int', 'variables' => 'array', + 'domain_payload' => 'array', 'threat_index' => 'int', 'coupon_id' => 'int', 'subtotal' => 'float', @@ -101,6 +104,7 @@ class Order extends Model 'status' => 'required|in:expired,pending,failed,processed', 'product_id' => 'exists:products,id', 'egg_id' => 'nullable|exists:eggs,id', + 'domain_payload' => 'nullable|array', 'type' => 'required|in:new,upg,ren', 'threat_index' => 'nullable|int|min:-1|max:100', 'payment_intent_id' => 'required|string|unique:orders,payment_intent_id', diff --git a/app/Models/Billing/Product.php b/app/Models/Billing/Product.php index 59b1c7d656..075076003f 100644 --- a/app/Models/Billing/Product.php +++ b/app/Models/Billing/Product.php @@ -22,6 +22,7 @@ * @property int $backup_limit * @property int $database_limit * @property int $allocation_limit + * @property int|null $subdomain_limit * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at */ @@ -45,7 +46,7 @@ class Product extends Model 'uuid', 'category_uuid', 'name', 'icon', 'price', 'base_price', 'description', 'cpu_limit', 'memory_limit', 'disk_limit', - 'backup_limit', 'database_limit', 'allocation_limit', + 'backup_limit', 'database_limit', 'allocation_limit', 'subdomain_limit', ]; /** @@ -60,6 +61,7 @@ class Product extends Model 'backup_limit' => 'integer', 'database_limit' => 'integer', 'allocation_limit' => 'integer', + 'subdomain_limit' => 'integer', ]; public static array $validationRules = [ @@ -78,6 +80,7 @@ class Product extends Model 'backup_limit' => 'required|integer', 'database_limit' => 'required|integer', 'allocation_limit' => 'required|integer', + 'subdomain_limit' => 'nullable|integer|min:0', ]; /** diff --git a/app/Models/CustomDomain.php b/app/Models/CustomDomain.php new file mode 100644 index 0000000000..91e528a1b2 --- /dev/null +++ b/app/Models/CustomDomain.php @@ -0,0 +1,48 @@ + 'boolean', + 'enabled' => 'boolean', + 'allowed_nest_ids' => 'array', + 'allowed_egg_ids' => 'array', + 'egg_service_tags' => 'array', + ]; + + public static array $validationRules = [ + 'domain' => ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/'], + 'cloudflare_zone_id' => 'nullable|string|max:191', + 'api_key_id' => 'nullable|integer|exists:custom_domain_api_keys,id', + 'allowed_nest_ids' => 'nullable|array', + 'allowed_nest_ids.*' => 'integer|exists:nests,id', + 'allowed_egg_ids' => 'nullable|array', + 'allowed_egg_ids.*' => 'integer|exists:eggs,id', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'egg_service_tags' => 'nullable|array', + 'egg_service_tags.*' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'wildcard_enabled' => 'boolean', + 'enabled' => 'boolean', + ]; + + public function apiKey(): BelongsTo + { + return $this->belongsTo(CustomDomainApiKey::class, 'api_key_id'); + } + + public function serverDomains(): HasMany + { + return $this->hasMany(ServerCustomDomain::class); + } +} diff --git a/app/Models/CustomDomainApiKey.php b/app/Models/CustomDomainApiKey.php new file mode 100644 index 0000000000..fb9ef18d3d --- /dev/null +++ b/app/Models/CustomDomainApiKey.php @@ -0,0 +1,30 @@ + 'encrypted', + 'enabled' => 'boolean', + ]; + + public static array $validationRules = [ + 'name' => 'required|string|max:191', + 'token' => 'required|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + + public function customDomains(): HasMany + { + return $this->hasMany(CustomDomain::class, 'api_key_id'); + } +} diff --git a/app/Models/CustomDomainDnsLog.php b/app/Models/CustomDomainDnsLog.php new file mode 100644 index 0000000000..ca9b9d9bbb --- /dev/null +++ b/app/Models/CustomDomainDnsLog.php @@ -0,0 +1,30 @@ + 'integer', + 'server_custom_domain_id' => 'integer', + 'payload' => 'array', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function serverCustomDomain(): BelongsTo + { + return $this->belongsTo(ServerCustomDomain::class); + } +} diff --git a/app/Models/ExtensionConfig.php b/app/Models/ExtensionConfig.php new file mode 100644 index 0000000000..a6a897ba94 --- /dev/null +++ b/app/Models/ExtensionConfig.php @@ -0,0 +1,132 @@ + 'boolean', + 'allowed_nests' => 'array', + 'allowed_eggs' => 'array', + 'settings' => 'array', + ]; + + /** + * Validation rules for the model. + */ + public static array $validationRules = [ + 'extension_id' => 'required|string|max:191', + 'enabled' => 'boolean', + 'allowed_nests' => 'nullable|array', + 'allowed_eggs' => 'nullable|array', + 'settings' => 'nullable|array', + ]; + + /** + * Get the extension configuration by extension ID. + */ + public static function getByExtensionId(string $extensionId): ?self + { + return self::where('extension_id', $extensionId)->first(); + } + + /** + * Check if a server is eligible for an extension based on its egg. + */ + public function isServerEligible(Server $server): bool + { + if (!$this->enabled) { + return false; + } + + $allowedNests = $this->allowed_nests ?? []; + $allowedEggs = $this->allowed_eggs ?? []; + + // If no restrictions, extension is available for all servers + if (empty($allowedNests) && empty($allowedEggs)) { + return true; + } + + // Check if server's nest is in allowed nests + if (!empty($allowedNests) && in_array($server->nest_id, $allowedNests)) { + // If nest is allowed, check if we need to filter by eggs + if (empty($allowedEggs)) { + return true; + } + } + + // Check if server's egg is in allowed eggs + if (!empty($allowedEggs) && in_array($server->egg_id, $allowedEggs)) { + return true; + } + + return false; + } + + /** + * Get all enabled extensions for a server. + */ + public static function getEnabledForServer(Server $server): array + { + $configs = self::where('enabled', true)->get(); + $enabled = []; + + foreach ($configs as $config) { + if ($config->isServerEligible($server)) { + $enabled[] = $config; + } + } + + return $enabled; + } + + /** + * Create or update extension configuration. + */ + public static function updateOrCreateConfig(string $extensionId, array $data): self + { + return self::updateOrCreate( + ['extension_id' => $extensionId], + $data + ); + } +} diff --git a/app/Models/ExtensionFileSnapshot.php b/app/Models/ExtensionFileSnapshot.php new file mode 100644 index 0000000000..cf161b4585 --- /dev/null +++ b/app/Models/ExtensionFileSnapshot.php @@ -0,0 +1,44 @@ + 'int', + 'actor_id' => 'int', + 'files' => 'array', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index fb6da6742b..b3b3581e68 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -41,6 +41,9 @@ * @property bool|null $deployable_free * @property int $servers_count * @property string|null $price_multiplier_description + * @property string $wings_type + * @property string|null $wings_version + * @property \Carbon\Carbon|null $wings_detected_at * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property Allocation[]|Collection $allocations @@ -67,6 +70,9 @@ class Node extends Model public const DAEMON_TOKEN_ID_LENGTH = 16; public const DAEMON_TOKEN_LENGTH = 64; + public const WINGS_TYPE_DEFAULT = 'default'; + public const WINGS_TYPE_RS = 'wings-rs'; + /** * The table associated with the model. */ @@ -95,6 +101,9 @@ class Node extends Model 'deployable_free' => 'boolean', 'price_multiplier' => 'float', 'price_multiplier_description' => 'string', + 'wings_type' => 'string', + 'wings_version' => 'string', + 'wings_detected_at' => 'datetime', ]; /** @@ -107,6 +116,7 @@ class Node extends Model 'memory', 'memory_overallocate', 'disk', 'disk_overallocate', 'upload_size', 'daemon_base', 'description', 'maintenance_mode', 'deployable', 'deployable_free', 'price_multiplier', 'price_multiplier_description', + 'wings_type', 'wings_version', 'wings_detected_at', ]; public static array $validationRules = [ @@ -147,8 +157,17 @@ class Node extends Model 'disk_overallocate' => 0, 'daemon_base' => self::DEFAULT_DAEMON_BASE, 'maintenance_mode' => false, + 'wings_type' => self::WINGS_TYPE_DEFAULT, ]; + /** + * Determine if this node is running Wings-RS (Supercharged). + */ + public function isSupercharged(): bool + { + return $this->wings_type === self::WINGS_TYPE_RS; + } + /** * Get the connection address to use when making calls to this node. */ diff --git a/app/Models/Permission.php b/app/Models/Permission.php index f9d44c1f18..827df54c8f 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -69,6 +69,9 @@ class Permission extends Model public const ACTION_BILLING_RENEW = 'billing.renew'; public const ACTION_BILLING_UPDATE = 'billing.update'; + public const ACTION_EXTENSION_READ = 'extension.read'; + public const ACTION_EXTENSION_MANAGE = 'extension.manage'; + /** * Should timestamps be used on this model. */ @@ -219,6 +222,14 @@ class Permission extends Model 'update' => 'Update general billing settings for the server.', ], ], + + 'extension' => [ + 'description' => 'Permissions that control a user\'s access to server extensions like player managers.', + 'keys' => [ + 'read' => 'Allows a user to view and access enabled extensions for the server.', + 'manage' => 'Allows a user to use extension features like player management (kick, ban, whitelist, etc.). Includes read access.', + ], + ], ]; /** @@ -229,4 +240,18 @@ public static function permissions(): Collection { return Collection::make(self::$permissions); } + + /** + * Expands a permissions list with implied permissions. + */ + public static function expandPermissions(array $permissions): array + { + $expanded = $permissions; + + if (in_array(self::ACTION_EXTENSION_MANAGE, $expanded, true)) { + $expanded[] = self::ACTION_EXTENSION_READ; + } + + return array_values(array_unique($expanded)); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 1b201d68f5..6532299f95 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -47,6 +47,7 @@ * @property int|null $database_limit * @property int $backup_limit * @property int $subuser_limit + * @property int|null $subdomain_limit * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $installed_at @@ -182,6 +183,7 @@ class Server extends Model 'allocation_limit' => 'sometimes|nullable|integer|min:0', 'backup_limit' => 'present|nullable|integer|min:0', 'subuser_limit' => 'nullable|integer|min:-1', + 'subdomain_limit' => 'nullable|integer|min:0', ]; /** @@ -244,6 +246,8 @@ public static function getRulesForUpdate($model, string $column = 'id'): array 'allocation_limit' => 'integer', 'backup_limit' => 'integer', 'subuser_limit' => 'integer', + 'subdomain_limit' => 'integer', + 'mods_enabled' => 'boolean', self::CREATED_AT => 'datetime', self::UPDATED_AT => 'datetime', 'deleted_at' => 'datetime', @@ -310,6 +314,14 @@ public function allocations(): HasMany return $this->hasMany(Allocation::class, 'server_id'); } + /** + * Gets all custom domain mappings associated with this server. + */ + public function customDomains(): HasMany + { + return $this->hasMany(ServerCustomDomain::class, 'server_id'); + } + /** * Gets information for the nest associated with this server. */ diff --git a/app/Models/ServerCustomDomain.php b/app/Models/ServerCustomDomain.php new file mode 100644 index 0000000000..877ee4eaa6 --- /dev/null +++ b/app/Models/ServerCustomDomain.php @@ -0,0 +1,55 @@ + 'integer', + 'allocation_id' => 'integer', + 'custom_domain_id' => 'integer', + 'port' => 'integer', + 'dns_records' => 'array', + 'last_synced_at' => 'datetime', + ]; + + public static array $validationRules = [ + 'server_id' => 'required|integer|exists:servers,id', + 'allocation_id' => 'nullable|integer|exists:allocations,id', + 'custom_domain_id' => 'required|integer|exists:custom_domains,id', + 'subdomain' => ['required', 'string', 'max:191', 'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i'], + 'full_domain' => ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9.-]+$/'], + 'port' => 'required|integer|min:1|max:65535', + 'protocol' => 'required|in:tcp,udp,both', + 'record_type' => 'nullable|in:srv,cname', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + ]; + + public function getRouteKeyName(): string + { + return 'id'; + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function allocation(): BelongsTo + { + return $this->belongsTo(Allocation::class); + } + + public function customDomain(): BelongsTo + { + return $this->belongsTo(CustomDomain::class); + } +} diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index 5a9a68d6ab..b87da20854 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -43,6 +43,7 @@ class Subuser extends Model 'user_id' => 'int', 'server_id' => 'int', 'permissions' => 'array', + 'disabled_extensions' => 'array', ]; public static array $validationRules = [ diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php index a8e3bf78af..2a2eb963f0 100644 --- a/app/Observers/ServerObserver.php +++ b/app/Observers/ServerObserver.php @@ -4,6 +4,7 @@ use Everest\Events; use Everest\Models\Server; +use Everest\Jobs\CustomDomains\CleanupServerCustomDomainsJob; use Illuminate\Foundation\Bus\DispatchesJobs; class ServerObserver @@ -49,6 +50,10 @@ public function deleting(Server $server): void public function deleted(Server $server): void { event(new Events\Server\Deleted($server)); + + if (config('modules.custom_domains.cleanup_on_delete', true)) { + CleanupServerCustomDomainsJob::dispatch($server->id); + } } /** diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 6799f3e4b7..85bb7237b7 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -4,6 +4,7 @@ use Everest\Models\User; use Everest\Models\Server; +use Everest\Models\Permission; class ServerPolicy { @@ -17,7 +18,9 @@ protected function checkPermission(User $user, Server $server, string $permissio return false; } - return in_array($permission, $subuser->permissions); + $permissions = Permission::expandPermissions($subuser->permissions ?? []); + + return in_array($permission, $permissions, true); } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index a059f42ef4..29b6e709a1 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -124,6 +124,26 @@ protected function configureRateLimiting(): void return Limit::perMinutes(5, 20)->by($email !== '' ? $email : $request->ip()); }); + RateLimiter::for('custom-domains-create', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.create_per_minute', 10)); + + return Limit::perMinute($limit)->by($key); + }); + + RateLimiter::for('custom-domains-sync', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.sync_per_minute', 5)); + + return Limit::perMinute($limit)->by($key); + }); + + RateLimiter::for('custom-domains-billing-options', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.billing_options_per_minute', 20)); + + return Limit::perMinute($limit)->by($key); + }); RateLimiter::for('email-verification', function (Request $request) { $key = optional($request->user())->id ?: $request->ip(); diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 76af4b0882..66887692a0 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -94,6 +94,17 @@ class SettingsServiceProvider extends ServiceProvider // Mods module settings 'modules:mods:enabled', 'modules:mods:curseforge_api_key', + + // Extensions module settings + 'modules:extensions:enabled', + + // Custom domains module settings + 'modules:custom_domains:cloudflare:token', + 'modules:custom_domains:security:allow_wildcard', + 'modules:custom_domains:security:max_wildcards_per_user', + 'modules:custom_domains:rate_limits:create_per_minute', + 'modules:custom_domains:rate_limits:sync_per_minute', + 'modules:custom_domains:rate_limits:billing_options_per_minute', ]; /** diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 8eaba10a75..587894e90e 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -77,6 +77,32 @@ public function getDirectory(string $path): array { Assert::isInstanceOf($this->server, Server::class); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list', $this->server->uuid), + [ + 'query' => [ + 'directory' => $path, + 'ignored' => [], + 'per_page' => 10000, + 'page' => 1, + ], + ] + ); + + $data = json_decode($response->getBody()->__toString(), true); + + if (is_array($data) && isset($data['entries']) && is_array($data['entries'])) { + return $data['entries']; + } + + if (is_array($data)) { + return $data; + } + } catch (TransferException) { + // Fallback for legacy Wings versions that don't support /files/list. + } + try { $response = $this->getHttpClient()->get( sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), diff --git a/app/Repositories/Wings/DaemonWingsRsRepository.php b/app/Repositories/Wings/DaemonWingsRsRepository.php new file mode 100644 index 0000000000..e53ec75030 --- /dev/null +++ b/app/Repositories/Wings/DaemonWingsRsRepository.php @@ -0,0 +1,493 @@ +node, Node::class); + + if (!$this->node->isSupercharged()) { + throw new \RuntimeException('This operation requires a Supercharged (Wings-RS) node.'); + } + } + + // ─── System / Node-Level Endpoints ─────────────────────────────────── + + /** + * GET /api/system/overview — detailed system overview. + */ + public function getSystemOverview(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/overview'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/stats — real-time system statistics. + */ + public function getSystemStats(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/stats'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/logs — list log files. + */ + public function getSystemLogs(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/logs'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/logs/{file} — read a specific log file. + */ + public function getSystemLogContents(string $file, ?int $lines = null): string + { + $this->assertSupercharged(); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/system/logs/%s', rawurlencode($file)), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + /** + * POST /api/system/upgrade — trigger Wings-RS self-upgrade. + */ + public function upgradeSystem(string $url, array $headers, string $sha256, string $restartCommand, array $restartArgs): void + { + $this->assertSupercharged(); + + try { + $this->getHttpClient()->post('/api/system/upgrade', [ + 'json' => [ + 'url' => $url, + 'headers' => $headers, + 'sha256' => $sha256, + 'restart_command' => $restartCommand, + 'restart_command_args' => $restartArgs, + ], + ]); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── File Manager Enhancements ─────────────────────────────────────── + + /** + * GET /api/servers/{server}/files/list — paginated file listing (Wings-RS enhanced). + */ + public function getFileList(string $directory, array $ignored = [], int $perPage = 100, int $page = 1): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list', $this->server->uuid), + [ + 'query' => [ + 'directory' => $directory, + 'ignored' => $ignored, + 'per_page' => $perPage, + 'page' => $page, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/servers/{server}/files/fingerprints — file checksums. + */ + public function getFingerprints(array $files, string $algorithm = 'sha256'): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/fingerprints', $this->server->uuid), + [ + 'query' => [ + 'algorithm' => $algorithm, + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/files/search — advanced file search. + */ + public function searchFiles(array $params): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/search', $this->server->uuid), + ['json' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/files/compress — advanced compress with format and progress. + */ + public function compressFiles(?string $root, array $files, ?string $format = null, ?string $name = null, bool $foreground = true): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'root' => $root ?? '/', + 'files' => $files, + 'foreground' => $foreground, + ]; + + if ($format !== null) { + $payload['format'] = $format; + } + if ($name !== null) { + $payload['name'] = $name; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/compress', $this->server->uuid), + [ + 'json' => $payload, + 'timeout' => $foreground ? 60 * 15 : 30, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * DELETE /api/servers/{server}/files/operations/{operation} — cancel a running operation. + */ + public function cancelOperation(string $operationId): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->delete( + sprintf('/api/servers/%s/files/operations/%s', $this->server->uuid, $operationId) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── Server Scripts ────────────────────────────────────────────────── + + /** + * POST /api/servers/{server}/script — run async scripts. + */ + public function runScript(string $containerImage, string $entrypoint, string $script, array $environment = []): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'container_image' => $containerImage, + 'entrypoint' => $entrypoint, + 'script' => $script, + ]; + + if (!empty($environment)) { + $payload['environment'] = $environment; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/script', $this->server->uuid), + ['json' => $payload, 'timeout' => 60 * 30] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/install/abort — abort a running installation. + */ + public function abortInstall(): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/install/abort', $this->server->uuid) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * GET /api/servers/{server}/logs/install — get install logs. + */ + public function getInstallLogs(?int $lines = 100): string + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/logs/install', $this->server->uuid), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + /** + * GET /api/servers/{server}/logs — get server logs from Wings-RS. + */ + public function getServerLogs(?int $lines = 100): string + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/logs', $this->server->uuid), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + // ─── WebSocket Enhancements ────────────────────────────────────────── + + /** + * POST /api/servers/{server}/ws/permissions — live permission updates. + */ + public function updateWsPermissions(array $userPermissions): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/ws/permissions', $this->server->uuid), + ['json' => ['user_permissions' => $userPermissions]] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * POST /api/servers/{server}/ws/broadcast — broadcast message to connected users. + */ + public function broadcastMessage(array $users, array $permissions, string $event, array $args = []): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/ws/broadcast', $this->server->uuid), + [ + 'json' => [ + 'users' => $users, + 'permissions' => $permissions, + 'message' => [ + 'event' => $event, + 'args' => $args, + ], + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── Transfer Enhancements ─────────────────────────────────────────── + + /** + * POST /api/servers/{server}/transfer — enhanced transfer with archive format options. + */ + public function initiateTransfer( + string $url, + string $token, + ?string $archiveFormat = null, + ?string $compressionLevel = null, + array $backups = [], + bool $deleteBackups = false, + int $multiplexStreams = 0 + ): void { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'url' => $url, + 'token' => $token, + ]; + + if ($archiveFormat !== null) { + $payload['archive_format'] = $archiveFormat; + } + if ($compressionLevel !== null) { + $payload['compression_level'] = $compressionLevel; + } + if (!empty($backups)) { + $payload['backups'] = $backups; + } + if ($deleteBackups) { + $payload['delete_backups'] = true; + } + if ($multiplexStreams > 0) { + $payload['multiplex_streams'] = $multiplexStreams; + } + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/transfer', $this->server->uuid), + ['json' => $payload] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── File Copy Remote ──────────────────────────────────────────────── + + /** + * POST /api/servers/{server}/files/copy-remote — copy files to another node. + */ + public function copyRemote( + string $url, + string $token, + array $files, + string $destinationServer, + string $destinationPath, + ?string $root = null, + ?string $archiveFormat = null, + ?string $compressionLevel = null, + bool $foreground = true + ): array { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'url' => $url, + 'token' => $token, + 'files' => $files, + 'destination_server' => $destinationServer, + 'destination_path' => $destinationPath, + 'foreground' => $foreground, + ]; + + if ($root !== null) { + $payload['root'] = $root; + } + if ($archiveFormat !== null) { + $payload['archive_format'] = $archiveFormat; + } + if ($compressionLevel !== null) { + $payload['compression_level'] = $compressionLevel; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/copy-remote', $this->server->uuid), + ['json' => $payload, 'timeout' => $foreground ? 60 * 15 : 30] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } +} diff --git a/app/Services/Billing/BillingConfigImportService.php b/app/Services/Billing/BillingConfigImportService.php index 165f19b526..08a907e7c1 100644 --- a/app/Services/Billing/BillingConfigImportService.php +++ b/app/Services/Billing/BillingConfigImportService.php @@ -72,6 +72,7 @@ public function handle(array $import_data, bool $ignore_duplicates): void 'backup_limit' => (int) $product['backup_limit'], 'database_limit' => (int) $product['database_limit'], 'allocation_limit' => (int) $product['allocation_limit'], + 'subdomain_limit' => array_key_exists('subdomain_limit', $product) ? (is_null($product['subdomain_limit']) ? null : (int) $product['subdomain_limit']) : null, 'category_uuid' => $category_id, // Correctly assign the new category ID 'stripe_id' => null, // deprecated ]); diff --git a/app/Services/Billing/BillingValidationService.php b/app/Services/Billing/BillingValidationService.php index 346f18ffc7..3e539bb1b2 100644 --- a/app/Services/Billing/BillingValidationService.php +++ b/app/Services/Billing/BillingValidationService.php @@ -336,6 +336,15 @@ public function validatePlanDowngrade(Server $server, Product $newProduct): arra ]; } + $currentSubdomains = $server->customDomains()->count(); + if (is_null($server->subdomain_limit) && !is_null($newProduct->subdomain_limit) && $currentSubdomains > $newProduct->subdomain_limit) { + $violations['subdomains'] = [ + 'current' => $currentSubdomains, + 'limit' => $newProduct->subdomain_limit, + 'unit' => 'subdomains', + ]; + } + return $violations; } } diff --git a/app/Services/Billing/CreateOrderService.php b/app/Services/Billing/CreateOrderService.php index 53c5f08714..e5d0a1a694 100644 --- a/app/Services/Billing/CreateOrderService.php +++ b/app/Services/Billing/CreateOrderService.php @@ -58,6 +58,7 @@ public function create(?string $intent, User $user, Product $product, ?string $s $order->node_id = $nodeId; $order->server_id = $additionalData['server_id'] ?? null; $order->variables = $additionalData['variables'] ?? null; + $order->domain_payload = $additionalData['domain_payload'] ?? null; $order->type = $type; $order->payment_processor = $additionalData['payment_processor'] ?? 'stripe'; $order->mollie_payment_id = $additionalData['mollie_payment_id'] ?? null; diff --git a/app/Services/Billing/CreateServerService.php b/app/Services/Billing/CreateServerService.php index dbebdb5857..e876428c1c 100644 --- a/app/Services/Billing/CreateServerService.php +++ b/app/Services/Billing/CreateServerService.php @@ -116,6 +116,7 @@ public function process(Request $request, Product $product, object $metadata, Or 'backup_limit' => $product->backup_limit, 'allocation_limit' => $product->allocation_limit, 'subuser_limit' => 3, + 'subdomain_limit' => null, ]); } catch (BillingExceptionClass $e) { // Re-throw billing exceptions as-is diff --git a/app/Services/Billing/OrderProcessorService.php b/app/Services/Billing/OrderProcessorService.php index 4966ba1900..6820a10056 100644 --- a/app/Services/Billing/OrderProcessorService.php +++ b/app/Services/Billing/OrderProcessorService.php @@ -8,6 +8,8 @@ use Everest\Models\Billing\Order; use Everest\Models\Billing\Product; use Everest\Models\Billing\CouponUsage; +use Everest\Jobs\CustomDomains\ProvisionServerCustomDomainsJob; +use Everest\Services\CustomDomains\CustomDomainProvisioningService; /** * Unified order processing service for billing operations. @@ -27,6 +29,7 @@ public function __construct( private CreateOrderService $orderService, private CreateServerService $serverCreationService, private ServerRenewalService $renewalService, + private CustomDomainProvisioningService $customDomainProvisioning, ) { } @@ -45,6 +48,7 @@ public function __construct( * @param string|null $paymentIntentId The Stripe payment intent ID (for paid orders) * @param string|null $serverName The custom server name (optional) * @param int $billingDays The billing cycle days (defaults to 30) + * @param array $domainPayload Custom domain payload collected during checkout * * @return array{server: Server, order: Order} */ @@ -58,7 +62,8 @@ public function createServerOrder( array $variables = [], ?string $paymentIntentId = null, ?string $serverName = null, - int $billingDays = 30 + int $billingDays = 30, + array $domainPayload = [] ): array { // Create the order record $order = $this->orderService->create( @@ -69,7 +74,10 @@ public function createServerOrder( Order::TYPE_NEW, $couponId, $eggId, - ['billing_days' => $billingDays] + [ + 'billing_days' => $billingDays, + 'domain_payload' => $domainPayload, + ] ); // Create the server @@ -82,6 +90,9 @@ public function createServerOrder( $serverName ); + $this->customDomainProvisioning->syncFromOrder($server, $order); + ProvisionServerCustomDomainsJob::dispatch($server->id); + // Record coupon usage if applicable if ($couponId) { $this->recordCouponUsage($couponId, $user->id, $order->id); diff --git a/app/Services/Billing/PlanChangeService.php b/app/Services/Billing/PlanChangeService.php index 4cd2767558..fac90fbe4e 100644 --- a/app/Services/Billing/PlanChangeService.php +++ b/app/Services/Billing/PlanChangeService.php @@ -109,6 +109,10 @@ public function changePlan(Server $server, Product $newProduct, bool $force = fa 'allocation_limit' => $newProduct->allocation_limit, ]; + if (is_null($server->subdomain_limit)) { + $buildData['subdomain_limit'] = null; + } + return $this->buildModificationService->handle($server, $buildData); }); } @@ -133,6 +137,6 @@ private function isDowngrade(Server $server, Product $newProduct): bool $newProduct->cpu_limit < $server->cpu || $newProduct->database_limit < $server->database_limit || $newProduct->backup_limit < $server->backup_limit || - $newProduct->allocation_limit < $server->allocation_limit; + $newProduct->allocation_limit < $server->allocation_limit; } } diff --git a/app/Services/Billing/ServerFulfillmentService.php b/app/Services/Billing/ServerFulfillmentService.php index 83e44064b3..05a47dc546 100644 --- a/app/Services/Billing/ServerFulfillmentService.php +++ b/app/Services/Billing/ServerFulfillmentService.php @@ -10,6 +10,8 @@ use Illuminate\Support\Facades\Log; use Everest\Models\Billing\CouponUsage; use Everest\Exceptions\DisplayException; +use Everest\Jobs\CustomDomains\ProvisionServerCustomDomainsJob; +use Everest\Services\CustomDomains\CustomDomainProvisioningService; /** * Central server fulfillment service for paid orders. @@ -28,6 +30,7 @@ class ServerFulfillmentService public function __construct( private CreateServerService $serverCreation, private OrderProcessorService $processorService, + private CustomDomainProvisioningService $customDomainProvisioning, ) { } @@ -186,6 +189,9 @@ private function processNewServer(Request $request, Order $order, Product $produ // Create the server using the centralized creation service $server = $this->serverCreation->process($request, $product, $metadata, $order); + $this->customDomainProvisioning->syncFromOrder($server, $order); + ProvisionServerCustomDomainsJob::dispatch($server->id); + Log::info("Created new server {$server->id} for order {$order->id}"); return $server; diff --git a/app/Services/CustomDomains/CloudflareDnsService.php b/app/Services/CustomDomains/CloudflareDnsService.php new file mode 100644 index 0000000000..980ab490d0 --- /dev/null +++ b/app/Services/CustomDomains/CloudflareDnsService.php @@ -0,0 +1,301 @@ +normalizeToken($token); + + if ($token === '') { + throw new Exception('Cloudflare API token is not configured for custom domains.'); + } + + $retries = (int) config('modules.custom_domains.cloudflare.retries', 3); + $sleep = (int) config('modules.custom_domains.cloudflare.retry_sleep_ms', 250); + + return Http::retry($retries, $sleep) + ->acceptJson() + ->asJson() + ->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ]); + } + + private function normalizeToken(string $token): string + { + $normalized = trim($token); + + if ($normalized === '') { + return ''; + } + + $normalized = trim($normalized, "\"'"); + $normalized = preg_replace('/\s+/', '', $normalized) ?? ''; + + if (str_starts_with(strtolower($normalized), 'bearer')) { + $normalized = preg_replace('/^bearer/i', '', $normalized) ?? ''; + $normalized = trim($normalized); + } + + return $normalized; + } + + private function baseUrl(): string + { + return rtrim((string) config('modules.custom_domains.cloudflare.base_url', 'https://api.cloudflare.com/client/v4'), '/'); + } + + public function getZoneByName(string $domain, ?string $tokenOverride = null): ?array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones', [ + 'name' => $domain, + 'status' => 'active', + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare zone lookup failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return null; + } + + return Arr::first($json['result'] ?? []); + } + + public function createOrUpdateAOrCnameRecord( + string $zoneId, + string $name, + string $target, + ?string $tokenOverride = null, + ?string $forcedType = null, + ): array + { + $type = $forcedType !== null + ? strtoupper(trim($forcedType)) + : (filter_var($target, FILTER_VALIDATE_IP) ? 'A' : 'CNAME'); + + if (!in_array($type, ['A', 'CNAME'], true)) { + throw new Exception('Invalid DNS record type for host record.'); + } + + if ($type === 'A' && !filter_var($target, FILTER_VALIDATE_IP)) { + throw new Exception('A record content must be a valid IP address.'); + } + + if ($type === 'CNAME' && filter_var($target, FILTER_VALIDATE_IP)) { + throw new Exception('CNAME record content must be a hostname, not an IP address.'); + } + + $proxied = (bool) config('modules.custom_domains.cloudflare.proxied', false); + + $existingRecords = $this->findRecordsByName($zoneId, $name, $tokenOverride); + $existing = collect($existingRecords)->first(fn (array $record) => ($record['type'] ?? null) === $type); + + foreach ($existingRecords as $record) { + if (($record['type'] ?? null) === $type) { + continue; + } + + if (!in_array($record['type'] ?? '', ['A', 'CNAME'], true)) { + continue; + } + + if (!empty($record['id'])) { + $this->deleteRecord($zoneId, (string) $record['id'], $tokenOverride); + } + } + + $payload = [ + 'type' => $type, + 'name' => $name, + 'content' => $target, + 'proxied' => $type === 'A' ? $proxied : false, + 'ttl' => 1, + ]; + + if ($existing) { + return $this->updateRecord($zoneId, $existing['id'], $payload, $tokenOverride); + } + + return $this->createRecord($zoneId, $payload, $tokenOverride); + } + + /** + * @return array> + */ + public function getRecordsByName(string $zoneId, string $name, ?string $tokenOverride = null): array + { + return $this->findRecordsByName($zoneId, $name, $tokenOverride); + } + + /** + * @return array> + */ + private function findRecordsByName(string $zoneId, string $name, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', [ + 'name' => $name, + 'per_page' => 100, + 'page' => 1, + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS lookup request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return []; + } + + return is_array($json['result'] ?? null) ? $json['result'] : []; + } + + public function createOrUpdateSrvRecord( + string $zoneId, + string $fqdn, + string $servicePrefix, + string $proto, + int $port, + string $target, + ?string $tokenOverride = null, + ): array { + $normalizedPrefix = $this->normalizeServicePrefix($servicePrefix); + $recordName = $normalizedPrefix . $proto . '.' . $fqdn; + + $payload = [ + 'type' => 'SRV', + 'name' => $recordName, + 'data' => [ + 'priority' => 1, + 'weight' => 1, + 'port' => $port, + 'target' => $target, + ], + 'ttl' => 1, + ]; + + $existing = $this->findRecord($zoneId, 'SRV', $recordName, $tokenOverride); + + if ($existing) { + return $this->updateRecord($zoneId, $existing['id'], $payload, $tokenOverride); + } + + return $this->createRecord($zoneId, $payload, $tokenOverride); + } + + private function normalizeServicePrefix(string $servicePrefix): string + { + $value = strtolower(trim($servicePrefix)); + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_?([a-z0-9][a-z0-9-]*)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + throw new Exception('Invalid SRV service prefix. Use format like _minecraft._'); + } + + public function deleteRecord(string $zoneId, string $recordId, ?string $tokenOverride = null): void + { + try { + $this->client($tokenOverride)->delete($this->baseUrl() . '/zones/' . $zoneId . '/dns_records/' . $recordId)->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS delete request failed.', $exception)); + } + } + + private function findRecord(string $zoneId, string $type, string $name, ?string $tokenOverride = null): ?array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', [ + 'type' => $type, + 'name' => $name, + 'per_page' => 1, + 'page' => 1, + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS lookup request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return null; + } + + return Arr::first($json['result'] ?? []); + } + + private function createRecord(string $zoneId, array $payload, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride) + ->post($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', $payload) + ->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS create request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + throw new Exception('Cloudflare DNS create request failed.'); + } + + return $json['result']; + } + + private function updateRecord(string $zoneId, string $recordId, array $payload, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride) + ->put($this->baseUrl() . '/zones/' . $zoneId . '/dns_records/' . $recordId, $payload) + ->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS update request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + throw new Exception('Cloudflare DNS update request failed.'); + } + + return $json['result']; + } + + private function formatCloudflareError(string $prefix, RequestException $exception): string + { + $body = trim((string) optional($exception->response)->body()); + + return $body !== '' ? $prefix . ' Response: ' . $body : $prefix; + } +} diff --git a/app/Services/CustomDomains/CustomDomainProvisioningService.php b/app/Services/CustomDomains/CustomDomainProvisioningService.php new file mode 100644 index 0000000000..3069cc0330 --- /dev/null +++ b/app/Services/CustomDomains/CustomDomainProvisioningService.php @@ -0,0 +1,630 @@ + '_minecraft._', + 'velocity' => '_minecraft._', + 'bungeecord' => '_minecraft._', + 'bedrock' => '_minecraft._', + ]; + + private const RUST_HINTS = [ + 'rust', + ]; + + private const WEB_INTERFACE_HINTS = [ + 'web', + 'nginx', + 'apache', + 'http', + 'dashboard', + 'panel', + ]; + + public function __construct(private CloudflareDnsService $cloudflare) + { + } + + public function getAvailableDomains(?Server $server = null): array + { + $domains = CustomDomain::query()->where('enabled', true)->orderBy('domain')->get(); + + if (!$server) { + return $domains->all(); + } + + return $domains->filter(fn (CustomDomain $domain) => $this->supportsServer($domain, $server))->values()->all(); + } + + public function createFromPayload(Server $server, array $payload): void + { + if (empty($payload)) { + return; + } + + $server->loadMissing('allocation'); + $resolvedPort = (int) ($server->allocation?->port ?? 0); + if ($resolvedPort < 1) { + throw new DisplayException('Custom domain mappings can only be created after the server allocation is ready.'); + } + + DB::transaction(function () use ($server, $payload, $resolvedPort) { + foreach ($payload as $entry) { + $domainId = (int) ($entry['domain_id'] ?? 0); + $subdomain = strtolower(trim((string) ($entry['subdomain'] ?? ''))); + $port = $resolvedPort; + $protocol = 'both'; + $requestedRecordType = isset($entry['record_type']) ? strtolower(trim((string) $entry['record_type'])) : null; + $recordType = $this->resolveRecordTypeForServer($server, $requestedRecordType); + $serviceTag = isset($entry['service_tag']) ? trim((string) $entry['service_tag']) : null; + $serviceTag = $this->normalizeServiceTagPrefix($serviceTag); + + if ($recordType !== 'srv') { + $serviceTag = null; + } elseif ($serviceTag === null && $this->getDnsModeForServer($server) === 'rust') { + $serviceTag = '_rust._'; + } + + $domain = CustomDomain::query()->where('enabled', true)->findOrFail($domainId); + if (!$this->supportsServer($domain, $server)) { + throw new DisplayException('The selected custom domain is not available for this server type.'); + } + + $this->validateSubdomain($subdomain, $domain); + + $allocation = $server->allocations()->where('port', $port)->first(); + $fullDomain = $subdomain . '.' . $domain->domain; + + $this->assertServerSubdomainLimitNotReached($server, $fullDomain, $port, $protocol); + + $this->assertSubdomainAvailable($domain, $fullDomain); + + $existing = ServerCustomDomain::query() + ->where('full_domain', $fullDomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->first(); + + if ($existing && $existing->server_id !== $server->id) { + throw new DisplayException('The selected domain and port mapping is already in use by another server.'); + } + + ServerCustomDomain::query()->updateOrCreate( + [ + 'full_domain' => $fullDomain, + 'port' => $port, + 'protocol' => $protocol, + ], + [ + 'server_id' => $server->id, + 'allocation_id' => $allocation?->id, + 'custom_domain_id' => $domain->id, + 'subdomain' => $subdomain, + 'record_type' => $recordType, + 'service_tag' => $serviceTag, + 'status' => 'pending', + 'last_error' => null, + ] + ); + } + }); + } + + public function syncFromOrder(Server $server, ?Order $order): void + { + if (!$order || !is_array($order->domain_payload)) { + return; + } + + $this->createFromPayload($server, $order->domain_payload); + } + + public function provision(ServerCustomDomain $mapping): void + { + try { + $mapping->loadMissing(['customDomain.apiKey', 'server.node', 'server.egg', 'server.nest', 'allocation']); + + if ($mapping->subdomain === '*') { + throw new DisplayException('Wildcard subdomains are not supported.'); + } + + $token = trim((string) ($mapping->customDomain->apiKey?->token ?? '')); + if ($token === '') { + throw new DisplayException('No API key is configured for this custom domain.'); + } + + $zoneId = $mapping->customDomain->cloudflare_zone_id; + if (empty($zoneId)) { + $zone = $this->cloudflare->getZoneByName($mapping->customDomain->domain, $token); + if (!$zone) { + throw new Exception('Cloudflare zone could not be resolved for domain: ' . $mapping->customDomain->domain); + } + + $zoneId = $zone['id']; + $mapping->customDomain->forceFill(['cloudflare_zone_id' => $zoneId])->save(); + } + + $recordType = $this->resolveRecordTypeForServer($mapping->server, $mapping->record_type); + $useSrv = $recordType === 'srv'; + + $target = $useSrv + ? $this->resolveTarget($mapping) + : $this->resolveSrvTargetHostname($mapping); + $records = []; + + $existingRecords = (array) ($mapping->dns_records ?? []); + + $hostRecord = $this->cloudflare->createOrUpdateAOrCnameRecord( + $zoneId, + $mapping->full_domain, + $target, + $token, + $useSrv ? null : 'CNAME' + ); + $records[] = [ + 'kind' => 'host', + 'id' => $hostRecord['id'] ?? null, + 'type' => $hostRecord['type'] ?? null, + ]; + + $service = $this->resolveServiceTag($mapping); + $protocols = ['tcp', 'udp']; + + if ($useSrv && $mapping->subdomain !== '*' && $service !== null) { + $srvTarget = $this->resolveSrvTargetHostname($mapping); + + foreach ($protocols as $proto) { + $srvRecord = $this->cloudflare->createOrUpdateSrvRecord( + $zoneId, + $mapping->full_domain, + $service, + $proto, + $mapping->port, + $srvTarget, + $token + ); + + $records[] = [ + 'kind' => 'srv', + 'id' => $srvRecord['id'] ?? null, + 'type' => 'SRV', + 'proto' => $proto, + ]; + } + } + + $recordIdsToKeep = array_filter(array_map(fn (array $record) => $record['id'] ?? null, $records)); + foreach ($existingRecords as $existingRecord) { + $recordId = $existingRecord['id'] ?? null; + if (!$recordId || in_array($recordId, $recordIdsToKeep, true)) { + continue; + } + + try { + $this->cloudflare->deleteRecord($zoneId, (string) $recordId, $token); + } catch (\Throwable $exception) { + $this->writeLog($mapping, 'delete', 'failed', ['record_id' => $recordId], $exception->getMessage()); + } + } + + $mapping->forceFill([ + 'record_type' => $recordType, + 'dns_records' => $records, + 'status' => 'active', + 'last_error' => null, + 'last_synced_at' => now(), + ])->save(); + + $this->writeLog($mapping, 'sync', 'success', ['records' => $records], 'DNS provisioned successfully.'); + } catch (\Throwable $exception) { + $mapping->forceFill([ + 'status' => 'failed', + 'last_error' => $exception->getMessage(), + 'last_synced_at' => now(), + ])->save(); + + $this->writeLog($mapping, 'sync', 'failed', ['error' => $exception->getMessage()], $exception->getMessage()); + } + } + + public function cleanup(ServerCustomDomain $mapping): void + { + $mapping->loadMissing('customDomain.apiKey'); + $zoneId = $mapping->customDomain->cloudflare_zone_id; + $token = trim((string) ($mapping->customDomain->apiKey?->token ?? '')); + + if (!$zoneId || $token === '') { + return; + } + + $records = $mapping->dns_records ?? []; + foreach ($records as $record) { + $recordId = $record['id'] ?? null; + if (!$recordId) { + continue; + } + + try { + $this->cloudflare->deleteRecord($zoneId, $recordId, $token); + } catch (\Throwable $exception) { + $this->writeLog($mapping, 'delete', 'failed', ['record_id' => $recordId], $exception->getMessage()); + } + } + + $this->writeLog($mapping, 'delete', 'success', ['records' => $records], 'DNS records removed.'); + } + + private function validateSubdomain(string $subdomain, CustomDomain $domain): void + { + if ($subdomain === '*') { + throw new DisplayException('Wildcard subdomains are not supported.'); + } + + if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain)) { + throw new DisplayException('Invalid subdomain value: ' . $subdomain); + } + } + + private function assertSubdomainAvailable(CustomDomain $domain, string $fullDomain): void + { + $existingMapping = ServerCustomDomain::query()->where('full_domain', $fullDomain)->exists(); + if ($existingMapping) { + throw new DisplayException('This subdomain is unavailable.'); + } + + $domain->loadMissing('apiKey'); + $token = trim((string) ($domain->apiKey?->token ?? '')); + + try { + $zoneId = (string) ($domain->cloudflare_zone_id ?? ''); + if ($zoneId === '') { + $zone = $this->cloudflare->getZoneByName($domain->domain, $token !== '' ? $token : null); + $zoneId = (string) ($zone['id'] ?? ''); + } + + if ($zoneId === '') { + throw new DisplayException('Unable to verify subdomain availability right now.'); + } + + $dnsRecords = $this->cloudflare->getRecordsByName($zoneId, $fullDomain, $token !== '' ? $token : null); + if (!empty($dnsRecords)) { + throw new DisplayException('This subdomain is unavailable.'); + } + } catch (DisplayException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new DisplayException('Unable to verify subdomain availability right now.'); + } + } + + private function resolveEffectiveSubdomainLimit(Server $server): ?int + { + if (!is_null($server->subdomain_limit)) { + return max(0, (int) $server->subdomain_limit); + } + + $server->loadMissing('product'); + if (!is_null($server->product?->subdomain_limit)) { + return max(0, (int) $server->product->subdomain_limit); + } + + return null; + } + + private function assertServerSubdomainLimitNotReached(Server $server, string $fullDomain, int $port, string $protocol): void + { + $limit = $this->resolveEffectiveSubdomainLimit($server); + if (is_null($limit)) { + return; + } + + $existingForTarget = ServerCustomDomain::query() + ->where('server_id', $server->id) + ->where('full_domain', $fullDomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->exists(); + + if ($existingForTarget) { + return; + } + + $currentCount = $server->customDomains()->count(); + if ($currentCount >= $limit) { + throw new DisplayException("Subdomain limit reached for this server ({$currentCount}/{$limit})."); + } + } + + public function resolveSuggestedServiceTag(Server $server, ?CustomDomain $domain = null): ?string + { + if (!$this->isSrvSupportedForServer($server)) { + return null; + } + + if ($domain) { + $eggTags = (array) ($domain->egg_service_tags ?? []); + $eggIdKey = (string) (int) ($server->egg_id ?? 0); + + if ($eggIdKey !== '0' && array_key_exists($eggIdKey, $eggTags) && is_string($eggTags[$eggIdKey])) { + return $this->normalizeServiceTagPrefix($eggTags[$eggIdKey]); + } + + if ($domain->service_tag) { + return $this->normalizeServiceTagPrefix((string) $domain->service_tag); + } + } + + $labels = strtolower(trim(($server->egg?->name ?? '') . ' ' . ($server->nest?->name ?? ''))); + if ($labels === '') { + return null; + } + + foreach (self::WEB_INTERFACE_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return null; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $tag) { + if (str_contains($labels, $needle)) { + return $this->normalizeServiceTagPrefix($tag); + } + } + + return null; + } + + public function getDefaultServiceTagForEgg(?string $eggName, ?string $nestName = null): ?string + { + $labels = strtolower(trim(($eggName ?? '') . ' ' . ($nestName ?? ''))); + if ($labels === '') { + return null; + } + + foreach (self::WEB_INTERFACE_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return null; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $tag) { + if (str_contains($labels, $needle)) { + return $this->normalizeServiceTagPrefix($tag); + } + } + + return null; + } + + public function getDnsModeForServer(Server $server): string + { + return $this->resolveDnsModeFromLabels($this->serverLabels($server)); + } + + public function getDnsModeForEgg(?string $eggName, ?string $nestName = null): string + { + $labels = strtolower(trim(($eggName ?? '') . ' ' . ($nestName ?? ''))); + + return $this->resolveDnsModeFromLabels($labels); + } + + public function isSrvSupportedForServer(Server $server): bool + { + return in_array($this->getDnsModeForServer($server), ['minecraft', 'rust'], true); + } + + public function resolveRecordTypeForServer(Server $server, ?string $requestedRecordType = null): string + { + $mode = $this->getDnsModeForServer($server); + $requested = strtolower(trim((string) $requestedRecordType)); + + if ($mode === 'minecraft') { + return $requested === 'cname' ? 'cname' : 'srv'; + } + + if ($mode === 'rust') { + return $requested === 'srv' ? 'srv' : 'cname'; + } + + return 'cname'; + } + + public function getDnsRecommendationForServer(Server $server): array + { + return $this->getDnsRecommendationForMode($this->getDnsModeForServer($server)); + } + + public function getDnsRecommendationForEgg(?string $eggName, ?string $nestName = null): array + { + return $this->getDnsRecommendationForMode($this->getDnsModeForEgg($eggName, $nestName)); + } + + private function getDnsRecommendationForMode(string $mode): array + { + + if ($mode === 'minecraft') { + return [ + 'mode' => 'minecraft', + 'recommended_record_type' => 'srv', + 'srv_supported' => true, + 'allow_record_type_selection' => true, + 'forced_record_type' => null, + 'notice' => 'SRV is recommended for Minecraft-family servers. CNAME is also supported.', + 'connection_hint' => 'Use SRV for best compatibility (usually no :port), or CNAME if you prefer connecting with :port.', + ]; + } + + if ($mode === 'rust') { + return [ + 'mode' => 'rust', + 'recommended_record_type' => 'cname', + 'srv_supported' => true, + 'allow_record_type_selection' => true, + 'forced_record_type' => null, + 'notice' => 'CNAME is recommended for Rust. SRV is available but not recommended.', + 'connection_hint' => 'Best option: CNAME with :port (example: play.example.com:28015).', + ]; + } + + return [ + 'mode' => 'generic', + 'recommended_record_type' => 'cname', + 'srv_supported' => false, + 'allow_record_type_selection' => false, + 'forced_record_type' => 'cname', + 'notice' => 'CNAME is the only supported option for this game profile.', + 'connection_hint' => 'Use the mapped domain with :port when connecting.', + ]; + } + + private function resolveDnsModeFromLabels(string $labels): string + { + foreach (self::RUST_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return 'rust'; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $_) { + if (str_contains($labels, $needle)) { + return 'minecraft'; + } + } + + return 'generic'; + } + + private function supportsServer(CustomDomain $domain, Server $server): bool + { + $allowedNests = array_values(array_filter((array) ($domain->allowed_nest_ids ?? []), fn ($id) => is_numeric($id))); + $allowedEggs = array_values(array_filter((array) ($domain->allowed_egg_ids ?? []), fn ($id) => is_numeric($id))); + + $nestAllowed = empty($allowedNests) || in_array((int) $server->nest_id, array_map('intval', $allowedNests), true); + $eggAllowed = empty($allowedEggs) || in_array((int) $server->egg_id, array_map('intval', $allowedEggs), true); + + return $nestAllowed && $eggAllowed; + } + + private function resolveServiceTag(ServerCustomDomain $mapping): ?string + { + if ($this->resolveRecordTypeForServer($mapping->server, $mapping->record_type) !== 'srv') { + return null; + } + + $customTag = $this->normalizeServiceTagPrefix((string) ($mapping->service_tag ?? '')); + if ($customTag !== null) { + return $customTag; + } + + if ($this->getDnsModeForServer($mapping->server) === 'rust') { + return '_rust._'; + } + + return $this->resolveSuggestedServiceTag($mapping->server, $mapping->customDomain); + } + + private function normalizeServiceTagPrefix(?string $serviceTag): ?string + { + $value = strtolower(trim((string) $serviceTag)); + if ($value === '') { + return null; + } + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._(?:tcp|udp)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_?([a-z0-9][a-z0-9-]*)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + throw new DisplayException('Invalid service tag. Use format like _minecraft._'); + } + + private function resolveTarget(ServerCustomDomain $mapping): string + { + if ($mapping->allocation && filter_var($mapping->allocation->ip, FILTER_VALIDATE_IP)) { + return $mapping->allocation->ip; + } + + if ($mapping->server->allocation && filter_var($mapping->server->allocation->ip, FILTER_VALIDATE_IP)) { + return $mapping->server->allocation->ip; + } + + return (string) $mapping->server->node->fqdn; + } + + private function resolveSrvTargetHostname(ServerCustomDomain $mapping): string + { + $raw = trim((string) $mapping->server->node->fqdn); + + if ($raw === '') { + throw new DisplayException('Node hostname is not configured.'); + } + + $hostname = $raw; + + if (str_contains($hostname, '://')) { + $parsed = parse_url($hostname, PHP_URL_HOST); + $hostname = is_string($parsed) ? $parsed : ''; + } else { + $hostname = preg_split('/[\/\?#]/', $hostname, 2)[0] ?? ''; + + if (str_starts_with($hostname, '[') && str_contains($hostname, ']')) { + $hostname = trim(explode(']', $hostname, 2)[0], '[]'); + } elseif (preg_match('/:[0-9]+$/', $hostname) === 1 && substr_count($hostname, ':') === 1) { + $hostname = substr($hostname, 0, (int) strrpos($hostname, ':')); + } + } + + $hostname = strtolower(rtrim(trim($hostname), '.')); + + if ($hostname === '') { + throw new DisplayException('Node hostname is invalid.'); + } + + if (filter_var($hostname, FILTER_VALIDATE_IP)) { + throw new DisplayException('Node hostname must be a DNS hostname, not an IP address.'); + } + + if (!preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/', $hostname)) { + throw new DisplayException('Node hostname is invalid for SRV target.'); + } + + return $hostname; + } + + private function writeLog(ServerCustomDomain $mapping, string $action, string $status, array $payload = [], ?string $message = null): void + { + CustomDomainDnsLog::query()->create([ + 'server_id' => $mapping->server_id, + 'server_custom_domain_id' => $mapping->id, + 'action' => $action, + 'status' => $status, + 'payload' => $payload, + 'message' => $message, + ]); + } + + private function serverLabels(Server $server): string + { + return strtolower(trim(($server->egg?->name ?? '') . ' ' . ($server->nest?->name ?? ''))); + } +} diff --git a/app/Services/CustomDomains/SslProvisioningService.php b/app/Services/CustomDomains/SslProvisioningService.php new file mode 100644 index 0000000000..7f579f7c4b --- /dev/null +++ b/app/Services/CustomDomains/SslProvisioningService.php @@ -0,0 +1,30 @@ +run($command); + + if (!$result->successful()) { + throw new Exception('SSL provisioning command failed: ' . $result->errorOutput()); + } + } +} diff --git a/app/Services/Extensions/ExtensionFileSnapshotService.php b/app/Services/Extensions/ExtensionFileSnapshotService.php new file mode 100644 index 0000000000..bf1a9cd518 --- /dev/null +++ b/app/Services/Extensions/ExtensionFileSnapshotService.php @@ -0,0 +1,43 @@ + $fileContentsMap Map of file path => plain text file contents. + */ + public function create(Server $server, string $extensionId, ?User $actor, string $action, array $fileContentsMap): ExtensionFileSnapshot + { + $encrypted = []; + foreach ($fileContentsMap as $path => $contents) { + $encrypted[$path] = Crypt::encryptString($contents); + } + + return ExtensionFileSnapshot::query()->create([ + 'server_id' => $server->id, + 'actor_id' => $actor?->id, + 'extension_id' => $extensionId, + 'action' => $action, + 'files' => $encrypted, + ]); + } + + /** + * @return array Map of file path => decrypted contents. + */ + public function decryptFiles(ExtensionFileSnapshot $snapshot): array + { + $decrypted = []; + foreach (($snapshot->files ?? []) as $path => $encrypted) { + $decrypted[$path] = Crypt::decryptString($encrypted); + } + + return $decrypted; + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php new file mode 100644 index 0000000000..5ae7a2f510 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php @@ -0,0 +1,167 @@ +ServerAddress = $Address; + $this->ServerPort = $Port; + $this->Timeout = $Timeout; + + if ($ResolveSRV) { + $this->ResolveSRV(); + } + } + + public function __destruct() + { + $this->Close(); + } + + public function Close(): void + { + if ($this->Socket !== null) { + \fclose($this->Socket); + $this->Socket = null; + } + } + + public function Connect(): void + { + $Socket = @\fsockopen($this->ServerAddress, $this->ServerPort, $errno, $errstr, $this->Timeout); + + if ($Socket === false) { + throw new MinecraftPingException("Failed to connect or create a socket: $errno ($errstr)"); + } + + $this->Socket = $Socket; + \stream_set_timeout($this->Socket, (int) $this->Timeout); + } + + /** @return array|false */ + public function Query(): array|bool + { + if ($this->Socket === null) { + throw new MinecraftPingException('Socket is not open.'); + } + + $TimeStart = \microtime(true); + + $Data = "\x00"; // packet ID = 0 (varint) + $Data .= "\xff\xff\xff\xff\x0f"; // Protocol version (varint) + $Data .= \pack('c', \strlen($this->ServerAddress)) . $this->ServerAddress; + $Data .= \pack('n', $this->ServerPort); + $Data .= "\x01"; // Next state: status (varint) + + $Data = \pack('c', \strlen($Data)) . $Data; + + fwrite($this->Socket, $Data . "\x01\x00"); + + $Length = $this->ReadVarInt(); + + if ($Length < 10) { + return false; + } + + $this->ReadVarInt(); // packet type + + $Length = $this->ReadVarInt(); // string length + + if ($Length < 2) { + return false; + } + + $Data = ""; + while (\strlen($Data) < $Length) { + if (\microtime(true) - $TimeStart > $this->Timeout) { + throw new MinecraftPingException('Server read timed out'); + } + + $Remainder = $Length - \strlen($Data); + + if ($Remainder <= 0) { + break; + } + + $block = \fread($this->Socket, $Remainder); + if (!$block) { + throw new MinecraftPingException('Server returned too few data'); + } + + $Data .= $block; + } + + $Data = \json_decode($Data, true); + + if (\json_last_error() !== JSON_ERROR_NONE) { + throw new MinecraftPingException('JSON parsing failed: ' . \json_last_error_msg()); + } + + if (!\is_array($Data)) { + return false; + } + + return $Data; + } + + private function ReadVarInt(): int + { + $i = 0; + $j = 0; + + while (true) { + $k = @\fgetc($this->Socket); + + if ($k === false) { + return 0; + } + + $k = \ord($k); + + $i |= ($k & 0x7F) << $j++ * 7; + + if ($j > 5) { + throw new MinecraftPingException('VarInt too big'); + } + + if (($k & 0x80) != 128) { + break; + } + } + + return $i; + } + + private function ResolveSRV(): void + { + if (\ip2long($this->ServerAddress) !== false) { + return; + } + + $Record = @\dns_get_record('_minecraft._tcp.' . $this->ServerAddress, DNS_SRV); + + if (empty($Record)) { + return; + } + + if (isset($Record[0]['target'])) { + $this->ServerAddress = $Record[0]['target']; + } + + if (isset($Record[0]['port'])) { + $this->ServerPort = (int) $Record[0]['port']; + } + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php new file mode 100644 index 0000000000..abc5eaa0f9 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php @@ -0,0 +1,7 @@ +ResolveSRV($Ip, $Port); + } + + $Socket = @\fsockopen('udp://' . $Ip, $Port, $ErrNo, $ErrStr, $Timeout); + + if ($ErrNo || $Socket === false) { + throw new MinecraftQueryException('Could not create socket: ' . $ErrStr); + } + + $this->Socket = $Socket; + + \stream_set_timeout($this->Socket, (int) $Timeout); + \stream_set_blocking($this->Socket, true); + + try { + $Challenge = $this->GetChallenge(); + $this->GetStatus($Challenge); + } finally { + \fclose($Socket); + } + } + + /** @return array|false */ + public function GetInfo(): array|bool + { + return isset($this->Info) ? $this->Info : false; + } + + /** @return array|false */ + public function GetPlayers(): array|bool + { + return isset($this->Players) ? $this->Players : false; + } + + private function GetChallenge(): string + { + $Data = $this->WriteData(self::HANDSHAKE); + + if ($Data === false) { + throw new MinecraftQueryException('Failed to receive challenge.'); + } + + return \pack('N', $Data); + } + + private function GetStatus(string $Challenge): void + { + $Data = $this->WriteData(self::STATISTIC, $Challenge . \pack('c*', 0x00, 0x00, 0x00, 0x00)); + + if (!$Data) { + throw new MinecraftQueryException('Failed to receive status.'); + } + + $Info = []; + + $Data = \substr($Data, 11); + $Data = \explode("\x00\x00\x01player_\x00\x00", $Data); + + if (\count($Data) !== 2) { + throw new MinecraftQueryException("Failed to parse server's response."); + } + + $Players = \substr($Data[1], 0, -2); + $Data = \explode("\x00", $Data[0]); + + $Keys = [ + 'hostname' => 'HostName', + 'gametype' => 'GameType', + 'version' => 'Version', + 'plugins' => 'Plugins', + 'map' => 'Map', + 'numplayers' => 'Players', + 'maxplayers' => 'MaxPlayers', + 'hostport' => 'HostPort', + 'hostip' => 'HostIp', + 'game_id' => 'GameName' + ]; + + $Last = ''; + foreach ($Data as $Key => $Value) { + if (~$Key & 1) { + if (!isset($Keys[$Value])) { + $Last = false; + continue; + } + + $Last = $Keys[$Value]; + $Info[$Last] = ''; + } elseif ($Last != false) { + $Info[$Last] = \mb_convert_encoding($Value, 'UTF-8'); + } + } + + $Info['Players'] = (int) ($Info['Players'] ?? 0); + $Info['MaxPlayers'] = (int) ($Info['MaxPlayers'] ?? 0); + $Info['HostPort'] = (int) ($Info['HostPort'] ?? 0); + + if (isset($Info['Plugins'])) { + $Data = \explode(": ", $Info['Plugins'], 2); + + $Info['RawPlugins'] = $Info['Plugins']; + $Info['Software'] = $Data[0]; + + if (\count($Data) == 2) { + $Info['Plugins'] = \explode("; ", $Data[1]); + } + } else { + $Info['Software'] = 'Vanilla'; + } + + $this->Info = $Info; + + if (empty($Players)) { + $this->Players = null; + } else { + $this->Players = \explode("\x00", $Players); + } + } + + private function WriteData(int $Command, string $Append = ""): mixed + { + if ($this->Socket === null) { + throw new MinecraftQueryException('Socket is not open.'); + } + + $Command = \pack('c*', 0xFE, 0xFD, $Command, 0x01, 0x02, 0x03, 0x04) . $Append; + $Length = \strlen($Command); + + if ($Length !== \fwrite($this->Socket, $Command, $Length)) { + throw new MinecraftQueryException("Failed to write on socket."); + } + + $Data = \fread($this->Socket, 4096); + + if (empty($Data)) { + throw new MinecraftQueryException("Failed to read from socket."); + } + + if (\strlen($Data) < 5 || $Data[0] != $Command[2]) { + return false; + } + + return \substr($Data, 5); + } + + private function ResolveSRV(string &$Address, int &$Port): void + { + if (\ip2long($Address) !== false) { + return; + } + + $Record = @\dns_get_record('_minecraft._tcp.' . $Address, DNS_SRV); + + if (empty($Record)) { + return; + } + + if (isset($Record[0]['target'])) { + $Address = $Record[0]['target']; + } + + if (isset($Record[0]['port'])) { + $Port = (int) $Record[0]['port']; + } + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php new file mode 100644 index 0000000000..8e8e97c4b5 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php @@ -0,0 +1,7 @@ +data = gzdecode($compressed); + } else { + $this->data = $compressed; + } + + if ($this->data === false) { + throw new \Exception("Failed to decompress NBT file"); + } + + $this->offset = 0; + return $this->readTag(); + } + + /** + * Parse NBT data from raw bytes. + */ + public function parse(string $data): array + { + $this->data = $data; + $this->offset = 0; + return $this->readTag(); + } + + private function readTag(): array + { + $type = $this->readByte(); + + if ($type === self::TAG_END) { + return ['type' => 'end']; + } + + $name = $this->readString(); + $value = $this->readPayload($type); + + return [ + 'name' => $name, + 'value' => $value, + ]; + } + + private function readPayload(int $type): mixed + { + return match ($type) { + self::TAG_END => null, + self::TAG_BYTE => $this->readByte(), + self::TAG_SHORT => $this->readShort(), + self::TAG_INT => $this->readInt(), + self::TAG_LONG => $this->readLong(), + self::TAG_FLOAT => $this->readFloat(), + self::TAG_DOUBLE => $this->readDouble(), + self::TAG_BYTE_ARRAY => $this->readByteArray(), + self::TAG_STRING => $this->readString(), + self::TAG_LIST => $this->readList(), + self::TAG_COMPOUND => $this->readCompound(), + self::TAG_INT_ARRAY => $this->readIntArray(), + self::TAG_LONG_ARRAY => $this->readLongArray(), + default => throw new \Exception("Unknown NBT tag type: $type"), + }; + } + + private function readByte(): int + { + $value = ord($this->data[$this->offset]); + $this->offset++; + // Convert to signed byte + return $value > 127 ? $value - 256 : $value; + } + + private function readUnsignedByte(): int + { + $value = ord($this->data[$this->offset]); + $this->offset++; + return $value; + } + + private function readShort(): int + { + $bytes = substr($this->data, $this->offset, 2); + $this->offset += 2; + $value = unpack('n', $bytes)[1]; + // Convert to signed short + return $value > 32767 ? $value - 65536 : $value; + } + + private function readInt(): int + { + $bytes = substr($this->data, $this->offset, 4); + $this->offset += 4; + $value = unpack('N', $bytes)[1]; + // Convert to signed int (PHP handles this) + if ($value > 2147483647) { + $value -= 4294967296; + } + return $value; + } + + private function readLong(): int|string + { + $bytes = substr($this->data, $this->offset, 8); + $this->offset += 8; + $value = unpack('J', $bytes)[1]; + return $value; + } + + private function readFloat(): float + { + $bytes = substr($this->data, $this->offset, 4); + $this->offset += 4; + // Reverse bytes for big-endian + $bytes = strrev($bytes); + return unpack('f', $bytes)[1]; + } + + private function readDouble(): float + { + $bytes = substr($this->data, $this->offset, 8); + $this->offset += 8; + // Reverse bytes for big-endian + $bytes = strrev($bytes); + return unpack('d', $bytes)[1]; + } + + private function readString(): string + { + $length = $this->readShort(); + if ($length < 0) { + $length = 0; + } + $value = substr($this->data, $this->offset, $length); + $this->offset += $length; + return $value; + } + + private function readByteArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readByte(); + } + return $values; + } + + private function readIntArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readInt(); + } + return $values; + } + + private function readLongArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readLong(); + } + return $values; + } + + private function readList(): array + { + $itemType = $this->readUnsignedByte(); + $length = $this->readInt(); + + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readPayload($itemType); + } + return $values; + } + + private function readCompound(): array + { + $values = []; + + while (true) { + $type = $this->readUnsignedByte(); + + if ($type === self::TAG_END) { + break; + } + + $name = $this->readString(); + $values[$name] = $this->readPayload($type); + } + + return $values; + } + + /** + * Extract player inventory from parsed NBT data. + */ + public static function extractInventory(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $inventory = []; + + // Main inventory (slots 0-35) + if (isset($data['Inventory']) && is_array($data['Inventory'])) { + foreach ($data['Inventory'] as $item) { + $inventory[] = self::parseItem($item); + } + } + + return $inventory; + } + + /** + * Extract armor from parsed NBT data. + */ + public static function extractArmor(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $armor = [ + 'helmet' => null, + 'chestplate' => null, + 'leggings' => null, + 'boots' => null, + ]; + + // Method 1: Check equipment field (Minecraft 1.20.5+) + // Equipment format: {head: {}, chest: {}, legs: {}, feet: {}, mainhand: {}, offhand: {}} + // Or as a list: [{slot: "head", item: {}}, ...] + if (isset($data['equipment']) && is_array($data['equipment'])) { + $equipment = $data['equipment']; + + // Check for named keys format (1.21+) + if (isset($equipment['head']) && is_array($equipment['head']) && !empty($equipment['head'])) { + $armor['helmet'] = self::parseItem($equipment['head']); + } + if (isset($equipment['chest']) && is_array($equipment['chest']) && !empty($equipment['chest'])) { + $armor['chestplate'] = self::parseItem($equipment['chest']); + } + if (isset($equipment['legs']) && is_array($equipment['legs']) && !empty($equipment['legs'])) { + $armor['leggings'] = self::parseItem($equipment['legs']); + } + if (isset($equipment['feet']) && is_array($equipment['feet']) && !empty($equipment['feet'])) { + $armor['boots'] = self::parseItem($equipment['feet']); + } + + // Check for list format with slot names + if (isset($equipment[0])) { + foreach ($equipment as $slot) { + if (!is_array($slot)) continue; + $slotName = $slot['slot'] ?? ''; + $item = $slot['item'] ?? $slot; + + if (empty($item) || !isset($item['id'])) continue; + + switch ($slotName) { + case 'head': + case 'minecraft:head': + $armor['helmet'] = self::parseItem($item); + break; + case 'chest': + case 'minecraft:chest': + $armor['chestplate'] = self::parseItem($item); + break; + case 'legs': + case 'minecraft:legs': + $armor['leggings'] = self::parseItem($item); + break; + case 'feet': + case 'minecraft:feet': + $armor['boots'] = self::parseItem($item); + break; + } + } + } + } + + // Method 2: Fallback to Inventory slots 100-103 (pre-1.20.5) + if (isset($data['Inventory']) && is_array($data['Inventory'])) { + foreach ($data['Inventory'] as $item) { + $slot = $item['Slot'] ?? -1; + + // Handle if slot is wrapped in an array or value key + if (is_array($slot)) { + $slot = $slot['value'] ?? $slot[0] ?? -1; + } + + // Convert to int + $slot = (int) $slot; + + // Handle negative values (signed byte interpretation) + if ($slot < 0) { + $slot = $slot + 256; + } + + switch ($slot) { + case 100: + if ($armor['boots'] === null) { + $armor['boots'] = self::parseItem($item); + } + break; + case 101: + if ($armor['leggings'] === null) { + $armor['leggings'] = self::parseItem($item); + } + break; + case 102: + if ($armor['chestplate'] === null) { + $armor['chestplate'] = self::parseItem($item); + } + break; + case 103: + if ($armor['helmet'] === null) { + $armor['helmet'] = self::parseItem($item); + } + break; + } + } + } + + return $armor; + } + + /** + * Extract ender chest contents from parsed NBT data. + */ + public static function extractEnderChest(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $enderChest = []; + + if (isset($data['EnderItems']) && is_array($data['EnderItems'])) { + foreach ($data['EnderItems'] as $item) { + $enderChest[] = self::parseItem($item); + } + } + + return $enderChest; + } + + /** + * Extract player location from parsed NBT data. + */ + public static function extractLocation(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + + $pos = $data['Pos'] ?? [0, 0, 0]; + $rotation = $data['Rotation'] ?? [0, 0]; + $dimension = $data['Dimension'] ?? 'minecraft:overworld'; + + // Handle old-style dimension IDs + if (is_int($dimension)) { + $dimension = match ($dimension) { + -1 => 'minecraft:the_nether', + 0 => 'minecraft:overworld', + 1 => 'minecraft:the_end', + default => 'minecraft:overworld', + }; + } + + return [ + 'x' => round($pos[0] ?? 0, 2), + 'y' => round($pos[1] ?? 0, 2), + 'z' => round($pos[2] ?? 0, 2), + 'yaw' => round($rotation[0] ?? 0, 2), + 'pitch' => round($rotation[1] ?? 0, 2), + 'dimension' => $dimension, + 'world' => self::getDimensionName($dimension), + ]; + } + + /** + * Extract player health and food data. + */ + public static function extractStats(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + + return [ + 'health' => $data['Health'] ?? 20, + 'maxHealth' => 20, // Default, can be modified by attributes + 'food' => $data['foodLevel'] ?? 20, + 'saturation' => round($data['foodSaturationLevel'] ?? 5, 2), + 'xpLevel' => $data['XpLevel'] ?? 0, + 'xpTotal' => $data['XpTotal'] ?? 0, + 'xpProgress' => round(($data['XpP'] ?? 0) * 100, 1), + 'gamemode' => self::getGamemodeName($data['playerGameType'] ?? 0), + 'score' => $data['Score'] ?? 0, + ]; + } + + /** + * Parse a single item from NBT (public wrapper). + */ + public static function parseItemPublic(array $item): array + { + return self::parseItem($item); + } + + /** + * Parse a single item from NBT. + */ + private static function parseItem(array $item): array + { + $id = $item['id'] ?? $item['Id'] ?? 'minecraft:air'; + + // Handle numeric IDs (legacy) + if (is_int($id)) { + $id = "minecraft:legacy_$id"; + } + + // Remove minecraft: prefix for display + $displayId = str_replace('minecraft:', '', $id); + + $parsed = [ + 'id' => $id, + 'displayId' => $displayId, + 'name' => self::getItemName($displayId), + 'slot' => $item['Slot'] ?? 0, + 'count' => $item['Count'] ?? $item['count'] ?? 1, + 'damage' => $item['Damage'] ?? 0, + 'enchantments' => [], + 'storedEnchantments' => [], + 'customName' => null, + 'lore' => [], + 'durability' => null, + 'contents' => [], + ]; + + // Parse tag data (contains enchantments, custom name, etc.) + $tag = $item['tag'] ?? $item['components'] ?? []; + + if (!empty($tag)) { + // Custom name + if (isset($tag['display']['Name'])) { + $name = $tag['display']['Name']; + // Try to parse JSON text component + if (str_starts_with($name, '{') || str_starts_with($name, '"')) { + $decoded = json_decode($name, true); + $parsed['customName'] = $decoded['text'] ?? $name; + } else { + $parsed['customName'] = $name; + } + } + + // Custom name (1.20.5+ format) + if (isset($tag['minecraft:custom_name'])) { + $name = $tag['minecraft:custom_name']; + if (is_string($name)) { + $decoded = json_decode($name, true); + $parsed['customName'] = $decoded['text'] ?? $name; + } + } + + // Lore + if (isset($tag['display']['Lore']) && is_array($tag['display']['Lore'])) { + foreach ($tag['display']['Lore'] as $line) { + if (str_starts_with($line, '{') || str_starts_with($line, '"')) { + $decoded = json_decode($line, true); + $parsed['lore'][] = $decoded['text'] ?? $line; + } else { + $parsed['lore'][] = $line; + } + } + } + + // Enchantments (multiple formats for different versions) + // Pre-1.20.5: tag.Enchantments or tag.ench (array of {id, lvl}) + // 1.20.5+: tag.minecraft:enchantments (object {minecraft:enchant_id: level}) + $enchants = $tag['Enchantments'] ?? $tag['ench'] ?? []; + + // Handle 1.20.5+ format: minecraft:enchantments is an object directly + if (empty($enchants) && isset($tag['minecraft:enchantments'])) { + $enchantsData = $tag['minecraft:enchantments']; + // It could be {levels: {...}} or directly {...} + if (isset($enchantsData['levels']) && is_array($enchantsData['levels'])) { + $enchants = $enchantsData['levels']; + } elseif (is_array($enchantsData)) { + $enchants = $enchantsData; + } + } + + if (is_array($enchants)) { + foreach ($enchants as $key => $enchant) { + if (is_array($enchant)) { + // Old format: {id: "minecraft:mending", lvl: 1} + $enchId = $enchant['id'] ?? ''; + $level = $enchant['lvl'] ?? 1; + } else { + // 1.20.5+ format: key is enchant id, value is level + $enchId = $key; + $level = (int) $enchant; + } + + // Remove minecraft: prefix + $enchId = str_replace('minecraft:', '', $enchId); + + if (!empty($enchId)) { + $parsed['enchantments'][] = [ + 'id' => $enchId, + 'name' => self::getEnchantmentName($enchId), + 'level' => $level, + 'levelRoman' => self::toRoman($level), + ]; + } + } + } + + // Stored Enchantments (for enchanted books) + // Pre-1.20.5: tag.StoredEnchantments (array of {id, lvl}) + // 1.20.5+: tag.minecraft:stored_enchantments (object {minecraft:enchant_id: level}) + $storedEnchants = $tag['StoredEnchantments'] ?? []; + + // Handle 1.20.5+ format + if (empty($storedEnchants) && isset($tag['minecraft:stored_enchantments'])) { + $storedData = $tag['minecraft:stored_enchantments']; + if (isset($storedData['levels']) && is_array($storedData['levels'])) { + $storedEnchants = $storedData['levels']; + } elseif (is_array($storedData)) { + $storedEnchants = $storedData; + } + } + + if (is_array($storedEnchants)) { + foreach ($storedEnchants as $key => $enchant) { + if (is_array($enchant)) { + $enchId = $enchant['id'] ?? ''; + $level = $enchant['lvl'] ?? 1; + } else { + // 1.20.5+ format + $enchId = $key; + $level = (int) $enchant; + } + + $enchId = str_replace('minecraft:', '', $enchId); + + if (!empty($enchId)) { + $parsed['storedEnchantments'][] = [ + 'id' => $enchId, + 'name' => self::getEnchantmentName($enchId), + 'level' => $level, + 'levelRoman' => self::toRoman($level), + ]; + } + } + } + + // Bundle contents (1.17+) + $bundleContents = $tag['Items'] ?? $tag['minecraft:bundle_contents'] ?? []; + if (is_array($bundleContents) && !empty($bundleContents)) { + foreach ($bundleContents as $contentItem) { + // 1.20.5+ format: each item might have 'item' wrapper + if (isset($contentItem['item'])) { + $parsed['contents'][] = self::parseItem($contentItem['item']); + } else { + $parsed['contents'][] = self::parseItem($contentItem); + } + } + } + + // Shulker box / Block entity contents + // Pre-1.20.5: tag.BlockEntityTag.Items + // 1.20.5+: components.minecraft:container (array of {slot, item}) + $blockEntityTag = $tag['BlockEntityTag'] ?? null; + $containerComponent = $tag['minecraft:container'] ?? null; + + // Handle pre-1.20.5 format (BlockEntityTag.Items) + if ($blockEntityTag !== null && is_array($blockEntityTag)) { + $containerItems = $blockEntityTag['Items'] ?? []; + if (is_array($containerItems) && !empty($containerItems)) { + foreach ($containerItems as $contentItem) { + if (isset($contentItem['id'])) { + $parsed['contents'][] = self::parseItem($contentItem); + } + } + } + } + + // Handle 1.20.5+ format (minecraft:container array of {slot, item}) + if ($containerComponent !== null && is_array($containerComponent)) { + foreach ($containerComponent as $slotData) { + // Format: [{slot: 0, item: {id: "minecraft:...", ...}}, ...] + if (isset($slotData['item'])) { + $parsed['contents'][] = self::parseItem($slotData['item']); + } elseif (isset($slotData['id'])) { + // Direct item format + $parsed['contents'][] = self::parseItem($slotData); + } + } + } + + // Durability + if (isset($tag['Damage'])) { + $parsed['damage'] = $tag['Damage']; + } + if (isset($tag['minecraft:damage'])) { + $parsed['damage'] = $tag['minecraft:damage']; + } + + // Max durability + $maxDurability = self::getMaxDurability($displayId); + if ($maxDurability > 0) { + $parsed['durability'] = [ + 'current' => $maxDurability - ($parsed['damage'] ?? 0), + 'max' => $maxDurability, + 'percentage' => round((($maxDurability - ($parsed['damage'] ?? 0)) / $maxDurability) * 100, 1), + ]; + } + } + + return $parsed; + } + + /** + * Get human-readable dimension name. + */ + private static function getDimensionName(string $dimension): string + { + return match ($dimension) { + 'minecraft:overworld' => 'Overworld', + 'minecraft:the_nether' => 'The Nether', + 'minecraft:the_end' => 'The End', + default => ucwords(str_replace(['minecraft:', '_'], ['', ' '], $dimension)), + }; + } + + /** + * Get human-readable gamemode name. + */ + private static function getGamemodeName(int $mode): string + { + return match ($mode) { + 0 => 'Survival', + 1 => 'Creative', + 2 => 'Adventure', + 3 => 'Spectator', + default => 'Unknown', + }; + } + + /** + * Convert item ID to readable name. + */ + private static function getItemName(string $id): string + { + // Convert snake_case to Title Case + $name = str_replace('_', ' ', $id); + return ucwords($name); + } + + /** + * Convert enchantment ID to readable name. + */ + private static function getEnchantmentName(string $id): string + { + $names = [ + 'protection' => 'Protection', + 'fire_protection' => 'Fire Protection', + 'feather_falling' => 'Feather Falling', + 'blast_protection' => 'Blast Protection', + 'projectile_protection' => 'Projectile Protection', + 'respiration' => 'Respiration', + 'aqua_affinity' => 'Aqua Affinity', + 'thorns' => 'Thorns', + 'depth_strider' => 'Depth Strider', + 'frost_walker' => 'Frost Walker', + 'binding_curse' => 'Curse of Binding', + 'soul_speed' => 'Soul Speed', + 'swift_sneak' => 'Swift Sneak', + 'sharpness' => 'Sharpness', + 'smite' => 'Smite', + 'bane_of_arthropods' => 'Bane of Arthropods', + 'knockback' => 'Knockback', + 'fire_aspect' => 'Fire Aspect', + 'looting' => 'Looting', + 'sweeping' => 'Sweeping Edge', + 'sweeping_edge' => 'Sweeping Edge', + 'efficiency' => 'Efficiency', + 'silk_touch' => 'Silk Touch', + 'unbreaking' => 'Unbreaking', + 'fortune' => 'Fortune', + 'power' => 'Power', + 'punch' => 'Punch', + 'flame' => 'Flame', + 'infinity' => 'Infinity', + 'luck_of_the_sea' => 'Luck of the Sea', + 'lure' => 'Lure', + 'loyalty' => 'Loyalty', + 'impaling' => 'Impaling', + 'riptide' => 'Riptide', + 'channeling' => 'Channeling', + 'multishot' => 'Multishot', + 'quick_charge' => 'Quick Charge', + 'piercing' => 'Piercing', + 'mending' => 'Mending', + 'vanishing_curse' => 'Curse of Vanishing', + 'density' => 'Density', + 'breach' => 'Breach', + 'wind_burst' => 'Wind Burst', + ]; + + return $names[$id] ?? ucwords(str_replace('_', ' ', $id)); + } + + /** + * Get max durability for an item. + */ + private static function getMaxDurability(string $id): int + { + $durabilities = [ + // Tools - Wood + 'wooden_sword' => 59, 'wooden_pickaxe' => 59, 'wooden_axe' => 59, + 'wooden_shovel' => 59, 'wooden_hoe' => 59, + // Tools - Stone + 'stone_sword' => 131, 'stone_pickaxe' => 131, 'stone_axe' => 131, + 'stone_shovel' => 131, 'stone_hoe' => 131, + // Tools - Iron + 'iron_sword' => 250, 'iron_pickaxe' => 250, 'iron_axe' => 250, + 'iron_shovel' => 250, 'iron_hoe' => 250, + // Tools - Gold + 'golden_sword' => 32, 'golden_pickaxe' => 32, 'golden_axe' => 32, + 'golden_shovel' => 32, 'golden_hoe' => 32, + // Tools - Diamond + 'diamond_sword' => 1561, 'diamond_pickaxe' => 1561, 'diamond_axe' => 1561, + 'diamond_shovel' => 1561, 'diamond_hoe' => 1561, + // Tools - Netherite + 'netherite_sword' => 2031, 'netherite_pickaxe' => 2031, 'netherite_axe' => 2031, + 'netherite_shovel' => 2031, 'netherite_hoe' => 2031, + // Armor - Leather + 'leather_helmet' => 55, 'leather_chestplate' => 80, + 'leather_leggings' => 75, 'leather_boots' => 65, + // Armor - Chain + 'chainmail_helmet' => 165, 'chainmail_chestplate' => 240, + 'chainmail_leggings' => 225, 'chainmail_boots' => 195, + // Armor - Iron + 'iron_helmet' => 165, 'iron_chestplate' => 240, + 'iron_leggings' => 225, 'iron_boots' => 195, + // Armor - Gold + 'golden_helmet' => 77, 'golden_chestplate' => 112, + 'golden_leggings' => 105, 'golden_boots' => 91, + // Armor - Diamond + 'diamond_helmet' => 363, 'diamond_chestplate' => 528, + 'diamond_leggings' => 495, 'diamond_boots' => 429, + // Armor - Netherite + 'netherite_helmet' => 407, 'netherite_chestplate' => 592, + 'netherite_leggings' => 555, 'netherite_boots' => 481, + // Other + 'bow' => 384, 'crossbow' => 465, 'trident' => 250, + 'shield' => 336, 'elytra' => 432, + 'fishing_rod' => 64, 'shears' => 238, 'flint_and_steel' => 64, + 'carrot_on_a_stick' => 25, 'warped_fungus_on_a_stick' => 100, + 'brush' => 64, 'mace' => 500, + ]; + + return $durabilities[$id] ?? 0; + } + + /** + * Convert number to Roman numeral. + */ + private static function toRoman(int $num): string + { + if ($num <= 0 || $num > 255) { + return (string) $num; + } + + $map = [ + 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', + 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I', + ]; + + $result = ''; + foreach ($map as $value => $roman) { + while ($num >= $value) { + $result .= $roman; + $num -= $value; + } + } + return $result; + } +} diff --git a/app/Services/Nodes/WingsDetectionService.php b/app/Services/Nodes/WingsDetectionService.php new file mode 100644 index 0000000000..2054164c81 --- /dev/null +++ b/app/Services/Nodes/WingsDetectionService.php @@ -0,0 +1,110 @@ +configurationRepository->setNode($node); + + $overviewData = $this->fetchWingsRsOverview($repository); + $systemData = $repository->getSystemInformation(); + + $isSupercharged = !is_null($overviewData) + || !empty($systemData['supercharged']) + || $this->isWingsRsVersion($systemData['version'] ?? ''); + + $wingsVersion = $overviewData['version'] + ?? $systemData['version'] + ?? null; + + $node->update([ + 'wings_type' => $isSupercharged ? Node::WINGS_TYPE_RS : Node::WINGS_TYPE_DEFAULT, + 'wings_version' => $wingsVersion, + 'wings_detected_at' => CarbonImmutable::now(), + ]); + + return $isSupercharged; + } catch (\Exception $e) { + Log::warning('Failed to detect Wings type for node ' . $node->name, [ + 'node_id' => $node->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Attempt to fetch Wings-RS system overview. + * Returns null when endpoint is not available (normal Wings) or on error. + */ + private function fetchWingsRsOverview(DaemonConfigurationRepository $repository): ?array + { + try { + $response = $repository->getHttpClient()->get('/api/system/overview'); + $data = json_decode($response->getBody()->__toString(), true); + + if (!is_array($data)) { + return null; + } + + return $data; + } catch (\Exception) { + return null; + } + } + + /** + * Check if the version string indicates Wings-RS. + */ + private function isWingsRsVersion(string $version): bool + { + // Wings-RS uses Rust-style version strings or contains 'rs' identifier + return str_contains(strtolower($version), 'rs') + || str_contains(strtolower($version), 'rust') + || str_contains(strtolower($version), 'supercharged'); + } + + /** + * Detect Wings-RS for a node and return system overview data if available. + * This calls /api/system/overview which is Wings-RS exclusive. + */ + public function getOverview(Node $node): ?array + { + if (!$node->isSupercharged()) { + return null; + } + + try { + $response = $this->configurationRepository->setNode($node) + ->getHttpClient() + ->get('/api/system/overview'); + + return json_decode($response->getBody()->__toString(), true); + } catch (\Exception $e) { + Log::debug('Failed to get Wings-RS overview for node ' . $node->name, [ + 'node_id' => $node->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/app/Services/Servers/BuildModificationService.php b/app/Services/Servers/BuildModificationService.php index 6d72148f52..99a0a159ec 100644 --- a/app/Services/Servers/BuildModificationService.php +++ b/app/Services/Servers/BuildModificationService.php @@ -53,6 +53,7 @@ public function handle(Server $server, array $data): Server 'backup_limit' => Arr::get($data, 'backup_limit', 0) ?? 0, 'database_limit' => Arr::get($data, 'database_limit', 0) ?? null, 'subuser_limit' => Arr::get($data, 'subuser_limit', 0) ?? null, + 'subdomain_limit' => Arr::get($data, 'subdomain_limit', $server->subdomain_limit), ]))->saveOrFail(); return $server->refresh(); @@ -89,21 +90,15 @@ private function processAllocations(Server $server, array &$data): void // Handle the addition of allocations to this server. Only assign allocations that are not currently // assigned to a different server, and only allocations on the same node as the server. if (!empty($data['add_allocations'])) { - // Get all matching allocations first to track the first ID for potential primary allocation reassignment - $allocations = $server->node->allocations() + $query = $server->node->allocations() ->whereIn('id', $data['add_allocations']) - ->whereNull('server_id') - ->get(); + ->whereNull('server_id'); - // Keep track of the first allocation we're adding so that we can use it - // to reset the default allocation if needed. - $freshlyAllocated = $allocations->first()->id ?? null; + // Keep track of all the allocations we're just now adding so that we can use the first + // one to reset the default allocation to. + $freshlyAllocated = $query->first()->id ?? null; - // Update all matched allocations to assign them to this server - $server->node->allocations() - ->whereIn('id', $data['add_allocations']) - ->whereNull('server_id') - ->update(['server_id' => $server->id, 'notes' => null]); + $query->update(['server_id' => $server->id, 'notes' => null]); } if (!empty($data['remove_allocations'])) { diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index 4f67cff74b..0aa4e404d4 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -4,6 +4,7 @@ use Everest\Models\User; use Everest\Models\Server; +use Everest\Models\Permission; class GetUserPermissionsService { @@ -29,6 +30,6 @@ public function handle(Server $server, User $user): array /** @var \Everest\Models\Subuser|null $subuserPermissions */ $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); - return $subuserPermissions ? $subuserPermissions->permissions : []; + return $subuserPermissions ? Permission::expandPermissions($subuserPermissions->permissions ?? []) : []; } } diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index 76ef2bebcd..4924bbceb6 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -165,6 +165,7 @@ private function createModel(array $data): Server 'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0, 'backup_limit' => Arr::get($data, 'backup_limit') ?? 0, 'subuser_limit' => Arr::get($data, 'subuser_limit') ?? 0, + 'subdomain_limit' => Arr::get($data, 'subdomain_limit', 1), ]); return $model; diff --git a/app/Transformers/Api/Application/ProductTransformer.php b/app/Transformers/Api/Application/ProductTransformer.php index 2f7f7e0319..a471a524ca 100644 --- a/app/Transformers/Api/Application/ProductTransformer.php +++ b/app/Transformers/Api/Application/ProductTransformer.php @@ -43,6 +43,7 @@ public function transform(Product $model): array 'backup' => $model->backup_limit, 'database' => $model->database_limit, 'allocation' => $model->allocation_limit, + 'subdomain' => $model->subdomain_limit, ], 'created_at' => $model->created_at->toIso8601String(), 'updated_at' => $model->updated_at->toIso8601String() ? $model->updated_at->toIso8601String() : null, diff --git a/app/Transformers/Api/Application/ServerTransformer.php b/app/Transformers/Api/Application/ServerTransformer.php index 71d873d5ba..f8f20c1b1a 100644 --- a/app/Transformers/Api/Application/ServerTransformer.php +++ b/app/Transformers/Api/Application/ServerTransformer.php @@ -74,6 +74,7 @@ public function transform(Server $model): array 'backups' => $model->backup_limit, 'databases' => $model->database_limit, 'subusers' => $model->subuser_limit, + 'subdomains' => $model->subdomain_limit ?? $model->product?->subdomain_limit, ], 'owner_id' => $model->owner_id, 'node_id' => $model->node_id, diff --git a/app/Transformers/Api/Application/SubuserTransformer.php b/app/Transformers/Api/Application/SubuserTransformer.php index 1f7426aaf7..2ab55cd29c 100644 --- a/app/Transformers/Api/Application/SubuserTransformer.php +++ b/app/Transformers/Api/Application/SubuserTransformer.php @@ -3,6 +3,7 @@ namespace Everest\Transformers\Api\Application; use Everest\Models\Subuser; +use Everest\Models\Permission; use League\Fractal\Resource\Item; use Everest\Services\Acl\Api\AdminAcl; use Everest\Transformers\Api\Transformer; @@ -32,7 +33,7 @@ public function transform(Subuser $model): array 'id' => $model->id, 'user_id' => $model->user_id, 'server_id' => $model->server_id, - 'permissions' => $model->permissions, + 'permissions' => Permission::expandPermissions($model->permissions ?? []), 'created_at' => $model->created_at->toIso8601String(), 'updated_at' => $model->updated_at->toIso8601String(), ]; diff --git a/app/Transformers/Api/Client/ProductTransformer.php b/app/Transformers/Api/Client/ProductTransformer.php index a288ca77ad..05d915dc8f 100644 --- a/app/Transformers/Api/Client/ProductTransformer.php +++ b/app/Transformers/Api/Client/ProductTransformer.php @@ -40,6 +40,7 @@ public function transform(Product $model): array 'backup' => $model->backup_limit, 'database' => $model->database_limit, 'allocation' => $model->allocation_limit, + 'subdomain' => $model->subdomain_limit, ], ]; } diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 735944e34b..42c8fa020c 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -6,6 +6,7 @@ use Everest\Models\Server; use Everest\Models\Allocation; use Everest\Models\Permission; +use Everest\Models\ExtensionConfig; use League\Fractal\Resource\Item; use Illuminate\Container\Container; use League\Fractal\Resource\Collection; @@ -57,6 +58,10 @@ public function transform(Server $server): array $modpacksSupported = $hasProjectId && $hasVersionId; } + // Check if any extensions are enabled for this server + $extensionsEnabled = config('modules.extensions.enabled', false) && + !empty(ExtensionConfig::getEnabledForServer($server)); + return [ 'server_owner' => $user->id === $server->owner_id, 'identifier' => $server->uuidShort, @@ -67,6 +72,7 @@ public function transform(Server $server): array 'node' => $server->node->name, 'node_id' => $server->node_id, 'is_node_under_maintenance' => $server->node->isUnderMaintenance(), + 'is_node_supercharged' => $server->node->isSupercharged(), 'sftp_details' => [ 'ip' => $server->node->fqdn, 'port' => $server->node->public_port_sftp, @@ -86,6 +92,7 @@ public function transform(Server $server): array 'egg_features' => $server->egg->inherit_features, 'egg_id' => $server->egg_id, 'modpacks_supported' => $modpacksSupported, + 'extensions_enabled' => $extensionsEnabled, 'billing_product_id' => $server->billing_product_id, 'billing_days' => $server->billing_days, 'feature_limits' => [ @@ -93,6 +100,7 @@ public function transform(Server $server): array 'allocations' => $server->allocation_limit, 'backups' => $server->backup_limit, 'subusers' => $server->subuser_limit, + 'subdomains' => $server->subdomain_limit ?? $server->product?->subdomain_limit, ], 'status' => $server->status, 'renewal_date' => $server->renewal_date, diff --git a/app/Transformers/Api/Client/SubuserTransformer.php b/app/Transformers/Api/Client/SubuserTransformer.php index c8e8673e30..399ddf3e2f 100644 --- a/app/Transformers/Api/Client/SubuserTransformer.php +++ b/app/Transformers/Api/Client/SubuserTransformer.php @@ -3,6 +3,7 @@ namespace Everest\Transformers\Api\Client; use Everest\Models\Subuser; +use Everest\Models\Permission; use Everest\Transformers\Api\Transformer; class SubuserTransformer extends Transformer @@ -22,7 +23,7 @@ public function transform(Subuser $model): array { return array_merge( (new UserTransformer())->transform($model->user), - ['permissions' => $model->permissions] + ['permissions' => Permission::expandPermissions($model->permissions ?? [])] ); } } diff --git a/config/modules/custom_domains.php b/config/modules/custom_domains.php new file mode 100644 index 0000000000..f22f29d6f3 --- /dev/null +++ b/config/modules/custom_domains.php @@ -0,0 +1,26 @@ + env('CUSTOM_DOMAINS_ENABLED', true), + + 'cloudflare' => [ + 'token' => env('CUSTOM_DOMAINS_CLOUDFLARE_TOKEN', ''), + 'base_url' => env('CUSTOM_DOMAINS_CLOUDFLARE_BASE_URL', 'https://api.cloudflare.com/client/v4'), + 'retries' => (int) env('CUSTOM_DOMAINS_CLOUDFLARE_RETRIES', 3), + 'retry_sleep_ms' => (int) env('CUSTOM_DOMAINS_CLOUDFLARE_RETRY_SLEEP_MS', 250), + 'proxied' => (bool) env('CUSTOM_DOMAINS_CLOUDFLARE_PROXIED', false), + ], + + 'cleanup_on_delete' => (bool) env('CUSTOM_DOMAINS_CLEANUP_ON_DELETE', true), + + 'security' => [ + 'allow_wildcard' => (bool) env('CUSTOM_DOMAINS_ALLOW_WILDCARD', false), + 'max_wildcards_per_user' => (int) env('CUSTOM_DOMAINS_MAX_WILDCARDS_PER_USER', 1), + ], + + 'rate_limits' => [ + 'create_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_CREATE_PER_MINUTE', 10), + 'sync_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_SYNC_PER_MINUTE', 5), + 'billing_options_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_BILLING_OPTIONS_PER_MINUTE', 20), + ], +]; diff --git a/config/modules/extensions.php b/config/modules/extensions.php new file mode 100644 index 0000000000..f89e4afaa0 --- /dev/null +++ b/config/modules/extensions.php @@ -0,0 +1,204 @@ + env('EXTENSIONS_ENABLED', false), + + /* + * Available extensions configuration. + * Each extension can be enabled/disabled independently. + * + * --------------------------- + * Extension Settings (Admin UI) + * --------------------------- + * Extensions may define arbitrary admin-configurable settings using a `settings_schema`. + * + * - The admin panel renders the schema into a form automatically. + * - Saved values are persisted in the database table `extension_configs.settings` (JSON), per extension id. + * - These settings are GLOBAL for the extension (not per-server). + * - The client extension endpoints can then read those saved values and apply them as defaults. + * + * Schema format (array of fields): + * - key: string (required) + * - label: string (required) + * - type: one of: text | password | textarea | select | boolean | number + * - help: string (optional) + * - placeholder: string (optional) + * - options: array<{ label: string, value: string|number|boolean }> (select only) + * + * Where the schema is used: + * - Admin API returns `settingsSchema` from this config file. + * - Admin UI reads that schema and shows a "Settings" section in the Configure modal. + * - When you click Save, it sends a `settings` object back to the API which is stored in `extension_configs.settings`. + * + * Reading settings later (backend / extensions): + * - Read from the DB via `ExtensionConfig`: + * $config = \Everest\Models\ExtensionConfig::getByExtensionId('your_extension_id'); + * $settings = is_array($config?->settings) ? $config->settings : []; + * $value = $settings['your_key'] ?? null; + * + * Concrete examples: + * + * 1) URL string setting (text) + * Schema: + * 'settings_schema' => [ + * ['key' => 'jar_url', 'label' => 'Jar URL', 'type' => 'text'], + * ] + * Read + apply precedence (request override -> admin setting -> fallback): + * $jarUrl = $request->input('jar_url'); + * if (!$jarUrl) $jarUrl = $settings['jar_url'] ?? null; + * if (!$jarUrl) $jarUrl = $fallbackUrl; + * + * 2) Feature toggle (boolean) + * Schema: + * 'settings_schema' => [ + * ['key' => 'enable_fast_mode', 'label' => 'Enable Fast Mode', 'type' => 'boolean'], + * ] + * Stored value is `true`/`false` JSON in the DB. + * Read (PHP): + * $fastMode = (bool) ($settings['enable_fast_mode'] ?? false); + * + * If you ever integrate with systems that represent booleans as 1/0, + * treat them as truthy/falsey on read: + * $raw = $settings['enable_fast_mode'] ?? 0; + * $fastMode = (int) $raw === 1 || $raw === true; + * + * 3) Select / enum-like setting (select) + * Schema: + * 'settings_schema' => [ + * [ + * 'key' => 'log_level', + * 'label' => 'Log Level', + * 'type' => 'select', + * 'options' => [ + * ['label' => 'Info', 'value' => 'info'], + * ['label' => 'Debug', 'value' => 'debug'], + * ], + * ], + * ] + * Note: the browser will submit select values as strings; validate/cast if needed. + * Read (PHP): + * $level = (string) ($settings['log_level'] ?? 'info'); + * if (!in_array($level, ['info', 'debug'], true)) $level = 'info'; + * + * 4) Number setting (number) + * Schema: + * 'settings_schema' => [ + * ['key' => 'timeout_seconds', 'label' => 'Timeout (seconds)', 'type' => 'number'], + * ] + * Read (PHP): + * $timeout = (int) ($settings['timeout_seconds'] ?? 15); + * + * Notes: + * - These settings are not automatically validated server-side beyond "must be an array". + * If a setting is security-sensitive, validate it in your request/controller. + * - If you need PER-SERVER settings, do not use this store; create a server-scoped table or use a server metadata mechanism. + * + */ + 'available' => [ + /* + * Example extension (copy/paste template) + * + * 1) Pick a unique ID (array key). This becomes the extension_id everywhere. + * 2) Create routes at: routes/extensions/client/.php + * and ensure the route prefix matches the `route` value below. + * 3) Add frontend route entry in the extensions registry (server UI). + * 4) Optional: define `settings_schema` to get schema-driven admin settings. + * + * NOTE: This block is commented out — it does nothing until you remove the comment. + */ + + // 'example_extension' => [ + // 'name' => 'Example Extension', + // 'description' => 'An example extension showing how to wire settings + routes.', + // 'version' => '0.1.0', + // 'author' => 'YourName', + // // Icon key (shown in admin + server extension lists). + // // Available: puzzle|users|gamepad|cube|server|discord|link|wrench|shield|terminal|globe|database|chart|bell|robot|cloud|folder|file|key|bolt|cogs|lock|scroll + // 'icon' => 'puzzle', + // 'route' => 'example_extension', + // + // // If you want a default enable flag from env: + // 'enabled' => env('EXTENSION_EXAMPLE_EXTENSION_ENABLED', false), + // + // // Eligibility (admin can override these in the UI) + // // Empty arrays mean "all nests/eggs". + // 'allowed_nests' => [], + // 'allowed_eggs' => [], + // + // // Optional admin-configurable settings (saved to extension_configs.settings) + // 'settings_schema' => [ + // [ + // 'key' => 'api_base_url', + // 'label' => 'API Base URL', + // 'type' => 'text', + // 'placeholder' => 'https://api.example.com', + // 'help' => 'Used as the default base URL for outbound API calls.', + // ], + // [ + // 'key' => 'enabled_mode', + // 'label' => 'Mode', + // 'type' => 'select', + // 'help' => 'Example select field. Stored in DB as a string.', + // 'options' => [ + // ['label' => 'Safe', 'value' => 'safe'], + // ['label' => 'Fast', 'value' => 'fast'], + // ], + // ], + // [ + // 'key' => 'feature_flag', + // 'label' => 'Enable Feature', + // 'type' => 'boolean', + // 'help' => 'Example boolean toggle. Stored as true/false JSON.', + // ], + // ], + // ], + + 'minecraft_player_manager' => [ + 'name' => 'Minecraft Player Manager', + 'description' => 'Manage Minecraft Java Edition players directly from the panel. Includes whitelist management, banning, kicking, operator controls, inventory viewing, attribute editing, and more.', + 'version' => '1.0.1', + 'author' => 'Bimbab189', + 'icon' => 'users', + 'route' => 'minecraft_player_manager', + 'enabled' => env('EXTENSION_MINECRAFT_PLAYER_MANAGER_ENABLED', false), + /* + * Default nests and eggs this extension works with. + * These can be overridden in the admin panel. + * Format: nest_id => [egg_ids] or nest_id => [] for all eggs in nest + */ + 'allowed_nests' => [], + 'allowed_eggs' => [], + ], + + 'discordsrv_helper' => [ + 'name' => 'DiscordSRV Helper', + 'description' => 'Quickly install and configure DiscordSRV (install plugin, set bot token, link chat channel, and generate invite link).', + 'version' => '1.0.0', + 'author' => 'Bimbab189', + 'icon' => 'server', + 'route' => 'discordsrv_helper', + 'enabled' => env('EXTENSION_DISCORDSRV_HELPER_ENABLED', false), + 'allowed_nests' => [], + 'allowed_eggs' => [], + 'settings_schema' => [ + [ + 'key' => 'jar_url', + 'label' => 'DiscordSRV Jar URL', + 'type' => 'text', + 'placeholder' => 'https://github.com/DiscordSRV/DiscordSRV/releases/download/.../DiscordSRV-Build-....jar', + 'help' => 'Optional. If set, the Install action uses this URL instead of auto-detecting the latest release.', + ], + ], + ], + ], + + /* + * Extension permissions prefix. + * All extension permissions will be prefixed with this. + */ + 'permission_prefix' => 'extension', +]; diff --git a/database/migrations/2026_02_03_000000_create_extension_configs_table.php b/database/migrations/2026_02_03_000000_create_extension_configs_table.php new file mode 100644 index 0000000000..a60969eff1 --- /dev/null +++ b/database/migrations/2026_02_03_000000_create_extension_configs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('extension_id')->index(); + $table->boolean('enabled')->default(false); + $table->json('allowed_nests')->nullable(); + $table->json('allowed_eggs')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique('extension_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('extension_configs'); + } +}; diff --git a/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php b/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php new file mode 100644 index 0000000000..69de4359b1 --- /dev/null +++ b/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php @@ -0,0 +1,28 @@ +json('disabled_extensions')->nullable()->after('permissions'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subusers', function (Blueprint $table) { + $table->dropColumn('disabled_extensions'); + }); + } +}; diff --git a/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php b/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php new file mode 100644 index 0000000000..a41215e747 --- /dev/null +++ b/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php @@ -0,0 +1,44 @@ +limit(1)->exists(); + + if ($hasRows) { + if (!Schema::hasTable('extension_file_snapshots_legacy')) { + Schema::rename('extension_file_snapshots', 'extension_file_snapshots_legacy'); + } else { + Schema::drop('extension_file_snapshots'); + } + } else { + Schema::drop('extension_file_snapshots'); + } + } + + Schema::create('extension_file_snapshots', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('server_id')->index(); + $table->unsignedInteger('actor_id')->nullable()->index(); + $table->string('extension_id')->index(); + $table->string('action')->index(); + $table->longText('files'); + $table->timestamps(); + + $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete(); + $table->foreign('actor_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_file_snapshots'); + } +}; diff --git a/database/migrations/2026_02_17_120000_create_custom_domains_table.php b/database/migrations/2026_02_17_120000_create_custom_domains_table.php new file mode 100644 index 0000000000..54be8c2378 --- /dev/null +++ b/database/migrations/2026_02_17_120000_create_custom_domains_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('domain')->unique(); + $table->string('cloudflare_zone_id')->nullable(); + $table->boolean('wildcard_enabled')->default(false); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domains'); + } +}; diff --git a/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php b/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php new file mode 100644 index 0000000000..df2beb5986 --- /dev/null +++ b/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('allocation_id')->nullable(); + $table->foreignId('custom_domain_id')->constrained('custom_domains')->cascadeOnDelete(); + $table->string('subdomain'); + $table->string('full_domain'); + $table->unsignedInteger('port'); + $table->enum('protocol', ['tcp', 'udp', 'both'])->default('both'); + $table->boolean('ssl_enabled')->default(false); + $table->enum('ssl_status', ['disabled', 'pending', 'issued', 'failed'])->default('disabled'); + $table->enum('status', ['pending', 'active', 'failed'])->default('pending'); + $table->json('dns_records')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['full_domain', 'port', 'protocol'], 'server_custom_domains_unique_target'); + $table->index(['server_id', 'status']); + $table->index('allocation_id'); + + $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete(); + $table->foreign('allocation_id')->references('id')->on('allocations')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_custom_domains'); + } +}; diff --git a/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php b/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php new file mode 100644 index 0000000000..7495835347 --- /dev/null +++ b/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedInteger('server_id')->nullable(); + $table->foreignId('server_custom_domain_id')->nullable()->constrained('server_custom_domains')->nullOnDelete(); + $table->enum('action', ['create', 'update', 'delete', 'sync', 'ssl']); + $table->enum('status', ['success', 'failed']); + $table->json('payload')->nullable(); + $table->text('message')->nullable(); + $table->timestamps(); + + $table->index(['server_id', 'created_at']); + $table->foreign('server_id')->references('id')->on('servers')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domain_dns_logs'); + } +}; diff --git a/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php b/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php new file mode 100644 index 0000000000..50e0cd6e0d --- /dev/null +++ b/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php @@ -0,0 +1,21 @@ +json('domain_payload')->nullable()->after('variables'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('domain_payload'); + }); + } +}; diff --git a/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php b/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php new file mode 100644 index 0000000000..20c3458e66 --- /dev/null +++ b/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name')->unique(); + $table->text('token'); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domain_api_keys'); + } +}; diff --git a/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php b/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php new file mode 100644 index 0000000000..ce973ea8a3 --- /dev/null +++ b/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php @@ -0,0 +1,43 @@ +unsignedInteger('subdomain_limit')->nullable()->default(1)->after('subuser_limit'); + } + }); + + Schema::table('products', function (Blueprint $table) { + if (!Schema::hasColumn('products', 'subdomain_limit')) { + $table->unsignedInteger('subdomain_limit')->nullable()->default(1)->after('allocation_limit'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + if (Schema::hasColumn('servers', 'subdomain_limit')) { + $table->dropColumn('subdomain_limit'); + } + }); + + Schema::table('products', function (Blueprint $table) { + if (Schema::hasColumn('products', 'subdomain_limit')) { + $table->dropColumn('subdomain_limit'); + } + }); + } +}; diff --git a/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php b/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php new file mode 100644 index 0000000000..61bd169a08 --- /dev/null +++ b/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php @@ -0,0 +1,41 @@ +foreignId('api_key_id')->nullable()->after('cloudflare_zone_id')->constrained('custom_domain_api_keys')->nullOnDelete(); + $table->json('allowed_nest_ids')->nullable()->after('api_key_id'); + $table->json('allowed_egg_ids')->nullable()->after('allowed_nest_ids'); + $table->string('service_tag')->nullable()->after('allowed_egg_ids'); + }); + + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('ssl_enabled'); + $table->dropColumn('ssl_status'); + $table->string('service_tag')->nullable()->after('protocol'); + }); + } + + public function down(): void + { + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('service_tag'); + + $table->boolean('ssl_enabled')->default(false); + $table->enum('ssl_status', ['disabled', 'pending', 'issued', 'failed'])->default('disabled'); + }); + + Schema::table('custom_domains', function (Blueprint $table) { + $table->dropColumn('service_tag'); + $table->dropColumn('allowed_egg_ids'); + $table->dropColumn('allowed_nest_ids'); + + $table->dropConstrainedForeignId('api_key_id'); + }); + } +}; diff --git a/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php b/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php new file mode 100644 index 0000000000..cc327c686e --- /dev/null +++ b/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php @@ -0,0 +1,21 @@ +json('egg_service_tags')->nullable()->after('service_tag'); + }); + } + + public function down(): void + { + Schema::table('custom_domains', function (Blueprint $table) { + $table->dropColumn('egg_service_tags'); + }); + } +}; diff --git a/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php b/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php new file mode 100644 index 0000000000..4fe06265cc --- /dev/null +++ b/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php @@ -0,0 +1,21 @@ +enum('record_type', ['srv', 'cname'])->nullable()->after('protocol'); + }); + } + + public function down(): void + { + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('record_type'); + }); + } +}; diff --git a/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php b/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php new file mode 100644 index 0000000000..c28277834c --- /dev/null +++ b/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php @@ -0,0 +1,42 @@ +string('wings_type', 20)->default('default')->after('maintenance_mode'); + } + if (!Schema::hasColumn('nodes', 'wings_version')) { + $table->string('wings_version', 50)->nullable()->after('wings_type'); + } + if (!Schema::hasColumn('nodes', 'wings_detected_at')) { + $table->timestamp('wings_detected_at')->nullable()->after('wings_version'); + } + }); + } + + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $columns = []; + if (Schema::hasColumn('nodes', 'wings_type')) { + $columns[] = 'wings_type'; + } + if (Schema::hasColumn('nodes', 'wings_version')) { + $columns[] = 'wings_version'; + } + if (Schema::hasColumn('nodes', 'wings_detected_at')) { + $columns[] = 'wings_detected_at'; + } + if (!empty($columns)) { + $table->dropColumn($columns); + } + }); + } +}; diff --git a/docs/wings-rs-integration.md b/docs/wings-rs-integration.md new file mode 100644 index 0000000000..2c30599a8e --- /dev/null +++ b/docs/wings-rs-integration.md @@ -0,0 +1,132 @@ +# Wings-RS Integration + +This document describes the Wings-RS (Supercharged) integration for the Jexactyl panel. + +## Overview + +Wings-RS is a Rust-based alternative daemon that provides enhanced features compared to the standard Pterodactyl Wings daemon. When a node is running Wings-RS, the panel automatically detects it and unlocks supercharged features. + +## Architecture + +### Detection Flow + +1. The panel calls `GET /api/system` on the node +2. If the response contains `"supercharged": true` or a Wings-RS version string, the node is detected as supercharged +3. Node record is updated with `wings_type = 'wings-rs'`, version, and detection timestamp +4. All Wings-RS exclusive features become available in the admin and client UIs + +### Backend Components + +| File | Purpose | +|------|---------| +| `app/Models/Node.php` | Updated with `WINGS_TYPE_RS`, `WINGS_TYPE_DEFAULT` constants, `isSupercharged()` method | +| `app/Services/Nodes/WingsDetectionService.php` | Detects Wings-RS nodes and fetches system overview | +| `app/Repositories/Wings/DaemonWingsRsRepository.php` | Repository for all Wings-RS exclusive API endpoints | +| `app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php` | Admin API for node management | +| `app/Http/Controllers/Api/Client/Servers/WingsRsController.php` | Client API for server-level features | +| `database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php` | Database migration | + +### Frontend Components + +| File | Purpose | +|------|---------| +| `resources/scripts/api/routes/admin/nodes/wingsRs.ts` | Admin API functions | +| `resources/scripts/api/routes/server/wingsRs.ts` | Client API functions | +| `resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx` | Admin Wings-RS tab page | +| `resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx` | Real-time system stats | +| `resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx` | System log viewer | +| `resources/scripts/components/server/wingsrs/WingsRsContainer.tsx` | Server-level Wings-RS features | +| `resources/scripts/components/server/files/CompressFormatDialog.tsx` | Advanced compression with format selection | +| `resources/scripts/components/server/files/FileSearchDialog.tsx` | Advanced file search (glob/regex) | +| `resources/scripts/components/server/files/FileFingerprintDialog.tsx` | File checksum generation | +| `resources/scripts/components/server/files/SshInfoPanel.tsx` | SSH access guidance | + +## API Endpoints + +### Application API (Admin) + +All endpoints under `/api/application/nodes/{node}/wings-rs/`: + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/detect` | Detect if node is running Wings-RS | +| GET | `/overview` | Get Wings-RS system overview (version, features, uptime) | +| GET | `/stats` | Get real-time system stats (CPU, memory, disk, network) | +| GET | `/logs` | List available log files | +| GET | `/logs/{file}` | Get contents of a specific log file | +| POST | `/upgrade` | Trigger Wings-RS self-upgrade | + +### Client API (Server) + +All endpoints under `/api/client/servers/{server}/wings-rs/`: + +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| GET | `/status` | Get supercharged status and features | - | +| POST | `/fingerprints` | Compute file checksums | `file.read` | +| POST | `/search` | Advanced file search (glob/regex) | `file.read` | +| POST | `/compress` | Compress with format selection | `file.archive` | +| DELETE | `/operations/{operation}` | Cancel an ongoing operation | `file.update` | +| POST | `/script` | Execute a shell script | `startup.update` | +| POST | `/abort-install` | Abort ongoing installation | `settings.reinstall` | +| GET | `/install-logs` | View installation logs | `control.console` | +| GET | `/ssh` | Get SSH connection details | `file.sftp` | + +## Supported Archive Formats + +Wings-RS supports these archive formats for compression: + +- `.tar` — Uncompressed tar +- `.tar.gz` — Gzip compressed tar (default) +- `.tar.xz` — XZ compressed tar +- `.tar.bz2` — Bzip2 compressed tar +- `.tar.lz4` — LZ4 compressed tar (fastest) +- `.tar.zst` — Zstandard compressed tar +- `.zip` — ZIP archive +- `.7z` — 7-Zip archive + +## Fingerprint Algorithms + +Supported hash algorithms for file checksums: + +- SHA-256 (default) +- SHA-1 +- MD5 +- BLAKE3 + +## Graceful Fallback + +All Wings-RS features are conditionally enabled: + +- **Backend**: Every Wings-RS controller method validates `$server->node->isSupercharged()` and returns HTTP 400 if the node is not supercharged +- **Admin UI**: The Wings-RS tab appears for all nodes but shows a detection button for non-RS nodes +- **Client UI**: The Wings-RS sidebar tab only appears when `isNodeSupercharged` is true on the server +- **File Manager**: Advanced compress, search, and checksum features only appear for supercharged nodes + +Standard Wings nodes continue to work exactly as before with zero impact. + +## Database Changes + +The migration adds three columns to the `nodes` table: + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `wings_type` | string | `'default'` | Either `'default'` or `'wings-rs'` | +| `wings_version` | string (nullable) | null | The Wings-RS version string | +| `wings_detected_at` | timestamp (nullable) | null | When Wings-RS was last detected | + +## Running Tests + +```bash +php artisan test --filter=WingsDetection +php artisan test --filter=WingsRsController +``` + +## Security Considerations + +- All admin endpoints require application API key authentication +- All client endpoints require user authentication and appropriate permissions +- The `assertSupercharged()` method in `DaemonWingsRsRepository` prevents calls to Wings-RS endpoints on standard nodes +- Script execution requires `startup.update` permission +- Install abort requires `settings.reinstall` permission +- File operations respect existing permission scopes (file.read, file.archive, etc.) diff --git a/openapi.txt b/openapi.txt new file mode 100644 index 0000000000..2c347bb2d6 --- /dev/null +++ b/openapi.txt @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Pterodactyl Wings API","version":"1.0.0-pre.2"},"paths":{"/api/backups/{backup}":{"delete":{"operationId":"delete_api_backups_backup","parameters":[{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/deauthorize-user":{"post":{"operationId":"post_api_deauthorize-user","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["servers","user"],"properties":{"servers":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"user":{"type":"string","format":"uuid"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}}}}}}},"/api/servers":{"get":{"operationId":"get_api_servers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Server"}}}}}}},"post":{"operationId":"post_api_servers","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["uuid"],"properties":{"uuid":{"type":"string","format":"uuid"},"start_on_completion":{"type":"boolean"},"skip_scripts":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/power":{"post":{"operationId":"post_api_servers_power","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["servers","action"],"properties":{"servers":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"action":{"$ref":"#/components/schemas/ServerPowerAction"},"wait_seconds":{"type":["integer","null"],"format":"int64","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["affected"],"properties":{"affected":{"type":"integer","minimum":0}}}}}}}}},"/api/servers/{server}":{"get":{"operationId":"get_api_servers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Server"}}}}}},"delete":{"operationId":"delete_api_servers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/backup":{"post":{"operationId":"post_api_servers_server_backup","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter","uuid","ignore"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"},"uuid":{"type":"string","format":"uuid"},"ignore":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/backup/{backup}":{"delete":{"operationId":"delete_api_servers_server_backup_backup","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/backup/{backup}/restore":{"post":{"operationId":"post_api_servers_server_backup_backup_restore","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter","truncate_directory"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"},"truncate_directory":{"type":"boolean"},"download_url":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/commands":{"post":{"operationId":"post_api_servers_server_commands","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["commands"],"properties":{"commands":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/chmod":{"post":{"operationId":"post_api_servers_server_files_chmod","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["file","mode"],"properties":{"file":{"$ref":"#/components/schemas/CompactString"},"mode":{"$ref":"#/components/schemas/CompactString"}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["updated"],"properties":{"updated":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/compress":{"post":{"operationId":"post_api_servers_server_files_compress","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"format":{"$ref":"#/components/schemas/ArchiveFormat"},"name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryEntry"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/contents":{"get":{"operationId":"get_api_servers_server_files_contents","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"file","in":"query","description":"The file to view contents of","required":true,"schema":{"type":"string"}},{"name":"download","in":"query","description":"Whether to add 'download headers' to the file","required":true,"schema":{"type":"boolean"}},{"name":"max_size","in":"query","description":"The maximum size of the file to return. If the file is larger than this, an error will be returned.","required":false,"schema":{"type":"integer","format":"int64","minimum":0}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy":{"post":{"operationId":"post_api_servers_server_files_copy","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["path"],"properties":{"path":{"$ref":"#/components/schemas/CompactString"},"name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryEntry"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy-many":{"post":{"operationId":"post_api_servers_server_files_copy-many","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["from","to"],"properties":{"from":{"$ref":"#/components/schemas/CompactString"},"to":{"$ref":"#/components/schemas/CompactString"}}}},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["copied"],"properties":{"copied":{"type":"integer","minimum":0}}}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy-remote":{"post":{"operationId":"post_api_servers_server_files_copy-remote","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","token","files","destination_server","destination_path"],"properties":{"url":{"type":"string"},"token":{"type":"string"},"archive_format":{"$ref":"#/components/schemas/TransferArchiveFormat"},"compression_level":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompressionLevel"}]},"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"destination_server":{"type":"string","format":"uuid"},"destination_path":{"$ref":"#/components/schemas/CompactString"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/create-directory":{"post":{"operationId":"post_api_servers_server_files_create-directory","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["root","name"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"name":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/decompress":{"post":{"operationId":"post_api_servers_server_files_decompress","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["file"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"file":{"$ref":"#/components/schemas/CompactString"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/delete":{"post":{"operationId":"post_api_servers_server_files_delete","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["deleted"],"properties":{"deleted":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/fingerprints":{"get":{"operationId":"get_api_servers_server_files_fingerprints","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"algorithm","in":"query","description":"The algorithm to use for the fingerprint","required":true,"schema":{"$ref":"#/components/schemas/Algorithm"}},{"name":"files","in":"query","description":"The list of files to fingerprint","required":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["fingerprints"],"properties":{"fingerprints":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}}}}}}}}}},"/api/servers/{server}/files/list":{"get":{"operationId":"get_api_servers_server_files_list","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"directory","in":"query","description":"The directory to list files from","required":true,"schema":{"type":"string"}},{"name":"ignored","in":"query","description":"Additional ignored files","required":true,"schema":{"type":"array","items":{"type":"string"}}},{"name":"per_page","in":"query","description":"The number of entries to return per page","required":true,"schema":{"type":"integer","minimum":0}},{"name":"page","in":"query","description":"The page number to return","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["total","filesystem_writable","filesystem_fast","entries"],"properties":{"total":{"type":"integer","minimum":0},"filesystem_writable":{"type":"boolean"},"filesystem_fast":{"type":"boolean"},"entries":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/list-directory":{"get":{"operationId":"get_api_servers_server_files_list-directory","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"directory","in":"query","description":"The directory to list files from","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"deprecated":true}},"/api/servers/{server}/files/operations/{operation}":{"delete":{"operationId":"delete_api_servers_server_files_operations_operation","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"operation","in":"path","description":"The operation uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull":{"get":{"operationId":"get_api_servers_server_files_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["downloads"],"properties":{"downloads":{"type":"array","items":{"$ref":"#/components/schemas/Download"}}}}}}}},"deprecated":true},"post":{"operationId":"post_api_servers_server_files_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"url":{"$ref":"#/components/schemas/CompactString"},"file_name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"use_header":{"type":"boolean"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull/query":{"post":{"operationId":"post_api_servers_server_files_pull_query","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["final_url","headers"],"properties":{"file_name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"file_size":{"type":["integer","null"],"format":"int64","minimum":0},"final_url":{"$ref":"#/components/schemas/CompactString"},"headers":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}}}}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull/{pull}":{"delete":{"operationId":"delete_api_servers_server_files_pull_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"pull","in":"path","description":"The pull uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"deprecated":true}},"/api/servers/{server}/files/rename":{"put":{"operationId":"put_api_servers_server_files_rename","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["from","to"],"properties":{"from":{"$ref":"#/components/schemas/CompactString"},"to":{"$ref":"#/components/schemas/CompactString"}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["renamed"],"properties":{"renamed":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/search":{"post":{"operationId":"post_api_servers_server_files_search","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["per_page"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"path_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["include"],"properties":{"include":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"exclude":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"case_insensitive":{"type":"boolean"}}}]},"size_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["max"],"properties":{"min":{"type":"integer","format":"int64","minimum":0},"max":{"type":"integer","format":"int64","minimum":0}}}]},"content_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["query","max_search_size"],"properties":{"query":{"$ref":"#/components/schemas/CompactString"},"max_search_size":{"type":"integer","format":"int64","minimum":0},"include_unmatched":{"type":"boolean"},"case_insensitive":{"type":"boolean"}}}]},"per_page":{"type":"integer","minimum":0}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/write":{"post":{"operationId":"post_api_servers_server_files_write","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"file","in":"query","description":"The file to view contents of","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/install/abort":{"post":{"operationId":"post_api_servers_server_install_abort","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/logs":{"get":{"operationId":"get_api_servers_server_logs","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"lines","in":"query","description":"The number of lines to tail from the log","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/servers/{server}/logs/install":{"get":{"operationId":"get_api_servers_server_logs_install","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"lines","in":"query","description":"The number of lines to tail from the log","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/power":{"post":{"operationId":"post_api_servers_server_power","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"$ref":"#/components/schemas/ServerPowerAction"},"wait_seconds":{"type":["integer","null"],"format":"int64","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/reinstall":{"post":{"operationId":"post_api_servers_server_reinstall","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"truncate_directory":{"type":"boolean"},"installation_script":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/InstallationScript"}]}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}":{"get":{"operationId":"get_api_servers_server_schedules_schedule","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"$ref":"#/components/schemas/ScheduleStatus"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}/abort":{"post":{"operationId":"post_api_servers_server_schedules_schedule_abort","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}/trigger":{"post":{"operationId":"post_api_servers_server_schedules_schedule_trigger","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"skip_condition":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/script":{"post":{"operationId":"post_api_servers_server_script","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallationScript"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["stdout","stderr"],"properties":{"stdout":{"type":"string"},"stderr":{"type":"string"}}}}}}}}},"/api/servers/{server}/sync":{"post":{"operationId":"post_api_servers_server_sync","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["server"],"properties":{"server":{}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/transfer":{"post":{"operationId":"post_api_servers_server_transfer","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","token"],"properties":{"url":{"type":"string"},"token":{"type":"string"},"archive_format":{"$ref":"#/components/schemas/TransferArchiveFormat"},"compression_level":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompressionLevel"}]},"backups":{"type":"array","items":{"type":"string","format":"uuid"}},"delete_backups":{"type":"boolean"},"multiplex_streams":{"type":"integer","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"delete_api_servers_server_transfer","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/version":{"get":{"operationId":"get_api_servers_server_version","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"game","in":"query","description":"The game logic to use for the sha256 hash","required":true,"schema":{"$ref":"#/components/schemas/Game"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["hash"],"properties":{"hash":{"$ref":"#/components/schemas/CompactString"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/ws/broadcast":{"post":{"operationId":"post_api_servers_server_ws_broadcast","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["users","permissions","message"],"properties":{"users":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"permissions":{"type":"array","items":{"type":"string"}},"message":{"$ref":"#/components/schemas/WebsocketMessage"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/ws/deny":{"post":{"operationId":"post_api_servers_server_ws_deny","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["jtis"],"properties":{"jtis":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/ws/permissions":{"post":{"operationId":"post_api_servers_server_ws_permissions","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["user_permissions"],"properties":{"user_permissions":{"type":"array","items":{"type":"object","required":["user","permissions"],"properties":{"user":{"type":"string","format":"uuid"},"permissions":{"type":"array","items":{"type":"string"}},"ignored_files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/system":{"get":{"operationId":"get_api_system","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["architecture","cpu_count","kernel_version","os","version"],"properties":{"architecture":{"type":"string"},"cpu_count":{"type":"integer","minimum":0},"kernel_version":{"type":"string"},"os":{"type":"string"},"version":{"type":"string"}}}}}}}}},"/api/system/config":{"get":{"operationId":"get_api_system_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["remote"],"properties":{"debug":{"type":"boolean"},"app_name":{"type":"string"},"uuid":{"type":"string","format":"uuid"},"token_id":{"type":"string"},"token":{"type":"string"},"api":{"type":"object","properties":{"host":{"type":"string","default":"0.0.0.0"},"port":{"type":"integer","format":"int32","default":8080,"minimum":0},"ssl":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"cert":{"type":"string","default":""},"key":{"type":"string","default":""}}}],"default":{"enabled":false,"cert":"","key":""}},"redirects":{"type":"object","default":{},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"disable_openapi_docs":{"type":"boolean","default":false},"disable_remote_download":{"type":"boolean","default":false},"server_remote_download_limit":{"type":"integer","default":3,"minimum":0},"remote_download_blocked_cidrs":{"type":"array","items":{"type":"string"},"default":["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","169.254.0.0/16","::1","fe80::/10","fc00::/7"]},"disable_directory_size":{"type":"boolean","default":false},"directory_entry_limit":{"type":"integer","default":10000,"minimum":0},"send_offline_server_logs":{"type":"boolean","default":false},"file_search_threads":{"type":"integer","default":4,"minimum":0},"file_copy_threads":{"type":"integer","default":4,"minimum":0},"file_decompression_threads":{"type":"integer","default":2,"minimum":0},"file_compression_threads":{"type":"integer","default":2,"minimum":0},"upload_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":100},"max_jwt_uses":{"type":"integer","default":5,"minimum":0},"trusted_proxies":{"type":"array","items":{"type":"string"},"default":[]}}},"system":{"type":"object","properties":{"root_directory":{"type":"string","default":"/var/lib/pterodactyl"},"log_directory":{"type":"string","default":"/var/log/pterodactyl"},"vmount_directory":{"type":"string","default":"/var/lib/pterodactyl/vmounts"},"data":{"type":"string","default":"/var/lib/pterodactyl/volumes"},"archive_directory":{"type":"string","default":"/var/lib/pterodactyl/archives"},"backup_directory":{"type":"string","default":"/var/lib/pterodactyl/backups"},"tmp_directory":{"type":"string","default":"/tmp/pterodactyl"},"username":{"oneOf":[{"$ref":"#/components/schemas/CompactString"}],"default":"pterodactyl"},"timezone":{"oneOf":[{"$ref":"#/components/schemas/CompactString"}],"default":"Europe/Vilnius"},"user":{"oneOf":[{"type":"object","properties":{"rootless":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"container_uid":{"type":"integer","format":"int32","default":0,"minimum":0},"container_gid":{"type":"integer","format":"int32","default":0,"minimum":0}}}],"default":{"enabled":false,"container_uid":0,"container_gid":0}},"uid":{"type":"integer","format":"int32","default":0,"minimum":0},"gid":{"type":"integer","format":"int32","default":0,"minimum":0}}}],"default":{"rootless":{"enabled":false,"container_uid":0,"container_gid":0},"uid":0,"gid":0}},"passwd":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"directory":{"type":"string","default":"/run/wings/etc"}}}],"default":{"enabled":false,"directory":"/run/wings/etc"}},"disk_check_interval":{"type":"integer","format":"int64","default":150,"minimum":0},"disk_check_threads":{"type":"integer","default":2,"minimum":0},"disk_limiter_mode":{"oneOf":[{"$ref":"#/components/schemas/DiskLimiterMode"}],"default":"none"},"activity_send_interval":{"type":"integer","format":"int64","default":60,"minimum":0},"activity_send_count":{"type":"integer","default":100,"minimum":0},"check_permissions_on_boot":{"type":"boolean","default":true},"check_permissions_on_boot_threads":{"type":"integer","default":4,"minimum":0},"websocket_log_count":{"type":"integer","default":150,"minimum":0},"sftp":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"bind_address":{"type":"string","default":"0.0.0.0"},"bind_port":{"type":"integer","format":"int32","default":2022,"minimum":0},"read_only":{"type":"boolean","default":false},"key_algorithm":{"type":"string","default":"ssh-ed25519"},"disable_password_auth":{"type":"boolean","default":false},"directory_entry_limit":{"type":"integer","format":"int64","default":20000,"minimum":0},"directory_entry_send_amount":{"type":"integer","default":500,"minimum":0},"limits":{"oneOf":[{"type":"object","properties":{"authentication_password_attempts":{"type":"integer","default":3,"minimum":0},"authentication_pubkey_attempts":{"type":"integer","default":20,"minimum":0},"authentication_cooldown":{"type":"integer","format":"int64","default":60,"minimum":0}}}],"default":{"authentication_password_attempts":3,"authentication_pubkey_attempts":20,"authentication_cooldown":60}},"shell":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"cli":{"oneOf":[{"type":"object","properties":{"name":{"type":"string","default":".wings"}}}],"default":{"name":".wings"}}}}],"default":{"enabled":true,"cli":{"name":".wings"}}},"activity":{"oneOf":[{"type":"object","properties":{"log_logins":{"type":"boolean","default":false},"log_file_reads":{"type":"boolean","default":false}}}],"default":{"log_logins":false,"log_file_reads":false}}}}],"default":{"enabled":true,"bind_address":"0.0.0.0","bind_port":2022,"read_only":false,"key_algorithm":"ssh-ed25519","disable_password_auth":false,"directory_entry_limit":20000,"directory_entry_send_amount":500,"limits":{"authentication_password_attempts":3,"authentication_pubkey_attempts":20,"authentication_cooldown":60},"shell":{"enabled":true,"cli":{"name":".wings"}},"activity":{"log_logins":false,"log_file_reads":false}}},"crash_detection":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"detect_clean_exit_as_crash":{"type":"boolean","default":true},"timeout":{"type":"integer","format":"int64","default":60,"minimum":0}}}],"default":{"enabled":true,"detect_clean_exit_as_crash":true,"timeout":60}},"backups":{"oneOf":[{"type":"object","properties":{"write_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0},"read_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0},"compression_level":{"oneOf":[{"$ref":"#/components/schemas/CompressionLevel"}],"default":"best_speed"},"mounting":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"path":{"type":"string","default":".backups"}}}],"default":{"enabled":true,"path":".backups"}},"wings":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"restore_threads":{"type":"integer","default":4,"minimum":0},"archive_format":{"oneOf":[{"$ref":"#/components/schemas/ArchiveFormat"}],"default":"tar_gz"}}}],"default":{"create_threads":4,"restore_threads":4,"archive_format":"tar_gz"}},"s3":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"part_upload_timeout":{"type":"integer","format":"int64","default":7200,"minimum":0},"retry_limit":{"type":"integer","format":"int64","default":10,"minimum":0}}}],"default":{"create_threads":4,"part_upload_timeout":7200,"retry_limit":10}},"ddup_bak":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"compression_format":{"oneOf":[{"$ref":"#/components/schemas/SystemBackupsDdupBakCompressionFormat"}],"default":"deflate"}}}],"default":{"create_threads":4,"compression_format":"deflate"}},"restic":{"oneOf":[{"type":"object","properties":{"repository":{"type":"string","default":"/var/lib/pterodactyl/backups/restic"},"password_file":{"type":"string","default":"/var/lib/pterodactyl/backups/restic_password"},"retry_lock_seconds":{"type":"integer","format":"int64","default":60,"minimum":0},"environment":{"type":"object","default":{},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}}}}],"default":{"repository":"/var/lib/pterodactyl/backups/restic","password_file":"/var/lib/pterodactyl/backups/restic_password","retry_lock_seconds":60,"environment":{}}},"btrfs":{"oneOf":[{"type":"object","properties":{"restore_threads":{"type":"integer","default":4,"minimum":0},"create_read_only":{"type":"boolean","default":true}}}],"default":{"restore_threads":4,"create_read_only":true}},"zfs":{"oneOf":[{"type":"object","properties":{"restore_threads":{"type":"integer","default":4,"minimum":0}}}],"default":{"restore_threads":4}}}}],"default":{"write_limit":0,"read_limit":0,"compression_level":"best_speed","mounting":{"enabled":true,"path":".backups"},"wings":{"create_threads":4,"restore_threads":4,"archive_format":"tar_gz"},"s3":{"create_threads":4,"part_upload_timeout":7200,"retry_limit":10},"ddup_bak":{"create_threads":4,"compression_format":"deflate"},"restic":{"repository":"/var/lib/pterodactyl/backups/restic","password_file":"/var/lib/pterodactyl/backups/restic_password","retry_lock_seconds":60,"environment":{}},"btrfs":{"restore_threads":4,"create_read_only":true},"zfs":{"restore_threads":4}}},"transfers":{"oneOf":[{"type":"object","properties":{"download_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0}}}],"default":{"download_limit":0}}}},"docker":{"type":"object","properties":{"socket":{"type":"string","default":"/var/run/docker.sock"},"server_name_in_container_name":{"type":"boolean","default":false},"delete_container_on_stop":{"type":"boolean","default":true},"network":{"oneOf":[{"type":"object","properties":{"interface":{"type":"string","default":"172.18.0.1"},"disable_interface_binding":{"type":"boolean","default":false},"dns":{"type":"array","items":{"type":"string"},"default":["1.1.1.1","1.0.0.1"]},"name":{"type":"string","default":"pterodactyl_nw"},"ispn":{"type":"boolean","default":false},"driver":{"type":"string","default":"bridge"},"mode":{"type":"string","default":"pterodactyl_nw"},"is_internal":{"type":"boolean","default":false},"enable_icc":{"type":"boolean","default":true},"network_mtu":{"type":"integer","format":"int64","default":1500,"minimum":0},"interfaces":{"oneOf":[{"type":"object","properties":{"v4":{"oneOf":[{"type":"object","properties":{"subnet":{"type":"string","default":"172.18.0.0/16"},"gateway":{"type":"string","default":"172.18.0.1"}}}],"default":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"}},"v6":{"oneOf":[{"type":"object","properties":{"subnet":{"type":"string","default":"fdba:17c8:6c94::/64"},"gateway":{"type":"string","default":"fdba:17c8:6c94::1011"}}}],"default":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}}],"default":{"v4":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"},"v6":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}}}],"default":{"interface":"172.18.0.1","disable_interface_binding":false,"dns":["1.1.1.1","1.0.0.1"],"name":"pterodactyl_nw","ispn":false,"driver":"bridge","mode":"pterodactyl_nw","is_internal":false,"enable_icc":true,"network_mtu":1500,"interfaces":{"v4":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"},"v6":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}},"domainname":{"type":"string","default":""},"registries":{"type":"object","default":{},"additionalProperties":{"type":"object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string"}}},"propertyNames":{"type":"string"}},"tmpfs_size":{"type":"integer","format":"int64","default":100,"minimum":0},"container_pid_limit":{"type":"integer","format":"int64","default":5120,"minimum":0},"installer_limits":{"oneOf":[{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","default":1800,"minimum":0},"memory":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":1024},"cpu":{"type":"integer","format":"int64","description":"%","default":100,"minimum":0}}}],"default":{"timeout":1800,"memory":1024,"cpu":100}},"overhead":{"oneOf":[{"type":"object","properties":{"override":{"type":"boolean","default":false},"default_multiplier":{"type":"number","format":"double","default":1.05},"multipliers":{"type":"object","description":"Memory Limit MiB -> Multiplier","default":{},"additionalProperties":{"type":"number","format":"double"},"propertyNames":{"type":"integer","format":"int64","description":"Represents a size in Mebibytes (MiB). The inner value is the number of MiB (not bytes!!).","minimum":0}}}}],"default":{"override":false,"default_multiplier":1.05,"multipliers":{}}},"userns_mode":{"type":"string","default":""},"log_config":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","default":"local"},"config":{"type":"object","default":{"compress":"false","max-file":"1","max-size":"5m","mode":"non-blocking"},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}}}}],"default":{"type":"local","config":{"compress":"false","max-file":"1","max-size":"5m","mode":"non-blocking"}}}}},"throttles":{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"lines":{"type":"integer","format":"int64","default":2000,"minimum":0},"line_reset_interval":{"type":"integer","format":"int64","description":"ms","default":100,"minimum":0}}},"remote":{"type":"string"},"remote_headers":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"remote_query":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","default":30,"minimum":0},"boot_servers_per_page":{"type":"integer","format":"int64","default":50,"minimum":0},"retry_limit":{"type":"integer","format":"int64","default":10,"minimum":0}}},"allowed_mounts":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"allowed_origins":{"type":"array","items":{"type":"string"}},"allow_cors_private_network":{"type":"boolean"},"ignore_panel_config_updates":{"type":"boolean"}}}}}}}}},"/api/system/logs":{"get":{"operationId":"get_api_system_logs","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["log_files"],"properties":{"log_files":{"type":"array","items":{"type":"object","required":["name","size","last_modified"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"size":{"type":"integer","format":"int64","minimum":0},"last_modified":{"type":"string","format":"date-time"}}}}}}}}}}}},"/api/system/logs/{file}":{"get":{"operationId":"get_api_system_logs_file","parameters":[{"name":"file","in":"path","description":"The log file name","required":true,"schema":{"type":"string"},"example":"wings.log"},{"name":"lines","in":"query","description":"The number of lines to tail from the log file","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/system/overview":{"get":{"operationId":"get_api_system_overview","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["version","container_type","cpu","memory","servers","architecture","kernel_version"],"properties":{"version":{"type":"string"},"container_type":{"$ref":"#/components/schemas/AppContainerType"},"cpu":{"type":"object","required":["name","brand","vendor_id","frequency_mhz","cpu_count"],"properties":{"name":{"type":"string"},"brand":{"type":"string"},"vendor_id":{"type":"string"},"frequency_mhz":{"type":"integer","format":"int64","minimum":0},"cpu_count":{"type":"integer","minimum":0}}},"memory":{"type":"object","required":["total_bytes","free_bytes","used_bytes","used_bytes_process"],"properties":{"total_bytes":{"type":"integer","format":"int64","minimum":0},"free_bytes":{"type":"integer","format":"int64","minimum":0},"used_bytes":{"type":"integer","format":"int64","minimum":0},"used_bytes_process":{"type":"integer","format":"int64","minimum":0}}},"servers":{"type":"object","required":["total","online","offline"],"properties":{"total":{"type":"integer","minimum":0},"online":{"type":"integer","minimum":0},"offline":{"type":"integer","minimum":0}}},"architecture":{"type":"string"},"kernel_version":{"type":"string"}}}}}}}}},"/api/system/stats":{"get":{"operationId":"get_api_system_stats","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["stats"],"properties":{"stats":{"$ref":"#/components/schemas/SystemStats"}}}}}}}}},"/api/system/upgrade":{"post":{"operationId":"post_api_system_upgrade","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","headers","sha256","restart_command","restart_command_args"],"properties":{"url":{"$ref":"#/components/schemas/CompactString"},"headers":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}},"sha256":{"$ref":"#/components/schemas/CompactString"},"restart_command":{"$ref":"#/components/schemas/CompactString"},"restart_command_args":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}}}}}}},"/api/transfers":{"post":{"operationId":"post_api_transfers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/transfers/files":{"post":{"operationId":"post_api_transfers_files","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/transfers/{server}":{"delete":{"operationId":"delete_api_transfers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/update":{"post":{"operationId":"post_api_update","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"debug":{"type":["boolean","null"]},"app_name":{"type":["string","null"]},"api":{"oneOf":[{"type":"null"},{"type":"object","properties":{"host":{"type":["string","null"]},"port":{"type":["integer","null"],"format":"int32","minimum":0},"ssl":{"oneOf":[{"type":"null"},{"type":"object","properties":{"enabled":{"type":["boolean","null"]},"cert":{"type":["string","null"]},"key":{"type":["string","null"]}}}]},"upload_limit":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MiB"}]}}}]},"system":{"oneOf":[{"type":"null"},{"type":"object","properties":{"sftp":{"oneOf":[{"type":"null"},{"type":"object","properties":{"bind_address":{"type":["string","null"]},"bind_port":{"type":["integer","null"],"format":"int32","minimum":0}}}]}}}]},"allowed_origins":{"type":["array","null"],"items":{"type":"string"}},"allow_cors_private_network":{"type":["boolean","null"]},"ignore_panel_config_updates":{"type":["boolean","null"]}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["applied"],"properties":{"applied":{"type":"boolean"}}}}}}}}},"/download/backup":{"get":{"operationId":"get_download_backup","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/directory":{"get":{"operationId":"get_download_directory","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/file":{"get":{"operationId":"get_download_file","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/files":{"get":{"operationId":"get_download_files","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/upload/file":{"post":{"operationId":"post_upload_file","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}},{"name":"directory","in":"query","description":"The directory to upload the file to","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}},"components":{"schemas":{"ApiError":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"AppContainerType":{"type":"string","enum":["official","unknown","none"]},"ArchiveFormat":{"type":"string","enum":["tar","tar_gz","tar_xz","tar_lzip","tar_bz2","tar_lz4","tar_zstd","zip","seven_zip"]},"BackupAdapter":{"type":"string","enum":["wings","s3","ddup-bak","btrfs","zfs","restic"]},"CompactString":{"type":"string"},"CompressionLevel":{"type":"string","enum":["best_speed","good_speed","good_compression","best_compression"]},"DirectoryEntry":{"type":"object","required":["name","created","modified","mode","mode_bits","size","directory","file","symlink","mime"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"created":{"type":"string","format":"date-time"},"modified":{"type":"string","format":"date-time"},"mode":{"$ref":"#/components/schemas/CompactString"},"mode_bits":{"$ref":"#/components/schemas/CompactString"},"size":{"type":"integer","format":"int64","minimum":0},"directory":{"type":"boolean"},"file":{"type":"boolean"},"symlink":{"type":"boolean"},"mime":{"type":"string"}}},"DiskLimiterMode":{"type":"string","enum":["none","btrfs_subvolume","zfs_dataset","xfs_quota","fuse_quota"]},"Download":{"type":"object","required":["identifier","destination","progress","total"],"properties":{"identifier":{"type":"string","format":"uuid"},"destination":{"type":"string"},"progress":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0}}},"InstallationScript":{"type":"object","required":["container_image","entrypoint","script"],"properties":{"container_image":{"$ref":"#/components/schemas/CompactString"},"entrypoint":{"$ref":"#/components/schemas/CompactString"},"script":{"type":"string"},"environment":{"type":"object","additionalProperties":{},"propertyNames":{"type":"string"}}}},"MiB":{"type":"integer","format":"int64","description":"Represents a size in Mebibytes (MiB). The inner value is the number of MiB (not bytes!!).","minimum":0},"Mount":{"type":"object","required":["target","source","read_only"],"properties":{"target":{"$ref":"#/components/schemas/CompactString"},"source":{"$ref":"#/components/schemas/CompactString"},"read_only":{"type":"boolean"}}},"ResourceUsage":{"type":"object","required":["memory_bytes","memory_limit_bytes","disk_bytes","state","network","cpu_absolute","uptime"],"properties":{"memory_bytes":{"type":"integer","format":"int64","minimum":0},"memory_limit_bytes":{"type":"integer","format":"int64","minimum":0},"disk_bytes":{"type":"integer","format":"int64","minimum":0},"state":{"$ref":"#/components/schemas/ServerState"},"network":{"type":"object","required":["rx_bytes","tx_bytes"],"properties":{"rx_bytes":{"type":"integer","format":"int64","minimum":0},"tx_bytes":{"type":"integer","format":"int64","minimum":0}}},"cpu_absolute":{"type":"number","format":"double"},"uptime":{"type":"integer","format":"int64","minimum":0}}},"Schedule":{"type":"object","required":["uuid","triggers","condition","actions"],"properties":{"uuid":{"type":"string","format":"uuid"},"triggers":{},"condition":{},"actions":{"type":"array","items":{}}}},"ScheduleStatus":{"type":"object","required":["running","errors"],"properties":{"running":{"type":"boolean"},"errors":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","format":"uuid"}},"step":{"type":["string","null"],"format":"uuid"}}},"Server":{"type":"object","required":["state","is_suspended","utilization","configuration"],"properties":{"state":{"$ref":"#/components/schemas/ServerState"},"is_suspended":{"type":"boolean"},"utilization":{"$ref":"#/components/schemas/ResourceUsage"},"configuration":{"$ref":"#/components/schemas/ServerConfiguration"}}},"ServerAutoStartBehavior":{"type":"string","enum":["always","unless_stopped","never"]},"ServerConfiguration":{"type":"object","required":["uuid","meta","suspended","invocation","skip_egg_scripts","environment","allocations","build","mounts","egg","container"],"properties":{"uuid":{"type":"string","format":"uuid"},"start_on_completion":{"type":["boolean","null"]},"meta":{"type":"object","required":["name","description"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"description":{"$ref":"#/components/schemas/CompactString"}}},"suspended":{"type":"boolean"},"invocation":{"$ref":"#/components/schemas/CompactString"},"skip_egg_scripts":{"type":"boolean"},"environment":{"type":"object","additionalProperties":{},"propertyNames":{"type":"string"}},"labels":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"backups":{"type":"array","items":{"type":"string","format":"uuid"}},"schedules":{"type":"array","items":{"$ref":"#/components/schemas/Schedule"}},"allocations":{"type":"object","required":["force_outgoing_ip"],"properties":{"force_outgoing_ip":{"type":"boolean"},"default":{"oneOf":[{"type":"null"},{"type":"object","required":["ip","port"],"properties":{"ip":{"$ref":"#/components/schemas/CompactString"},"port":{"type":"integer","format":"int32","minimum":0}}}]},"mappings":{"type":"object","additionalProperties":{"type":"array","items":{"type":"integer","format":"int32","minimum":0}},"propertyNames":{"type":"string"}}}},"build":{"type":"object","required":["memory_limit","swap","cpu_limit","disk_space","oom_disabled"],"properties":{"memory_limit":{"type":"integer","format":"int64"},"overhead_memory":{"type":"integer","format":"int64"},"swap":{"type":"integer","format":"int64"},"io_weight":{"type":["integer","null"],"format":"int32","minimum":0},"cpu_limit":{"type":"integer","format":"int64"},"disk_space":{"type":"integer","format":"int64","minimum":0},"threads":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"oom_disabled":{"type":"boolean"}}},"mounts":{"type":"array","items":{"$ref":"#/components/schemas/Mount"}},"egg":{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"file_denylist":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}},"container":{"type":"object","required":["image"],"properties":{"image":{"$ref":"#/components/schemas/CompactString"},"timezone":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"hugepages_passthrough_enabled":{"type":"boolean"},"kvm_passthrough_enabled":{"type":"boolean"},"seccomp":{"type":"object","properties":{"remove_allowed":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"auto_kill":{"type":"object","properties":{"enabled":{"type":"boolean"},"seconds":{"type":"integer","format":"int64","minimum":0}}},"auto_start_behavior":{"$ref":"#/components/schemas/ServerAutoStartBehavior"}}},"ServerPowerAction":{"type":"string","enum":["start","stop","restart","kill"]},"ServerState":{"type":"string","enum":["offline","starting","stopping","running"]},"SystemBackupsDdupBakCompressionFormat":{"type":"string","enum":["none","deflate","gzip","brotli"]},"SystemStats":{"type":"object","required":["cpu","network","memory","disk"],"properties":{"cpu":{"type":"object","required":["used","threads","model"],"properties":{"used":{"type":"number","format":"float"},"threads":{"type":"integer","minimum":0},"model":{"type":"string"}}},"network":{"type":"object","required":["received","receiving_rate","sent","sending_rate"],"properties":{"received":{"type":"integer","format":"int64","minimum":0},"receiving_rate":{"type":"number","format":"double"},"sent":{"type":"integer","format":"int64","minimum":0},"sending_rate":{"type":"number","format":"double"}}},"memory":{"type":"object","required":["used","used_process","total"],"properties":{"used":{"type":"integer","format":"int64","minimum":0},"used_process":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0}}},"disk":{"type":"object","required":["used","total","read","reading_rate","written","writing_rate"],"properties":{"used":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0},"read":{"type":"integer","format":"int64","minimum":0},"reading_rate":{"type":"number","format":"double"},"written":{"type":"integer","format":"int64","minimum":0},"writing_rate":{"type":"number","format":"double"}}}}},"TransferArchiveFormat":{"type":"string","enum":["tar","tar_gz","tar_xz","tar_lzip","tar_bz2","tar_lz4","tar_zstd"]},"WebsocketEvent":{"type":"string","enum":["auth success","token expiring","token expired","auth","configure socket","set state","send logs","send command","send stats","daemon error","jwt error","ping","pong","stats","status","custom event","console output","install output","image pull progress","image pull completed","install started","install completed","daemon message","backup started","backup progress","backup completed","backup restore started","backup restore progress","backup restore completed","transfer logs","transfer status","schedule started","schedule step status","schedule step error","schedule completed","operation progress","operation error","operation completed"]},"WebsocketMessage":{"type":"object","required":["event","args"],"properties":{"event":{"$ref":"#/components/schemas/WebsocketEvent"},"args":{"type":"array","items":{"type":"string"}}}}},"securitySchemes":{"api_key":{"type":"apiKey","in":"header","name": \ No newline at end of file diff --git a/package.json b/package.json index bbe92c2998..72f1cd5716 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "vite build", + "postbuild": "bash ./scripts/reload-dev-services.sh", "clean": "rimraf public/build", "coverage": "vitest run --coverage", "dev": "vite", @@ -131,6 +132,7 @@ "prettier": "2.8.4", "prettier-plugin-tailwindcss": "0.2.3", "rimraf": "3.0.2", + "source-map-explorer": "^2.5.3", "tailwindcss": "3.2.7", "ts-essentials": "9.3.0", "twin.macro": "2.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839fc1d0b7..1c7ff028d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: rimraf: specifier: 3.0.2 version: 3.0.2 + source-map-explorer: + specifier: ^2.5.3 + version: 2.5.3 tailwindcss: specifier: 3.2.7 version: 3.2.7(postcss@8.4.21) @@ -1447,9 +1450,8 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1515,8 +1517,11 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} browserslist@4.28.1: @@ -1524,6 +1529,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + btoa@1.2.1: + resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} + engines: {node: '>= 0.4.0'} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1592,6 +1602,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1833,6 +1846,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1854,8 +1870,19 @@ packages: react-native: optional: true - electron-to-chromium@1.5.302: - resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.4.309: + resolution: {integrity: sha512-U7DTiKe4h+irqBG6h4EZ0XXaZuJj4md3xIXXaGSYhwiumPZ4BSc6rgf9UD0hVUMaeP/jB0q5pKWCPxvhO8fvZA==} + + electron-to-chromium@1.5.167: + resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1904,6 +1931,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2039,8 +2073,11 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + + fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} find-root@1.1.0: @@ -2122,8 +2159,15 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-func-name@2.0.0: + resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} + + get-intrinsic@1.2.0: + resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==} get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} @@ -2175,6 +2219,10 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + happy-dom@8.7.2: resolution: {integrity: sha512-lkm1l7SLNtI9svaU3PflbM8zahYahLrUZf0fZTUkQ8W6bo5gtXjC/2utOkcjpv9rhWTkHFUuDVjAvBWg4ClAxA==} @@ -2321,13 +2369,18 @@ 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'} - is-finalizationregistry@1.1.1: - 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-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} @@ -2397,14 +2450,23 @@ 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==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-sdsl@4.4.2: - resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + js-sdsl@4.3.0: + resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2543,11 +2605,19 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mlly@1.1.1: + resolution: {integrity: sha512-Jnlh4W/aI4GySPo6+DyTN17Q75KKbLTyFK8BrGhjNP4rxuUjbRWhE6gHg3bs33URWAF44FRm7gdQA348i3XxRw==} modern-normalize@1.1.0: resolution: {integrity: sha512-2lMlY1Yc1+CUy0gw4H95uNN7vjbpoED7NNRSBHE25nWfLBdmMzFCsPshlzbxHz+gYMcBEUN8V4pU16prcdPSgA==} @@ -2657,8 +2727,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + optionator@0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} engines: {node: '>= 0.8.0'} own-keys@1.0.1: @@ -3173,6 +3247,10 @@ packages: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} + 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'} @@ -3197,6 +3275,11 @@ packages: rgba-regex@1.0.0: resolution: {integrity: sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg==} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -3299,6 +3382,15 @@ packages: sockette@2.0.6: resolution: {integrity: sha512-W6iG8RGV6Zife3Cj+FhuyHV447E6fqFM2hKmnaQrTvg3OydINV3Msj3WPFbX76blUlUxvQSMMMdrJxce8NqI5Q==} + source-map-explorer@2.5.3: + resolution: {integrity: sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==} + engines: {node: '>=12'} + hasBin: true + + source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3336,6 +3428,10 @@ packages: resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string-width@5.1.2: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} @@ -3430,6 +3526,10 @@ packages: peerDependencies: postcss: ^8.0.9 + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -3685,6 +3785,10 @@ 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'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3720,6 +3824,10 @@ packages: resolution: {integrity: sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==} deprecated: This package is now deprecated. Move to @xterm/xterm instead. + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3727,6 +3835,14 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5068,7 +5184,7 @@ snapshots: assertion-error@1.1.0: {} - async-function@1.0.0: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -5147,7 +5263,11 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - braces@3.0.3: + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.2: dependencies: fill-range: 7.1.1 @@ -5159,6 +5279,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + btoa@1.2.1: {} + buffer-from@1.1.2: {} bytes@3.1.2: {} @@ -5234,6 +5356,12 @@ snapshots: client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -5474,6 +5602,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer@0.1.2: {} + eastasianwidth@0.2.0: {} easy-peasy@5.2.0(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -5491,7 +5621,15 @@ snapshots: '@types/react-dom': 18.0.11 react-dom: 18.2.0(react@18.2.0) - electron-to-chromium@1.5.302: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.4.309: {} + + electron-to-chromium@1.5.167: {} + + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -5610,6 +5748,10 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@8.6.0(eslint@8.34.0): @@ -5780,7 +5922,11 @@ snapshots: dependencies: flat-cache: 3.2.0 - fill-range@7.1.1: + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + + fill-range@7.0.1: dependencies: to-regex-range: 5.0.1 @@ -5864,7 +6010,15 @@ snapshots: gensync@1.0.0-beta.2: {} - get-func-name@2.0.2: {} + get-caller-file@2.0.5: {} + + get-func-name@2.0.0: {} + + get-intrinsic@1.2.0: + dependencies: + function-bind: 1.1.1 + has: 1.0.3 + has-symbols: 1.0.3 get-intrinsic@1.3.0: dependencies: @@ -5933,6 +6087,10 @@ snapshots: grapheme-splitter@1.0.4: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + happy-dom@8.7.2: dependencies: css.escape: 1.5.1 @@ -6085,11 +6243,11 @@ 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-fullwidth-code-point@4.0.0: {} @@ -6157,11 +6315,21 @@ 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: {} - js-sdsl@4.4.2: {} + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.4 + picocolors: 1.1.1 + + js-sdsl@4.3.0: {} js-tokens@4.0.0: {} @@ -6278,9 +6446,17 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} - mlly@1.8.0: + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mlly@1.1.1: dependencies: acorn: 8.16.0 pathe: 2.0.3 @@ -6383,7 +6559,12 @@ snapshots: dependencies: wrappy: 1.0.2 - optionator@0.9.4: + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.1: dependencies: deep-is: 0.1.4 fast-levenshtein: 2.0.6 @@ -6885,6 +7066,8 @@ snapshots: regexpp@3.2.0: {} + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve@1.22.11: @@ -6908,6 +7091,10 @@ snapshots: rgba-regex@1.0.0: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -7028,6 +7215,23 @@ snapshots: sockette@2.0.6: {} + source-map-explorer@2.5.3: + dependencies: + btoa: 1.2.1 + chalk: 4.1.2 + convert-source-map: 1.9.0 + ejs: 3.1.10 + escape-html: 1.0.3 + glob: 7.2.3 + gzip-size: 6.0.0 + lodash: 4.17.21 + open: 7.4.2 + source-map: 0.7.4 + temp: 0.9.4 + yargs: 16.2.0 + + source-map-js@1.0.2: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -7054,6 +7258,12 @@ snapshots: string-similarity@4.0.4: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string-width@5.1.2: dependencies: eastasianwidth: 0.2.0 @@ -7224,6 +7434,11 @@ snapshots: transitivePeerDependencies: - ts-node + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + text-table@0.2.0: {} timsort@0.3.0: {} @@ -7516,6 +7731,12 @@ 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 + wrappy@1.0.2: {} xtend@4.0.2: {} @@ -7541,10 +7762,24 @@ snapshots: xterm@5.1.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@1.10.2: {} + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + 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: 20.2.9 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/resources/lang/en/activity.php b/resources/lang/en/activity.php index 25ad929c97..6c9d8a9527 100644 --- a/resources/lang/en/activity.php +++ b/resources/lang/en/activity.php @@ -85,6 +85,8 @@ ], 'sftp' => [ 'denied' => 'Blocked SFTP access due to permissions', + 'login' => 'Logged in via SFTP (:method)', + 'logout' => 'Disconnected from SFTP session', 'create_one' => 'Created :files.0', 'create_other' => 'Created :count new files', 'write_one' => 'Modified the contents of :files.0', @@ -96,6 +98,12 @@ 'rename_one' => 'Renamed :files.0.from to :files.0.to', 'rename_other' => 'Renamed or moved :count files', ], + 'ssh' => [ + 'login' => 'Logged in via SSH (:type)', + 'logout' => 'Disconnected from SSH session', + 'command' => 'Executed ":command" via SSH', + 'power' => 'Sent :action power command via SSH', + ], 'allocation' => [ 'create' => 'Added :allocation to the server', 'notes' => 'Updated the notes for :allocation from ":old" to ":new"', diff --git a/resources/scripts/api/definitions/account/billing/models.d.ts b/resources/scripts/api/definitions/account/billing/models.d.ts index 695dc1720d..7ac8b4fb34 100644 --- a/resources/scripts/api/definitions/account/billing/models.d.ts +++ b/resources/scripts/api/definitions/account/billing/models.d.ts @@ -54,6 +54,7 @@ interface Product extends Model { backup: number; database: number; allocation: number; + subdomain: number | null; }; } diff --git a/resources/scripts/api/definitions/account/billing/transformers.ts b/resources/scripts/api/definitions/account/billing/transformers.ts index 69cf41ecd6..7a2dea70eb 100644 --- a/resources/scripts/api/definitions/account/billing/transformers.ts +++ b/resources/scripts/api/definitions/account/billing/transformers.ts @@ -55,6 +55,7 @@ export default class Transformers { backup: data.limits.backup, database: data.limits.database, allocation: data.limits.allocation, + subdomain: data.limits.subdomain ?? null, }, }); diff --git a/resources/scripts/api/definitions/admin/models.d.ts b/resources/scripts/api/definitions/admin/models.d.ts index 6bbef9baf5..7e0997df12 100644 --- a/resources/scripts/api/definitions/admin/models.d.ts +++ b/resources/scripts/api/definitions/admin/models.d.ts @@ -197,6 +197,7 @@ interface Product extends Model { backup: number; database: number; allocation: number; + subdomain: number | null; }; createdAt: Date; diff --git a/resources/scripts/api/definitions/admin/transformers.ts b/resources/scripts/api/definitions/admin/transformers.ts index 2754bd1fe9..ff7b018e22 100644 --- a/resources/scripts/api/definitions/admin/transformers.ts +++ b/resources/scripts/api/definitions/admin/transformers.ts @@ -207,6 +207,7 @@ export default class Transformers { backup: attributes.limits.backup, database: attributes.limits.database, allocation: attributes.limits.allocation, + subdomain: attributes.limits.subdomain ?? null, }, createdAt: new Date(attributes.created_at), diff --git a/resources/scripts/api/definitions/server/models.d.ts b/resources/scripts/api/definitions/server/models.d.ts index 7aacd1d854..149f2a25df 100644 --- a/resources/scripts/api/definitions/server/models.d.ts +++ b/resources/scripts/api/definitions/server/models.d.ts @@ -3,6 +3,7 @@ import { type SubuserPermission } from '@/state/server/subusers'; import { ServerStatus } from '@/api/routes/server'; interface Server { + serverOwner?: boolean; id: string; internalId: number | string; uuid: string; @@ -11,6 +12,7 @@ interface Server { name: string; node: string; isNodeUnderMaintenance: boolean; + isNodeSupercharged: boolean; status: ServerStatus; sftpDetails: { ip: string; @@ -30,6 +32,7 @@ interface Server { }; eggFeatures: string[]; modpacksSupported: boolean; + extensionsEnabled: boolean; billingProductId?: number; billingDays?: number; renewalDate?: Date | undefined; @@ -42,6 +45,7 @@ interface Server { allocations: number; backups: number; subusers: number; + subdomains: number | null; }; isTransferring: boolean; variables: EggVariable[]; diff --git a/resources/scripts/api/definitions/server/transformers.ts b/resources/scripts/api/definitions/server/transformers.ts index 9cc274cf9f..0723e29bd1 100644 --- a/resources/scripts/api/definitions/server/transformers.ts +++ b/resources/scripts/api/definitions/server/transformers.ts @@ -3,6 +3,7 @@ import * as Models from '@definitions/server/models.d'; export default class Transformers { static toServer = ({ attributes: data }: FractalResponseData): Models.Server => ({ + serverOwner: data.server_owner, id: data.identifier, internalId: data.internal_id, groupId: data.group_id, @@ -11,6 +12,7 @@ export default class Transformers { node: data.node, nodeId: data.node_id, isNodeUnderMaintenance: data.is_node_under_maintenance, + isNodeSupercharged: data.is_node_supercharged, status: data.status, invocation: data.invocation, dockerImage: data.docker_image, @@ -23,6 +25,7 @@ export default class Transformers { limits: { ...data.limits }, eggFeatures: data.egg_features || [], modpacksSupported: data.modpacks_supported || false, + extensionsEnabled: data.extensions_enabled || false, billingProductId: data.billing_product_id, billingDays: data.billing_days, renewalDate: data.renewal_date ? new Date(data.renewal_date) : undefined, @@ -59,7 +62,10 @@ export default class Transformers { databaseHostId: attributes.database_host_id, connectionString: `${attributes.host.address}:${attributes.host.port}`, allowConnectionsFrom: attributes.connections_from, - password: attributes.relationships?.password?.attributes?.password, + password: + attributes.relationships?.password && 'attributes' in attributes.relationships.password + ? (attributes.relationships.password as FractalResponseData).attributes.password + : undefined, }); static toSubuser = (data: FractalResponseData): Models.Subuser => ({ @@ -105,22 +111,48 @@ export default class Transformers { modifiedAt: new Date(data.attributes.modified_at), isArchiveType: function () { + const lowerName = this.name.toLowerCase(); + + const archiveExtensions = [ + '.zip', + '.7z', + '.ddup', + '.rar', + '.tar', + '.tar.gz', + '.tgz', + '.tar.bz2', + '.tbz2', + '.tar.xz', + '.txz', + '.tar.zst', + '.tzst', + '.tar.lz4', + '.tlz4', + '.tar.br', + ]; + + const archiveMimeTypes = [ + 'application/vnd.rar', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/x-br', + 'application/x-bzip2', + 'application/gzip', + 'application/x-gzip', + 'application/x-lzip', + 'application/x-sz', + 'application/x-xz', + 'application/zstd', + 'application/zip', + 'application/x-zip-compressed', + 'application/x-7z-compressed', + ]; + return ( this.isFile && - [ - 'application/vnd.rar', // .rar - 'application/x-rar-compressed', // .rar (2) - 'application/x-tar', // .tar - 'application/x-br', // .tar.br - 'application/x-bzip2', // .tar.bz2, .bz2 - 'application/gzip', // .tar.gz, .gz - 'application/x-gzip', - 'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) - 'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct) - 'application/x-xz', // .tar.xz, .xz - 'application/zstd', // .tar.zst, .zst - 'application/zip', // .zip - ].indexOf(this.mimetype) >= 0 + (archiveExtensions.some(extension => lowerName.endsWith(extension)) || + archiveMimeTypes.indexOf(this.mimetype) >= 0) ); }, diff --git a/resources/scripts/api/routes/account/billing/customDomains.ts b/resources/scripts/api/routes/account/billing/customDomains.ts new file mode 100644 index 0000000000..63a3920f19 --- /dev/null +++ b/resources/scripts/api/routes/account/billing/customDomains.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; + +export interface AvailableCustomDomain { + id: number; + domain: string; + wildcard_enabled: boolean; + default_service_tag: string | null; + recommended_record_type: 'srv' | 'cname'; + srv_supported: boolean; + allow_record_type_selection: boolean; + forced_record_type: 'srv' | 'cname' | null; + dns_mode: 'minecraft' | 'rust' | 'generic'; + recommendation_notice: string; + connection_hint: string; +} + +export const getAvailableCustomDomains = async (eggId?: number): Promise => { + const query = eggId ? `?egg_id=${eggId}` : ''; + const { data } = await http.get(`/api/client/billing/custom-domains/options${query}`); + + return data.data || []; +}; diff --git a/resources/scripts/api/routes/account/billing/orders/mollie.ts b/resources/scripts/api/routes/account/billing/orders/mollie.ts index 3861a1726e..4f4c37a375 100644 --- a/resources/scripts/api/routes/account/billing/orders/mollie.ts +++ b/resources/scripts/api/routes/account/billing/orders/mollie.ts @@ -49,6 +49,7 @@ export const updateMolliePayment = ({ eggId, billingDays, name, + domainPayload, }: { id: number; paymentId: string; @@ -60,6 +61,11 @@ export const updateMolliePayment = ({ eggId?: number; billingDays?: number; name: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; }): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/mollie/payment`, { @@ -72,6 +78,7 @@ export const updateMolliePayment = ({ egg_id: eggId, billing_days: billingDays, name, + domain_payload: domainPayload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/paypal.ts b/resources/scripts/api/routes/account/billing/orders/paypal.ts index 23ce626c15..6615642288 100644 --- a/resources/scripts/api/routes/account/billing/orders/paypal.ts +++ b/resources/scripts/api/routes/account/billing/orders/paypal.ts @@ -58,6 +58,7 @@ export const updatePayPalOrder = ({ eggId, billingDays, name, + domainPayload, }: { id: number; orderId: string; @@ -69,6 +70,11 @@ export const updatePayPalOrder = ({ eggId?: number; billingDays?: number; name: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; }): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/paypal/order`, { @@ -81,6 +87,7 @@ export const updatePayPalOrder = ({ egg_id: eggId, billing_days: billingDays, name, + domain_payload: domainPayload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/process.ts b/resources/scripts/api/routes/account/billing/orders/process.ts index 7a710e6d84..c8b3263ea2 100644 --- a/resources/scripts/api/routes/account/billing/orders/process.ts +++ b/resources/scripts/api/routes/account/billing/orders/process.ts @@ -18,6 +18,11 @@ export const processUnpaidOrder = ( coupon_id?: number, egg_id?: number, name?: string, + domain_payload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>, ): Promise => { return new Promise((resolve, reject) => { http.post(`/api/client/billing/process/free`, { @@ -29,6 +34,7 @@ export const processUnpaidOrder = ( coupon_id, egg_id, name, + domain_payload, }) .then(({ data }) => resolve(data)) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/stripe.ts b/resources/scripts/api/routes/account/billing/orders/stripe.ts index 2ec4300f3e..62351ef8b3 100644 --- a/resources/scripts/api/routes/account/billing/orders/stripe.ts +++ b/resources/scripts/api/routes/account/billing/orders/stripe.ts @@ -32,6 +32,7 @@ export const updateStripeIntent = ({ egg_id, name, billing_days, + domain_payload, }: UpdateStripeIntent): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/intent`, { @@ -44,6 +45,7 @@ export const updateStripeIntent = ({ egg_id, name, billing_days, + domain_payload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/types.d.ts b/resources/scripts/api/routes/account/billing/orders/types.d.ts index 6141035e51..aae2e1ee83 100644 --- a/resources/scripts/api/routes/account/billing/orders/types.d.ts +++ b/resources/scripts/api/routes/account/billing/orders/types.d.ts @@ -26,4 +26,9 @@ export interface UpdateStripeIntent { egg_id?: number; name?: string; billing_days?: number; + domain_payload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } diff --git a/resources/scripts/api/routes/admin/billing/types.d.ts b/resources/scripts/api/routes/admin/billing/types.d.ts index c66cf78eb0..c58be41f4f 100644 --- a/resources/scripts/api/routes/admin/billing/types.d.ts +++ b/resources/scripts/api/routes/admin/billing/types.d.ts @@ -20,6 +20,7 @@ export interface ProductValues { backup: number; database: number; allocation: number; + subdomain: number | null; }; } diff --git a/resources/scripts/api/routes/admin/customDomains.ts b/resources/scripts/api/routes/admin/customDomains.ts new file mode 100644 index 0000000000..b8eb7853a6 --- /dev/null +++ b/resources/scripts/api/routes/admin/customDomains.ts @@ -0,0 +1,141 @@ +import http from '@/api/http'; + +export interface AdminCustomDomain { + id: number; + domain: string; + cloudflare_zone_id: string | null; + api_key_id: number | null; + api_key_name: string | null; + allowed_nest_ids: number[]; + allowed_egg_ids: number[]; + service_tag: string | null; + egg_service_tags: Record; + wildcard_enabled: boolean; + enabled: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CustomDomainApiKey { + id: number; + name: string; + enabled: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CreateAdminCustomDomainPayload { + domain: string; + cloudflare_zone_id?: string | null; + api_key_id?: number | null; + allowed_nest_ids?: number[]; + allowed_egg_ids?: number[]; + service_tag?: string | null; + egg_service_tags?: Record; + wildcard_enabled?: boolean; + enabled?: boolean; +} + +export interface UpdateAdminCustomDomainPayload { + domain?: string; + cloudflare_zone_id?: string | null; + api_key_id?: number | null; + allowed_nest_ids?: number[]; + allowed_egg_ids?: number[]; + service_tag?: string | null; + egg_service_tags?: Record; + wildcard_enabled?: boolean; + enabled?: boolean; +} + +export interface CustomDomainTargetOptions { + nests: Array<{ id: number; uuid: string; name: string; description: string | null }>; + eggs: Array<{ + id: number; + uuid: string; + nest_id: number; + nest_name: string; + name: string; + description: string | null; + default_service_tag: string | null; + }>; +} + +export interface CustomDomainSettings { + cloudflare_token: string; + allow_wildcard: boolean; + max_wildcards_per_user: number; + rate_limit_create_per_minute: number; + rate_limit_sync_per_minute: number; + rate_limit_billing_options_per_minute: number; +} + +export const getCustomDomains = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains'); + + return data.data || []; +}; + +export const createCustomDomain = async (payload: CreateAdminCustomDomainPayload): Promise => { + const { data } = await http.post('/api/application/custom-domains', payload); + + return data.data; +}; + +export const updateCustomDomain = async ( + id: number, + payload: UpdateAdminCustomDomainPayload, +): Promise => { + const { data } = await http.patch(`/api/application/custom-domains/${id}`, payload); + + return data.data; +}; + +export const deleteCustomDomain = async (id: number): Promise => { + await http.delete(`/api/application/custom-domains/${id}`); +}; + +export const getCustomDomainApiKeys = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/api-keys'); + + return data.data || []; +}; + +export const createCustomDomainApiKey = async (payload: { + name: string; + token: string; + enabled?: boolean; +}): Promise => { + const { data } = await http.post('/api/application/custom-domains/api-keys', payload); + + return data.data; +}; + +export const updateCustomDomainApiKey = async ( + id: number, + payload: { name?: string; token?: string; enabled?: boolean }, +): Promise => { + const { data } = await http.patch(`/api/application/custom-domains/api-keys/${id}`, payload); + + return data.data; +}; + +export const deleteCustomDomainApiKey = async (id: number): Promise => { + await http.delete(`/api/application/custom-domains/api-keys/${id}`); +}; + +export const getCustomDomainTargetOptions = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/options'); + + return data.data; +}; + +export const getCustomDomainSettings = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/settings'); + + return data.data; +}; + +export const updateCustomDomainSettings = async (payload: Partial): Promise => { + await http.put('/api/application/custom-domains/settings', payload); +}; diff --git a/resources/scripts/api/routes/admin/extensions/index.ts b/resources/scripts/api/routes/admin/extensions/index.ts new file mode 100644 index 0000000000..bcf11e35c0 --- /dev/null +++ b/resources/scripts/api/routes/admin/extensions/index.ts @@ -0,0 +1,90 @@ +import http from '@/api/http'; + +export interface ExtensionSettingOption { + label: string; + value: string | number | boolean; +} + +export type ExtensionSettingFieldType = 'text' | 'password' | 'textarea' | 'select' | 'boolean' | 'number'; + +export interface ExtensionSettingField { + key: string; + label: string; + type: ExtensionSettingFieldType; + help?: string; + placeholder?: string; + options?: ExtensionSettingOption[]; +} + +export interface ExtensionData { + id: string; + name: string; + description: string; + version: string; + author: string; + icon: string; + enabled: boolean; + allowedNests: number[]; + allowedEggs: number[]; + settings: Record; + settingsSchema?: ExtensionSettingField[]; +} + +export interface NestOption { + id: number; + uuid: string; + name: string; + description: string | null; +} + +export interface EggOption { + id: number; + uuid: string; + name: string; + description: string | null; + nestId: number; + nestName: string; +} + +export interface NestsAndEggs { + nests: NestOption[]; + eggs: EggOption[]; +} + +export const getExtensions = async (): Promise => { + const { data } = await http.get('/api/application/extensions'); + return data.data; +}; + +export const getExtension = async (extensionId: string): Promise => { + const { data } = await http.get(`/api/application/extensions/${extensionId}`); + return data; +}; + +export const updateExtension = async ( + extensionId: string, + allowedNests: number[], + allowedEggs: number[], + settings: Record = {} +): Promise => { + const { data } = await http.put(`/api/application/extensions/${extensionId}`, { + allowed_nests: allowedNests, + allowed_eggs: allowedEggs, + settings, + }); + return data; +}; + +export const toggleExtension = async (extensionId: string): Promise => { + const { data } = await http.post(`/api/application/extensions/${extensionId}/toggle`); + return data; +}; + +export const updateModuleSettings = async (enabled: boolean): Promise => { + await http.put('/api/application/extensions/settings', { key: 'enabled', value: enabled }); +}; + +export const getNestsAndEggs = async (): Promise => { + const { data } = await http.get('/api/application/extensions/nests-eggs'); + return data; +}; diff --git a/resources/scripts/api/routes/admin/nodes/getNodes.ts b/resources/scripts/api/routes/admin/nodes/getNodes.ts index 775a203717..ede7d9b827 100644 --- a/resources/scripts/api/routes/admin/nodes/getNodes.ts +++ b/resources/scripts/api/routes/admin/nodes/getNodes.ts @@ -27,6 +27,9 @@ export interface Node { daemonBase: string; deployable: boolean; deployableFree: boolean; + wingsType: string; + wingsVersion: string | null; + wingsDetectedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -62,6 +65,9 @@ export const rawDataToNode = ({ attributes, meta }: FractalResponseData): Node = daemonBase: attributes.daemon_base, deployable: attributes.deployable, deployableFree: attributes.deployable_free, + wingsType: attributes.wings_type ?? 'default', + wingsVersion: attributes.wings_version ?? null, + wingsDetectedAt: attributes.wings_detected_at ? new Date(attributes.wings_detected_at) : null, createdAt: new Date(attributes.created_at), updatedAt: new Date(attributes.updated_at), diff --git a/resources/scripts/api/routes/admin/nodes/wingsRs.ts b/resources/scripts/api/routes/admin/nodes/wingsRs.ts new file mode 100644 index 0000000000..e5e72c10c1 --- /dev/null +++ b/resources/scripts/api/routes/admin/nodes/wingsRs.ts @@ -0,0 +1,153 @@ +import http from '@/api/http'; + +export interface WingsRsDetectionResult { + detected: boolean; + wings_type: string; + wings_version: string | null; +} + +export interface SystemOverview { + version: string; + rust_version?: string; + build_date?: string; + os: string; + arch: string; + kernel: string; + uptime?: number; + features: string[]; +} + +export interface SystemStats { + cpu: { + used: number; + threads: number; + model: string; + }; + network: { + received_rate: number; + sent_rate: number; + }; + memory: { + used: number; + process: number; + total: number; + }; + disk: { + used: number; + total: number; + read_rate: number; + write_rate: number; + }; +} + +export interface LogFile { + name: string; + size: number; + modified: string; +} + +export const detectWingsRs = (nodeId: number): Promise => { + return http.post(`/api/application/nodes/${nodeId}/wings-rs/detect`).then(({ data }) => data); +}; + +export const getSystemOverview = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/overview`).then(({ data }) => ({ + version: data?.version ?? 'unknown', + rust_version: data?.rust_version ?? data?.rust, + build_date: data?.build_date ?? data?.build, + os: data?.os ?? data?.container_type ?? 'unknown', + arch: data?.arch ?? data?.architecture ?? 'unknown', + kernel: data?.kernel ?? data?.kernel_version ?? 'unknown', + uptime: data?.uptime !== undefined ? Number(data?.uptime) : undefined, + features: Array.isArray(data?.features) ? data.features : [], + })); +}; + +export const getSystemStats = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/stats`).then(({ data }) => { + const stats = data?.stats ?? data; + + return { + cpu: { + used: Number(stats?.cpu?.used ?? stats?.cpu_used ?? 0), + threads: Number(stats?.cpu?.threads ?? stats?.cpu_threads ?? 0), + model: stats?.cpu?.model ?? stats?.cpu_model ?? 'Unknown', + }, + network: { + received_rate: Number( + stats?.network?.receiving_rate + ?? stats?.network?.received_rate + ?? stats?.network_receiving_rate + ?? 0 + ), + sent_rate: Number( + stats?.network?.sending_rate + ?? stats?.network?.sent_rate + ?? stats?.network_sending_rate + ?? 0 + ), + }, + memory: { + used: Number(stats?.memory?.used ?? stats?.memory_used ?? 0), + process: Number(stats?.memory?.used_process ?? stats?.memory?.process ?? stats?.memory_process ?? 0), + total: Number(stats?.memory?.total ?? stats?.memory_total ?? 0), + }, + disk: { + used: Number(stats?.disk?.used ?? stats?.disk_used ?? 0), + total: Number(stats?.disk?.total ?? stats?.disk_total ?? 0), + read_rate: Number(stats?.disk?.reading_rate ?? stats?.disk?.read_rate ?? stats?.disk_reading_rate ?? 0), + write_rate: Number(stats?.disk?.writing_rate ?? stats?.disk?.write_rate ?? stats?.disk_writing_rate ?? 0), + }, + }; + }); +}; + +export const getSystemLogs = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/logs`).then(({ data }) => { + const items = Array.isArray(data) ? data : data?.log_files ?? data?.files; + + if (!Array.isArray(items)) { + return []; + } + + return items.map((entry: any) => ({ + name: entry?.name ?? entry?.file ?? 'unknown.log', + size: Number(entry?.size ?? 0), + modified: entry?.modified ?? entry?.updated_at ?? '', + })); + }); +}; + +export const getSystemLogContents = (nodeId: number, file: string, lines?: number): Promise => { + return http + .get(`/api/application/nodes/${nodeId}/wings-rs/logs/${file}`, { params: { lines } }) + .then(({ data }) => { + if (Array.isArray(data)) { + return data; + } + + if (Array.isArray(data?.content)) { + return data.content; + } + + if (typeof data?.content === 'string') { + return data.content.split('\n'); + } + + if (typeof data === 'string') { + return data.split('\n'); + } + + return []; + }); +}; + +export interface UpgradeRequest { + url: string; + sha256?: string; + restart_command?: string; +} + +export const upgradeNode = (nodeId: number, data: UpgradeRequest): Promise => { + return http.post(`/api/application/nodes/${nodeId}/wings-rs/upgrade`, data); +}; diff --git a/resources/scripts/api/routes/admin/server.ts b/resources/scripts/api/routes/admin/server.ts index 202ead5604..86523630bd 100644 --- a/resources/scripts/api/routes/admin/server.ts +++ b/resources/scripts/api/routes/admin/server.ts @@ -49,6 +49,7 @@ export interface Server extends Model { allocations: number; backups: number; subusers: number; + subdomains: number | null; }; container: { startup: string | null; diff --git a/resources/scripts/api/routes/admin/servers/createServer.ts b/resources/scripts/api/routes/admin/servers/createServer.ts index 6e76db15d6..153217a214 100644 --- a/resources/scripts/api/routes/admin/servers/createServer.ts +++ b/resources/scripts/api/routes/admin/servers/createServer.ts @@ -23,6 +23,7 @@ export interface CreateServerRequest { backups: number; databases: number; subusers: number; + subdomains: number | null; }; allocation: { @@ -64,6 +65,7 @@ export default (r: CreateServerRequest, include: string[] = []): Promise backups: r.featureLimits.backups, databases: r.featureLimits.databases, subusers: r.featureLimits.subusers, + subdomains: r.featureLimits.subdomains, }, allocation: { diff --git a/resources/scripts/api/routes/admin/servers/getServers.ts b/resources/scripts/api/routes/admin/servers/getServers.ts index 1d1ab128bd..2c152a4ddb 100644 --- a/resources/scripts/api/routes/admin/servers/getServers.ts +++ b/resources/scripts/api/routes/admin/servers/getServers.ts @@ -63,6 +63,7 @@ export interface Server { allocations: number; backups: number; subusers: number; + subdomains: number | null; }; ownerId: number; @@ -114,6 +115,7 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server => allocations: attributes.feature_limits.allocations, backups: attributes.feature_limits.backups, subusers: attributes.feature_limits.subusers, + subdomains: attributes.feature_limits.subdomains ?? null, }, ownerId: attributes.owner_id, diff --git a/resources/scripts/api/routes/admin/servers/updateServer.ts b/resources/scripts/api/routes/admin/servers/updateServer.ts index 135bc2da93..0e7b97def2 100644 --- a/resources/scripts/api/routes/admin/servers/updateServer.ts +++ b/resources/scripts/api/routes/admin/servers/updateServer.ts @@ -21,6 +21,7 @@ export interface Values { backups: number; databases: number; subusers: number; + subdomains: number | null; }; renewalDate?: Date | null | undefined; @@ -56,6 +57,7 @@ export default (id: number, server: Partial, include: string[] = []): Pr backups: server.featureLimits?.backups, databases: server.featureLimits?.databases, subusers: server.featureLimits?.subusers, + subdomains: server.featureLimits?.subdomains, }, renewal_date: diff --git a/resources/scripts/api/routes/admin/servers/wingsRs.ts b/resources/scripts/api/routes/admin/servers/wingsRs.ts new file mode 100644 index 0000000000..5674e184bd --- /dev/null +++ b/resources/scripts/api/routes/admin/servers/wingsRs.ts @@ -0,0 +1,63 @@ +import http from '@/api/http'; + +export interface AdminServerWingsStatus { + supercharged: boolean; + wings_type: string; + wings_version: string | null; +} + +export interface AdminServerSystemStats { + cpu: { used: number; threads: number; model: string }; + network: { receiving_rate: number; sending_rate: number }; + memory: { used: number; used_process: number; total: number }; + disk: { used: number; total: number; reading_rate: number; writing_rate: number }; +} + +export const getAdminServerWingsStatus = (serverId: number): Promise => { + return http.get(`/api/application/servers/${serverId}/wings-rs/status`).then(({ data }) => data); +}; + +export const getAdminServerWingsStats = (serverId: number): Promise => { + return http.get(`/api/application/servers/${serverId}/wings-rs/stats`).then(({ data }) => { + const stats = data?.stats ?? data; + + return { + cpu: { + used: Number(stats?.cpu?.used ?? 0), + threads: Number(stats?.cpu?.threads ?? 0), + model: stats?.cpu?.model ?? 'Unknown', + }, + network: { + receiving_rate: Number(stats?.network?.receiving_rate ?? 0), + sending_rate: Number(stats?.network?.sending_rate ?? 0), + }, + memory: { + used: Number(stats?.memory?.used ?? 0), + used_process: Number(stats?.memory?.used_process ?? 0), + total: Number(stats?.memory?.total ?? 0), + }, + disk: { + used: Number(stats?.disk?.used ?? 0), + total: Number(stats?.disk?.total ?? 0), + reading_rate: Number(stats?.disk?.reading_rate ?? 0), + writing_rate: Number(stats?.disk?.writing_rate ?? 0), + }, + }; + }); +}; + +export const getAdminServerInstallLogs = (serverId: number, lines = 100): Promise<{ content: string[]; missing: boolean }> => { + return http.get(`/api/application/servers/${serverId}/wings-rs/install-logs`, { params: { lines } }).then(({ data }) => { + const raw = data?.content; + + if (Array.isArray(raw)) { + return { content: raw, missing: Boolean(data?.missing) }; + } + + if (typeof raw === 'string') { + return { content: raw.length ? raw.split('\n') : [], missing: Boolean(data?.missing) }; + } + + return { content: [], missing: Boolean(data?.missing) }; + }); +}; diff --git a/resources/scripts/api/routes/server/billing.ts b/resources/scripts/api/routes/server/billing.ts index 00fae6d9d6..00f179adac 100644 --- a/resources/scripts/api/routes/server/billing.ts +++ b/resources/scripts/api/routes/server/billing.ts @@ -29,6 +29,7 @@ export interface PlanChangeResponse { database: number; backup: number; allocation: number; + subdomain?: number | null; }; }; } diff --git a/resources/scripts/api/routes/server/customDomains.ts b/resources/scripts/api/routes/server/customDomains.ts new file mode 100644 index 0000000000..2c68207488 --- /dev/null +++ b/resources/scripts/api/routes/server/customDomains.ts @@ -0,0 +1,75 @@ +import useSWR from 'swr'; +import http from '@/api/http'; +import { ServerContext } from '@/state/server'; + +export interface ServerCustomDomainRecord { + id: number; + domain_id: number; + domain: string; + subdomain: string; + full_domain: string; + port: number; + protocol: 'tcp' | 'udp' | 'both'; + service_tag: string | null; + record_type: 'srv' | 'cname'; + host_record_type: 'A' | 'CNAME' | null; + status: 'pending' | 'active' | 'failed'; + last_error: string | null; + last_synced_at: string | null; +} + +export interface AvailableServerCustomDomain { + id: number; + domain: string; + wildcard_enabled: boolean; + default_service_tag: string | null; + recommended_record_type: 'srv' | 'cname'; + srv_supported: boolean; + allow_record_type_selection: boolean; + forced_record_type: 'srv' | 'cname' | null; + dns_mode: 'minecraft' | 'rust' | 'generic'; + recommendation_notice: string; + connection_hint: string; +} + +export const getServerCustomDomains = () => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + + return useSWR( + ['server:custom-domains', uuid], + async () => { + const { data } = await http.get(`/api/client/servers/${uuid}/custom-domains`); + + return data.data || []; + }, + { revalidateOnFocus: false }, + ); +}; + +export const createServerCustomDomain = async ( + uuid: string, + payload: { + domain_id: number; + subdomain: string; + port: number; + protocol: 'tcp' | 'udp' | 'both'; + record_type?: 'srv' | 'cname'; + service_tag?: string; + }, +): Promise => { + await http.post(`/api/client/servers/${uuid}/custom-domains`, payload); +}; + +export const getServerCustomDomainOptions = async (uuid: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/custom-domains/options`); + + return data.data || []; +}; + +export const deleteServerCustomDomain = async (uuid: string, id: number): Promise => { + await http.delete(`/api/client/servers/${uuid}/custom-domains/${id}`); +}; + +export const syncServerCustomDomains = async (uuid: string): Promise => { + await http.post(`/api/client/servers/${uuid}/custom-domains/sync`); +}; diff --git a/resources/scripts/api/routes/server/wingsRs.ts b/resources/scripts/api/routes/server/wingsRs.ts new file mode 100644 index 0000000000..825a21177c --- /dev/null +++ b/resources/scripts/api/routes/server/wingsRs.ts @@ -0,0 +1,139 @@ +import http from '@/api/http'; + +export interface WingsRsStatus { + supercharged: boolean; + wings_type: string; + wings_version: string | null; + features: string[]; +} + +export interface FileFingerprint { + path: string; + algorithm: string; + hash: string; +} + +export interface SearchResult { + path: string; + name: string; + size: number; + modified: string; + is_file: boolean; + mime_type?: string; +} + +export interface CompressRequest { + root: string; + files: string[]; + format: 'tar' | 'tar_gz' | 'tar_xz' | 'tar_bz2' | 'tar_lz4' | 'tar_zstd' | 'zip' | 'seven_zip'; + name?: string; + foreground?: boolean; +} + +export interface CompressResult { + operation_id?: string; + file?: string; +} + +export interface ScriptRequest { + container_image?: string; + entrypoint?: string; + script: string; + environment?: Record; +} + +export type ArchiveFormat = 'tar' | 'tar_gz' | 'tar_xz' | 'tar_bz2' | 'tar_lz4' | 'tar_zstd' | 'zip' | 'seven_zip'; + +export const getWingsRsStatus = (uuid: string): Promise => { + return http.get(`/api/client/servers/${uuid}/wings-rs/status`).then(({ data }) => ({ + supercharged: Boolean(data?.supercharged), + wings_type: data?.wings_type ?? 'default', + wings_version: data?.wings_version ?? null, + features: Array.isArray(data?.features) ? data.features : [], + })); +}; + +export const getFingerprints = ( + uuid: string, + root: string, + files: string[], + algorithm?: string, +): Promise => { + return http + .post(`/api/client/servers/${uuid}/wings-rs/fingerprints`, { root, files, algorithm }) + .then(({ data }) => data); +}; + +export const searchFiles = ( + uuid: string, + params: { + root?: string; + pattern: string; + glob?: boolean; + regex?: boolean; + case_sensitive?: boolean; + }, +): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/search`, params).then(({ data }) => data); +}; + +export const compressAdvanced = (uuid: string, data: CompressRequest): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/compress`, data, { + timeout: 10000, + timeoutErrorMessage: 'The compression is taking a while. It will complete in the background.', + }).then(({ data }) => data); +}; + +export const cancelOperation = (uuid: string, operationId: string): Promise => { + return http.delete(`/api/client/servers/${uuid}/wings-rs/operations/${operationId}`); +}; + +export const runScript = (uuid: string, data: ScriptRequest): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/script`, data); +}; + +export const abortInstall = (uuid: string): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/abort-install`); +}; + +export const getInstallLogs = (uuid: string, lines?: number): Promise => { + return http + .get(`/api/client/servers/${uuid}/wings-rs/install-logs`, { params: { lines } }) + .then(({ data }) => { + if (Array.isArray(data)) { + return data; + } + + if (Array.isArray(data?.content)) { + return data.content; + } + + if (typeof data?.content === 'string') { + return data.content.split('\n'); + } + + if (typeof data === 'string') { + return data.split('\n'); + } + + return []; + }); +}; + +export interface SshInfo { + host: string; + port: number; + username: string; + command?: string; + container_supported: boolean; +} + +export const getSshInfo = (uuid: string): Promise => { + return http.get(`/api/client/servers/${uuid}/wings-rs/ssh`).then(({ data }) => ({ + host: data?.host ?? data?.ip ?? '', + port: Number(data?.port ?? 22), + username: data?.username ?? '', + command: data?.command, + container_supported: Boolean(data?.container_supported ?? data?.shell_available ?? false), + })); +}; diff --git a/resources/scripts/api/server/extensions/discordSrvHelper.ts b/resources/scripts/api/server/extensions/discordSrvHelper.ts new file mode 100644 index 0000000000..33b825bf6e --- /dev/null +++ b/resources/scripts/api/server/extensions/discordSrvHelper.ts @@ -0,0 +1,60 @@ +import http from '@/api/http'; + +const base = (uuid: string) => `/api/client/servers/${uuid}/extensions/discordsrv_helper`; + +export interface DiscordSrvHelperStatus { + installed: boolean; + plugin_jar: string | null; + plugin_folder_present: boolean; + token_file_present: boolean; + config_present: boolean; +} + +export interface DiscordSrvHelperHistoryEntry { + id: number; + action: string; + created_at: string; + actor: { id: number; email: string } | null; +} + +export interface DiscordSrvHelperSubuserAccess { + uuid: string; + email: string; + username: string; + disabled: boolean; +} + +export const getDiscordSrvHelperStatus = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/status`); + return data; +}; + +export const installDiscordSrv = async (uuid: string, jarUrl?: string): Promise => { + await http.post(`${base(uuid)}/install`, jarUrl ? { jar_url: jarUrl } : {}); +}; + +export const setDiscordSrvToken = async (uuid: string, token: string): Promise => { + await http.post(`${base(uuid)}/token`, { token }); +}; + +export const setDiscordSrvGlobalChannel = async (uuid: string, channelId: string): Promise => { + await http.post(`${base(uuid)}/channel`, { channel_id: channelId }); +}; + +export const getDiscordSrvHistory = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/history`); + return data.data || []; +}; + +export const revertDiscordSrvHistory = async (uuid: string, snapshotId: number): Promise => { + await http.post(`${base(uuid)}/history/${snapshotId}/revert`); +}; + +export const getDiscordSrvSubusers = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/subusers`); + return data.data || []; +}; + +export const setDiscordSrvSubuserAccess = async (uuid: string, subuserUuid: string, enabled: boolean): Promise => { + await http.post(`${base(uuid)}/subusers/${subuserUuid}`, { enabled }); +}; diff --git a/resources/scripts/api/server/extensions/index.ts b/resources/scripts/api/server/extensions/index.ts new file mode 100644 index 0000000000..e8f170b6b9 --- /dev/null +++ b/resources/scripts/api/server/extensions/index.ts @@ -0,0 +1,20 @@ +import http from '@/api/http'; + +export interface ServerExtension { + id: string; + name: string; + description: string; + icon: string; + version: string; + route: string; +} + +export const getServerExtensions = async (uuid: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/extensions`); + return data.data; +}; + +export const checkExtensionEnabled = async (uuid: string, extensionId: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/extensions/${extensionId}`); + return data.enabled; +}; diff --git a/resources/scripts/api/server/extensions/playerManager.ts b/resources/scripts/api/server/extensions/playerManager.ts new file mode 100644 index 0000000000..992f20637a --- /dev/null +++ b/resources/scripts/api/server/extensions/playerManager.ts @@ -0,0 +1,252 @@ +import http from '@/api/http'; + +const extensionId = 'minecraft_player_manager'; + +const getBasePath = (uuid: string): string => `/api/client/servers/${uuid}/extensions/${extensionId}`; + +export interface OnlinePlayer { + name: string; + uuid?: string; +} + +export interface ServerStatus { + online: boolean; + players: { + online: number; + max: number; + list: OnlinePlayer[]; + }; + version: string; + motd: string; +} + +export interface PlayerEntry { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; + source?: string; + created?: string; + reason?: string; + expires?: string; +} + +export interface PlayerManagerStatus { + server: ServerStatus; + operators: PlayerEntry[]; + whitelist: PlayerEntry[]; + bannedPlayers: PlayerEntry[]; + bannedIps: { ip: string; reason: string; created: string; source: string; expires: string | null }[]; + whitelistEnabled: boolean; +} + +export const getPlayerManagerStatus = async (uuid: string): Promise => { + const { data } = await http.get(getBasePath(uuid)); + // Handle case where API returns nested data structure + if (data && data.data) { + return data.data; + } + return data; +}; + +export const setWhitelistEnabled = async (uuid: string, enabled: boolean): Promise => { + await http.post(`${getBasePath(uuid)}/whitelist`, { enabled }); +}; + +export const addToWhitelist = async (uuid: string, player: string): Promise => { + await http.put(`${getBasePath(uuid)}/whitelist/${player}`); +}; + +export const removeFromWhitelist = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/whitelist/${player}`); +}; + +export const opPlayer = async (uuid: string, player: string): Promise => { + await http.put(`${getBasePath(uuid)}/op/${player}`); +}; + +export const deopPlayer = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/op/${player}`); +}; + +export const banPlayer = async (uuid: string, player: string, reason: string): Promise => { + await http.put(`${getBasePath(uuid)}/ban/${player}`, { reason }); +}; + +export const unbanPlayer = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/ban/${player}`); +}; + +export const banIp = async (uuid: string, ip: string, reason: string): Promise => { + await http.put(`${getBasePath(uuid)}/ban-ip/${ip}`, { reason }); +}; + +export const unbanIp = async (uuid: string, ip: string): Promise => { + await http.delete(`${getBasePath(uuid)}/ban-ip/${ip}`); +}; + +export const kickPlayer = async (uuid: string, player: string, reason?: string): Promise => { + await http.post(`${getBasePath(uuid)}/kick/${player}`, { reason }); +}; + +export const whisperPlayer = async (uuid: string, player: string, message: string): Promise => { + await http.post(`${getBasePath(uuid)}/whisper/${player}`, { message }); +}; + +export const killPlayer = async (uuid: string, player: string): Promise => { + await http.post(`${getBasePath(uuid)}/kill/${player}`); +}; + +// v1.0.1 - Server Version +export interface ServerVersion { + raw: string; + major: number; + minor: number; + patch: number; + protocol: number; + supportsAttributes: boolean; +} + +export interface ServerVersionResponse { + success: boolean; + version?: ServerVersion; + error?: string; +} + +export const getServerVersion = async (uuid: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/version`); + return data.data || data; +}; + +// v1.0.1 - Player Data Types +export interface ItemEnchantment { + id: string; + name: string; + level: number; + levelRoman: string; +} + +export interface ItemDurability { + current: number; + max: number; + percentage: number; +} + +export interface InventoryItem { + id: string; + displayId: string; + name: string; + slot: number; + count: number; + damage: number; + enchantments: ItemEnchantment[]; + storedEnchantments: ItemEnchantment[]; + customName: string | null; + lore: string[]; + durability: ItemDurability | null; + contents: InventoryItem[]; +} + +export interface PlayerArmor { + helmet: InventoryItem | null; + chestplate: InventoryItem | null; + leggings: InventoryItem | null; + boots: InventoryItem | null; +} + +export interface PlayerLocation { + x: number; + y: number; + z: number; + yaw: number; + pitch: number; + dimension: string; + world: string; +} + +export interface PlayerStats { + health: number; + maxHealth: number; + food: number; + saturation: number; + xpLevel: number; + xpTotal: number; + xpProgress: number; + gamemode: string; + score: number; +} + +export interface PlayerDataResponse { + success: boolean; + player?: { + uuid: string; + name: string; + }; + inventory?: InventoryItem[]; + armor?: PlayerArmor; + offhand?: InventoryItem | null; + enderChest?: InventoryItem[]; + location?: PlayerLocation; + stats?: PlayerStats; + error?: string; + debug?: { + allSlots: { slot: number; id: string }[]; + nbtKeys?: string[]; + }; +} + +export const getPlayerData = async (uuid: string, player: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/player/${player}/data`); + return data.data || data; +}; + +// v1.0.1 - Attributes +export interface AttributeInfo { + id: string; + name: string; + default: number; + min: number; + max: number; + description: string; +} + +export interface AttributeCategory { + category: string; + attributes: AttributeInfo[]; +} + +export interface AttributesResponse { + success: boolean; + attributes?: AttributeCategory[]; + error?: string; +} + +export const getAttributes = async (uuid: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/attributes`); + return data.data || data; +}; + +export interface SetAttributeResponse { + success: boolean; + attribute?: string; + value?: number; + error?: string; +} + +export const setAttribute = async (uuid: string, player: string, attribute: string, value: number): Promise => { + const { data } = await http.post(`${getBasePath(uuid)}/player/${player}/attribute/${attribute}`, { value }); + return data.data || data; +}; + +export interface ResetAttributeResponse { + success: boolean; + attribute?: string; + defaultValue?: number; + error?: string; +} + +export const resetAttribute = async (uuid: string, player: string, attribute: string): Promise => { + const { data } = await http.delete(`${getBasePath(uuid)}/player/${player}/attribute/${attribute}`); + return data.data || data; +}; + diff --git a/resources/scripts/components/account/billing/ProductsContainer.tsx b/resources/scripts/components/account/billing/ProductsContainer.tsx index f18e2b2e2e..19e6aacf82 100644 --- a/resources/scripts/components/account/billing/ProductsContainer.tsx +++ b/resources/scripts/components/account/billing/ProductsContainer.tsx @@ -12,6 +12,7 @@ import { faDatabase, faEthernet, faExclamationTriangle, + faGlobe, faHdd, faMemory, faMicrochip, @@ -192,6 +193,16 @@ export default () => { } /> + + {product.limits.subdomain === null + ? 'Unlimited subdomains' + : `${product.limits.subdomain} subdomain${product.limits.subdomain === 1 ? '' : 's'}`} + + } + />
{Number(product.price) > 0 && paidProductsBlocked ? ( diff --git a/resources/scripts/components/account/billing/order/BillingCycleBox.tsx b/resources/scripts/components/account/billing/order/BillingCycleBox.tsx index c3a6989274..7b13dcb918 100644 --- a/resources/scripts/components/account/billing/order/BillingCycleBox.tsx +++ b/resources/scripts/components/account/billing/order/BillingCycleBox.tsx @@ -36,7 +36,7 @@ export default ({ cycle, selected, setSelected }: Props) => {
setSelected(cycle.days)} className={classNames( - 'relative cursor-pointer rounded-lg border-2 p-4 transition-all hover:scale-[1.02]', + 'relative cursor-pointer rounded-lg border-2 p-4 transition-all', isSelected ? 'border-gray-600 hover:border-gray-500' : 'border-gray-700 hover:border-gray-600', )} style={ @@ -45,25 +45,28 @@ export default ({ cycle, selected, setSelected }: Props) => { : { backgroundColor: colors.secondary, borderColor: '#374151' } } > -
-
- -
-
-

- {cycle.days} {cycle.days === 1 ? 'Day' : 'Days'} -

- {cycle.isDefault && ( - - Default - - )} -
- {getDiscountLabel()} +
+ +
+
+

+ {cycle.days} {cycle.days === 1 ? 'Day' : 'Days'} +

+ {cycle.isDefault && ( + + Default + + )} +
+
+ + ${cycle.price.toFixed(2)} +
+ {getDiscountLabel()}
diff --git a/resources/scripts/components/account/billing/order/EggBox.tsx b/resources/scripts/components/account/billing/order/EggBox.tsx index f417de94e5..cb92c4aaed 100644 --- a/resources/scripts/components/account/billing/order/EggBox.tsx +++ b/resources/scripts/components/account/billing/order/EggBox.tsx @@ -26,7 +26,7 @@ export default ({ egg, selected, setSelected, onEggChange }: Props) => {
; } export default (data: Props) => { @@ -47,6 +52,7 @@ export default (data: Props) => { eggId: data.selectedEggId, billingDays: data.billingDays, name: data.serverName, + domainPayload: data.domainPayload, }); // Redirect to Mollie checkout diff --git a/resources/scripts/components/account/billing/order/NodeBox.tsx b/resources/scripts/components/account/billing/order/NodeBox.tsx index 4f13944f03..d589d16ff9 100644 --- a/resources/scripts/components/account/billing/order/NodeBox.tsx +++ b/resources/scripts/components/account/billing/order/NodeBox.tsx @@ -28,7 +28,7 @@ export default ({ node, selected, setSelected, basePrice, billingDays }: Props)
setSelected(Number(node.id))} className={classNames( - 'relative cursor-pointer rounded-lg border-2 p-4 transition-all hover:scale-[1.02]', + 'relative cursor-pointer rounded-lg border-2 p-4 transition-all', isSelected ? 'border-gray-600 hover:border-gray-500' : 'border-gray-700 hover:border-gray-600', )} style={ diff --git a/resources/scripts/components/account/billing/order/OrderContainer.tsx b/resources/scripts/components/account/billing/order/OrderContainer.tsx index f71a97c97c..afda957522 100644 --- a/resources/scripts/components/account/billing/order/OrderContainer.tsx +++ b/resources/scripts/components/account/billing/order/OrderContainer.tsx @@ -27,6 +27,9 @@ import { } from '@/api/routes/account/billing/products'; import AdminCheckbox from '@/elements/AdminCheckbox'; import { ValidateCouponResponse } from '@/api/routes/account/billing/coupons'; +import { AvailableCustomDomain, getAvailableCustomDomains } from '@/api/routes/account/billing/customDomains'; +import Input from '@/elements/Input'; +import Select from '@/elements/Select'; import classNames from 'classnames'; const getResponseStatus = (reason: unknown): number | undefined => { @@ -61,6 +64,28 @@ export default () => { const hasValidSelectedNode = Number.isInteger(selectedNode) && selectedNode > 0; const hasEditableVariables = eggs?.some(v => v.isEditable) ?? false; + const reviewStep = hasEditableVariables ? 5 : 4; + + const [customDomainOptions, setCustomDomainOptions] = useState([]); + const [domainMappings, setDomainMappings] = useState< + Array<{ + domain_id: number; + domain: string; + subdomain: string; + record_type: 'srv' | 'cname'; + }> + >([]); + const [selectedDomainId, setSelectedDomainId] = useState(0); + const [mappingSubdomain, setMappingSubdomain] = useState(''); + const [mappingRecordType, setMappingRecordType] = useState<'srv' | 'cname'>('cname'); + + const selectedDomainOption = customDomainOptions.find(option => option.id === selectedDomainId); + const effectiveRecordType: 'srv' | 'cname' = selectedDomainOption?.allow_record_type_selection + ? mappingRecordType + : (selectedDomainOption?.forced_record_type ?? selectedDomainOption?.recommended_record_type ?? 'cname'); + + // Wizard step state + const [currentStep, setCurrentStep] = useState(1); const { colors } = useStoreState(state => state.theme.data!); @@ -93,6 +118,69 @@ export default () => { const handleCouponApplied = (data: ValidateCouponResponse | null, status: 'applied' | 'removed' | 'invalid') => { if (status === 'invalid') return; setCouponData(data); + + // Only regenerate intent if the final total is not zero and using Stripe + if (product && product.price !== 0 && billing.processors?.stripe?.available) { + const finalTotal = data ? data.total : product.price; + + // If coupon makes it free, don't fetch intent + if (finalTotal === 0) { + setIntent(null); + } else { + // Regenerate intent with new amount for paid products + getStripeIntent(Number(params.id), data?.coupon.id) + .then(intentData => setIntent({ id: intentData.id, secret: intentData.secret })) + .catch(error => console.error('Error updating payment intent:', error)); + } + } + }; + + const getDomainPayload = () => + domainMappings.map(mapping => ({ + domain_id: mapping.domain_id, + subdomain: mapping.subdomain, + record_type: mapping.record_type, + })); + + const addDomainMapping = () => { + const selected = customDomainOptions.find(domain => domain.id === selectedDomainId); + if (!selected || !mappingSubdomain.trim()) { + return; + } + + setDomainMappings(current => + current.concat({ + domain_id: selected.id, + domain: selected.domain, + subdomain: mappingSubdomain.trim().toLowerCase(), + record_type: effectiveRecordType, + }), + ); + + setMappingSubdomain(''); + }; + + const removeDomainMapping = (index: number) => { + setDomainMappings(current => current.filter((_, idx) => idx !== index)); + }; + + const createFree = () => { + if (product && serverName.trim()) { + const variables = Array.from(vars, ([key, value]) => ({ key, value })); + processUnpaidOrder( + product.id, + selectedNode, + undefined, + variables, + undefined, + couponData?.coupon.id, + selectedEggId, + serverName.trim(), + getDomainPayload(), + ) + .then(() => navigate('/')) + .catch(error => clearAndAddHttpError({ key: 'account:billing:order', error })); + } }; useEffect(() => { @@ -159,8 +247,41 @@ export default () => { // Fetch nodes const nodesData = await getViableNodes(productData.id); setNodes(nodesData); - const firstNodeId = nodesData.length > 0 ? Number(nodesData[0].id) : 0; + const firstNodeId = Number(nodesData.at(0)?.id ?? 0); setSelectedNode(Number.isInteger(firstNodeId) && firstNodeId > 0 ? firstNodeId : 0); + setSelectedNode(Number(nodesData[0]?.id) ?? 0); + + const domainsData = await getAvailableCustomDomains(allowedEggs[0]); + setCustomDomainOptions(domainsData); + const firstDomain = domainsData[0]; + if (firstDomain) { + setSelectedDomainId(firstDomain.id); + setMappingRecordType(firstDomain.recommended_record_type); + } + + if (productData.price !== 0) { + // Check which processors are available and fetch resources accordingly + const stripeAvailable = billing.processors?.stripe?.available ?? false; + + // Fetch Stripe resources if Stripe is available + if (stripeAvailable) { + try { + // Fetch payment intent + const intentData = await getStripeIntent(Number(params.id)); + setIntent({ id: intentData.id, secret: intentData.secret }); + + // Fetch Stripe public key and initialize Stripe + const stripePublicKey = await getStripeKey(Number(params.id)); + const stripeInstance = await loadStripeOnce(stripePublicKey.key); + setStripe(stripeInstance); + } catch (error) { + console.error('Error initializing Stripe:', error); + } + } + + // Mollie doesn't need pre-initialization like Stripe + // Payment is created when user clicks the button + } } catch (error: unknown) { console.error('Error fetching billing order data:', error); @@ -186,6 +307,31 @@ export default () => { .catch(error => console.error(error)); }, [product, selectedEggId]); + useEffect(() => { + if (!selectedEggId) { + return; + } + + getAvailableCustomDomains(selectedEggId) + .then(domains => { + setCustomDomainOptions(domains); + + const currentlySelected = domains.find(option => option.id === selectedDomainId); + const nextSelected = currentlySelected ?? domains[0]; + + if (nextSelected) { + if (!currentlySelected) { + setSelectedDomainId(nextSelected.id); + } + + setMappingRecordType(nextSelected.recommended_record_type); + } else { + setSelectedDomainId(0); + } + }) + .catch(error => console.error(error)); + }, [selectedEggId]); + // Auto-generate server name when selections change useEffect(() => { if (!serverNameTouched && product && selectedNode && selectedEggId) { @@ -457,6 +603,23 @@ export default () => {

✓ Accepted

+ +
+ ) : ( + + )} )}
@@ -514,4 +677,4 @@ export default () => {
); -}; +}; \ No newline at end of file diff --git a/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx b/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx index cfd4298062..80757668ce 100644 --- a/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx +++ b/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx @@ -14,6 +14,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } export default (data: Props) => { @@ -63,6 +68,7 @@ export default (data: Props) => { eggId: data.selectedEggId, billingDays: data.billingDays, name: data.serverName, + domainPayload: data.domainPayload, }); console.log('[PayPal] Order updated successfully'); diff --git a/resources/scripts/components/account/billing/order/PaymentButton.tsx b/resources/scripts/components/account/billing/order/PaymentButton.tsx index 95ad6bd9e7..6305537293 100644 --- a/resources/scripts/components/account/billing/order/PaymentButton.tsx +++ b/resources/scripts/components/account/billing/order/PaymentButton.tsx @@ -16,6 +16,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } export default (data: Props) => { @@ -41,6 +46,7 @@ export default (data: Props) => { coupon_id: data.couponId, egg_id: data.selectedEggId, name: data.serverName, + domain_payload: data.domainPayload, billing_days: data.billingDays, }) .then(() => { diff --git a/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx b/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx index 098492257b..68b7f6e8c5 100644 --- a/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx +++ b/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx @@ -22,6 +22,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } type PaymentMethod = 'stripe' | 'mollie' | 'paypal'; @@ -264,6 +269,7 @@ export default (props: Props) => { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
@@ -277,6 +283,7 @@ export default (props: Props) => { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
) : selectedMethod === 'paypal' ? ( @@ -289,6 +296,7 @@ export default (props: Props) => { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
) : null} diff --git a/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx new file mode 100644 index 0000000000..38d5369839 --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { getSystemLogs, getSystemLogContents, LogFile } from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFileAlt, faArrowLeft, faSync } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { Button } from '@/elements/button'; + +const stripAnsi = (input: string): string => { + return input.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +}; + +export default () => { + const { clearFlashes, addError } = useFlash(); + const [loading, setLoading] = useState(true); + const [logFiles, setLogFiles] = useState([]); + const [selectedLog, setSelectedLog] = useState(null); + const [logContents, setLogContents] = useState([]); + const [logLoading, setLogLoading] = useState(false); + + const node = Context.useStoreState(state => state.node); + + if (!node) return null; + + useEffect(() => { + clearFlashes('node:logs'); + getSystemLogs(node.id) + .then(data => { + setLogFiles(data); + setLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:logs', message: 'Failed to load log files.' }); + setLoading(false); + }); + }, []); + + const openLog = (file: string) => { + setSelectedLog(file); + setLogLoading(true); + getSystemLogContents(node.id, file, 200) + .then(lines => { + setLogContents(lines); + setLogLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:logs', message: `Failed to load log: ${file}` }); + setLogLoading(false); + }); + }; + + const refreshLog = () => { + if (selectedLog) openLog(selectedLog); + }; + + const formatSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; + + if (selectedLog) { + return ( + + + +
+ } + css={tw`relative`} + > + +
+ {logContents.length === 0 ? ( +

No log entries found.

+ ) : ( + logContents.map((line, i) => ( +
+ {i + 1} + {stripAnsi(line)} +
+ )) + )} +
+ + ); + } + + return ( + + + {logFiles.length === 0 ? ( +

No log files available.

+ ) : ( +
+ {logFiles.map(file => ( +
openLog(file.name)} + className={ + 'flex cursor-pointer items-center justify-between rounded bg-black/30 p-3 transition hover:bg-black/50' + } + > +
+ +
+

{file.name}

+

+ {formatSize(file.size)} + {file.modified && ` · Modified ${new Date(file.modified).toLocaleString()}`} +

+
+
+ +
+ ))} +
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/management/nodes/NodeRouter.tsx b/resources/scripts/components/admin/management/nodes/NodeRouter.tsx index 106396198d..e6a1ee4c7d 100644 --- a/resources/scripts/components/admin/management/nodes/NodeRouter.tsx +++ b/resources/scripts/components/admin/management/nodes/NodeRouter.tsx @@ -15,9 +15,10 @@ import NodeAboutContainer from '@admin/management/nodes/NodeAboutContainer'; import NodeConfigurationContainer from '@admin/management/nodes/NodeConfigurationContainer'; import NodeAllocationContainer from '@admin/management/nodes/NodeAllocationContainer'; import NodeServers from '@admin/management/nodes/NodeServers'; +import NodeWingsRsContainer from '@admin/management/nodes/NodeWingsRsContainer'; import type { ApplicationStore } from '@/state'; import NodeStatus from './NodeStatus'; -import { CodeIcon, OfficeBuildingIcon, ServerIcon, WifiIcon } from '@heroicons/react/outline'; +import { CodeIcon, LightningBoltIcon, OfficeBuildingIcon, ServerIcon, WifiIcon } from '@heroicons/react/outline'; import { CogIcon } from '@heroicons/react/solid'; interface ctx { @@ -105,6 +106,10 @@ const NodeRouter = () => { + + + + @@ -113,6 +118,7 @@ const NodeRouter = () => { } /> } /> } /> + } /> ); diff --git a/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx new file mode 100644 index 0000000000..404a6db097 --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState, useRef } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { getSystemStats, SystemStats } from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faMicrochip, + faMemory, + faHdd, + faArrowUp, + faArrowDown, + faSync, + faBoltLightning, +} from '@fortawesome/free-solid-svg-icons'; +import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; + +const toNumber = (value: unknown, fallback = 0): number => { + const number = Number(value); + + return Number.isFinite(number) ? number : fallback; +}; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const formatRate = (bytesPerSec: number): string => { + return formatBytes(bytesPerSec) + '/s'; +}; + +const StatCard = ({ + icon, + title, + value, + subtitle, + large, +}: { + icon: IconDefinition; + title: string; + value: string; + subtitle?: string; + large?: boolean; +}) => ( +
+
+
+
+
+ +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+
+
+); + +export default () => { + const { clearFlashes, addError } = useFlash(); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(true); + const intervalRef = useRef(null); + + const node = Context.useStoreState(state => state.node); + + if (!node) return null; + + const fetchStats = () => { + clearFlashes('node:stats'); + getSystemStats(node.id) + .then(data => { + setStats(data); + setLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:stats', message: 'Failed to load system stats.' }); + setLoading(false); + }); + }; + + useEffect(() => { + fetchStats(); + }, []); + + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(fetchStats, 5000); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh]); + + return ( + setAutoRefresh(!autoRefresh)} + css={tw`ml-auto text-sm text-neutral-300 hover:text-neutral-100`} + > + + {autoRefresh ? 'Auto-refreshing' : 'Paused'} + + } + css={tw`relative`} + > + + {stats && ( +
+ + + + + +
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx new file mode 100644 index 0000000000..82bca7056a --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { detectWingsRs, getSystemOverview, SystemOverview, WingsRsDetectionResult } from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBoltLightning, faRocket, faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { Button } from '@/elements/button'; +import NodeStatsContainer from '@admin/management/nodes/NodeStatsContainer'; +import NodeLogsContainer from '@admin/management/nodes/NodeLogsContainer'; + +const Code = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export default () => { + const { clearFlashes, addError, addFlash } = useFlash(); + const [detecting, setDetecting] = useState(false); + const [overview, setOverview] = useState(null); + const [_overviewLoading, setOverviewLoading] = useState(true); + + const node = Context.useStoreState(state => state.node); + const setNode = Context.useStoreActions(actions => actions.setNode); + + if (!node) return null; + + const isSupercharged = node.wingsType === 'wings-rs'; + + useEffect(() => { + if (isSupercharged) { + clearFlashes('node:wingsrs'); + getSystemOverview(node.id) + .then(data => { + setOverview(data); + setOverviewLoading(false); + }) + .catch(() => { + setOverviewLoading(false); + }); + } else { + setOverviewLoading(false); + } + }, [isSupercharged]); + + const handleDetect = () => { + setDetecting(true); + clearFlashes('node:wingsrs'); + detectWingsRs(node.id) + .then((result: WingsRsDetectionResult) => { + if (result.detected) { + addFlash({ + key: 'node:wingsrs', + type: 'success', + message: `Wings-RS detected! Version: ${result.wings_version}`, + }); + // Update node in context + setNode({ + ...node, + wingsType: result.wings_type, + wingsVersion: result.wings_version, + wingsDetectedAt: new Date(), + }); + } else { + addFlash({ + key: 'node:wingsrs', + type: 'info', + message: 'This node is running standard Wings. Wings-RS features are not available.', + }); + } + setDetecting(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:wingsrs', message: 'Failed to detect Wings type.' }); + setDetecting(false); + }); + }; + + return ( +
+ + + {detecting ? 'Detecting...' : 'Re-detect'} + + } + css={tw`relative`} + > + +
+
+ + + {isSupercharged + ? 'This node is running Wings-RS (Supercharged)' + : 'This node is running standard Wings'} + +
+ + {isSupercharged && ( + + + + + + + + + + + {node.wingsDetectedAt && ( + + + + + )} + +
Wings Type + {node.wingsType} +
Version + {node.wingsVersion || 'Unknown'} +
Detected At + {new Date(node.wingsDetectedAt).toLocaleString()} +
+ )} + + {isSupercharged && overview && ( + <> +
+ + + + + + + + + + + + + + + + + + + {overview.features.length > 0 && ( + + + + + )} + +
Rust Version + {overview.rust_version || 'N/A'} +
Build Date + {overview.build_date || 'N/A'} +
Kernel + {overview.kernel} +
Uptime + + {typeof overview.uptime === 'number' + ? `${Math.floor(overview.uptime / 3600)}h ${Math.floor((overview.uptime % 3600) / 60)}m` + : 'N/A'} + +
Features +
+ {overview.features.map(feature => ( + + {feature} + + ))} +
+
+ + )} + + {!isSupercharged && ( +

+ Click "Re-detect" to check if this node has been upgraded to Wings-RS. + Wings-RS enables supercharged features like real-time stats, log viewing, advanced + file operations, and more. +

+ )} +
+
+ + {isSupercharged && } + {isSupercharged && } +
+ ); +}; diff --git a/resources/scripts/components/admin/management/servers/NewServerContainer.tsx b/resources/scripts/components/admin/management/servers/NewServerContainer.tsx index 184b236e1a..854689f47b 100644 --- a/resources/scripts/components/admin/management/servers/NewServerContainer.tsx +++ b/resources/scripts/components/admin/management/servers/NewServerContainer.tsx @@ -698,6 +698,13 @@ function InternalForm() { type={'number'} description={'The total number of subusers that can be added to this server.'} /> +
@@ -888,6 +895,7 @@ export default () => { backups: 0, databases: 0, subusers: 0, + subdomains: 1, }, allocation: { default: 0, diff --git a/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx b/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx index 10a0dadbf9..3bea5fdd60 100644 --- a/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx @@ -55,6 +55,7 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, addAllocations: [] as number[], diff --git a/resources/scripts/components/admin/management/servers/ServerRouter.tsx b/resources/scripts/components/admin/management/servers/ServerRouter.tsx index 46e8d20865..cb0b6712b3 100644 --- a/resources/scripts/components/admin/management/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/management/servers/ServerRouter.tsx @@ -13,6 +13,7 @@ import { CogIcon, CurrencyDollarIcon, DatabaseIcon, + LightningBoltIcon, ExclamationIcon, ExternalLinkIcon, InformationCircleIcon, @@ -25,6 +26,7 @@ import ServerOverviewContainer from './ServerOverviewContainer'; import ServerConfigurationContainer from './ServerConfigurationContainer'; import ServerResourcesContainer from './ServerResourcesContainer'; import ServerDangerZoneContainer from './ServerDangerZoneContainer'; +import ServerWingsRsContainer from './ServerWingsRsContainer'; import Pill from '@/elements/Pill'; export default () => { @@ -113,6 +115,11 @@ export default () => { name={'Danger Zone'} icon={ExclamationIcon} /> + { } /> } /> } /> + } /> ); diff --git a/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx index a4f6360499..17e061b326 100644 --- a/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx @@ -71,6 +71,7 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, addAllocations: [] as number[], diff --git a/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx b/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx new file mode 100644 index 0000000000..25b6da2e79 --- /dev/null +++ b/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { useServerFromRoute } from '@/api/routes/admin/server'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBoltLightning, faFileAlt, faSync } from '@fortawesome/free-solid-svg-icons'; +import { getAdminServerWingsStatus, getAdminServerInstallLogs } from '@/api/routes/admin/servers/wingsRs'; +import useFlash from '@/plugins/useFlash'; + +export default () => { + const { data: server } = useServerFromRoute(); + const { addError } = useFlash(); + + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState<{ supercharged: boolean; wings_type: string; wings_version: string | null } | null>(null); + const [logs, setLogs] = useState([]); + const [logsMissing, setLogsMissing] = useState(false); + const [logsLoading, setLogsLoading] = useState(false); + + const load = async () => { + if (!server) return; + + try { + setLoading(true); + const statusData = await getAdminServerWingsStatus(server.id); + + setStatus(statusData); + } catch (error) { + console.error(error); + addError({ key: 'server', message: 'Failed to load Wings-RS server details.' }); + } finally { + setLoading(false); + } + }; + + const loadLogs = async () => { + if (!server) return; + + try { + setLogsLoading(true); + const response = await getAdminServerInstallLogs(server.id, 100); + setLogs(response.content); + setLogsMissing(response.missing); + } catch (error) { + console.error(error); + addError({ key: 'server', message: 'Failed to load install logs.' }); + } finally { + setLogsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [server?.id]); + + if (!server) return null; + + if (status && !status.supercharged) { + return ( + +

This server's node is not running Wings-RS.

+
+ ); + } + + return ( +
+ + +
+
Type: {status?.wings_type ?? 'unknown'}
+
Version: {status?.wings_version ?? 'unknown'}
+
+
+ + + Refresh + + } + css={tw`relative`} + > + + {logsMissing ? ( +

No installation log file exists yet for this server.

+ ) : logs.length === 0 ? ( +

Click refresh to load installation logs.

+ ) : ( +
+ {logs.map((line, index) => ( +
{line}
+ ))} +
+ )} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx b/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx index c2b66954f2..fed7d688b6 100644 --- a/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx +++ b/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx @@ -39,6 +39,13 @@ export default () => { type={'number'} description={'The total number of subusers that can be added to this server.'} /> + ); diff --git a/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx b/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx index 8fba652ae1..8f0093961a 100644 --- a/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx +++ b/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx @@ -20,8 +20,8 @@ export default () => { const { setFieldValue } = useFormikContext(); const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes); const [availableAllocations, setAvailableAllocations] = useState([]); - const [selectedAvailableIds, setSelectedAvailableIds] = useState([]); - const [selectedCurrentIds, setSelectedCurrentIds] = useState([]); + const [selectedAvailableId, setSelectedAvailableId] = useState(null); + const [selectedCurrentId, setSelectedCurrentId] = useState(null); const [loading, setLoading] = useState(false); const [loadingAvailable, setLoadingAvailable] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -97,15 +97,14 @@ export default () => { const canAddMore = allocationLimit === 0 || currentAllocations.length < allocationLimit; const handleAddAllocation = async () => { - if (selectedAvailableIds.length === 0) return; + if (!selectedAvailableId) return; // Check allocation limit before adding - const newTotal = currentAllocations.length + selectedAvailableIds.length; - if (allocationLimit > 0 && newTotal > allocationLimit) { + if (!canAddMore) { clearAndAddHttpError({ key: 'server:networking', error: { - message: `Cannot add ${selectedAvailableIds.length} allocation(s). Would exceed limit of ${allocationLimit}.`, + message: `Allocation limit of ${allocationLimit} reached. Remove allocations or increase the limit.`, }, }); return; @@ -133,14 +132,15 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, - addAllocations: selectedAvailableIds, + addAllocations: [selectedAvailableId], removeAllocations: [], }); await mutate(); - setSelectedAvailableIds([]); + setSelectedAvailableId(null); } catch (error) { console.error('Failed to add allocation:', error); clearAndAddHttpError({ key: 'server:networking', error }); @@ -150,11 +150,11 @@ export default () => { }; const handleRemoveAllocation = async () => { - if (selectedCurrentIds.length === 0) return; + if (!selectedCurrentId) return; // Can't remove the primary allocation if there are no other allocations - const isPrimarySelected = selectedCurrentIds.includes(server.allocationId); - const remainingCount = currentAllocations.length - selectedCurrentIds.length; + const isPrimarySelected = selectedCurrentId === server.allocationId; + const remainingCount = currentAllocations.length - 1; if (isPrimarySelected && remainingCount === 0) { clearAndAddHttpError({ @@ -173,8 +173,8 @@ export default () => { try { // If removing primary, set a new primary first let newPrimaryId = server.allocationId; - if (isPrimarySelected) { - const remaining = currentAllocations.find(a => !selectedCurrentIds.includes(a.id)); + if (selectedCurrentId === server.allocationId) { + const remaining = currentAllocations.find(a => a.id !== selectedCurrentId); if (remaining) { newPrimaryId = remaining.id; } @@ -198,14 +198,15 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: newPrimaryId, addAllocations: [], - removeAllocations: selectedCurrentIds, + removeAllocations: [selectedCurrentId], }); await mutate(); - setSelectedCurrentIds([]); + setSelectedCurrentId(null); } catch (error) { console.error('Failed to remove allocation:', error); clearAndAddHttpError({ key: 'server:networking', error }); @@ -215,7 +216,7 @@ export default () => { }; const handleSetPrimary = async () => { - if (selectedCurrentIds.length !== 1 || selectedCurrentIds[0] === server.allocationId) return; + if (!selectedCurrentId || selectedCurrentId === server.allocationId) return; setLoading(true); clearFlashes('server:networking'); @@ -239,8 +240,9 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, - allocationId: selectedCurrentIds[0], + allocationId: selectedCurrentId, addAllocations: [], removeAllocations: [], }); @@ -310,18 +312,13 @@ export default () => {
@@ -350,30 +347,22 @@ export default () => {
- setSelectedCurrentIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], + setSelectedCurrentId(prev => + prev === allocation.id ? null : allocation.id, ) } css={tw`flex items-center justify-between p-3 cursor-pointer transition-colors hover:bg-gray-700`} style={{ - backgroundColor: selectedCurrentIds.includes(allocation.id) + backgroundColor: selectedCurrentId === allocation.id ? '#374151' : undefined, }} >
- setSelectedCurrentIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], - ) - } + type="radio" + checked={selectedCurrentId === allocation.id} + onChange={() => setSelectedCurrentId(allocation.id)} css={tw`cursor-pointer`} onClick={e => e.stopPropagation()} /> @@ -400,17 +389,16 @@ export default () => {
@@ -431,29 +419,21 @@ export default () => {
- setSelectedAvailableIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], + setSelectedAvailableId(prev => + prev === allocation.id ? null : allocation.id, ) } css={tw`flex items-center gap-3 p-3 cursor-pointer transition-colors hover:bg-gray-700`} style={{ - backgroundColor: selectedAvailableIds.includes(allocation.id) + backgroundColor: selectedAvailableId === allocation.id ? '#374151' : undefined, }} > - setSelectedAvailableIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], - ) - } + type="radio" + checked={selectedAvailableId === allocation.id} + onChange={() => setSelectedAvailableId(allocation.id)} css={tw`cursor-pointer`} onClick={e => e.stopPropagation()} /> @@ -475,11 +455,10 @@ export default () => { {/* Info Message */}

- 💡 How to use: Select multiple allocations using checkboxes from either - list. Click "Add" to add selected available allocations immediately, or - "Remove" to remove selected current allocations. Select a single allocation and - click "Set Primary" to make it the primary allocation. Changes are saved - automatically. + 💡 How to use: Select an allocation from either list. Click + "Add" to add the selected available allocation, or "Remove" to + remove the selected current allocation. Select a current allocation and click + "Set Primary" to make it the primary allocation. Changes are saved automatically.

diff --git a/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx b/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx index fbee0af584..f0abf8c4b8 100644 --- a/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx +++ b/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx @@ -182,6 +182,7 @@ export default ({ product }: { product?: Product }) => { backup: product?.limits.backup ?? 0, database: product?.limits.database ?? 0, allocation: product?.limits.allocation ?? 1, + subdomain: product?.limits.subdomain ?? 1, }, }} validationSchema={object().shape({ @@ -201,6 +202,7 @@ export default ({ product }: { product?: Product }) => { backup: number().required().min(0), database: number().required().min(0), allocation: number().required().min(1), + subdomain: number().nullable().min(0), }), })} > @@ -343,6 +345,13 @@ export default ({ product }: { product?: Product }) => { label={'Allocation (Port) Limit'} description={'The amount of ports this product can have.'} /> + diff --git a/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx b/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx new file mode 100644 index 0000000000..cc17ea3ccf --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx @@ -0,0 +1,40 @@ +import { Route, Routes } from 'react-router-dom'; +import { NotFound } from '@/elements/ScreenBlock'; +import AdminContentBlock from '@/elements/AdminContentBlock'; +import FlashMessageRender from '@/elements/FlashMessageRender'; +import { SubNavigation, SubNavigationLink } from '@admin/SubNavigation'; +import { CogIcon, GlobeAltIcon } from '@heroicons/react/outline'; +import DomainsContainer from './domains/DomainsContainer'; +import SettingsContainer from './settings/SettingsContainer'; + +export default () => { + return ( + +
+
+

Custom Domains

+

+ Manage domain inventory and Cloudflare credentials for automated DNS provisioning. +

+
+
+ + + + + + + + + + + + + + } /> + } /> + } /> + +
+ ); +}; diff --git a/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx b/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx new file mode 100644 index 0000000000..0935da3f11 --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx @@ -0,0 +1,468 @@ +import { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { useStoreState } from '@/state/hooks'; +import { Button } from '@/elements/button'; +import Input from '@/elements/Input'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { + AdminCustomDomain, + CustomDomainApiKey, + createCustomDomain, + deleteCustomDomain, + getCustomDomainApiKeys, + getCustomDomainTargetOptions, + getCustomDomains, + updateCustomDomain, +} from '@/api/routes/admin/customDomains'; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { colors } = useStoreState(state => state.theme.data!); + + const [loading, setLoading] = useState(false); + const [domains, setDomains] = useState([]); + const [domain, setDomain] = useState(''); + const [zoneId, setZoneId] = useState(''); + const [apiKeyId, setApiKeyId] = useState(0); + const [serviceTag, setServiceTag] = useState(''); + const [eggServiceTags, setEggServiceTags] = useState>({}); + const [selectedEggForTagId, setSelectedEggForTagId] = useState(0); + const [selectedEggTagInput, setSelectedEggTagInput] = useState(''); + const [allowedNestIds, setAllowedNestIds] = useState([]); + const [allowedEggIds, setAllowedEggIds] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [nests, setNests] = useState>([]); + const [eggs, setEggs] = useState>([]); + + const loadDomains = async () => { + const rows = await getCustomDomains(); + setDomains(rows); + }; + + const loadOptions = async () => { + const [keys, options] = await Promise.all([getCustomDomainApiKeys(), getCustomDomainTargetOptions()]); + setApiKeys(keys); + setNests(options.nests.map(nest => ({ id: nest.id, name: nest.name }))); + setEggs( + options.eggs.map(egg => ({ + id: egg.id, + name: egg.name, + nest_id: egg.nest_id, + nest_name: egg.nest_name || `Nest #${egg.nest_id}`, + default_service_tag: egg.default_service_tag, + })), + ); + + if (keys[0] && !apiKeyId) { + setApiKeyId(keys[0].id); + } + }; + + const toggleNest = (id: number) => { + setAllowedNestIds(current => (current.includes(id) ? current.filter(item => item !== id) : current.concat(id))); + }; + + const toggleEgg = (id: number) => { + setAllowedEggIds(current => (current.includes(id) ? current.filter(item => item !== id) : current.concat(id))); + }; + + const filteredEggs = eggs.filter(egg => allowedNestIds.length === 0 || allowedNestIds.includes(egg.nest_id)); + + const selectedEggForTag = eggs.find(egg => egg.id === selectedEggForTagId); + + useEffect(() => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + Promise.all([loadDomains(), loadOptions()]) + .catch(error => clearAndAddHttpError({ key: 'admin:custom-domains', error })) + .finally(() => setLoading(false)); + }, []); + + const onCreate = async () => { + if (!domain.trim()) { + return; + } + + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await createCustomDomain({ + domain: domain.trim().toLowerCase(), + cloudflare_zone_id: zoneId.trim() || null, + api_key_id: apiKeyId || null, + allowed_nest_ids: allowedNestIds, + allowed_egg_ids: allowedEggIds, + service_tag: serviceTag.trim() || null, + egg_service_tags: eggServiceTags, + enabled: true, + }); + + setDomain(''); + setZoneId(''); + setServiceTag(''); + setEggServiceTags({}); + setSelectedEggForTagId(0); + setSelectedEggTagInput(''); + setAllowedNestIds([]); + setAllowedEggIds([]); + + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onToggleEnabled = async (row: AdminCustomDomain) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomain(row.id, { enabled: !row.enabled }); + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onDelete = async (id: number) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await deleteCustomDomain(id); + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + +
+
+ setDomain(e.currentTarget.value)} + placeholder={'example.com'} + /> +
+
+ setZoneId(e.currentTarget.value)} + placeholder={'Cloudflare zone ID (optional)'} + /> +
+
+ +
+
+ +
+
+ +
+
+ setServiceTag(e.currentTarget.value.toLowerCase())} + placeholder={'Default service tag (e.g. _minecraft._)'} + /> +
+
+ SRV is reliable for Minecraft-family eggs. Rust and most other eggs should use CNAME and connect with :port. +
+
+ Leave nests/eggs unselected to allow all. SRV tags only affect Minecraft-family servers. +
+
+ +
+
+
Per-Egg Service Tag Override
+
+
+ Select an egg to override its service tag. The field auto-fills with the egg default tag when available. +
+
+ + + setSelectedEggTagInput(e.currentTarget.value.toLowerCase())} + placeholder={selectedEggForTag?.default_service_tag || 'e.g. _minecraft._'} + disabled={!selectedEggForTagId} + /> + +
+ + +
+
+
+ {selectedEggForTag?.default_service_tag + ? `Detected default for this egg: ${selectedEggForTag.default_service_tag}` + : 'No SRV default detected for this egg. Recommended: CNAME + :port for non-Minecraft games.'} +
+
+ {Object.keys(eggServiceTags).length < 1 ? ( +
No per-egg overrides configured.
+ ) : ( + Object.entries(eggServiceTags).map(([eggId, tag]) => { + const egg = eggs.find(item => String(item.id) === eggId); + + return ( +
+ {(egg?.name || `Egg #${eggId}`)} → {tag} +
+ ); + }) + )} +
+
+ +
+
+
+

Allowed Nests

+
+ + | + +
+
+
+ {nests.map(nest => ( + + ))} +
+
+ +
+
+

+ Allowed Eggs + {allowedNestIds.length > 0 && ( + + ({filteredEggs.length} in selected nests) + + )} +

+
+ + | + +
+
+ {filteredEggs.length < 1 ? ( +
+ {allowedNestIds.length > 0 ? 'No eggs found in selected nests.' : 'No eggs available.'} +
+ ) : ( +
+ {filteredEggs.map(egg => ( + + ))} +
+ )} +
+
+ +
+ {domains.length < 1 && ( +
+ No custom domains configured yet. +
+ )} + + {domains.map(row => ( +
+
+
{row.domain}
+
+ Zone: {row.cloudflare_zone_id || 'Auto-resolve'} • API key: {row.api_key_name || 'none'} +
+
+ Service tag: {row.service_tag || 'auto (no explicit default)'} • Nests: {row.allowed_nest_ids?.length || 0} • Eggs:{' '} + {row.allowed_egg_ids?.length || 0} +
+
+ Egg overrides: {Object.keys(row.egg_service_tags || {}).length} +
+
+ +
+ + +
+
+ ))} +
+ + ); +}; diff --git a/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx b/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx new file mode 100644 index 0000000000..7164b347ea --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react'; +import Input from '@/elements/Input'; +import { Button } from '@/elements/button'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import { useStoreState } from '@/state/hooks'; +import { + createCustomDomainApiKey, + deleteCustomDomainApiKey, + getCustomDomainSettings, + getCustomDomainApiKeys, + updateCustomDomainSettings, + updateCustomDomainApiKey, + type CustomDomainApiKey, +} from '@/api/routes/admin/customDomains'; + +export default () => { + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const { colors } = useStoreState(state => state.theme.data!); + + const [loading, setLoading] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + const [name, setName] = useState(''); + const [token, setToken] = useState(''); + const [rateLimitCreatePerMinute, setRateLimitCreatePerMinute] = useState(10); + const [rateLimitSyncPerMinute, setRateLimitSyncPerMinute] = useState(5); + const [rateLimitBillingOptionsPerMinute, setRateLimitBillingOptionsPerMinute] = useState(20); + + const loadApiKeys = async () => { + const rows = await getCustomDomainApiKeys(); + setApiKeys(rows); + }; + + const loadSettings = async () => { + const data = await getCustomDomainSettings(); + setRateLimitCreatePerMinute(Number(data.rate_limit_create_per_minute || 10)); + setRateLimitSyncPerMinute(Number(data.rate_limit_sync_per_minute || 5)); + setRateLimitBillingOptionsPerMinute(Number(data.rate_limit_billing_options_per_minute || 20)); + }; + + useEffect(() => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + Promise.all([loadApiKeys(), loadSettings()]) + .catch(error => clearAndAddHttpError({ key: 'admin:custom-domains', error })) + .finally(() => setLoading(false)); + }, []); + + const onSaveSecurityAndLimits = async () => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomainSettings({ + allow_wildcard: false, + max_wildcards_per_user: 1, + rate_limit_create_per_minute: rateLimitCreatePerMinute, + rate_limit_sync_per_minute: rateLimitSyncPerMinute, + rate_limit_billing_options_per_minute: rateLimitBillingOptionsPerMinute, + }); + + addFlash({ + key: 'admin:custom-domains', + type: 'success', + message: 'Security and rate limit settings saved.', + }); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onCreate = async () => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await createCustomDomainApiKey({ name: name.trim(), token: token.trim(), enabled: true }); + setName(''); + setToken(''); + await loadApiKeys(); + + addFlash({ + key: 'admin:custom-domains', + type: 'success', + message: 'Cloudflare API key saved.', + }); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onToggle = async (row: CustomDomainApiKey) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomainApiKey(row.id, { enabled: !row.enabled }); + await loadApiKeys(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onDelete = async (id: number) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await deleteCustomDomainApiKey(id); + await loadApiKeys(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + +
+
+

Cloudflare API Keys

+
+

+ Add multiple Cloudflare API keys with human-readable names. Keys are encrypted at rest. +

+ +
+ setName(e.currentTarget.value)} + placeholder={'Key name (e.g. Main CF Account)'} + autoComplete={'off'} + /> + setToken(e.currentTarget.value)} + placeholder={'Cloudflare API token'} + autoComplete={'off'} + /> + +
+ +
+ {apiKeys.length < 1 && ( +
+ No API keys configured yet. +
+ )} + + {apiKeys.map(row => ( +
+
+
{row.name}
+
{row.enabled ? 'Enabled' : 'Disabled'}
+
+ +
+ + +
+
+ ))} +
+
+ +
+
+

Security & Rate Limits

+
+

+ Wildcards are fully disabled. Configure API rate limits for custom domain endpoints below. +

+
+ Rate limits are scoped per authenticated user UUID (fallback to client IP if unauthenticated), + not per server and not a single global bucket for all users. +
+ +
+
+ + setRateLimitCreatePerMinute(Number(e.currentTarget.value || 1))} + /> +
+ +
+ + setRateLimitSyncPerMinute(Number(e.currentTarget.value || 1))} + /> +
+ +
+ + setRateLimitBillingOptionsPerMinute(Number(e.currentTarget.value || 1))} + /> +
+
+ +
+ +
+
+ + ); +}; diff --git a/resources/scripts/components/admin/modules/extensions/EnableExtensionsContainer.tsx b/resources/scripts/components/admin/modules/extensions/EnableExtensionsContainer.tsx new file mode 100644 index 0000000000..a752ecc97c --- /dev/null +++ b/resources/scripts/components/admin/modules/extensions/EnableExtensionsContainer.tsx @@ -0,0 +1,20 @@ +import { useStoreState } from '@/state/hooks'; +import { faPuzzlePiece } from '@fortawesome/free-solid-svg-icons'; +import FeatureContainer from '@/elements/FeatureContainer'; +import ToggleExtensionsButton from './ToggleExtensionsButton'; +import ExtensionsSvg from './ExtensionsSvg'; + +export default () => { + const primary = useStoreState(state => state.theme.data!.colors.primary); + + return ( + } icon={faPuzzlePiece} title={'Extensions Module'}> + The Extensions module allows you to enable powerful add-ons for your servers. Configure which nests and eggs + can use each extension, giving server owners access to specialized tools like the Minecraft Player Manager. + Extensions can be configured individually with their own settings and access controls. +

+ +

+
+ ); +}; diff --git a/resources/scripts/components/admin/modules/extensions/ExtensionCard.tsx b/resources/scripts/components/admin/modules/extensions/ExtensionCard.tsx new file mode 100644 index 0000000000..30bc69b961 --- /dev/null +++ b/resources/scripts/components/admin/modules/extensions/ExtensionCard.tsx @@ -0,0 +1,535 @@ +import { useState, useEffect } from 'react'; +import classNames from 'classnames'; +import { useStoreState } from '@/state/hooks'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faPuzzlePiece, + faUsers, + faGamepad, + faCube, + faServer, + faToggleOn, + faToggleOff, + faCog, + faCheck, + faLink, + faWrench, + faShieldHalved, + faTerminal, + faGlobe, + faDatabase, + faChartLine, + faBell, + faRobot, + faCloud, + faFolder, + faFile, + faKey, + faBolt, + faCogs, + faLock, + faScroll, +} from '@fortawesome/free-solid-svg-icons'; +import { faDiscord } from '@fortawesome/free-brands-svg-icons'; +import { Button } from '@/elements/button'; +import Modal from '@/elements/Modal'; +import { + ExtensionData, + ExtensionSettingField, + NestOption, + EggOption, + getNestsAndEggs, + toggleExtension, + updateExtension +} from '@/api/routes/admin/extensions'; +import useFlash from '@/plugins/useFlash'; +import Spinner from '@/elements/Spinner'; +import Input, { Textarea } from '@/elements/Input'; +import Select from '@/elements/Select'; + +interface Props { + extension: ExtensionData; + onUpdate: () => void; +} + +const iconMap: Record = { + 'puzzle': faPuzzlePiece, + 'users': faUsers, + 'gamepad': faGamepad, + 'cube': faCube, + 'server': faServer, + + // Extra icons for extension authors. + 'discord': faDiscord, + 'link': faLink, + 'wrench': faWrench, + 'shield': faShieldHalved, + 'terminal': faTerminal, + 'globe': faGlobe, + 'database': faDatabase, + 'chart': faChartLine, + 'bell': faBell, + 'robot': faRobot, + 'cloud': faCloud, + 'folder': faFolder, + 'file': faFile, + 'key': faKey, + 'bolt': faBolt, + 'cogs': faCogs, + 'lock': faLock, + 'scroll': faScroll, +}; + +export default ({ extension, onUpdate }: Props) => { + const primary = useStoreState(state => state.theme.data!.colors.primary); + const { addFlash, clearFlashes, clearAndAddHttpError } = useFlash(); + + const [configOpen, setConfigOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [nestsAndEggs, setNestsAndEggs] = useState<{ nests: NestOption[]; eggs: EggOption[] } | null>(null); + const [selectedNests, setSelectedNests] = useState(extension.allowedNests); + const [selectedEggs, setSelectedEggs] = useState(extension.allowedEggs); + const [settings, setSettings] = useState>(extension.settings ?? {}); + + const icon = iconMap[extension.icon] || faPuzzlePiece; + + useEffect(() => { + if (configOpen && !nestsAndEggs) { + getNestsAndEggs() + .then(data => setNestsAndEggs(data)) + .catch(error => clearAndAddHttpError({ key: 'admin:extensions', error })); + } + }, [configOpen]); + + useEffect(() => { + if (configOpen) { + setSelectedNests(extension.allowedNests); + setSelectedEggs(extension.allowedEggs); + setSettings(extension.settings ?? {}); + } + }, [configOpen, extension.allowedNests, extension.allowedEggs, extension.settings]); + + const handleToggle = () => { + setLoading(true); + clearFlashes('admin:extensions'); + + toggleExtension(extension.id) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} has been ${extension.enabled ? 'disabled' : 'enabled'}.`, + }); + onUpdate(); + }) + .catch(error => { + clearAndAddHttpError({ key: 'admin:extensions', error }); + }) + .finally(() => setLoading(false)); + }; + + const handleSaveConfig = () => { + setLoading(true); + clearFlashes('admin:extensions'); + + updateExtension(extension.id, selectedNests, selectedEggs, settings) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} configuration has been updated.`, + }); + setConfigOpen(false); + onUpdate(); + }) + .catch(error => { + clearAndAddHttpError({ key: 'admin:extensions', error }); + }) + .finally(() => setLoading(false)); + }; + + const toggleNest = (nestId: number) => { + setSelectedNests((prev: number[]) => + prev.includes(nestId) + ? prev.filter((id: number) => id !== nestId) + : [...prev, nestId] + ); + }; + + const toggleEgg = (eggId: number) => { + setSelectedEggs((prev: number[]) => + prev.includes(eggId) + ? prev.filter((id: number) => id !== eggId) + : [...prev, eggId] + ); + }; + + const selectAllNests = () => { + if (nestsAndEggs) { + setSelectedNests(nestsAndEggs.nests.map((n: NestOption) => n.id)); + } + }; + + const clearNests = () => { + setSelectedNests([]); + }; + + // Get filtered eggs based on selected nests + const filteredEggs = nestsAndEggs?.eggs.filter((egg: EggOption) => + selectedNests.length === 0 || selectedNests.includes(egg.nestId) + ) ?? []; + + const selectAllEggs = () => { + if (filteredEggs.length > 0) { + setSelectedEggs(filteredEggs.map((e: EggOption) => e.id)); + } + }; + + const clearEggs = () => { + setSelectedEggs([]); + }; + + const updateSetting = (key: string, value: unknown) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const renderSettingField = (field: ExtensionSettingField) => { + const value = settings[field.key]; + + if (field.type === 'boolean') { + return ( + + ); + } + + const commonProps = { + name: field.key, + placeholder: field.placeholder, + }; + + return ( +
+ + {field.help &&

{field.help}

} +
+ {field.type === 'textarea' ? ( +