feat(media): serve avatars and project icons from S3 or CDN

This commit is contained in:
Andras Bacsai 2026-08-27 22:32:16 +02:00
parent ac9b1c68c1
commit 97831ef06e
13 changed files with 200 additions and 12 deletions

View file

@ -53,3 +53,4 @@ DUSK_DRIVER_URL=http://selenium:4444
BUNNY_API_KEY=
# For asset uploads
BUNNY_STORAGE_API_KEY=
AVATAR_CDN_URL=

View file

@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify
PUSHER_APP_ID=coolify
PUSHER_APP_KEY=coolify
PUSHER_APP_SECRET=coolify
AVATAR_CDN_URL=

View file

@ -47,7 +47,7 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): bool
$avatarStorage->store(Auth::user(), $this->avatar);
$this->reset('avatar');
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp]));
$this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh()));
$this->dispatch('success', 'Profile picture updated.');
return true;

View file

@ -53,10 +53,7 @@ public function render(): View
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'iconUrl' => $project->icon_path ? route('project.icon', [
'project_uuid' => $project->uuid,
'v' => $project->updated_at->timestamp,
]) : null,
'iconUrl' => $project->icon_path ? project_icon_url($project) : null,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,

View file

@ -12,6 +12,8 @@
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@ -815,6 +817,54 @@ function firstDomainFromList(?string $fqdns): string
{
return trim((string) str($fqdns ?? '')->explode(',')->first());
}
function profile_avatar_url(User $user): string
{
if ($user->avatar_storage_type === 's3') {
$url = s3_image_url($user->avatar_s3_storage_id, $user->avatar_path, $user->updated_at->timestamp);
if ($url) {
return $url;
}
}
return route('profile.avatar', ['v' => $user->updated_at->timestamp]);
}
function project_icon_url(Project $project): string
{
if ($project->icon_storage_type === 's3') {
$url = s3_image_url($project->icon_s3_storage_id, $project->icon_path, $project->updated_at->timestamp);
if ($url) {
return $url;
}
}
return route('project.icon', [
'project_uuid' => $project->uuid,
'v' => $project->updated_at->timestamp,
]);
}
function s3_image_url(?int $storageId, ?string $path, int $version): ?string
{
if (! $storageId || blank($path)) {
return null;
}
$storage = S3Storage::query()
->whereKey($storageId)
->whereTeamId(0)
->where('is_usable', true)
->first();
if (! $storage) {
return null;
}
$baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl();
return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
}
/**
* If fqdn is set, return it, otherwise return public ip.
*/

View file

@ -14,6 +14,7 @@
'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'),

View file

