Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-07 10:43:26 +00:00
9 changed files with 157 additions and 40 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support;
/**
* Builds the favicon URL that the browser loads for a domain row.
*
* Domains on a private network get no favicon: loading it from a public Coolify page triggers the
* Chrome "access other devices on your local network" permission prompt.
*/
class DomainFavicon
{
/** @var list<string> */
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;
}
}
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.4.1"
"version": "4.4.2"
},
"nightly": {
"version": "4.5-rc.1"
@@ -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;
@@ -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) {
+28 -29
View File
@@ -50,10 +50,35 @@
@else
<div class="grid grid-cols-1 items-stretch gap-4 border-b border-neutral-200 p-4 lg:grid-cols-[2fr_3fr] dark:border-white/[0.07]">
{{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear
down the WebGL canvas. --}}
<div wire:ignore class="flex items-center justify-center">
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. --}}
<div wire:ignore class="flex items-center justify-center"
x-data="{
controller: null,
refreshCleanup: null,
isDark() { return document.documentElement.classList.contains('dark'); },
init() {
const canvas = this.$refs.canvas;
if (typeof window.mountTrafficGlobe !== 'function') { return; }
this.controller = window.mountTrafficGlobe(canvas, @js($geoInit), this.isDark());
// Expose for row-hover focus() from the Alpine list scope.
canvas._trafficGlobe = this.controller;
this.refreshCleanup = Livewire.on('refreshChartData-{!! $chartId !!}-status', payload => {
const data = Array.isArray(payload) ? payload[0] : payload;
if (data && Array.isArray(data.geo)) { this.controller.update(data.geo, this.isDark()); }
});
},
destroy() {
if (this.refreshCleanup) { this.refreshCleanup(); this.refreshCleanup = null; }
if (this.controller) { this.controller.destroy(); this.controller = null; }
if (this.$refs.canvas) { this.$refs.canvas._trafficGlobe = null; }
},
}">
<div class="relative aspect-square w-full max-w-[320px]">
<canvas id="{!! $globeId !!}"
<canvas id="{!! $globeId !!}" x-ref="canvas"
class="h-full w-full [touch-action:none]"
style="width: 100%; height: 100%;"></canvas>
</div>
@@ -108,31 +133,5 @@
{{ $attribution }}
</p>
@endif
@script
<script>
(() => {
const canvas = document.getElementById('{!! $globeId !!}');
if (!canvas || typeof window.mountTrafficGlobe !== 'function') { return; }
const isDark = () => document.documentElement.classList.contains('dark');
const controller = window.mountTrafficGlobe(canvas, @json($geoInit), isDark());
// Expose for row-hover focus() from the Alpine list scope.
canvas._trafficGlobe = controller;
Livewire.on('refreshChartData-{!! $chartId !!}-status', payload => {
const data = Array.isArray(payload) ? payload[0] : payload;
if (data && Array.isArray(data.geo)) {
controller.update(data.geo, isDark());
}
});
// Tear down the WebGL context when navigating away (wire:navigate).
document.addEventListener('livewire:navigating', () => {
if (canvas._trafficGlobe) { canvas._trafficGlobe.destroy(); canvas._trafficGlobe = null; }
}, { once: true });
})();
</script>
@endscript
@endif
</div>
@@ -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');
});
+40
View File
@@ -0,0 +1,40 @@
<?php
use App\Support\DomainFavicon;
it('builds the favicon URL for a public domain', function (string $url, string $expected) {
expect(DomainFavicon::url($url))->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],
]);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.4.1"
"version": "4.4.2"
},
"nightly": {
"version": "4.5-rc.1"