feat(settings): configure CDN URL for stored images
Add a persisted instance setting for S3 image CDN URLs and use it when building image links. Cache profile avatars and project icons with immutable one-year headers.
This commit is contained in:
parent
9c3fb1da39
commit
3c0e43f482
13 changed files with 82 additions and 16 deletions
|
|
@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
|
||||||
BUNNY_API_KEY=
|
BUNNY_API_KEY=
|
||||||
# For asset uploads
|
# For asset uploads
|
||||||
BUNNY_STORAGE_API_KEY=
|
BUNNY_STORAGE_API_KEY=
|
||||||
AVATAR_CDN_URL=
|
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
|
||||||
PUSHER_APP_ID=coolify
|
PUSHER_APP_ID=coolify
|
||||||
PUSHER_APP_KEY=coolify
|
PUSHER_APP_KEY=coolify
|
||||||
PUSHER_APP_SECRET=coolify
|
PUSHER_APP_SECRET=coolify
|
||||||
AVATAR_CDN_URL=
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public function __invoke(AvatarStorageService $avatarStorage): Response
|
||||||
|
|
||||||
return response($contents, 200, [
|
return response($contents, 200, [
|
||||||
'Content-Type' => 'image/jpeg',
|
'Content-Type' => 'image/jpeg',
|
||||||
'Cache-Control' => 'private, max-age=300',
|
'Cache-Control' => 'private, max-age=31536000, immutable',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ public function __invoke(string $project_uuid, ProjectIconStorageService $iconSt
|
||||||
|
|
||||||
abort_if($contents === null, 404);
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ class Advanced extends Component
|
||||||
|
|
||||||
public string $avatar_storage = 'local';
|
public string $avatar_storage = 'local';
|
||||||
|
|
||||||
|
public ?string $image_cdn_url = null;
|
||||||
|
|
||||||
public array $avatar_storage_options = [];
|
public array $avatar_storage_options = [];
|
||||||
|
|
||||||
public function rules()
|
public function rules()
|
||||||
|
|
@ -71,6 +73,7 @@ public function rules()
|
||||||
'webhook_allowed_internal_hosts' => 'nullable|string',
|
'webhook_allowed_internal_hosts' => 'nullable|string',
|
||||||
'webhook_allow_localhost' => 'boolean',
|
'webhook_allow_localhost' => 'boolean',
|
||||||
'domain_connect_private_key' => 'nullable|string',
|
'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
|
$this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id
|
||||||
? 's3:'.$this->settings->avatar_s3_storage_id
|
? 's3:'.$this->settings->avatar_s3_storage_id
|
||||||
: 'local';
|
: 'local';
|
||||||
|
$this->image_cdn_url = $this->settings->image_cdn_url;
|
||||||
$this->avatar_storage_options = [
|
$this->avatar_storage_options = [
|
||||||
['value' => 'local', 'label' => 'Local storage'],
|
['value' => 'local', 'label' => 'Local storage'],
|
||||||
...S3Storage::query()
|
...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->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_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
|
||||||
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
|
$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->saveAvatarStorageSetting();
|
||||||
$this->settings->save();
|
$this->settings->save();
|
||||||
$this->dispatch('success', 'Settings updated!');
|
$this->dispatch('success', 'Settings updated!');
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class InstanceSettings extends Model
|
||||||
'webhook_allow_localhost',
|
'webhook_allow_localhost',
|
||||||
'avatar_storage_type',
|
'avatar_storage_type',
|
||||||
'avatar_s3_storage_id',
|
'avatar_s3_storage_id',
|
||||||
|
'image_cdn_url',
|
||||||
'is_dashboard_force_https_enabled',
|
'is_dashboard_force_https_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string
|
||||||
return null;
|
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;
|
return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
|
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
|
||||||
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
|
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
|
||||||
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
|
'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'),
|
'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'),
|
'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'),
|
'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->string('image_cdn_url')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('image_cdn_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
listboxes (API, MCP, telemetry, …) update the snapshot on the server
|
listboxes (API, MCP, telemetry, …) update the snapshot on the server
|
||||||
immediately; without wire:target they briefly flash this bar. --}}
|
immediately; without wire:target they briefly flash this bar. --}}
|
||||||
<x-unsaved-bar action="submit"
|
<x-unsaved-bar action="submit"
|
||||||
targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key" />
|
targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key,image_cdn_url" />
|
||||||
|
|
||||||
<x-application.settings-section id="access-section" title="Access">
|
<x-application.settings-section id="access-section" title="Access">
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
|
@ -144,9 +144,11 @@
|
||||||
|
|
||||||
<x-application.settings-section id="avatar-storage-section" title="Image storage"
|
<x-application.settings-section id="avatar-storage-section" title="Image storage"
|
||||||
helper="Choose where compressed profile pictures and project icons are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
|
helper="Choose where compressed profile pictures and project icons are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
|
||||||
<div class="max-w-md">
|
<div class="flex max-w-md flex-col gap-4">
|
||||||
<x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave"
|
<x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave"
|
||||||
:options="$avatar_storage_options" />
|
:options="$avatar_storage_options" />
|
||||||
|
<x-forms.input id="image_cdn_url" label="Image CDN URL"
|
||||||
|
helper="Optional public CDN URL for profile pictures and project icons stored on S3. Leave empty to use the S3 endpoint." placeholder="https://images.example.com" />
|
||||||
</div>
|
</div>
|
||||||
@if (count($avatar_storage_options) === 1)
|
@if (count($avatar_storage_options) === 1)
|
||||||
<x-callout type="info" title="No usable S3 storage configured" class="mt-4">
|
<x-callout type="info" title="No usable S3 storage configured" class="mt-4">
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,14 @@
|
||||||
$this->withoutMiddleware()->actingAs($user)
|
$this->withoutMiddleware()->actingAs($user)
|
||||||
->get(route('profile.avatar'))
|
->get(route('profile.avatar'))
|
||||||
->assertSuccessful()
|
->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 () {
|
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]);
|
Team::factory()->create(['id' => 0]);
|
||||||
$storage = S3Storage::query()->create([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
|
|
@ -98,7 +101,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () {
|
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]);
|
Team::factory()->create(['id' => 0]);
|
||||||
$storage = S3Storage::query()->create([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
|
|
@ -120,7 +122,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not use an unrelated S3 storage URL for a profile picture', function () {
|
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([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => Team::factory()->create()->id,
|
'team_id' => Team::factory()->create()->id,
|
||||||
'name' => 'Unrelated storage',
|
'name' => 'Unrelated storage',
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,8 @@
|
||||||
|
|
||||||
$this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
|
$this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertHeader('content-type', 'image/jpeg');
|
->assertHeader('content-type', 'image/jpeg')
|
||||||
|
->assertHeader('cache-control', 'immutable, max-age=31536000, private');
|
||||||
|
|
||||||
$otherUser = User::factory()->create();
|
$otherUser = User::factory()->create();
|
||||||
$otherTeam = Team::factory()->create();
|
$otherTeam = Team::factory()->create();
|
||||||
|
|
@ -104,7 +105,9 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads an S3 project icon from the configured CDN', function () {
|
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]);
|
Team::factory()->create(['id' => 0]);
|
||||||
$storage = S3Storage::query()->create([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
|
|
@ -127,7 +130,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads an S3 project icon directly from S3 when the CDN is not configured', function () {
|
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]);
|
Team::factory()->create(['id' => 0]);
|
||||||
$storage = S3Storage::query()->create([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
|
|
@ -149,7 +151,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not use an unusable S3 storage URL for a project icon', function () {
|
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]);
|
Team::factory()->create(['id' => 0]);
|
||||||
$storage = S3Storage::query()->create([
|
$storage = S3Storage::query()->create([
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@
|
||||||
->not->toContain('Two-step confirmations enabled');
|
->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('<div class="flex max-w-md flex-col gap-4">');
|
||||||
|
});
|
||||||
|
|
||||||
test('instance admin can toggle registration via listbox instantSave', function () {
|
test('instance admin can toggle registration via listbox instantSave', function () {
|
||||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||||
|
|
@ -82,6 +88,28 @@
|
||||||
expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue();
|
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 () {
|
test('open API allowlist warning is hidden when API access is disabled', function () {
|
||||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue