diff --git a/.env.development.example b/.env.development.example index 56c17128ce..380f10a446 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= -AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index 626d76ff63..b067b4c5c0 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify -AVATAR_CDN_URL= diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 595a21e799..b9b8b361f2 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -1,8 +1,8 @@ name: Sync main to next on: - push: - branches: [main] + schedule: + - cron: '0 3 * * *' workflow_dispatch: permissions: diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..11048ad5d2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- @@ -567,6 +570,15 @@ that hide secondary columns before allowing horizontal overflow. --- +### Domain rows on mobile + +Domain tables become compact summary cards below 600px. Keep the public URL on +its own line, followed by a short routing summary such as `HTTP → HTTPS · Port +80 · Noindex`. Put DNS status and the existing icon actions on the final row. +Do not squeeze desktop label/value columns into a mobile card or move settings +behind an overflow menu. Long domains wrap, and icon actions retain 40px touch +targets. + ## 8. Modals, confirmations, and toasts ### Modals @@ -626,8 +638,9 @@ Current toast behavior: - Reicon status tile for success, info, warning, danger, or default; - title plus optional description; - dismiss and copy-details actions; -- up to four stacked notifications; +- normally up to four stacked notifications, without evicting persistent notices; - four-second dismissal, paused while hovered; +- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button; - support for all six screen positions and sanitized custom HTML. Do not bring back the old oversized dark rectangle. diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 3feb5117d8..12ac569009 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -44,8 +44,6 @@ class StopApplication $commands = [dockerStopCommand($timeout, $containerName, $server)]; if ($removeContainers) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process(command: $commands, server: $server, throwError: false); @@ -78,5 +76,7 @@ class StopApplication $application->update($status); ServiceStatusChanged::dispatch($application->environment->project->team->id); + + return null; } } diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php index af5f3fc0f0..8bb3a3dc08 100644 --- a/app/Actions/Application/StopApplicationPreview.php +++ b/app/Actions/Application/StopApplicationPreview.php @@ -20,8 +20,6 @@ class StopApplicationPreview $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; if ($removeContainer) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process($commands, $server, false); } diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index c7fbff37b5..cb1c517539 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -32,7 +32,11 @@ class StartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } - $database->resetRestartLimit(); + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); $activity = activity() ->withProperties([ @@ -49,7 +53,6 @@ class StartDatabase if ($activity === null) { return 'Database start could not be queued because activity logging is disabled.'; - } DatabaseStartJob::dispatch( diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index f3c591acfc..d3c6fafc4d 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -32,7 +32,11 @@ class StopDatabase // Reset restart tracking when database is manually stopped $database->update(['status' => 'exited']); if ($resetRestartCount) { - $database->resetRestartLimit(); + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } if ($dockerCleanup) { @@ -58,8 +62,6 @@ class StopDatabase $commands = [dockerStopCommand($timeout, $containerName, $server)]; if ($removeContainer) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process(command: $commands, server: $server, throwError: false); } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index be098e481c..c69bd1855f 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -5,7 +5,6 @@ namespace App\Actions\Docker; use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; -use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; @@ -249,9 +248,12 @@ class GetContainersStatus $database->update($updateData); - if ($database->trackRestartCount((int) $restartCount)) { - StopDatabase::dispatch($database, false, false, false); - $database->team()?->notify(new ApplicationRestartLimitReached($database)); + if ($restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); } if ($isPublic) { @@ -357,7 +359,9 @@ class GetContainersStatus continue; } - if (! $exitedService->stoppedAfterRestartLimit()) { + if ($exitedService instanceof ServiceDatabase) { + $exitedService->update(['status' => 'exited']); + } elseif (! $exitedService->stoppedAfterRestartLimit()) { $exitedService->update([ 'status' => 'exited', 'restart_count' => 0, @@ -424,9 +428,6 @@ class GetContainersStatus $notRunningDatabases = $databases->pluck('id')->diff($foundDatabases); foreach ($notRunningDatabases as $database) { $database = $databases->where('id', $database)->first(); - if ($database->stoppedAfterRestartLimit()) { - continue; - } if (str($database->status)->startsWith('exited')) { continue; } @@ -442,7 +443,6 @@ class GetContainersStatus 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - 'restart_limit_reached' => false, ]); // Stop proxy if database was public @@ -582,7 +582,7 @@ class GetContainersStatus $restartCount = isset($this->serviceContainerRestartCounts) ? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0) : 0; - if ($subResource->trackRestartCount($restartCount)) { + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { StopServiceApplication::dispatch($subResource, false, false); $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 13371d1265..3dc5c98b3f 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -25,7 +25,6 @@ class StartService $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); $service->applications()->get()->each->resetRestartLimit(); - $service->databases()->get()->each->resetRestartLimit(); $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 341687d0d2..52d9edda19 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -55,7 +55,6 @@ class StopService }); $dbs->each(function ($database): void { $database->update(['status' => 'exited']); - $database->resetRestartLimit(); }); if ($deleteConnectedNetworks) { diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index fa93a78807..1b53472656 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -22,15 +22,12 @@ class StopServiceApplication if ($removeContainer) { $commands = ["docker rm -f {$containerName}"]; } else { - $commands = [ - "docker update --restart=no {$containerName}", - "docker stop {$containerName}", - ]; + $commands = ["docker stop {$containerName}"]; } instant_remote_process($commands, $server, throwError: ! $removeContainer); $serviceApplication->update(['status' => 'exited']); - if ($resetRestartCount) { + if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) { $serviceApplication->resetRestartLimit(); } ServiceStatusChanged::dispatch($service->environment->project->team->id); diff --git a/app/Actions/Stripe/CreateCheckoutSession.php b/app/Actions/Stripe/CreateCheckoutSession.php new file mode 100644 index 0000000000..42d14c0f25 --- /dev/null +++ b/app/Actions/Stripe/CreateCheckoutSession.php @@ -0,0 +1,221 @@ +stripe ??= app(StripeClient::class); + } + + public static function lockKey(int $teamId): string + { + return "stripe-checkout:team:{$teamId}"; + } + + public function execute(Team $team, User $user, string $priceId): object + { + $lock = Cache::lock(self::lockKey($team->id), 30); + + if (! $lock->get()) { + throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.'); + } + + $previousMaxNetworkRetries = Stripe::getMaxNetworkRetries(); + Stripe::setMaxNetworkRetries(2); + + try { + return $this->createOrReuseSession($team, $user, $priceId); + } finally { + Stripe::setMaxNetworkRetries($previousMaxNetworkRetries); + $lock->release(); + } + } + + private function createOrReuseSession(Team $team, User $user, string $priceId): object + { + $subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]); + $customerId = $subscription->stripe_customer_id; + + if (! $customerId) { + $customer = $this->stripe->customers->create([ + 'email' => $user->email, + 'metadata' => [ + 'team_id' => $team->id, + ], + ], [ + 'idempotency_key' => "coolify-team-{$team->id}-customer", + ]); + $customerId = $customer->id; + $subscription->stripe_customer_id = $customerId; + $subscription->save(); + + Log::info('Stripe customer assigned for subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + ]); + } + + $blockingSubscription = null; + foreach ($this->stripe->subscriptions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'all', + ])->autoPagingIterator() as $stripeSubscription) { + if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) { + $blockingSubscription = $stripeSubscription; + break; + } + } + + $this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription); + + $sessions = $this->stripe->checkout->sessions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'open', + ]); + $subscriptionSessions = collect($sessions->data)->filter( + fn (object $session): bool => ($session->mode ?? null) === 'subscription' + ); + $openSession = $subscriptionSessions->first( + fn (object $session): bool => ($session->status ?? null) === 'open' + ); + + if ($openSession) { + $lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id); + if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) { + Log::info('Reusing pending Stripe subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $openSession->id, + 'stripe_subscription_id' => $openSession->subscription ?? null, + ]); + + return $openSession; + } + + $this->stripe->checkout->sessions->expire($openSession->id); + } + + $session = $this->stripe->checkout->sessions->create([ + 'allow_promotion_codes' => true, + 'billing_address_collection' => 'required', + 'client_reference_id' => $user->id.':'.$team->id, + 'customer' => $customerId, + 'customer_update' => [ + 'name' => 'auto', + 'address' => 'auto', + ], + 'line_items' => [[ + 'price' => $priceId, + 'adjustable_quantity' => [ + 'enabled' => true, + 'minimum' => 2, + ], + 'quantity' => 2, + ]], + 'tax_id_collection' => [ + 'enabled' => true, + ], + 'automatic_tax' => [ + 'enabled' => true, + ], + 'subscription_data' => [ + 'metadata' => [ + 'user_id' => $user->id, + 'team_id' => $team->id, + ], + ], + 'payment_method_collection' => 'if_required', + 'mode' => 'subscription', + 'expires_at' => now()->addMinutes(35)->timestamp, + 'success_url' => route('dashboard', ['success' => true]), + 'cancel_url' => route('subscription.index', ['cancelled' => true]), + ]); + + Log::info('Stripe subscription checkout created.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $session->id, + 'stripe_subscription_id' => $session->subscription ?? null, + ]); + + return $session; + } + + private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void + { + if (! $blockingSubscription) { + return; + } + + Log::warning('Stripe subscription checkout blocked by existing subscription.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_subscription_id' => $blockingSubscription->id, + 'stripe_subscription_status' => $blockingSubscription->status, + ]); + + $portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true) + ? $this->billingPortalUrl($customerId) + : null; + + throw new CheckoutUnavailableException( + $this->blockingSubscriptionMessage($blockingSubscription->status), + $portalUrl, + ); + } + + private function blockingSubscriptionMessage(string $status): string + { + return match ($status) { + 'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.", + 'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.", + 'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.", + 'paused' => "This team's subscription is paused. Resume it in the billing portal.", + default => 'Team already has an active subscription.', + }; + } + + private function billingPortalUrl(string $customerId): ?string + { + try { + $session = $this->stripe->billingPortal->sessions->create([ + 'customer' => $customerId, + 'return_url' => route('subscription.show'), + ]); + } catch (Throwable) { + return null; + } + + return is_string($session->url ?? null) ? $session->url : null; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8d4d017c81..79660c2490 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,6 +5,7 @@ namespace App\Console; use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; +use App\Jobs\CheckMissingDatabaseBackupsJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; @@ -47,11 +48,13 @@ class Kernel extends ConsoleKernel ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:stucked-resources') - ->daily() + ->dailyAt('03:17') ->onOneServer() - ->withoutOverlapping(60); + ->withoutOverlapping(60) + ->runInBackground(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); + $this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Exceptions/CheckoutUnavailableException.php b/app/Exceptions/CheckoutUnavailableException.php new file mode 100644 index 0000000000..329941dbc6 --- /dev/null +++ b/app/Exceptions/CheckoutUnavailableException.php @@ -0,0 +1,18 @@ +offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1719,7 +1722,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1950,7 +1955,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -3369,7 +3376,12 @@ class ApplicationsController extends Controller } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts( + $dockerComposeDomainsJson, + $application->domain_port_overrides, + ); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + data_set($data, 'domain_port_overrides', $domainPortOverrides); } $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); @@ -6108,4 +6120,28 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Destination detached.']); } + + /** + * @param Collection $domains + * @param array|null $existingOverrides + * @return array{Collection, ?array} + */ + private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array + { + $allDomains = $domains + ->pluck('domain') + ->filter() + ->implode(','); + $normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides); + + $domains = $domains->map(function (array $entry): array { + $entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + + return $entry; + }); + + return [$domains, $normalized['overrides']]; + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index aeb69ac8b4..881aa5a897 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -769,6 +769,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0], ], ), ) @@ -805,7 +806,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -833,6 +834,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { @@ -1025,6 +1027,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365], ], ), ) @@ -1054,7 +1057,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1080,6 +1083,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { return response()->json([ @@ -4885,6 +4889,8 @@ class DatabasesController extends Controller 'id', 'created_at', 'updated_at', + 'last_execution_at', + 'missing_backup_notification_sent_at', ])->fill([ 'uuid' => new_public_id(), 'database_id' => $newDatabase->id, diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index e8446467de..5bf51bc027 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -9,6 +9,7 @@ use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -333,7 +334,7 @@ class ServiceApplicationsController extends Controller ]; $validationRules = [ - 'url' => 'nullable|string', + 'url' => ValidationPatterns::applicationDomainRules(), 'noindex_domains' => 'sometimes|array|nullable', 'noindex_domains.*' => 'string', 'human_name' => 'nullable|string|max:255', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9bfcfd8539..1a199c07ec 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -386,7 +386,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -602,7 +602,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -1187,7 +1187,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', ]; diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e8..f53cef7c51 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller return response($contents, 200, [ 'Content-Type' => 'image/jpeg', - 'Cache-Control' => 'private, max-age=300', + 'Cache-Control' => 'private, max-age=31536000, immutable', ]); } } diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php index fb7ebc8860..d99e9166d0 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ class ProjectIconController extends Controller abort_if($contents === null, 404); - return response($contents)->header('Content-Type', 'image/jpeg'); + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 28e92dcd49..c4fdc5fd5c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -83,7 +83,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -334,7 +337,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 0887e7e864..19c0b750ec 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2495,12 +2495,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue destination: $destination, no_questions_asked: true, ); - $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [ - 'project_uuid' => data_get($this->application, 'environment.project.uuid'), - 'application_uuid' => data_get($this->application, 'uuid'), - 'deployment_uuid' => $deployment_uuid, - 'environment_uuid' => data_get($this->application, 'environment.uuid'), - ])); + $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}"; + $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}"); } } diff --git a/app/Jobs/CheckMissingDatabaseBackupsJob.php b/app/Jobs/CheckMissingDatabaseBackupsJob.php new file mode 100644 index 0000000000..06ba79a33f --- /dev/null +++ b/app/Jobs/CheckMissingDatabaseBackupsJob.php @@ -0,0 +1,59 @@ +with(['team', 'database', 'latest_log']) + ->where('enabled', true) + ->where('missing_backup_notification_days', '>', 0) + ->chunkById(100, function ($backups): void { + foreach ($backups as $backup) { + $this->notifyIfMissing($backup); + } + }); + } + + private function notifyIfMissing(ScheduledDatabaseBackup $backup): void + { + $lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at; + $lastActivityAt = $lastExecutionAt ?? $backup->created_at; + + if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) { + return; + } + + if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) { + return; + } + + if (! $backup->team) { + Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found"); + + return; + } + + if ($backup->team->getEnabledChannels('backup_failure') === []) { + return; + } + + $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)); + $backup->forceFill(['missing_backup_notification_sent_at' => now()])->save(); + } +} diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 054a739bc6..e56b93c9e5 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; @@ -33,8 +35,13 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { + $this->server->refresh(); $this->clearOutdatedInfo(); + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) { + return; + } + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); @@ -116,7 +123,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue private function clearOutdatedInfo(): void { - $this->server->update(['traefik_outdated_info' => null]); + $this->server->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); } /** diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f5..0a9eeba005 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + Server::query() + ->where(function ($query) { + $query->whereNull('proxy') + ->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value); + }) + ->where(function ($query) { + $query->whereNotNull('detected_traefik_version') + ->orWhereNotNull('traefik_outdated_info'); + }) + ->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); + // Load versions from cached data $traefikVersions = get_traefik_versions(); diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f1635d6d4d..52b4064feb 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S public function __construct(public Server $server) {} + private static function helperContainersCommand(): string + { + return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; + } + public function handle(): void { try { @@ -36,7 +41,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S 'active_deployment_uuids' => $activeDeployments, ]); - $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); $helperContainers = collect(json_decode($containers)); if ($helperContainers->count() > 0) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 0e73ee41b2..ef83d19446 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -5,7 +5,6 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; -use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; @@ -483,7 +482,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced ]) ->with([ 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', - 'databases:id,service_id,status,last_online_at,is_public,name,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', + 'databases:id,service_id,status,last_online_at,is_public,name', ]) ->get(); } @@ -506,8 +505,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'restart_count', 'last_restart_at', 'last_restart_type', - 'max_restart_count', - 'restart_limit_reached', ]; return collect([ @@ -675,7 +672,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } $restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0; - if ($subResource->trackRestartCount($restartCount)) { + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { StopServiceApplication::dispatch($subResource, false, false); $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); @@ -821,11 +818,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $database->status = $containerStatus; $database->save(); } - if (is_numeric($restartCount) && $database->trackRestartCount((int) $restartCount)) { - StopDatabase::dispatch($database, false, false, false); - $database->team()?->notify(new ApplicationRestartLimitReached($database)); - - return; + if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); } if (! $this->isCompleteSnapshot()) { return; @@ -883,16 +881,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $notFoundDatabaseUuids->each(function ($databaseUuid) { $database = $this->databasesByUuid->get($databaseUuid); if ($database) { - if ($database->stoppedAfterRestartLimit()) { - return; - } if (! str($database->status)->startsWith('exited')) { $database->update([ 'status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - 'restart_limit_reached' => false, ]); } if ($database->is_public) { @@ -918,9 +912,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update service databases if ($notFoundServiceDatabaseIds->isNotEmpty()) { ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds) - ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); + ->update(['status' => 'exited']); } } diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index 6f49cf30be..ed2d1c4546 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -66,7 +66,10 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue caCert: $caCert->ssl_certificate, caKey: $caCert->ssl_private_key, ); - $regenerated->push($certificate); + $resource = $certificate->database; + if ($resource) { + $regenerated->push($resource); + } } catch (\Exception $e) { Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 6ddbfe145c..0f56476e25 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -74,7 +74,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::updateOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -83,6 +83,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_past_due' => false, ] ); + logger()->info('Stripe subscription checkout completed.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => data_get($data, 'id'), + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + ]); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -218,7 +224,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::firstOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -226,6 +232,11 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_invoice_paid' => false, ] ); + if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) { + $subscription->update(['stripe_subscription_id' => $subscriptionId]); + } elseif ($subscription->stripe_customer_id !== $customerId) { + throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}."); + } break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index b567a71b7f..0b13c66609 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -73,6 +73,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); + $streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup; try { $source = $this->backup->sourcePath(); @@ -86,11 +87,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; - $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) - .' -v '.escapeshellarg($source.':/volume:ro') - .' '.escapeshellarg($image) - .' sh -c '.escapeshellarg($archiveScript) - .' > '.escapeshellarg($backupLocation); + if ($streamToS3) { + $this->execution->update(['local_storage_deleted' => true]); + $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); + $this->execution->update(['s3_cleanup_pending' => true]); + } else { + $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($archiveScript) + .' > '.escapeshellarg($backupLocation); + } if ($this->backup->stop_during_backup) { $containers = $this->containersUsingVolume($source, $server); @@ -104,21 +111,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - instant_remote_process([ + $archiveOutput = instant_remote_process(array_filter([ $verifySourceCommand, - 'mkdir -p '.escapeshellarg($backupDirectory), + $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), $archiveCommand, - ], $server, timeout: $this->timeout, disableMultiplexing: true); + ]), $server, timeout: $this->timeout, disableMultiplexing: true); $this->execution->update([ 'stop_container_ids' => null, 'stop_recovery_pending' => false, ]); - $size = (int) instant_remote_process( - ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], - $server, - disableMultiplexing: true, - ); + $size = $streamToS3 + ? (int) str($archiveOutput)->trim()->afterLast("\n")->toString() + : (int) instant_remote_process( + ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], + $server, + disableMultiplexing: true, + ); if ($size <= 0) { throw new \RuntimeException('The storage backup archive is empty or was not created.'); @@ -127,9 +136,12 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $warning = null; $s3Uploaded = null; $s3CleanupPending = false; - $localStorageDeleted = false; + $localStorageDeleted = $streamToS3; - if ($this->backup->save_s3) { + if ($streamToS3) { + $s3Uploaded = true; + $this->execution->update(['s3_cleanup_pending' => false]); + } elseif ($this->backup->save_s3) { $s3CleanupPending = true; $this->execution->update(['s3_cleanup_pending' => true]); @@ -181,13 +193,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } catch (Throwable $exception) { $recoveryError = $this->recoverIncompleteBackup($this->execution); - $archiveDeleted = false; + $archiveDeleted = $streamToS3; - try { - deleteBackupsLocally($backupLocation, $server, throwError: true); - $archiveDeleted = true; - } catch (Throwable $cleanupException) { - $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + if ($streamToS3) { + $exception = new \RuntimeException( + 'S3-only streaming backup failed: '.$exception->getMessage() + .'. The S3 destination may not support streaming uploads. Enable local backups to use the local archive upload method.', + previous: $exception, + ); + } + + if (! $streamToS3) { + try { + deleteBackupsLocally($backupLocation, $server, throwError: true); + $archiveDeleted = true; + } catch (Throwable $cleanupException) { + $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + } } $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; @@ -195,7 +217,9 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $this->execution->update([ 'status' => 'failed', 'message' => $exception->getMessage().$recoveryError, - 'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation, + 'filename' => $streamToS3 + ? ($s3CleanupPending ? $backupLocation : null) + : ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation), 'local_storage_deleted' => $archiveDeleted, ]); @@ -338,6 +362,34 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + { + $s3 = $this->backup->s3; + + if (! $s3) { + $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + + throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + } + + $s3->testConnection(shouldSave: true); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) + ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $destination = 'temporary/'.$s3->bucket.$backupLocation; + $streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary ' + .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret) + .' >/dev/null && ('.$archiveScript.' | mc pipe --quiet'.$resolveOptions.' '.escapeshellarg($destination).' >/dev/null)' + .' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination) + .' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p'); + + return 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($streamScript); + } + private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void { if (! isDev()) { diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php index 44dba0d5e8..6ecea8e96f 100644 --- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php +++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php @@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect } } - // Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). - try { - $settings = instanceSettings(); - $publicV4 = data_get($settings, 'public_ipv4'); - $publicV6 = data_get($settings, 'public_ipv6'); - if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ipv4 = $publicV4; + if ($this->usesInstanceNetworkAddressesForDnsHints()) { + try { + $settings = instanceSettings(); + $publicV4 = data_get($settings, 'public_ipv4'); + $publicV6 = data_get($settings, 'public_ipv6'); + if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipv4 = $publicV4; + } + if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipv6 = $publicV6; + } + } catch (\Throwable) { + // } - if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $ipv6 = $publicV6; - } - } catch (\Throwable) { - // } return [$ipv4, $ipv6]; @@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect return null; } + abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool; + abstract protected function authorizeUpdateForDomainConnect(): void; } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5dc..a9e1c0be28 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9f1e7fc176..906cc147a0 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -500,7 +515,7 @@ class Domains extends Component { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; - $port = $this->effectiveDomainInternalPort($url); + $port = $this->effectiveDomainInternalPort($url, $service); $row = [ 'url' => $url, @@ -532,7 +547,7 @@ class Domains extends Component /** * @return array{internal_port: ?int, has_port_override: bool} */ - protected function effectiveDomainInternalPort(string $url): array + protected function effectiveDomainInternalPort(string $url, ?string $service = null): array { $canonical = DomainPortOverrides::withoutPort($url); $overrides = $this->application->domain_port_overrides ?? []; @@ -554,6 +569,21 @@ class Domains extends Component ]; } + $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + if ($this->isCompose && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + if ($this->application->settings?->is_static) { return [ 'internal_port' => 80, @@ -598,7 +628,7 @@ class Domains extends Component return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -607,7 +637,7 @@ class Domains extends Component return false; } - return $this->application->portRequiresConfirmation($port); + return $this->application->portRequiresConfirmation($port, $serviceName); } protected function openPortWarning(?int $port, string $action): void @@ -653,6 +683,11 @@ class Domains extends Component $this->authorize('update', $this->application); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->application->destination?->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->application); @@ -953,7 +988,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void @@ -998,7 +1039,7 @@ class Domains extends Component } } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -1395,7 +1436,7 @@ class Domains extends Component return; } - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 21296978f7..cba3f18b65 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -38,6 +38,7 @@ class PreviewDomains extends Component public function mount(): void { + $this->authorize('view', $this->preview->application); $this->refreshDomains(); if ($this->preview->application->build_pack === 'dockercompose') { $this->newDomainService = $this->composeServices()[0] ?? null; @@ -74,7 +75,7 @@ class PreviewDomains extends Component return; } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -94,7 +95,7 @@ class PreviewDomains extends Component ? ($this->composeServices()[0] ?? null) : null; $this->forceUseUnknownPort = false; - $this->dispatch('close-modal'); + $this->dispatch('close-preview-domain-add', previewId: $this->preview->id); try { $server = $this->preview->application->destination?->server; @@ -149,6 +150,7 @@ class PreviewDomains extends Component public function startEdit(int $index): void { + $this->authorize('update', $this->preview->application); if (! isset($this->domainRows[$index])) { return; } @@ -159,7 +161,8 @@ class PreviewDomains extends Component if (filled($savedPort)) { $this->editingDomainParts['port'] = (string) $savedPort; } - $this->dispatch('open-preview-domain-edit'); + $this->resetErrorBag('editingDomainParts.host'); + $this->dispatch('open-preview-domain-edit', previewId: $this->preview->id); } public function updateDomain(): void @@ -173,7 +176,7 @@ class PreviewDomains extends Component return; } $oldUrl = $this->domainRows[$this->editingIndex]['url']; - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; @@ -193,7 +196,7 @@ class PreviewDomains extends Component return; } $this->forceUseUnknownPort = false; - $this->dispatch('close-preview-domain-edit'); + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); $this->dispatch('success', 'Domain updated.'); $this->checkDomainDns($index); } @@ -229,6 +232,12 @@ class PreviewDomains extends Component if (! isset($this->domainRows[$index])) { return; } + if ($this->editingIndex === $index) { + $this->editingIndex = null; + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); + } elseif ($this->editingIndex !== null && $this->editingIndex > $index) { + $this->editingIndex--; + } unset($this->domainRows[$index]); $this->domainRows = array_values($this->domainRows); if (! $this->persistDomains()) { @@ -268,6 +277,7 @@ class PreviewDomains extends Component public function pollDnsChecks(): void { + $this->authorize('view', $this->preview->application); $checkingRows = collect($this->domainRows) ->where('dns_status', 'checking') ->values(); @@ -321,6 +331,7 @@ class PreviewDomains extends Component private function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; $this->preview->refresh(); $statuses = $this->preview->domain_dns_statuses ?? []; $rows = []; @@ -336,6 +347,11 @@ class PreviewDomains extends Component } } $this->domainRows = $rows; + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && $row['service'] === $editingRow['service']); + $this->editingIndex = $index === false ? null : (int) $index; + } } private function persistDomains(): bool @@ -349,13 +365,16 @@ class PreviewDomains extends Component return false; } - $domains = collect($composeServices) - ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) - ->all(); + $existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: []; + $domains = []; + foreach ($composeServices as $service) { + $domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : []; + $domains[$service]['domain'] = ''; + } $validRows = collect($this->domainRows) ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); foreach ($validRows->groupBy('service') as $service => $rows) { - $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + $domains[$service]['domain'] = $rows->pluck('url')->implode(','); } $this->preview->docker_compose_domains = json_encode($domains); $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; @@ -439,11 +458,23 @@ class PreviewDomains extends Component private function makeRow(string $url, ?string $service, array $statuses = []): array { $status = $statuses[$this->statusKey($url, $service)] ?? []; - $port = $this->effectiveDomainInternalPort($url); + $port = $this->effectiveDomainInternalPort($url, $service); + $redirect = 'both'; + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + $usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3; + $domains = json_decode(($usesPreviewRedirect + ? $this->preview->docker_compose_domains + : $this->preview->application->docker_compose_domains) ?: '[]', true) ?: []; + $storedRedirect = $usesPreviewRedirect + ? ($domains[$service]['redirect'] ?? null) + : data_get($domains, "$service.redirect"); + $redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both'; + } return [ 'url' => $url, 'service' => $service, + 'redirect' => $redirect, 'internal_port' => $port['internal_port'], 'has_port_override' => $port['has_port_override'], 'dns_status' => $status['status'] ?? 'pending', @@ -478,7 +509,7 @@ class PreviewDomains extends Component return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -487,7 +518,7 @@ class PreviewDomains extends Component return false; } - return $this->preview->application->portRequiresConfirmation($port); + return $this->preview->application->portRequiresConfirmation($port, $serviceName); } private function openPortWarning(?int $port, string $action): void @@ -500,7 +531,7 @@ class PreviewDomains extends Component /** * @return array{internal_port: ?int, has_port_override: bool} */ - private function effectiveDomainInternalPort(string $url): array + private function effectiveDomainInternalPort(string $url, ?string $service = null): array { $canonical = DomainPortOverrides::withoutPort($url); $overrides = $this->preview->domain_port_overrides ?? []; @@ -522,6 +553,21 @@ class PreviewDomains extends Component ]; } + $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + if ($this->preview->application->settings?->is_static) { return [ 'internal_port' => 80, diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 14d9bcdb8d..46681d5d57 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,29 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 1d223b6967..d938ebde8d 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -85,6 +85,9 @@ class BackupEdit extends Component #[Validate(['required', 'int', 'min:60', 'max:36000'])] public int|string $timeout = 3600; + #[Validate(['required', 'integer', 'min:0', 'max:365'])] + public int $missingBackupNotificationDays = 0; + public function getListeners(): array { // Keep "Backup Now" in sync when the database starts/stops without a full page refresh. @@ -152,6 +155,7 @@ class BackupEdit extends Component $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; + $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; $this->customValidate(); $this->backup->save(); } else { @@ -170,6 +174,7 @@ class BackupEdit extends Component $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; + $this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days; } } @@ -245,6 +250,14 @@ class BackupEdit extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + $this->status = $database->status; + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $database = $this->backup->database; auditLog('ui.database.backup_started', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e45c797d1e..39a1960119 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -17,6 +17,13 @@ class BackupNow extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $database = $this->backup->database; auditLog('ui.database.backup_started', [ diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index b4236b215a..96d2ac7aaf 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($database->getMorphClass() === ServiceDatabase::class) { $service = $database->service; - $this->redirectRoute('project.service.database.backup.show', [ + $this->redirectRoute('project.service.volume-backups.index', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 87f24fb1da..6d7fb97f68 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -9,16 +9,21 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; +use Livewire\WithPagination; class BackupExecutions extends Component { use AuthorizesRequests; + use WithPagination; public Service $service; + public int $perPage = 10; + public bool $executionModalOpen = false; public ?array $selectedExecution = null; @@ -40,10 +45,18 @@ class BackupExecutions extends Component $this->authorize('view', $this->service); } + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage('executionsPage'); + } + public function openExecution(string $executionUuid): void { - $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); - abort_unless($this->selectedExecution, 404); + $this->authorize('view', $this->service); + $execution = $this->executionQuery($executionUuid)->first(); + abort_unless($execution, 404); + $this->selectedExecution = $this->formatExecutions(collect([$execution]))->first(); $this->executionModalOpen = true; } @@ -55,70 +68,89 @@ class BackupExecutions extends Component public function render(): View { + $this->authorize('view', $this->service); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + if ($executions->currentPage() > $executions->lastPage()) { + $this->setPage($executions->lastPage(), 'executionsPage'); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + } + $executions->setCollection($this->formatExecutions($executions->getCollection())); + return view('livewire.project.service.backup-executions', [ - 'executions' => $this->executions(), + 'executions' => $executions, ]); } - private function executions(): Collection + private function executionQuery(?string $uuid = null): Builder { $databaseScheduleIds = ScheduledDatabaseBackup::query() ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) - ->pluck('id'); + ->select('id'); + $volumeScheduleIds = ScheduledVolumeBackup::query() + ->forService($this->service) + ->select('id'); $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with('scheduledDatabaseBackup.database') + ->select('id', 'uuid', 'created_at') + ->selectRaw("'database' as type") ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) - ->latest() - ->limit(100) - ->get() - ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ - 'id' => 'database:'.$execution->id, + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'storage' as type") + ->whereIn('scheduled_volume_backup_id', $volumeScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + + return $databaseExecutions->toBase() + ->unionAll($volumeExecutions->toBase()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->orderBy('type'); + } + + private function formatExecutions(Collection $rows): Collection + { + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->whereIn('id', $rows->where('type', 'database')->pluck('id')) + ->get()->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) + ->get()->keyBy('id'); + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $isDatabase = $row->type === 'database'; + $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); + $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; + $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; + if ($storage?->team_id !== currentTeam()->id) { + $storage = null; + } + $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + if ($isDatabase && ! $schedule->save_s3) { + $storageLabel = 'Not configured'; + } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + $storageLabel = 'No destination recorded'; + } + + return [ + 'id' => $row->type.':'.$execution->id, 'uuid' => $execution->uuid, - 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, - 'type' => 'Database', - 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(), + 'type' => $isDatabase ? 'Database' : $schedule->targetType(), + 'schedule' => $schedule->frequency, + 's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel, 'status' => $execution->status, 'started_at' => $execution->created_at, 'size' => $execution->size, 'message' => $execution->message, 'filename' => $execution->filename, 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.backup', $execution->id) + ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) : null, - ]); - - $volumeSchedules = ScheduledVolumeBackup::query() - ->with('backupable.resource') - ->forService($this->service) - ->get() - ->keyBy('id'); - $volumeExecutions = ScheduledVolumeBackupExecution::query() - ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) - ->latest() - ->limit(100) - ->get() - ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { - $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); - - return [ - 'id' => 'storage:'.$execution->id, - 'uuid' => $execution->uuid, - 'target' => $schedule->targetName(), - 'type' => $schedule->targetType(), - 'schedule' => $schedule->frequency, - 'status' => $execution->status, - 'started_at' => $execution->created_at, - 'size' => $execution->size, - 'message' => $execution->message, - 'filename' => $execution->filename, - 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.volume-backup', $execution->id) - : null, - ]; - }); - - return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + ]; + }); } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d5254e093a..b4f76d12d9 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -459,6 +472,11 @@ class Domains extends Component $this->authorize('update', $this->service); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->service->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->service); @@ -919,6 +937,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -935,6 +954,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -943,12 +967,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -975,11 +1000,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1416,6 +1454,7 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1442,7 +1481,7 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index d6ab2ac151..cd209f6ae9 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -308,10 +308,10 @@ class FileStorage extends Component { return view('livewire.project.service.file-storage', [ 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], ], 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], ], 'hostFileDeletionCheckboxes' => [ ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 7980e07056..3b8ca7af19 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -307,36 +307,48 @@ class Index extends Component public function instantSave() { + $this->authorize('update', $this->serviceDatabase); try { - $this->authorize('update', $this->serviceDatabase); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncDatabaseData(true); - if ($this->serviceDatabase->is_public) { - if (! str($this->serviceDatabase->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + if ($this->isPublic) { + if (! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; - $this->serviceDatabase->is_public = false; return; } + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + + return; + } + $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { + $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { + $this->isPublic = ! $this->isPublic; + $this->persistPublicAccess(); + return handleError($e, $this); } } + private function persistPublicAccess(): void + { + $this->serviceDatabase->update([ + 'is_public' => $this->isPublic, + 'public_port' => $this->publicPort ?: null, + 'public_port_timeout' => $this->publicPortTimeout ?: null, + ]); + } + public function submitDatabase() { try { diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index e856d1373a..600b8b9047 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -11,6 +11,7 @@ use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component @@ -23,6 +24,9 @@ class Index extends Component public string $search = ''; + #[Url(as: 'backup_uuid', except: '')] + public string $backupUuid = ''; + public bool $scheduleModalOpen = false; public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; @@ -38,6 +42,7 @@ class Index extends Component return [ 'refreshVolumeBackups' => '$refresh', 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},ServiceChecked" => '$refresh', "echo-private:team.{$teamId},BackupCreated" => '$refresh', ]; } @@ -49,10 +54,14 @@ class Index extends Component $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + if ($this->backupUuid !== '') { + $this->openSchedule($this->backupUuid); + } } public function openSchedule(string $backupUuid): void { + $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); $this->s3s = currentTeam()->s3s; $this->scheduleModalOpen = true; @@ -60,6 +69,7 @@ class Index extends Component public function closeScheduleModal(): void { + $this->backupUuid = ''; $this->scheduleModalOpen = false; $this->selectedDatabaseBackup = null; $this->selectedVolumeBackup = null; @@ -72,6 +82,12 @@ class Index extends Component $this->loadSelectedSchedule($backupUuid); abort_unless($this->selectedDatabaseBackup, 404); $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) { + $this->selectedDatabaseBackup = null; + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); } else { abort_unless($type === 'storage', 404); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index ea8394c1b1..47080cd86b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -818,19 +818,21 @@ class All extends Component { $isMember = auth()->user()?->isMember(); - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } + return $variables + ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) + ->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } - return "$item->key=$item->value"; - })->join("\n"); + return "$item->key=$item->value"; + })->join("\n"); } public function switch() @@ -908,8 +910,7 @@ class All extends Component $deletedCount = $this->deleteRemovedVariables(false, $variables); if ($deletedCount > 0) { $changesMade = true; - } elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedCount < 0) { $errorOccurred = true; } @@ -926,8 +927,7 @@ class All extends Component $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { $changesMade = true; - } elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedPreviewCount < 0) { $errorOccurred = true; } @@ -988,6 +988,12 @@ class All extends Component // Get all environment variables that will be deleted $variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get(); + // Generated Compose variables are managed by Coolify and must survive a bulk + // replacement even when they are omitted from the pasted environment file. + $variablesToDelete = $variablesToDelete->reject( + fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key) + ); + // If there are no variables to delete, return 0 if ($variablesToDelete->isEmpty()) { return 0; @@ -1001,13 +1007,13 @@ class All extends Component if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$envVar->key}'

