From a5dedc07cd72d58a3f0a843fcee7d120f9000d7c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:19:41 +0200 Subject: [PATCH] feat(storage): persist local images and unify clipboard copying Add a private images disk with persistent container mounts and installation/upgrade setup, while routing avatar and project icon storage through it. Reuse the copy button component for API tokens and support clipboard copying through the shared helper. --- app/Services/AvatarStorageService.php | 2 +- config/filesystems.php | 7 +++++ docker-compose.prod.yml | 1 + docker-compose.windows.yml | 1 + other/nightly/docker-compose.prod.yml | 1 + .../components/forms/copy-button.blade.php | 9 ++---- .../livewire/security/api-tokens.blade.php | 13 +------- scripts/install.sh | 1 + scripts/upgrade.sh | 4 +++ tests/Feature/PersistentImageStorageTest.php | 31 +++++++++++++++++++ tests/Feature/ProfileAvatarTest.php | 22 ++++++------- tests/Feature/ProjectIconTest.php | 14 ++++----- .../Feature/ResourceDetailsVisibilityTest.php | 13 ++++++-- 13 files changed, 79 insertions(+), 40 deletions(-) create mode 100644 tests/Feature/PersistentImageStorageTest.php diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php index d1446ea37e..983b144b94 100644 --- a/app/Services/AvatarStorageService.php +++ b/app/Services/AvatarStorageService.php @@ -71,7 +71,7 @@ class AvatarStorageService protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter { if ($storageType !== 's3') { - return Storage::disk('local'); + return Storage::disk('images'); } $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first(); diff --git a/config/filesystems.php b/config/filesystems.php index ba0921a794..966cd0d5a8 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,6 +35,13 @@ return [ 'throw' => false, ], + 'images' => [ + 'driver' => 'local', + 'root' => storage_path('app/images'), + 'visibility' => 'private', + 'throw' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9edc00e702..0d7caceb95 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,6 +11,7 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups + - /data/coolify/images:/var/www/html/storage/app/images environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 43f6f0d0e9..33709873f2 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,6 +25,7 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups + - ./images:/var/www/html/storage/app/images env_file: - .env environment: diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 9edc00e702..0d7caceb95 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,6 +11,7 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups + - /data/coolify/images:/var/www/html/storage/app/images environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index 7a12cdfe43..e299610eb2 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -1,21 +1,18 @@ @props(['text', 'label' => null]) -
+
@if ($label) @endif
- Copied -
+ @endif diff --git a/scripts/install.sh b/scripts/install.sh index 1773f37f1f..226b1cc5e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -229,6 +229,7 @@ if [ "$WARNING_SPACE" = true ]; then fi mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} +mkdir -p /data/coolify/images mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index a909d2e49a..516a9d7ebc 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -170,6 +170,10 @@ else log "Network 'coolify' already exists" fi +mkdir -p /data/coolify/images/{avatars,project-icons} +chown -R 9999:root /data/coolify/images +chmod -R 700 /data/coolify/images + # Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621) # Only changes owner — preserves existing group to respect custom setups SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown") diff --git a/tests/Feature/PersistentImageStorageTest.php b/tests/Feature/PersistentImageStorageTest.php new file mode 100644 index 0000000000..59d88e3f0b --- /dev/null +++ b/tests/Feature/PersistentImageStorageTest.php @@ -0,0 +1,31 @@ +toMatchArray([ + 'driver' => 'local', + 'root' => storage_path('app/images'), + 'visibility' => 'private', + ]); +}); + +it('persists images in stable and nightly production compose files', function (string $composeFile) { + expect(file_get_contents(base_path($composeFile))) + ->toContain('/data/coolify/images:/var/www/html/storage/app/images'); +})->with([ + 'stable' => 'docker-compose.prod.yml', + 'nightly' => 'other/nightly/docker-compose.prod.yml', +]); + +it('persists images in the Windows compose file', function () { + expect(file_get_contents(base_path('docker-compose.windows.yml'))) + ->toContain('./images:/var/www/html/storage/app/images'); +}); + +it('creates the persistent image directory during installation and upgrades', function (string $script) { + expect(file_get_contents(base_path($script))) + ->toContain('/data/coolify/images'); +})->with([ + 'install' => 'scripts/install.sh', + 'upgrade' => 'scripts/upgrade.sh', +]); diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index e5abb2ef5c..cf313882f4 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -19,7 +19,7 @@ beforeEach(function () { }); it('compresses and stores an uploaded profile picture on the configured local storage', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create(['name' => 'Test User']); $this->actingAs($user); @@ -35,18 +35,18 @@ it('compresses and stores an uploaded profile picture on the configured local st ->and($user->avatar_storage_type)->toBe('local') ->and($user->avatar_s3_storage_id)->toBeNull(); - Storage::disk('local')->assertExists($user->avatar_path); + Storage::disk('images')->assertExists($user->avatar_path); - $image = getimagesizefromstring(Storage::disk('local')->get($user->avatar_path)); + $image = getimagesizefromstring(Storage::disk('images')->get($user->avatar_path)); expect($image[0])->toBeLessThanOrEqual(256) ->and($image[1])->toBeLessThanOrEqual(256) ->and($image['mime'])->toBe('image/jpeg') - ->and(Storage::disk('local')->size($user->avatar_path))->toBeLessThan(100_000); + ->and(Storage::disk('images')->size($user->avatar_path))->toBeLessThan(100_000); }); it('stores an already compressed browser JPEG without server image extensions', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create(['name' => 'Test User']); $contents = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gODAK/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8AAEQgAAgACAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+qaKKKAP/9k='); $path = tempnam(sys_get_temp_dir(), 'avatar'); @@ -55,17 +55,17 @@ it('stores an already compressed browser JPEG without server image extensions', app(AvatarStorageService::class)->store($user, $upload); - expect(Storage::disk('local')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); + expect(Storage::disk('images')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); }); it('serves the authenticated users profile picture', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create([ 'name' => 'Test User', 'avatar_path' => 'avatars/1/avatar.jpg', 'avatar_storage_type' => 'local', ]); - Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + Storage::disk('images')->put($user->avatar_path, 'avatar-content'); $this->withoutMiddleware()->actingAs($user) ->get(route('profile.avatar')) @@ -74,13 +74,13 @@ it('serves the authenticated users profile picture', function () { }); it('removes the current profile picture', function () { - Storage::fake('local'); + Storage::fake('images'); $user = User::factory()->create([ 'name' => 'Test User', 'avatar_path' => 'avatars/1/avatar.jpg', 'avatar_storage_type' => 'local', ]); - Storage::disk('local')->put($user->avatar_path, 'avatar-content'); + Storage::disk('images')->put($user->avatar_path, 'avatar-content'); $this->actingAs($user); Livewire::test(Index::class) @@ -88,7 +88,7 @@ it('removes the current profile picture', function () { ->assertHasNoErrors(); expect($user->refresh()->avatar_path)->toBeNull(); - Storage::disk('local')->assertMissing('avatars/1/avatar.jpg'); + Storage::disk('images')->assertMissing('avatars/1/avatar.jpg'); }); it('falls back cleanly when the avatars S3 storage no longer exists', function () { diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index 3599c0b4b5..b5b6ed0887 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -31,7 +31,7 @@ beforeEach(function () { }); it('stores a project icon using the instance image storage setting', function () { - Storage::fake('local'); + Storage::fake('images'); $upload = UploadedFile::fake()->createWithContent('project.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg'))); @@ -47,16 +47,16 @@ it('stores a project icon using the instance image storage setting', function () ->and($this->project->icon_storage_type)->toBe('local') ->and($this->project->icon_s3_storage_id)->toBeNull(); - Storage::disk('local')->assertExists($this->project->icon_path); + Storage::disk('images')->assertExists($this->project->icon_path); }); it('serves a project icon only to a member of its team', function () { - Storage::fake('local'); + Storage::fake('images'); $this->project->forceFill([ 'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg", 'icon_storage_type' => 'local', ])->save(); - Storage::disk('local')->put($this->project->icon_path, 'icon-content'); + Storage::disk('images')->put($this->project->icon_path, 'icon-content'); $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) ->assertSuccessful() @@ -73,20 +73,20 @@ it('serves a project icon only to a member of its team', function () { }); it('removes a project icon', function () { - Storage::fake('local'); + Storage::fake('images'); $path = "project-icons/{$this->project->uuid}/icon.jpg"; $this->project->forceFill([ 'icon_path' => $path, 'icon_storage_type' => 'local', ])->save(); - Storage::disk('local')->put($path, 'icon-content'); + Storage::disk('images')->put($path, 'icon-content'); Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid]) ->call('removeIcon') ->assertHasNoErrors(); expect($this->project->refresh()->icon_path)->toBeNull(); - Storage::disk('local')->assertMissing($path); + Storage::disk('images')->assertMissing($path); }); it('exposes the icon URL on the projects index', function () { diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 6466a33ed7..29f611cbaa 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -38,15 +38,22 @@ it('renders copy fields as visible readonly controls with an accessible copy act expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') ->toContain('readonly') - ->toContain("canCopy: window.isSecureContext && typeof navigator.clipboard?.writeText === 'function'") - ->toContain("x-bind:class=\"{ 'input-with-copy-button': canCopy }\"") - ->toContain('x-show="canCopy"') + ->toContain('window.copyToClipboard') + ->toContain('input-with-copy-button') ->toContain('copy-button') ->toContain('aria-label="Copy to clipboard"') ->toContain('title="Copy to clipboard"') ->toContain('class="size-[18px] text-green-500"'); }); +it('uses the shared copy field for newly issued api tokens', function () { + $blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php')); + + expect($blade) + ->toContain('') + ->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))'); +}); + it('keeps copy button padding above settings-workspace input overrides', function () { $css = file_get_contents(resource_path('css/app.css'));