feat(media): serve avatars and project icons from S3 or CDN
This commit is contained in:
parent
ac9b1c68c1
commit
97831ef06e
13 changed files with 200 additions and 12 deletions
|
|
@ -53,3 +53,4 @@ DUSK_DRIVER_URL=http://selenium:4444
|
|||
BUNNY_API_KEY=
|
||||
# For asset uploads
|
||||
BUNNY_STORAGE_API_KEY=
|
||||
AVATAR_CDN_URL=
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify
|
|||
PUSHER_APP_ID=coolify
|
||||
PUSHER_APP_KEY=coolify
|
||||
PUSHER_APP_SECRET=coolify
|
||||
AVATAR_CDN_URL=
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue