diff --git a/.env.development.example b/.env.development.example index 380f10a44..56c17128c 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,3 +53,4 @@ 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 b067b4c5c..626d76ff6 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify +AVATAR_CDN_URL= diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a20a1231b..04f58dadd 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -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; diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 2b472a1a2..f43b7542e 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -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, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index c3bd4a223..f4dd3d185 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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. */ diff --git a/config/constants.php b/config/constants.php index b6fac21d1..e6e1a50f8 100644 --- a/config/constants.php +++ b/config/constants.php @@ -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'), diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index b176f4f21..7fdc312cb 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -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; diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index a8f4d0369..ebee5d2a3 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -66,7 +66,7 @@ class="absolute inset-0 rounded-xl"
@if ($project->icon_path) - {{ $project->name }} icon @else diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index ef54d3e21..4e6b77709 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -91,7 +91,7 @@ Profile picture preview @if (auth()->user()->avatar_path) - {{ auth()->user()->name }} @else diff --git a/resources/views/livewire/project/edit.blade.php b/resources/views/livewire/project/edit.blade.php index 564da2764..f3d27997a 100644 --- a/resources/views/livewire/project/edit.blade.php +++ b/resources/views/livewire/project/edit.blade.php @@ -65,7 +65,7 @@
Project icon preview @if ($project->icon_path) - {{ $project->name }} icon @else diff --git a/resources/views/livewire/shared-variables/project/index.blade.php b/resources/views/livewire/shared-variables/project/index.blade.php index eb394ae83..63e4f4573 100644 --- a/resources/views/livewire/shared-variables/project/index.blade.php +++ b/resources/views/livewire/shared-variables/project/index.blade.php @@ -24,7 +24,7 @@ class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-whit
@if ($project->icon_path) - {{ $project->name }} icon @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) - {{ $project->name }} icon @else diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index cf313882f..68ced553d 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -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 () { diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index b5b6ed088..fc03c9f30 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -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",