@ -15,7 +15,7 @@
pageWidth: localStorage.getItem('pageWidth') || 'full',
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColorFrame: null,
avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null),
avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null),
openPanel() {
this.appearanceOpen = false;
this.open = true;

View file

@ -66,7 +66,7 @@ class="absolute inset-0 rounded-xl"
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
@if ($project->icon_path)
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
<img src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon"
class="h-full w-full rounded-lg object-cover">
@else

View file

@ -91,7 +91,7 @@
<img x-cloak x-show="preview" :src="preview" alt="Profile picture preview"
class="h-full w-full object-cover">
@if (auth()->user()->avatar_path)
<img src="{{ route('profile.avatar', ['v' => auth()->user()->updated_at->timestamp]) }}"
<img src="{{ profile_avatar_url(auth()->user()) }}"
x-show="!preview" alt="{{ auth()->user()->name }}" class="h-full w-full object-cover">
@else
<span x-show="!preview">

View file

@ -65,7 +65,7 @@
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<img x-cloak x-show="preview" :src="preview" alt="Project icon preview" class="h-full w-full object-cover">
@if ($project->icon_path)
<img x-show="!preview" src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
<img x-show="!preview" src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon" class="h-full w-full object-cover">
@else
<x-reicon x-show="!preview" name="projects" class="size-6" />

View file

@ -24,7 +24,7 @@ class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-whit
<div class="flex items-start gap-3">
<div class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
@if ($project->icon_path)
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
<img src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon"
class="h-full w-full rounded-lg object-cover">
@else
@ -49,7 +49,7 @@ class="h-full w-full rounded-lg object-cover">
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
@if ($project->icon_path)
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
<img src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon" class="size-4 shrink-0 rounded object-cover">
@else
<x-reicon name="projects" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />

View file

@ -2,6 +2,8 @@
use App\Livewire\Profile\Index;
use App\Models\InstanceSettings;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use App\Services\AvatarStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -73,6 +75,71 @@
->assertHeader('content-type', 'image/jpeg');
});
it('loads an S3 profile picture from the configured CDN', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/');
Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([
'team_id' => 0,
'name' => 'Avatar storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
]);
$user = User::factory()->create([
'avatar_path' => 'avatars/1/avatar.jpg',
'avatar_storage_type' => 's3',
'avatar_s3_storage_id' => $storage->id,
]);
expect(profile_avatar_url($user))->toBe("https://avatars.example.com/media/avatars/1/avatar.jpg?v={$user->updated_at->timestamp}");
});
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,
'name' => 'Avatar storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
]);
$user = User::factory()->create([
'avatar_path' => 'avatars/1/avatar.jpg',
'avatar_storage_type' => 's3',
'avatar_s3_storage_id' => $storage->id,
]);
expect(profile_avatar_url($user))->toBe("https://s3.example.com/avatars/avatars/1/avatar.jpg?v={$user->updated_at->timestamp}");
});
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',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://unrelated.example.com',
'is_usable' => true,
]);
$user = User::factory()->create([
'avatar_path' => 'avatars/1/avatar.jpg',
'avatar_storage_type' => 's3',
'avatar_s3_storage_id' => $storage->id,
]);
expect(profile_avatar_url($user))->toBe(route('profile.avatar', ['v' => $user->updated_at->timestamp]));
});
it('removes the current profile picture', function () {
Storage::fake('images');
$user = User::factory()->create([
@ -120,7 +187,7 @@
->not->toContain('Upload picture')
->not->toContain('type="file" x-on:change')
->and($menu)
->toContain("route('profile.avatar',");
->toContain('profile_avatar_url($user)');
});
it('offers runtime local or existing S3 profile picture storage', function () {

View file

@ -6,6 +6,7 @@
use App\Livewire\SharedVariables\Project\Index as SharedVariablesProjectIndex;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -102,6 +103,76 @@
]));
});
it('loads an S3 project icon from the configured CDN', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/');
Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([
'team_id' => 0,
'name' => 'Avatar storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
]);
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
'icon_storage_type' => 's3',
'icon_s3_storage_id' => $storage->id,
])->save();
Livewire::test(Index::class)
->assertViewHas('projectsJs', fn (array $projects): bool => $projects[0]['iconUrl'] === "https://avatars.example.com/media/project-icons/{$this->project->uuid}/icon.jpg?v={$this->project->updated_at->timestamp}");
});
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,
'name' => 'Avatar storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
]);
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
'icon_storage_type' => 's3',
'icon_s3_storage_id' => $storage->id,
])->save();
expect(project_icon_url($this->project))->toBe("https://s3.example.com/avatars/project-icons/{$this->project->uuid}/icon.jpg?v={$this->project->updated_at->timestamp}");
});
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,
'name' => 'Unusable storage',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'avatars',
'endpoint' => 'https://unusable.example.com',
'is_usable' => false,
]);
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
'icon_storage_type' => 's3',
'icon_s3_storage_id' => $storage->id,
])->save();
expect(project_icon_url($this->project))->toBe(route('project.icon', [
'project_uuid' => $this->project->uuid,
'v' => $this->project->updated_at->timestamp,
]));
});
it('displays the project icon on the dashboard', function () {
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",