diff --git a/app/Support/DomainFavicon.php b/app/Support/DomainFavicon.php new file mode 100644 index 0000000000..e872a14ac7 --- /dev/null +++ b/app/Support/DomainFavicon.php @@ -0,0 +1,63 @@ + */ + private const LOCAL_SUFFIXES = ['.localhost', '.local', '.lan', '.internal', '.home.arpa', '.test']; + + public static function url(string $publicUrl, ?string $dnsStatus = null, ?string $expectedIp = null): ?string + { + $parts = parse_url($publicUrl); + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return null; + } + + if (self::isPrivateHost($parts['host'])) { + return null; + } + + if ($dnsStatus === 'ok' && filled($expectedIp) && ! DnsRecordHints::isPublicAddress($expectedIp)) { + return null; + } + + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return $parts['scheme'].'://'.$parts['host'].$port.'/favicon.ico'; + } + + private static function isPrivateHost(string $host): bool + { + $host = strtolower(trim($host, '[]')); + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return ! DnsRecordHints::isPublicAddress($host); + } + + if ($host === 'localhost') { + return true; + } + + foreach (self::LOCAL_SUFFIXES as $suffix) { + if (str_ends_with($host, $suffix)) { + return true; + } + } + + if (preg_match('/(?:^|\.)(\d{1,3}(?:[.-]\d{1,3}){3})\.(?:sslip|nip)\.io$/', $host, $matches) === 1) { + $embeddedIp = str_replace('-', '.', $matches[1]); + + return filter_var($embeddedIp, FILTER_VALIDATE_IP) === false + || ! DnsRecordHints::isPublicAddress($embeddedIp); + } + + return false; + } +} diff --git a/config/constants.php b/config/constants.php index 74b8232988..e792499c35 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.4.1', + 'version' => env('COOLIFY_VERSION') ?: '4.4.2', 'helper_version' => '1.0.17', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a5f9992828..a1f7ec3a90 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.1" + "version": "4.4.2" }, "nightly": { "version": "4.5-rc.1" 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 018beefebd..60f0eb38f8 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -16,10 +16,7 @@ }; $gridClass = 'service-domains-overview-grid'; $publicUrl = getFqdnWithoutPort($row['url']); - $domainParts = $isSuggested ? null : parse_url($publicUrl); - $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) - ? $domainParts['scheme'].'://'.$domainParts['host'].'/favicon.ico' - : null; + $faviconUrl = $isSuggested ? 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 7038756440..af5d672525 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -45,7 +45,6 @@ ? \Illuminate\Support\Str::headline($row['service_name']) : '-'; $publicUrl = getFqdnWithoutPort($row['url']); - $domainParts = $isSuggested ? null : parse_url($publicUrl); $isNoindexed = $service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']); $rowDirection = $serviceRedirects[$row['service_application_id']] ?? 'both'; $directionLabel = match ($rowDirection) { @@ -53,9 +52,7 @@ 'non-www' => 'Redirect to non-www', default => 'Both www and non-www', }; - $faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host']) - ? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico' - : null; + $faviconUrl = $isSuggested ? 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/resources/views/livewire/traffic/_geo.blade.php b/resources/views/livewire/traffic/_geo.blade.php index f47e0506bf..06f24bfee6 100644 --- a/resources/views/livewire/traffic/_geo.blade.php +++ b/resources/views/livewire/traffic/_geo.blade.php @@ -50,10 +50,35 @@ @else
{{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear - down the WebGL canvas. --}} -
+ down the WebGL canvas. Mounted via Alpine, not Livewire's script directive: + it ran before the lazy-loaded canvas was in the DOM, and only once per + component, so a globe inserted by a later render (no data -> data after a + range change or live poll) never mounted either. Alpine's + init()/destroy() run each time this element enters or leaves the DOM. --}} +
-
@@ -108,31 +133,5 @@ {{ $attribution }}

@endif - - @script - - @endscript @endif
diff --git a/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php index bf239a743c..13e230d6a1 100644 --- a/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php +++ b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php @@ -114,3 +114,24 @@ it('shows a plain no-data state when no country data has been recorded', functio ->assertSee('Countries') ->assertSee('No country data for the selected range'); }); + +it('mounts the globe from the element itself when country data arrives after the first render', function () { + $server = bootGeoServer(); + + $fake = new FakeGeoTrafficClient($server); + $fake->responses = fakeGeoResponses([]); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + $component = loadLazy(Livewire::test(Analytics::class)) + ->assertSee('No country data for the selected range'); + + $fake->responses = fakeGeoResponses([ + ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000], + ]); + + // A @script block runs only once per component, so a globe inserted by a later + // render never mounted. The mount must live on the element (Alpine x-data). + $component->call('loadData') + ->assertSeeHtml('id="global-analytics-globe" x-ref="canvas"') + ->assertSeeHtml('window.mountTrafficGlobe(canvas'); +}); diff --git a/tests/Unit/DomainFaviconTest.php b/tests/Unit/DomainFaviconTest.php new file mode 100644 index 0000000000..1aeeb28ae1 --- /dev/null +++ b/tests/Unit/DomainFaviconTest.php @@ -0,0 +1,40 @@ +toBe($expected); +})->with([ + 'domain' => ['https://app.example.com', 'https://app.example.com/favicon.ico'], + 'domain with port' => ['https://app.example.com:8443', 'https://app.example.com:8443/favicon.ico'], + 'public IP' => ['http://8.8.8.8', 'http://8.8.8.8/favicon.ico'], + 'public sslip.io' => ['http://app.1.2.3.4.sslip.io', 'http://app.1.2.3.4.sslip.io/favicon.ico'], +]); + +it('skips the favicon for a private network host', function (string $url) { + expect(DomainFavicon::url($url))->toBeNull(); +})->with([ + 'private IPv4' => ['http://192.168.1.10'], + 'unique local IPv6' => ['http://[fd00::1]'], + 'tailscale CGNAT' => ['http://100.101.102.103'], + 'loopback' => ['http://127.0.0.1:8000'], + 'localhost' => ['http://localhost'], + 'mDNS name' => ['http://nas.local'], + 'internal name' => ['http://app.internal'], + 'private sslip.io' => ['http://app.10.0.0.5.sslip.io'], + 'private nip.io with dashes' => ['http://10-0-0-5.nip.io'], + 'invalid URL' => ['not a url'], +]); + +it('skips the favicon when the DNS check matched a private server IP', function () { + expect(DomainFavicon::url('https://app.example.com', 'ok', '10.0.0.5'))->toBeNull(); +}); + +it('keeps the favicon when the DNS check did not confirm a private server IP', function (?string $status, ?string $expectedIp) { + expect(DomainFavicon::url('https://app.example.com', $status, $expectedIp)) + ->toBe('https://app.example.com/favicon.ico'); +})->with([ + 'mismatch' => ['failed', '10.0.0.5'], + 'public server IP' => ['ok', '8.8.8.8'], + 'no server IP' => ['ok', null], +]); diff --git a/versions.json b/versions.json index a5f9992828..a1f7ec3a90 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.1" + "version": "4.4.2" }, "nightly": { "version": "4.5-rc.1"