Please remove it from the Docker Compose file first."); - return 0; + return -1; } } } // If we get here, no variables are used in Docker Compose, so we can delete them - $this->resource->$method()->whereNotIn('key', array_keys($variables))->delete(); + $this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete(); return $variablesToDelete->count(); } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index f170a0a6f0..717007bc04 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -102,7 +102,7 @@ class Add extends Component } } - private function saveScheduledTask(): mixed + private function saveScheduledTask(): void { try { $task = new ScheduledTask; @@ -128,7 +128,7 @@ class Add extends Component $this->dispatch('refreshTasks'); $this->dispatch('success', 'Scheduled task added.'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index ef7b36ff72..86023628e2 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -147,7 +147,11 @@ class VolumeBackups extends Component } $this->resetErrorBag('s3StorageId'); - $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', 'S3 storage updated.'); } @@ -163,11 +167,11 @@ class VolumeBackups extends Component $this->saveToS3 = ! $this->saveToS3; $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; - $this->backup?->update([ - 'save_s3' => $this->saveToS3, - 'disable_local_backup' => $this->disableLocalBackup, - 's3_storage_id' => $this->s3StorageId, - ]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 68cb52a926..296fd4da5d 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -106,6 +106,8 @@ class Proxy extends Component try { $this->authorize('update', $this->server); $this->server->proxy = null; + $this->server->detected_traefik_version = null; + $this->server->traefik_outdated_info = null; $this->server->save(); $this->dispatch('reloadWindow'); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 9ea87161d8..5d0d2538bd 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -5,11 +5,29 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Component; +use Livewire\WithPagination; class Resources extends Component { use AuthorizesRequests; + use WithPagination; + + public int $perPage = 10; + + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } public ?Server $server = null; @@ -93,6 +111,10 @@ class Resources extends Component public function loadManagedContainers() { try { + if ($this->activeTab !== 'managed') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'managed'; $this->server->refresh(); } catch (\Throwable $e) { @@ -102,6 +124,10 @@ class Resources extends Component public function loadUnmanagedContainers() { + if ($this->activeTab !== 'unmanaged') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'unmanaged'; try { $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); @@ -125,6 +151,29 @@ class Resources extends Component public function render() { - return view('livewire.server.resources'); + $resources = $this->activeTab === 'managed' + ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) + : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $search = trim($this->search); + if ($search !== '') { + $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; + $resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey)) + ->contains($search, ignoreCase: true)); + } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($resources->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + + return view('livewire.server.resources', [ + 'resources' => new LengthAwarePaginator( + $resources->forPage($page, $this->perPage)->values(), + $resources->count(), + $this->perPage, + $page, + ), + ]); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 1190cd59a1..49739ac6dd 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Sentinel; +use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -29,6 +30,35 @@ class Logs extends Component $this->authorize('viewSentinel', $this->server); } + public function enableSentinel(): void + { + $this->authorize('manageSentinel', $this->server); + + try { + $this->server->refresh(); + if ($this->server->isBuildServer()) { + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } + if ($this->server->isSwarm()) { + $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); + + return; + } + if ($this->server->isSentinelEnabled()) { + return; + } + + StartSentinel::run($this->server, true); + $this->server->refresh(); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'Sentinel has been enabled.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 38a2f85a73..4bb89c9f08 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -56,6 +56,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -75,6 +77,7 @@ class Advanced extends Component 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -102,6 +105,7 @@ class Advanced extends Component $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id : 'local'; + $this->image_cdn_url = $this->settings->image_cdn_url; $this->avatar_storage_options = [ ['value' => 'local', 'label' => 'Local storage'], ...S3Storage::query() @@ -216,6 +220,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index 022f6fdeed..31f2e9141d 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -2,8 +2,11 @@ namespace App\Livewire\Subscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; +use App\Jobs\ServerLimitCheckJob; use App\Models\InstanceSettings; use App\Providers\RouteServiceProvider; +use Illuminate\Support\Facades\Cache; use Livewire\Component; use Stripe\StripeClient; @@ -49,12 +52,19 @@ class Index extends Component return redirect($session->url); } - public function getStripeStatus() + public function getStripeStatus(): mixed { + $team = currentTeam(); + $user = auth()->user(); + abort_unless($team && $user?->isAdminOfTeam($team->id), 403); + try { - $subscription = currentTeam()->subscription; + $subscription = $team->subscription()->first(); + if (! $subscription?->stripe_customer_id) { + return null; + } $stripe = app(StripeClient::class); - $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); + $customer = $stripe->customers->retrieve($subscription->stripe_customer_id); if ($customer) { $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); $currentTeam = currentTeam()->id ?? null; @@ -65,6 +75,26 @@ class Index extends Component $subscription->update([ 'stripe_subscription_id' => $foundSubscription->id, ]); + if ($status === 'active') { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + 'stripe_plan_id' => data_get($foundSubscription, 'items.data.0.price.id'), + 'stripe_cancel_at_period_end' => data_get($foundSubscription, 'cancel_at_period_end', false), + ]); + if (str(data_get($foundSubscription, 'items.data.0.price.lookup_key'))->contains('dynamic')) { + $quantity = max( + UpdateSubscriptionQuantity::MIN_SERVER_LIMIT, + min((int) data_get($foundSubscription, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT) + ); + $team->update(['custom_server_limit' => $quantity]); + ServerLimitCheckJob::dispatch($team); + } + $team->unsetRelation('subscription'); + Cache::forget('user:'.$user->id.':team:'.$team->id); + + return redirect()->route('subscription.show'); + } if ($status === 'unpaid') { $this->isUnpaid = true; } @@ -82,6 +112,8 @@ class Index extends Component } finally { $this->loading = false; } + + return null; } public function render() diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 65966aea5f..e53c5c677b 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -2,22 +2,28 @@ namespace App\Livewire\Subscription; -use Illuminate\Support\Facades\Auth; +use App\Actions\Stripe\CreateCheckoutSession; +use App\Exceptions\CheckoutUnavailableException; use Livewire\Component; -use Stripe\Checkout\Session; -use Stripe\Stripe; +use RuntimeException; +use Stripe\Exception\ApiErrorException; class PricingPlans extends Component { - public function subscribeStripe($type) + public function subscribeStripe(string $type): mixed { - if (currentTeam()->subscription?->stripe_invoice_paid) { - $this->dispatch('error', 'Team already has an active subscription.'); + $team = currentTeam(); + $user = auth()->user(); - return; + if (! $team || ! $user?->isAdminOfTeam($team->id)) { + abort(403); } - Stripe::setApiKey(config('subscription.stripe_api_key')); + if ($team->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return null; + } $priceId = match ($type) { 'dynamic-monthly' => config('subscription.stripe_price_id_dynamic_monthly'), @@ -28,48 +34,29 @@ class PricingPlans extends Component if (! $priceId) { $this->dispatch('error', 'Price ID not found! Please contact the administrator.'); - return; + return null; } - $payload = [ - 'allow_promotion_codes' => true, - 'billing_address_collection' => 'required', - 'client_reference_id' => Auth::id().':'.currentTeam()->id, - 'line_items' => [[ - 'price' => $priceId, - 'adjustable_quantity' => [ - 'enabled' => true, - 'minimum' => 2, - ], - 'quantity' => 2, - ]], - 'tax_id_collection' => [ - 'enabled' => true, - ], - 'automatic_tax' => [ - 'enabled' => true, - ], - 'subscription_data' => [ - 'metadata' => [ - 'user_id' => Auth::id(), - 'team_id' => currentTeam()->id, - ], - ], - 'payment_method_collection' => 'if_required', - 'mode' => 'subscription', - 'success_url' => route('dashboard', ['success' => true]), - 'cancel_url' => route('subscription.index', ['cancelled' => true]), - ]; + try { + $session = app(CreateCheckoutSession::class)->execute($team, $user, $priceId); + } catch (ApiErrorException $exception) { + report($exception); + $this->dispatch('error', 'Unable to confirm checkout with Stripe. Please try again shortly.'); - $customer = currentTeam()->subscription?->stripe_customer_id ?? null; - if ($customer) { - $payload['customer'] = $customer; - $payload['customer_update'] = [ - 'name' => 'auto', - ]; - } else { - $payload['customer_email'] = Auth::user()->email; + return null; + } catch (CheckoutUnavailableException $exception) { + $message = $exception->getMessage(); + if ($exception->billingPortalUrl) { + $message .= ' Open billing portal'; + } + $this->dispatch('error', $message); + + return null; + } catch (RuntimeException $exception) { + report($exception); + $this->dispatch('error', 'Unable to start checkout. Please try again shortly.'); + + return null; } - $session = Session::create($payload); return redirect($session->url, 303); } diff --git a/app/Models/Application.php b/app/Models/Application.php index e7c2c1d90e..6737e57986 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; +use App\Enums\BuildPackTypes; use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; @@ -292,9 +293,11 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } - $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); - $application->fqdn = $normalized['fqdn']; - $application->domain_port_overrides = $normalized['overrides']; + if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) { + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; + } $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -623,32 +626,6 @@ class Application extends BaseModel && $this->restart_limit_reached === true; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.application.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'application_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function settings() { return $this->hasOne(ApplicationSetting::class); @@ -742,7 +719,7 @@ class Application extends BaseModel ); } - public function gitCommitLink($link): string + public function gitCommitLink($link): ?string { if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { @@ -759,6 +736,10 @@ class Application extends BaseModel $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } + if (! filter_var($git_repository, FILTER_VALIDATE_URL)) { + return null; + } + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); $url = $url->withUserInfo(''); $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; @@ -985,12 +966,16 @@ class Application extends BaseModel } /** - * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * Ports declared by the selected Compose service, or exposed and previously used application ports. * * @return list */ - public function availableInternalPorts(): array + public function availableInternalPorts(?string $serviceName = null): array { + if ($this->build_pack === 'dockercompose') { + return dockerComposeServicePorts($this->docker_compose_raw, $serviceName); + } + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) ->map(fn (mixed $port): int => (int) $port); @@ -1015,13 +1000,13 @@ class Application extends BaseModel return $ports->unique()->sort()->values()->all(); } - public function portRequiresConfirmation(?int $port): bool + public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool { if ($port === null || $port <= 0) { return false; } - return ! in_array($port, $this->availableInternalPorts(), true); + return ! in_array($port, $this->availableInternalPorts($serviceName), true); } public function detectPortFromEnvironment(?bool $isPreview = false): ?int diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 02f3e7ed50..1e7d8282a5 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -57,6 +57,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'image_cdn_url', 'is_dashboard_force_https_enabled', ]; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index e41c793c86..7a26658e4f 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -14,6 +14,9 @@ class ScheduledDatabaseBackup extends BaseModel 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', + 'missing_backup_notification_days' => 'integer', + 'missing_backup_notification_sent_at' => 'datetime', + 'last_execution_at' => 'datetime', ]; } @@ -37,6 +40,7 @@ class ScheduledDatabaseBackup extends BaseModel 'database_backup_retention_max_storage_s3', 'timeout', 'disable_local_backup', + 'missing_backup_notification_days', ]; public static function ownedByCurrentTeam() diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 8c5de1e8b1..1a479772cf 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -6,6 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ScheduledDatabaseBackupExecution extends BaseModel { + protected static function booted(): void + { + static::created(function (ScheduledDatabaseBackupExecution $execution): void { + $execution->scheduledDatabaseBackup()->update(['last_execution_at' => $execution->created_at ?? now()]); + }); + } + protected $fillable = [ 'uuid', 'scheduled_database_backup_id', diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 1e26c7be3f..8f496fc1e6 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel 'started_at' => 'datetime', 'finished_at' => 'datetime', 'retry_count' => 'integer', - 'duration' => 'decimal:2', + 'duration' => 'float', ]; } diff --git a/app/Models/Server.php b/app/Models/Server.php index b6e1b92d99..9cc91d40d4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1813,6 +1813,8 @@ $siteAddress { $this->proxy->set('last_saved_proxy_configuration', null); $this->proxy->set('last_saved_settings', null); $this->proxy->set('last_applied_settings', null); + $this->detected_traefik_version = null; + $this->traefik_outdated_info = null; $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 11756f3c7e..2f1a0264c2 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -18,7 +18,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; -use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; #[OA\Schema( @@ -1466,32 +1465,6 @@ class Service extends BaseModel return null; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.service.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'service_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = InstanceSettings::get(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function documentation() { $services = get_service_templates(); @@ -1507,7 +1480,10 @@ class Service extends BaseModel { try { $services = get_service_templates(); - $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); + if (blank($this->service_type)) { + return null; + } + $serviceName = $this->service_type; $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index cf0faef5bd..cba18c1f23 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -201,7 +201,7 @@ class ServiceApplication extends BaseModel } /** - * Return the public URLs with their persisted internal port overrides. + * Return editable URLs with persisted overrides or legacy embedded ports. */ protected function url(): Attribute { @@ -220,7 +220,7 @@ class ServiceApplication extends BaseModel $port = $overrides[$canonical] ?? null; if ($port === null) { - return $canonical; + return $url; } $parts = DomainUrlParts::split($canonical); @@ -366,7 +366,7 @@ class ServiceApplication extends BaseModel } $dockerCompose = Yaml::parse($dockerComposeRaw); - $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); + $serviceConfig = $dockerCompose['services'][$this->name] ?? null; if (! $serviceConfig) { return $this->service->getRequiredPort(); } @@ -417,9 +417,21 @@ class ServiceApplication extends BaseModel return $portFound; } + $composePort = firstDockerComposeServicePort($serviceConfig); + if ($composePort !== null) { + return $composePort; + } + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT // suffix), such as WordPress, inherit the one-click template `# port:`. if ($declaresHttpUrl) { + if (blank($this->service->service_type)) { + $savedPort = $this->getSavedLegacyRoutingPort($serviceConfig); + if ($savedPort !== null) { + return $savedPort; + } + } + return $this->service->getRequiredPort(); } @@ -428,4 +440,52 @@ class ServiceApplication extends BaseModel return null; } } + + /** + * Preserve only an unambiguous upstream from this legacy container's saved labels. + */ + private function getSavedLegacyRoutingPort(array $serviceConfig): ?int + { + $savedCompose = Yaml::parse($this->service->docker_compose ?? ''); + $savedService = $savedCompose['services'][$this->name] ?? null; + $image = $serviceConfig['image'] ?? null; + if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) { + return null; + } + + $labels = $savedService['labels'] ?? []; + if (! is_array($labels)) { + return null; + } + + $ports = []; + foreach ($labels as $key => $value) { + if (is_int($key)) { + if (! is_string($value)) { + return null; + } + [$key, $value] = array_pad(explode('=', $value, 2), 2, null); + } + + if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) { + $port = $value; + } elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) { + if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) { + return null; + } + $port = $matches[1]; + } else { + continue; + } + + if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) { + return null; + } + $ports[] = (int) $port; + } + + $ports = array_values(array_unique($ports)); + + return count($ports) === 1 ? $ports[0] : null; + } } diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index c932791a78..603d11a7f3 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -2,13 +2,12 @@ namespace App\Models; -use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class ServiceDatabase extends BaseModel { - use HasFactory, HasRestartLimit, SoftDeletes; + use HasFactory, SoftDeletes; protected $fillable = [ 'service_id', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index a627d00aa6..6265345ee9 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,12 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected array $auditExclude = ['last_online_at']; - protected $fillable = [ 'uuid', 'name', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 8371bd0493..da4804dd2d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index bbe55a4f5a..f4dbaec210 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index d35509ce99..c923b489bd 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -16,9 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index faf54e0e71..70b108087a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 5a1ecb8425..6a08a4dc45 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e91539d55b..f8dc5c0caa 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,9 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; - + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 674f7867fd..3bfcc5434e 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -6,7 +6,6 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -15,12 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected array $auditExclude = ['last_online_at']; - protected $fillable = [ 'uuid', 'name', diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a1..01f58af7ec 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -21,7 +21,7 @@ class ApiTokenExpiringNotification extends CustomEmailNotification $this->onQueue('high'); $this->tokenName = $token->name; $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; - $this->manageUrl = route('security.api-tokens'); + $this->manageUrl = base_url().'/security/api-tokens'; } public function via(object $notifiable): array @@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 687fd30867..de9de1f981 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -2,8 +2,11 @@ namespace App\Notifications\Application; +use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\BaseModel; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; @@ -49,14 +52,19 @@ class RestartLimitReached extends CustomEmailNotification if (str($this->fqdn)->explode(',')->count() > 1) { $this->fqdn = str($this->fqdn)->explode(',')->first(); } - $service = data_get($resource, 'service'); - $this->resource_url = match (true) { - method_exists($this->resource, 'link') => $this->resource->link(), - $resource instanceof ApplicationPreview => $resource->application->link(), - is_object($service) && method_exists($service, 'link') => $service->link(), - default => null, + $this->resource_url = $this->resolveResourceUrl($resource); + } + + private function resolveResourceUrl(BaseModel $resource): string + { + [$type, $uuid] = match (true) { + $resource instanceof Application => ['application', $resource->uuid], + $resource instanceof ApplicationPreview => ['application', $resource->application->uuid], + $resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => ['service', $resource->service->uuid], + default => ['database', $resource->uuid], }; - $this->resource_url ??= base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}"; + + return base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/{$type}/{$uuid}"; } public function via(object $notifiable): array diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index 4f311bf681..118ad4269c 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -9,6 +9,7 @@ use App\Notifications\Application\RestartLimitReached; use App\Notifications\Application\StatusChanged; use App\Notifications\Container\ContainerRestarted; use App\Notifications\Database\BackupFailed; +use App\Notifications\Database\BackupMissing; use App\Notifications\Database\BackupSuccess; use App\Notifications\ScheduledTask\TaskFailed; use App\Notifications\ScheduledTask\TaskSuccess; @@ -17,6 +18,7 @@ use App\Notifications\Server\DockerCleanupSuccess; use App\Notifications\Server\HighDiskUsage; use App\Notifications\Server\Reachable; use App\Notifications\Server\ServerPatchCheck; +use App\Notifications\Server\TraefikVersionOutdated; use App\Notifications\Server\Unreachable; class TelegramChannel @@ -39,7 +41,8 @@ class TelegramChannel RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id, BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, - BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, + BackupFailed::class, + BackupMissing::class => $settings->telegram_notifications_backup_failure_thread_id, TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, @@ -50,7 +53,7 @@ class TelegramChannel Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, - + TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id, default => null, }; diff --git a/app/Notifications/Database/BackupMissing.php b/app/Notifications/Database/BackupMissing.php new file mode 100644 index 0000000000..d7f127ce3c --- /dev/null +++ b/app/Notifications/Database/BackupMissing.php @@ -0,0 +1,83 @@ +onQueue('high'); + $this->databaseName = $backup->database?->name ?? $backup->description ?? $backup->uuid; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + return (new MailMessage) + ->subject("Coolify: [ACTION REQUIRED] No recent backup for {$this->databaseName}") + ->view('emails.backup-missing', $this->messageData()); + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Scheduled database backup missing', + description: $this->description(), + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + return ['message' => 'Coolify: '.$this->description()]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage(title: 'Scheduled database backup missing', level: 'error', message: $this->description()); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage(title: 'Scheduled database backup missing', description: $this->description(), color: SlackMessage::errorColor()); + } + + public function toWebhook(): array + { + return array_merge($this->messageData(), [ + 'success' => false, + 'message' => 'Scheduled database backup missing', + 'event' => 'backup_missing', + 'backup_uuid' => $this->backup->uuid, + ]); + } + + private function description(): string + { + return "The enabled backup schedule for {$this->databaseName} has produced no executions in the last {$this->backup->missing_backup_notification_days} day(s)."; + } + + private function messageData(): array + { + return [ + 'database_name' => $this->databaseName, + 'days' => $this->backup->missing_backup_notification_days, + 'last_execution_at' => $this->lastExecutionAt?->toDateTimeString(), + ]; + } +} diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d23672100..52e986ed6e 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b4..0000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ -onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8d..2978eaed32 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskSuccess extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860b..2d2ebabaf0 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60f..61022d36b5 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054b..6c2712b68d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage @@ -66,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9c..8d2a5e3952 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -7,7 +7,6 @@ use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Collection; -use Spatie\Url\Url; class SslExpirationNotification extends CustomEmailNotification { @@ -19,39 +18,9 @@ class SslExpirationNotification extends CustomEmailNotification { $this->onQueue('high'); $this->resources = collect($resources); - - // Collect URLs for each resource - $this->resources->each(function ($resource) { - if (data_get($resource, 'environment.project.uuid')) { - $routeName = match ($resource->type()) { - 'application' => 'project.application.configuration', - 'database' => 'project.database.configuration', - 'service' => 'project.service.configuration', - default => null - }; - - if ($routeName) { - $route = route($routeName, [ - 'project_uuid' => data_get($resource, 'environment.project.uuid'), - 'environment_uuid' => data_get($resource, 'environment.uuid'), - $resource->type().'_uuid' => data_get($resource, 'uuid'), - ]); - - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - $this->urls[$resource->name] = $url->__toString(); - } else { - $this->urls[$resource->name] = $route; - } - } - } - }); + $this->urls = $this->resources->mapWithKeys(fn ($resource) => [ + $resource->name => base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/database/{$resource->uuid}", + ])->all(); } public function via(object $notifiable): array @@ -148,4 +117,18 @@ class SslExpirationNotification extends CustomEmailNotification color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1a..2c1de948ba 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ class DatabaseBackupFileValidator return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index 7afe0d4f25..f3858cc61d 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -58,7 +58,16 @@ trait HasNoindexDomains private function currentDomains(): Collection { - return collect(ValidationPatterns::applicationDomainList($this->fqdn)) + $domains = collect(ValidationPatterns::applicationDomainList($this->fqdn)); + $composeDomains = json_decode((string) ($this->getAttributes()['docker_compose_domains'] ?? null), true); + + if (is_array($composeDomains)) { + foreach ($composeDomains as $entry) { + $domains->push(...ValidationPatterns::applicationDomainList(composeDomainEntryString($entry))); + } + } + + return $domains ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f80fccafb2..613a104e0e 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -601,6 +601,57 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } +function firstDockerComposeServicePort(mixed $service): ?int +{ + $portDefinitions = collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])); + + foreach ($portDefinitions as $definition) { + $protocol = is_array($definition) + ? data_get($definition, 'protocol', 'tcp') + : (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp'); + if ($protocol !== 'tcp') { + continue; + } + + $port = is_array($definition) + ? data_get($definition, 'target') + : str((string) $definition)->before('/')->afterLast(':')->value(); + + if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) { + return (int) $port; + } + } + + return null; +} + +function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + return dockerComposeServicePorts($compose, $serviceName)[0] ?? null; +} + +function dockerComposeServicePorts(?string $compose, ?string $serviceName): array +{ + if (blank($compose) || blank($serviceName)) { + return []; + } + + try { + $services = data_get(Yaml::parse($compose), 'services', []); + } catch (Throwable) { + return []; + } + + $service = is_array($services) ? ($services[$serviceName] ?? []) : []; + + return collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])) + ->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]])) + ->filter(fn ($port) => $port !== null) + ->unique()->values()->all(); +} + function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 4e4ad73e6f..28ff41b3df 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -443,6 +443,26 @@ function getComposeServiceDomainString(array|Collection $domains, string $servic return $matches[0]['domain']; } +/** + * Determine whether a compose service already has a domain-map entry, including + * an explicitly empty entry left when a user removes its generated domain. + * + * @param array|Collection $domains + */ +function hasComposeServiceDomainEntry(array|Collection $domains, string $serviceName): bool +{ + $normalized = normalizeComposeServiceName($serviceName); + + foreach (collect($domains)->keys() as $key) { + $key = (string) $key; + if ($key === $serviceName || normalizeComposeServiceName($key) === $normalized) { + return true; + } + } + + return false; +} + function composeDomainEntryString(mixed $entry): ?string { if (is_object($entry)) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f65b626984..e50b852eca 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -525,8 +525,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services)); if ($originalServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $originalServiceName); - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) { $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { @@ -648,12 +647,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($composeServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $composeServiceName); - // Update domain using URL with port if applicable $domainValue = $port ? $urlWithPort : $url; - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) { $resource->docker_compose_domains = json_encode(putComposeServiceDomain( $domains, $composeServiceName, @@ -1357,8 +1354,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $domainPortOverrides = $isPullRequest ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); - $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; + $onlyPort = firstDockerComposeServicePort($service); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1565,7 +1561,6 @@ function serviceParser(Service $resource): Collection $envComments = extractYamlEnvironmentComments($compose); $server = data_get($resource, 'server'); - $allServices = get_service_templates(); try { $yaml = Yaml::parse($compose); @@ -1698,22 +1693,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2083,22 +2063,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2641,7 +2606,7 @@ function serviceParser(Service $resource): Collection ? data_get($originalResource, 'redirect') : 'both'; $onlyPort = $originalResource instanceof ServiceApplication - ? ($originalResource->getRequiredPort() ?? $predefinedPort) + ? $originalResource->getRequiredPort() : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); @@ -2676,7 +2641,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection @@ -2709,7 +2674,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 1112e9442b..44ceb603b9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string return null; } - $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl(); return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; } @@ -2486,7 +2486,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } catch (Exception $e) { throw new RuntimeException($e->getMessage()); } - $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelConfigs = collect(data_get($yaml, 'configs', [])); @@ -2512,25 +2511,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { - // Workarounds for beta users. - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - // End of workarounds for beta users. + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) { + $predefinedPort = $resource->getRequiredPort(); $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3107,7 +3089,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($savedService->domain_port_overrides ?? []) : []; $onlyPort = $savedService instanceof ServiceApplication - ? ($savedService->getRequiredPort() ?? $predefinedPort) + ? $savedService->getRequiredPort() : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { @@ -3139,7 +3121,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3172,7 +3154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3915,8 +3897,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $domainPortOverrides = $pull_request_id === 0 ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); - $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; + $onlyPort = firstDockerComposeServicePort($service); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index 397efc387c..98dbe3af7a 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -59,13 +59,18 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array return $line; } + // Negation belongs to the shell, before the elevated command. + if (preg_match('/^\s*!\s+/', $line)) { + return preg_replace('/^(\s*(?:!\s+)+)/', '$1sudo ', $line); + } + // Check all keywords with word boundary matching // Match keyword followed by space, semicolon, or end of line foreach ($bashKeywords as $keyword) { if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) { - // Special handling for 'if' - insert sudo after 'if ' + // Keep any shell negation before sudo in the condition. if ($keyword === 'if') { - return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line); + return preg_replace('/^(\s*if\s+(?:!\s+)*)/', '$1sudo ', $line); } return $line; diff --git a/config/constants.php b/config/constants.php index 457a8a1ff8..1e6d40b7b6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.15', + 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', - 'realtime_version' => '1.0.18', + 'realtime_version' => '1.0.19', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -14,7 +14,6 @@ return [ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), - 'avatar_cdn_url' => env('AVATAR_CDN_URL'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), diff --git a/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php new file mode 100644 index 0000000000..fe7c1e6292 --- /dev/null +++ b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php @@ -0,0 +1,28 @@ +unsignedInteger('missing_backup_notification_days')->default(0); + $table->timestamp('missing_backup_notification_sent_at')->nullable(); + $table->timestamp('last_execution_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn([ + 'missing_backup_notification_days', + 'missing_backup_notification_sent_at', + 'last_execution_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php new file mode 100644 index 0000000000..710578fd92 --- /dev/null +++ b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php @@ -0,0 +1,56 @@ +dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } + + public function down(): void + { + foreach (self::STANDALONE_DATABASE_TABLES as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } +}; diff --git a/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php new file mode 100644 index 0000000000..1c898dd5da --- /dev/null +++ b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php @@ -0,0 +1,28 @@ +string('image_cdn_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('image_cdn_url'); + }); + } +}; diff --git a/database/seeders/TeamSeeder.php b/database/seeders/TeamSeeder.php index 67c5ec4897..08426044c6 100644 --- a/database/seeders/TeamSeeder.php +++ b/database/seeders/TeamSeeder.php @@ -10,14 +10,14 @@ class TeamSeeder extends Seeder { public function run(): void { - $normal_user_in_root_team = User::find(1); + $normal_user_in_root_team = User::where('email', 'test2@example.com')->firstOrFail(); $root_user_personal_team = Team::find(0); $root_user_personal_team->description = 'The root team'; $root_user_personal_team->save(); $normal_user_in_root_team->teams()->attach($root_user_personal_team); - $normal_user_not_in_root_team = User::find(2); - $normal_user_in_root_team_personal_team = Team::find(1); + $normal_user_not_in_root_team = User::where('email', 'test3@example.com')->firstOrFail(); + $normal_user_in_root_team_personal_team = $normal_user_in_root_team->teams()->where('personal_team', true)->wherePivot('role', 'owner')->firstOrFail(); $normal_user_not_in_root_team->teams()->attach($normal_user_in_root_team_personal_team, ['role' => 'admin']); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 19d3aa42e8..9f237dc3b5 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -22,5 +22,6 @@ class UserSeeder extends Seeder 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); + } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ebf12379d5..d611fc69f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cc266e5562..6a52a0126a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index b72574c8d0..09f5bd5e12 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, sanitizeSshArgs, @@ -401,7 +402,7 @@ async function handleCommand(ws, command, userId) { cols: 80, rows: 30, cwd: process.env.HOME, - env: {}, + env: getTerminalProcessEnv(), }; // NOTE: - Initiates a process within the Terminal container diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index c6865f1800..c2762f1d85 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,5 +1,13 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function getTerminalProcessEnv(environment = process.env) { + return { + PATH: environment.PATH || DEFAULT_TERMINAL_PATH, + }; +} + export function getTerminalSessionTimeout() { return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; } diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 7af98be898..e9acda3270 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -4,6 +4,7 @@ import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, @@ -11,6 +12,32 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '/usr/local/bin:/usr/bin:/bin', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/bin:/usr/bin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => { + assert.deepEqual(getTerminalProcessEnv({ + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { const sshArgs = extractSshArgs( "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" diff --git a/docker/development/etc/nginx/conf.d/custom.conf b/docker/development/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/development/etc/nginx/conf.d/custom.conf +++ b/docker/development/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/docker/production/etc/nginx/conf.d/custom.conf b/docker/production/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/production/etc/nginx/conf.d/custom.conf +++ b/docker/production/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 455918fdc0..97ab3b3a6a 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.15" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" diff --git a/resources/css/app.css b/resources/css/app.css index 9400d2aad0..b5219badf2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2576,7 +2576,7 @@ input[type="search"]::-webkit-search-results-decoration { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 12.5rem; width: 100%; } @@ -2728,7 +2728,10 @@ input[type="search"]::-webkit-search-results-decoration { } .volumes-col-backup { - align-items: flex-start; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; } .volumes-cell-actions { @@ -4448,3 +4451,122 @@ a.command-palette-item:focus-visible { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domain-mobile-summary { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .service-domain-detail { + justify-content: space-between; + gap: 0.5rem; + } + + .service-domain-detail-label { + display: inline; + color: var(--coollabs-fg-dim); + } +} + +@container service-domains (max-width: 600px) { + .data-table-row.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.625rem 0.75rem; + padding: 0.875rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .data-table-row.service-domains-overview-grid > :first-child a, + .data-table-row.service-domains-overview-grid > :first-child span[title] { + overflow: visible; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + } + + .service-domain-detail, + .domains-service-desktop { + display: none; + } + + .service-domain-mobile-summary { + display: flex; + grid-column: 1 / -1; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.75rem; + color: var(--coollabs-fg-dim); + font-size: 12px; + line-height: 1.25rem; + } + + .service-domain-mobile-summary > span:not(:last-child)::after { + margin-left: 0.75rem; + color: var(--coollabs-line); + content: "·"; + } + + .service-domain-dns { + justify-self: start; + } + + .service-domain-actions { + justify-self: end; + } + + .service-domain-actions .icon-button { + width: 2.5rem; + height: 2.5rem; + } +} diff --git a/resources/views/components/application/restart-limit-warning.blade.php b/resources/views/components/application/restart-limit-warning.blade.php index 3d08c803d5..5a35623fc4 100644 --- a/resources/views/components/application/restart-limit-warning.blade.php +++ b/resources/views/components/application/restart-limit-warning.blade.php @@ -1,6 +1,6 @@ @props(['application']) -@if ($application->stoppedAfterRestartLimit()) +@if (method_exists($application, 'stoppedAfterRestartLimit') && $application->stoppedAfterRestartLimit()) @php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0)) @php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit)) whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} @if ($preserveValue) wire:ignore @endif - @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"> + @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()" + @scroll.window.capture="open && positionPanel()"> @if ($portal) -