mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
@props(['text', 'label' => null])
|
||||
|
||||
<div class="w-full"
|
||||
x-data="{ copied: false, canCopy: window.isSecureContext && typeof navigator.clipboard?.writeText === 'function' }">
|
||||
<div class="w-full" x-data="{ copied: false }">
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
|
||||
@endif
|
||||
<div class="relative">
|
||||
<input type="text" value="{{ $text }}"
|
||||
class="input bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
x-bind:class="{ 'input-with-copy-button': canCopy }"
|
||||
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
readonly
|
||||
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
|
||||
@focus="$event.target.select()">
|
||||
<button
|
||||
x-show="canCopy"
|
||||
type="button"
|
||||
@click.prevent="copied = true; navigator.clipboard.writeText({{ Js::from($text) }}); setTimeout(() => copied = false, 1000)"
|
||||
@click.prevent="await window.copyToClipboard({{ Js::from($text) }}); copied = true; setTimeout(() => copied = false, 1000)"
|
||||
class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard">
|
||||
|
||||
@@ -109,18 +109,7 @@
|
||||
@if (session()->has('token'))
|
||||
<x-application.settings-section title="Copy your token"
|
||||
description="This value will not be shown again after you leave this page.">
|
||||
<div class="relative" x-data="{ copied: false }">
|
||||
<input type="text" value="{{ session('token') }}" readonly
|
||||
class="input w-full pr-12! font-mono text-[12px] text-black dark:text-fg">
|
||||
<button type="button"
|
||||
x-on:click="copied = true; navigator.clipboard.writeText(@js(session('token'))); setTimeout(() => copied = false, 1200)"
|
||||
class="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
title="Copy token">
|
||||
<x-reicon name="file-content" class="size-3.5" />
|
||||
</button>
|
||||
<span x-cloak x-show="copied"
|
||||
class="absolute top-full right-0 mt-1 text-[10px] text-success">Copied</span>
|
||||
</div>
|
||||
<x-forms.copy-button :text="session('token')" />
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
it('configures a dedicated private disk for persisted images', function () {
|
||||
expect(config('filesystems.disks.images'))
|
||||
->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',
|
||||
]);
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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('<x-forms.copy-button :text="session(\'token\')" />')
|
||||
->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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user