diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index e4d6282fe2..e28f09f6a2 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -10,6 +10,7 @@ use App\Traits\ReleasesManagedDnsRecords; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Symfony\Component\Yaml\Exception\ParseException; class ServiceApplication extends BaseModel { @@ -357,6 +358,33 @@ class ServiceApplication extends BaseModel return false; } + /** + * Whether the proxy labels of this container in the Compose file add HTTP basic auth + * (a Traefik `basicauth` middleware or a Caddy `basic_auth` directive). + */ + public function hasProxyBasicAuth(): bool + { + try { + $dockerCompose = parseDockerComposeYaml((string) data_get($this->service, 'docker_compose_raw')); + } catch (ParseException) { + return false; + } + + $labels = $dockerCompose['services'][$this->name]['labels'] ?? []; + if (! is_array($labels)) { + return false; + } + + foreach ($labels as $key => $value) { + $label = is_int($key) ? (string) $value : (string) $key; + if (preg_match('/basic_?auth/i', $label) === 1) { + return true; + } + } + + return false; + } + /** * Get the required port for this service application. * Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 8aff368171..195d4dbc83 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -55,7 +55,8 @@ function resource_logo_url(string $name): string * the service template, or how the application is built. Null when there is no specific logo. * * Applications with a domain also get the favicon URL of their first domain. The browser loads it - * and shows it in place of the logo when it loads. Coolify itself never requests it. + * and shows it in place of the logo when it loads. Coolify itself never requests it. Applications + * behind HTTP basic auth get no favicon: the request makes the browser ask for the credentials. * * @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null */ @@ -93,7 +94,9 @@ function resource_logo(Model $resource): ?array default => $resourceLogo('public-repo'), }; - return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)]; + $favicon = $resource->is_http_basic_auth_enabled ? null : resource_favicon_url($resource->fqdn); + + return [...$logo, 'favicon' => $favicon]; } if (! method_exists($resource, 'type')) { diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 4060bfab4a..23d2bf1f0a 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -16,7 +16,7 @@ }; $gridClass = 'service-domains-overview-grid'; $publicUrl = getFqdnWithoutPort($row['url']); - $faviconUrl = $isSuggested ? null : \App\Support\DomainFavicon::url($publicUrl, $row['dns_status'] ?? null, $row['expected_ip'] ?? null); + $faviconUrl = $isSuggested || $application->is_http_basic_auth_enabled ? null : \App\Support\DomainFavicon::url($publicUrl, $row['dns_status'] ?? null, $row['expected_ip'] ?? null); $rowDirection = $isCompose ? ($serviceRedirects[$this->serviceRedirectWireKey($row['service'])] ?? 'both') : $redirect; diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 448bc02a3b..be5b6e70f4 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -45,14 +45,15 @@ ? \Illuminate\Support\Str::headline($row['service_name']) : '-'; $publicUrl = getFqdnWithoutPort($row['url']); - $isNoindexed = $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']); + $serviceApplication = $service->applications->firstWhere('id', $row['service_application_id']); + $isNoindexed = $serviceApplication?->isDomainNoindexed($row['url']); $rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both'; $directionLabel = match ($rowDirection) { 'www' => 'Redirect to www', 'non-www' => 'Redirect to non-www', default => 'Both www and non-www', }; - $faviconUrl = $isSuggested ? null : \App\Support\DomainFavicon::url($publicUrl, $row['dns_status'] ?? null, $row['expected_ip'] ?? null); + $faviconUrl = $isSuggested || $serviceApplication?->hasProxyBasicAuth() ? null : \App\Support\DomainFavicon::url($publicUrl, $row['dns_status'] ?? null, $row['expected_ip'] ?? null); $domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? '')); $editingParts = \App\Support\DomainUrlParts::split($row['url']); if ($row['has_port_override'] ?? false) { diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 15315aa3f7..a7d18a9865 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -655,6 +655,14 @@ it('lists existing domains as individual rows', function () { expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0); }); +it('loads no domain favicon when HTTP basic auth protects the application', function () { + $this->application->update(['fqdn' => 'https://example.com', 'is_http_basic_auth_enabled' => true]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSee('https://example.com') + ->assertDontSee('https://example.com/favicon.ico', false); +}); + it('shows the HTTP redirect control for HTTPS domains and persists changes', function () { $this->application->update(['fqdn' => 'https://app.example.com']); diff --git a/tests/Feature/ResourceLogoTest.php b/tests/Feature/ResourceLogoTest.php index 79c36ef585..561827085a 100644 --- a/tests/Feature/ResourceLogoTest.php +++ b/tests/Feature/ResourceLogoTest.php @@ -63,6 +63,16 @@ test('applications with a domain get the favicon of their first domain', functio ['https://*.example.com', null], ]); +test('applications behind HTTP basic auth get no favicon, so the browser does not ask for credentials', function () { + $application = (new Application)->forceFill([ + 'build_pack' => 'nixpacks', + 'fqdn' => 'https://app.example.com', + 'is_http_basic_auth_enabled' => true, + ]); + + expect(resource_logo($application)['favicon'])->toBeNull(); +}); + test('resource logo URLs carry the file modification time, so browsers load changed logos', function () { $modifiedAt = filemtime(public_path('svgs/resources/docker-image.svg')); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 6b23366766..4aca098a61 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -164,6 +164,21 @@ it('groups configured domains and shows redirect settings in the table', functio ->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1); }); +it('loads no domain favicon when proxy labels add HTTP basic auth to the service', function (string $labels) { + $this->service->update([ + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n labels:\n{$labels}", + ]); + + $html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertSee('https://api.example.com') + ->html(); + + expect($html)->not->toContain('https://api.example.com/favicon.ico'); +})->with([ + 'traefik list' => [" - traefik.http.middlewares.auth.basicauth.users=admin:hash\n"], + 'caddy map' => [" caddy.basic_auth.admin: hash\n"], +]); + it('removes consecutive service domains by stable row identity after indexes change', function () { $this->apiApp->update([ 'fqdn' => 'https://first.example.com,https://second.example.com,https://third.example.com',