diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index c17a5641e..151f9854b 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -543,13 +543,14 @@ public function firewalls(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getFirewalls()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); } } @@ -637,13 +638,14 @@ public function networks(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getNetworks()); } catch (\Throwable $e) { - return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); } } @@ -914,10 +916,6 @@ public function createServer(Request $request) // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); - if ($request->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -946,6 +944,14 @@ public function createServer(Request $request) $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($request->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + // Validate server if requested if ($request->instant_validate) { ValidateServer::dispatch($server); diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 95d70fe41..0da4ef344 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -74,6 +74,10 @@ class ByHetzner extends Component public bool $enable_backups = false; + public bool $show_advanced_hetzner_options = false; + + public bool $show_cloud_init_script = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -127,6 +131,8 @@ public function resetSelection() $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; $this->selected_cloud_init_script_id = null; + $this->show_advanced_hetzner_options = false; + $this->show_cloud_init_script = false; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -185,6 +191,8 @@ protected function rules(): array 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', 'enable_backups' => 'required|boolean', + 'show_advanced_hetzner_options' => 'boolean', + 'show_cloud_init_script' => 'boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -444,6 +452,67 @@ public function getSelectedServerBackupSurchargeProperty(): ?string return '€'.number_format($price * 0.2, 2); } + public function getShouldShowAdvancedHetznerOptionsProperty(): bool + { + return $this->show_advanced_hetzner_options + || $this->selectedHetznerSshKeyIds !== [] + || $this->selectedHetznerFirewallIds !== [] + || $this->selectedHetznerNetworkIds !== [] + || $this->enable_backups + || ! $this->enable_ipv4 + || ! $this->enable_ipv6 + || $this->show_cloud_init_script + || filled($this->cloud_init_script) + || $this->save_cloud_init_script + || filled($this->cloud_init_script_name) + || filled($this->selected_cloud_init_script_id); + } + + public function getAdvancedHetznerOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedHetznerSshKeyIds) > 0) { + $summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds)); + } + + if (count($this->selectedHetznerFirewallIds) > 0) { + $summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds)); + } + + if (count($this->selectedHetznerNetworkIds) > 0) { + $summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds)); + } + + if ($this->enable_backups) { + $summary[] = 'Backups on'; + } + + if (! $this->enable_ipv4 || ! $this->enable_ipv6) { + $summary[] = collect([ + $this->enable_ipv4 ? 'IPv4' : null, + $this->enable_ipv6 ? 'IPv6' : null, + ])->filter()->join(' + ') ?: 'No public IP'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function toggleAdvancedHetznerOptions(): void + { + $this->show_advanced_hetzner_options = ! $this->show_advanced_hetzner_options; + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes @@ -475,6 +544,15 @@ public function updatedSelectedCloudInitScriptId($value) $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $this->cloud_init_script = $script->script; $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + $this->show_advanced_hetzner_options = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; } } @@ -484,6 +562,7 @@ public function clearCloudInitScript() $this->cloud_init_script = ''; $this->cloud_init_script_name = ''; $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; } private function createHetznerServer(HetznerService $hetznerService): array @@ -592,10 +671,6 @@ public function submit() // Create server on Hetzner $hetznerServer = $this->createHetznerServer($hetznerService); - if ($this->enable_backups) { - $hetznerService->enableServerBackup((int) $hetznerServer['id']); - } - // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -624,6 +699,14 @@ public function submit() $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->enable_backups) { + try { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } catch (\Throwable $e) { + report($e); + } + } + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 08e9b5bd4..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 17436ccfe..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:01:53+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 27c00d92b..46311e5b5 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -278,6 +278,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'web-firewall']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'firewalls' => [['id' => 38, 'name' => 'web-firewall']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('GET /api/v1/hetzner/networks', function () { @@ -301,6 +322,27 @@ $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'private-eu']); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'networks' => [['id' => 456, 'name' => 'private-eu']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('POST /api/v1/servers/hetzner', function () { @@ -411,6 +453,67 @@ && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup'); }); + test('registers server when backup enablement fails after Hetzner creation', function () { + Http::fake(function (HttpRequest $request) { + if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) { + return Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') { + return Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') { + return Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') { + return Http::response([ + 'error' => ['message' => 'backup unavailable'], + ], 500); + } + + return Http::response([], 404); + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + 'enable_backups' => true, + ]); + + $response->assertCreated(); + $response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']); + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'team_id' => $this->team->id, + 'hetzner_server_id' => 456, + ]); + }); + test('generates server name if not provided', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ @@ -711,4 +814,38 @@ $response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']); expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); }); + + test('firewalls endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/firewalls*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); + + test('networks endpoint returns generic 500 message on upstream failure', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/networks*' => Http::response([ + 'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'], + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(500); + $response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']); + expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc'); + }); }); diff --git a/tests/Feature/Server/HetznerServerCreationTest.php b/tests/Feature/Server/HetznerServerCreationTest.php index 0e4a228db..e5efe3862 100644 --- a/tests/Feature/Server/HetznerServerCreationTest.php +++ b/tests/Feature/Server/HetznerServerCreationTest.php @@ -144,15 +144,6 @@ ->and(count($sshKeys))->toBe(3); }); -it('validates network array merging removes duplicate Hetzner network ids', function () { - $selectedHetznerNetworkIds = [456, 789, 456]; - - $networkIds = array_values(array_unique($selectedHetznerNetworkIds)); - - expect($networkIds)->toBe([456, 789]) - ->and(count($networkIds))->toBe(2); -}); - describe('Boarding Flow Integration', function () { uses(RefreshDatabase::class); @@ -171,7 +162,7 @@ test('completes boarding when server is created from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component with from_onboarding flag $component = Livewire::test(ByHetzner::class) @@ -188,23 +179,51 @@ test('boarding flag remains unchanged when not from onboarding', function () { // Verify boarding is initially enabled - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); // Mount the component without from_onboarding flag (default false) Livewire::test(ByHetzner::class) ->set('from_onboarding', false); // Boarding should still be enabled since it wasn't created from onboarding - expect($this->team->fresh()->show_boarding)->toBeTrue(); + expect((bool) $this->team->fresh()->show_boarding)->toBeTrue(); }); - test('shows the backups option with the pricing note in the create dialog', function () { + test('keeps advanced Hetzner options collapsed by default in the create dialog', function () { Livewire::test(ByHetzner::class) ->set('current_step', 2) - ->assertSet('enable_backups', false) + ->assertSet('show_advanced_hetzner_options', false) + ->assertSee('Advanced Hetzner options') + ->assertDontSee('Extra SSH Keys') + ->assertDontSee('Firewalls') + ->assertDontSee('Private Networks') + ->assertDontSee('Enable Hetzner Backups') + ->assertDontSee('Cloud-Init Script'); + }); + + test('shows advanced Hetzner options when expanded', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->assertSee('Extra SSH Keys') + ->assertSee('Firewalls') + ->assertSee('Private Networks') ->assertSee('Enable Hetzner Backups') + ->assertSee('Add cloud-init script') ->assertSee('additional 20% of the server monthly fee'); }); + + test('shows the cloud init script name only when saving the script', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->set('show_advanced_hetzner_options', true) + ->set('show_cloud_init_script', true) + ->assertSee('Cloud-Init Script') + ->assertSee('Save this script for later use') + ->assertDontSee('Script name...') + ->set('save_cloud_init_script', true) + ->assertSee('Script name...'); + }); }); describe('Hetzner data loading', function () { @@ -239,7 +258,7 @@ ], 200), 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ - ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200),