diff --git a/.env.development.example b/.env.development.example index 56c17128c..380f10a44 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 626d76ff6..b067b4c5c 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/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e..f53cef7c5 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ public function __invoke(AvatarStorageService $avatarStorage): Response 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 fb7ebc886..d99e9166d 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ public function __invoke(string $project_uuid, ProjectIconStorageService $iconSt 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/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d..45aff3f3c 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -53,6 +53,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -71,6 +73,7 @@ public function rules() 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -97,6 +100,7 @@ public function mount() $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() @@ -210,6 +214,7 @@ public function instantSave(?array $webhookAllowedInternalHosts = null) $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/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ad..26aceec35 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -56,6 +56,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/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f9bf44dff..bc5af1e86 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; } diff --git a/config/constants.php b/config/constants.php index 22d5e1011..bdcd5d7c9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -14,7 +14,6 @@ '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_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 000000000..1c898dd5d --- /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/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 94927ba25..4c11cb41d 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -9,7 +9,7 @@ listboxes (API, MCP, telemetry, …) update the snapshot on the server immediately; without wire:target they briefly flash this bar. --}} + targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key,image_cdn_url" />
@@ -144,9 +144,11 @@ -
+
+
@if (count($avatar_storage_options) === 1) diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index 68ced553d..3512fccea 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -72,11 +72,14 @@ $this->withoutMiddleware()->actingAs($user) ->get(route('profile.avatar')) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); }); it('loads an S3 profile picture from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -98,7 +101,6 @@ }); it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -120,7 +122,6 @@ }); it('does not use an unrelated S3 storage URL for a profile picture', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); $storage = S3Storage::query()->create([ 'team_id' => Team::factory()->create()->id, 'name' => 'Unrelated storage', diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index fc03c9f30..99a6c0ac5 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -61,7 +61,8 @@ $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); $otherUser = User::factory()->create(); $otherTeam = Team::factory()->create(); @@ -104,7 +105,9 @@ }); it('loads an S3 project icon from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -127,7 +130,6 @@ }); it('loads an S3 project icon directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -149,7 +151,6 @@ }); it('does not use an unusable S3 storage URL for a project icon', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, diff --git a/tests/Feature/SettingsAccessListboxTest.php b/tests/Feature/SettingsAccessListboxTest.php index 4ec9dceb9..c1508bf73 100644 --- a/tests/Feature/SettingsAccessListboxTest.php +++ b/tests/Feature/SettingsAccessListboxTest.php @@ -29,6 +29,12 @@ ->not->toContain('Two-step confirmations enabled'); }); +test('image storage fields use the standard settings field gap', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php')); + + expect($contents)->toContain('
'); +}); + test('instance admin can toggle registration via listbox instantSave', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); @@ -82,6 +88,28 @@ expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue(); }); +test('instance admin can configure the image CDN URL at runtime', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + $settings = InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Advanced::class) + ->assertSee('Image CDN URL') + ->set('image_cdn_url', 'https://images.example.com/media/') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($settings->fresh()->image_cdn_url)->toBe('https://images.example.com/media'); +}); + test('open API allowlist warning is hidden when API access is disabled', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);