From 968ea69e7df3a166df590e148faa9f9c9eb832d0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:05:51 +0200 Subject: [PATCH 1/6] fix(ui): show loading state on compose validate button Disable the Validate control and show a spinner until validateCompose finishes, then clear state via compose-validate-finished. --- .../livewire/project/service/edit-compose.blade.php | 2 +- .../views/livewire/project/service/stack-form.blade.php | 9 +++++++-- tests/Feature/ComposeEditorLayoutTest.php | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/resources/views/livewire/project/service/edit-compose.blade.php b/resources/views/livewire/project/service/edit-compose.blade.php index 3acbfcc2a..78400cb76 100644 --- a/resources/views/livewire/project/service/edit-compose.blade.php +++ b/resources/views/livewire/project/service/edit-compose.blade.php @@ -1,6 +1,6 @@
diff --git a/resources/views/livewire/project/service/stack-form.blade.php b/resources/views/livewire/project/service/stack-form.blade.php index 65eb0e3fb..89dfa0e5c 100644 --- a/resources/views/livewire/project/service/stack-form.blade.php +++ b/resources/views/livewire/project/service/stack-form.blade.php @@ -12,7 +12,8 @@ -
@@ -20,7 +21,11 @@ @if (blank($service->service_type)) - Validate + + + Validate + @endif diff --git a/tests/Feature/ComposeEditorLayoutTest.php b/tests/Feature/ComposeEditorLayoutTest.php index 953bcc0e0..d8538e82d 100644 --- a/tests/Feature/ComposeEditorLayoutTest.php +++ b/tests/Feature/ComposeEditorLayoutTest.php @@ -13,6 +13,10 @@ it('uses the large modal treatment for the compose editor', function () { ->toContain("\$dispatch('compose-preview-toggle')") ->toContain("\$dispatch('compose-save')") ->toContain('@compose-save-finished.window="saving = false"') + ->toContain('@compose-validate-finished.window="validating = false"') + ->toContain('@click="validating = true; $dispatch(\'compose-validate\')"') + ->toContain('x-bind:disabled="validating"') + ->toContain('') ->toContain('') ->toContain('x-bind:disabled="saving"') ->not->toContain('name="refresh"') @@ -36,6 +40,7 @@ it('renders the compose editor with clear guidance settings and actions', functi ->toContain('min-h-[24rem]') ->toContain('@compose-preview-toggle.window') ->toContain('@compose-save.window') + ->toContain('@compose-validate.window="$wire.validateCompose().finally(() => $dispatch(\'compose-validate-finished\'))"') ->not->toContain("finally(() => \$dispatch('compose-save-finished'))") ->not->toContain('sticky bottom-0') ->not->toContain('Cancel') From 47da47d8e03c019e587cb2e568b74e008c365690 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:18:58 +0200 Subject: [PATCH 2/6] fix(docker): escape Traefik www redirect replacements for Compose Double-escape ${n} in Traefik redirectregex.replacement labels so Docker Compose emits $${n} and Traefik still receives the correct capture groups. --- bootstrap/helpers/docker.php | 4 ++-- tests/Feature/ApplicationParserDockerComposeDomainsTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 52783ff4d..b688ffbb8 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -555,12 +555,12 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; $redirect_to_non_www = [ "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", - "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\${1}://\${2}", + "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false", ]; $redirect_to_www = [ "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", - "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\${1}://www.\${2}", + "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false", ]; if ($schema === 'https') { diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index eca364238..539dba5f2 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -301,7 +301,7 @@ YAML, $parsedCompose = applicationParser($application); $labels = collect(data_get($parsedCompose, 'services.frontend.labels')); - expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=${1}://www.${2}')))->toBeTrue(); + expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=$${1}://www.$${2}')))->toBeTrue(); }); test('compose domain reconciliation preserves stored domains when parsing returns no services', function () { From e50108a90588f5704cdda39f1e41e3d9430f5277 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:31:19 +0200 Subject: [PATCH 3/6] fix(ui): prefer Compose env values and polish error pages When a managed variable shares a Compose-defined key, show the hardcoded Compose value as read-only and exclude it from managed rows. Render Contact support as a button, use the collapsible component for 419 proxy help, and style its list/code markers. --- .../Shared/EnvironmentVariable/All.php | 38 +++++++++++++------ resources/css/app.css | 6 ++- .../views/components/error-page.blade.php | 7 ++-- resources/views/errors/419.blade.php | 6 +-- .../project/application/general.blade.php | 2 - .../project/application/heading.blade.php | 2 - .../Feature/EnvironmentVariableSearchTest.php | 28 ++++++++++++++ tests/Feature/ErrorPagesRedesignTest.php | 13 +++++++ 8 files changed, 78 insertions(+), 24 deletions(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 4c4907200..89130799a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -512,6 +512,11 @@ class All extends Component ->where('resourceable_id', $this->resource->id) ->where('is_preview', $isPreview); + $hardcodedKeys = $this->hardcodedEnvironmentVariableKeys(); + if ($hardcodedKeys !== []) { + $query->whereNotIn('key', $hardcodedKeys); + } + if ($this->serviceFilters !== []) { $query->whereRaw('1 = 0'); } @@ -716,18 +721,6 @@ class All extends Component return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']); }); - // Filter out variables that exist in database (user has overridden/managed them) - // For preview, check against preview variables; for production, check against production variables - if ($isPreview) { - $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray(); - } else { - $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray(); - } - - $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) { - return ! in_array($var['key'], $managedKeys); - }); - if ($this->searchTerm() !== '') { $hardcodedVars = $hardcodedVars->filter(function ($var) { return str($var['key'])->contains($this->searchTerm(), true); @@ -749,6 +742,27 @@ class All extends Component return $hardcodedVars; } + /** @return list */ + private function hardcodedEnvironmentVariableKeys(): array + { + if (! $this->showsHardcodedEnvironmentVariables()) { + return []; + } + + $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; + + if (blank($dockerComposeRaw)) { + return []; + } + + return extractHardcodedEnvironmentVariables($dockerComposeRaw) + ->pluck('key') + ->reject(fn (string $key): bool => str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_'])) + ->unique() + ->values() + ->all(); + } + public function getDevView() { $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false)); diff --git a/resources/css/app.css b/resources/css/app.css index 98e7d3899..bcbabe389 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1207,7 +1207,8 @@ html[data-theme="custom"] textarea:disabled { color: var(--color-accent); } -.error-extra details ul { +.error-extra details ul, +.error-proxy-help ul { margin: 0.5rem 0 0; padding-left: 1.125rem; display: flex; @@ -1215,7 +1216,8 @@ html[data-theme="custom"] textarea:disabled { gap: 0.375rem; } -.error-extra details code { +.error-extra details code, +.error-proxy-help code { font-family: var(--font-mono); font-size: 0.75rem; padding: 0.05rem 0.3rem; diff --git a/resources/views/components/error-page.blade.php b/resources/views/components/error-page.blade.php index 7a29eb0c3..11ca60cc8 100644 --- a/resources/views/components/error-page.blade.php +++ b/resources/views/components/error-page.blade.php @@ -55,10 +55,11 @@ - Contact support - + + Contact support + + @endif
diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index c04efcf59..585e887a5 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -11,14 +11,14 @@ :show-dashboard="false" primary-href="/login" primary-label="Back to login"> -
- Using a reverse proxy or Cloudflare Tunnel? +
  • Set your domain in Settings → FQDN to match the URL you use to access Coolify.
  • Cloudflare users: disable Browser Integrity Check and Under Attack Mode for your Coolify domain, as these can interrupt login sessions.
  • If you can still access Coolify via localhost, log in there first to configure your FQDN.
-
+ + @livewireScripts @endsection diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 6445a155c..b3775a1dd 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -7,8 +7,6 @@ }">
- {{-- Temporarily hidden: the "Compose parser" dev hint and the "View details" - resource-details modal trigger. --}}
@if ($buildPack === 'dockercompose') diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index c524da243..be825792f 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -180,8 +180,6 @@
- {{-- Status badge temporarily hidden — will be redesigned later: - --}} @if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)) Load a Compose file to deploy. @else diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index cc114ae61..a44124ea7 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -182,6 +182,34 @@ YAML, ->toBe(['API_TOKEN']); }); +it('shows a Compose-defined value as read-only when a managed variable has the same key', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + - API_TOKEN=from-compose +YAML, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'from-environment-tab', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables'); + + expect($component->instance()->environmentVariablePageRows) + ->toHaveCount(1) + ->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('hardcoded') + ->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose'); +}); + it('searches service environment variables without requiring preview variables', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/Feature/ErrorPagesRedesignTest.php b/tests/Feature/ErrorPagesRedesignTest.php index a54a7e3a1..8d1c7ae32 100644 --- a/tests/Feature/ErrorPagesRedesignTest.php +++ b/tests/Feature/ErrorPagesRedesignTest.php @@ -57,9 +57,22 @@ it('uses login as primary action on session expired page', function () { ->toContain('/login') ->toContain('Back to login') ->toContain('Using a reverse proxy or Cloudflare Tunnel?') + ->toContain('x-data="{ open: false }"') + ->toContain('x-on:click="open = !open"') + ->toContain('livewire.js') ->not->toContain('>Dashboard $exception])->render(); + + expect($html) + ->toContain('href="'.config('constants.urls.contact').'"') + ->toMatch('/]*>\s*Contact support/s'); +}); + it('shows purified exception message on 500 page', function () { $exception = new RuntimeException('Database connection failed'); From 51561e4bee710fd095483b20be0f8e36a8c9d756 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:39:56 +0200 Subject: [PATCH 4/6] feat(profile): add uploadable avatars with local/S3 storage Users can upload and remove profile pictures on the profile page. Admins choose local or instance S3 storage in advanced settings. Avatars are compressed to JPEG and served via a private cached route. --- .../Controllers/ProfileAvatarController.php | 20 +++ app/Livewire/Profile/Index.php | 33 ++++ app/Livewire/Settings/Advanced.php | 44 ++++++ app/Models/InstanceSettings.php | 2 + app/Models/S3Storage.php | 32 ++-- app/Models/User.php | 3 + app/Services/AvatarStorageService.php | 147 ++++++++++++++++++ ...5535_add_avatar_columns_to_users_table.php | 31 ++++ ...ge_settings_to_instance_settings_table.php | 30 ++++ openapi.json | 40 +++++ openapi.yaml | 8 +- .../views/components/top-user-menu.blade.php | 31 ++-- .../views/livewire/profile/index.blade.php | 96 ++++++++++++ .../livewire/settings/advanced.blade.php | 13 ++ .../views/livewire/terminal/index.blade.php | 9 +- routes/web.php | 2 + templates/service-templates-latest.json | 8 +- templates/service-templates.json | 8 +- tests/Feature/ProfileAvatarTest.php | 131 ++++++++++++++++ .../Feature/RealtimeTerminalPackagingTest.php | 11 ++ tests/Feature/SentinelUnsavedBarFlashTest.php | 2 +- tests/Feature/TerminalPageHeaderTest.php | 9 ++ tests/Feature/TopUserMenuTest.php | 11 +- 23 files changed, 677 insertions(+), 44 deletions(-) create mode 100644 app/Http/Controllers/ProfileAvatarController.php create mode 100644 app/Services/AvatarStorageService.php create mode 100644 database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php create mode 100644 database/migrations/2026_08_07_185535_add_avatar_storage_settings_to_instance_settings_table.php create mode 100644 tests/Feature/ProfileAvatarTest.php diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php new file mode 100644 index 000000000..2cf01400e --- /dev/null +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -0,0 +1,20 @@ +contents(auth()->user()); + abort_if($contents === null, 404); + + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=300', + ]); + } +} diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 970593663..99c2567f2 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,15 +2,19 @@ namespace App\Livewire\Profile; +use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; +use Livewire\WithFileUploads; class Index extends Component { + use WithFileUploads; + public int $userId; public string $email; @@ -32,6 +36,35 @@ class Index extends Component public bool $show_verification = false; + public $avatar; + + public function uploadAvatar(AvatarStorageService $avatarStorage): void + { + try { + $this->validate([ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'], + ]); + + $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('success', 'Profile picture updated.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeAvatar(AvatarStorageService $avatarStorage): void + { + try { + $avatarStorage->delete(Auth::user()); + $this->dispatch('avatar-updated', url: null); + $this->dispatch('success', 'Profile picture removed.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function mount() { $this->userId = Auth::id(); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 754d563a8..fd5ee616d 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; +use App\Models\S3Storage; use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -50,6 +51,10 @@ class Advanced extends Component public ?string $domain_connect_private_key = null; + public string $avatar_storage = 'local'; + + public array $avatar_storage_options = []; + public function rules() { return [ @@ -89,6 +94,21 @@ class Advanced extends Component $this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false; // Do not prefill the secret into the form; only update when the admin pastes a new value. $this->domain_connect_private_key = null; + $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id + ? 's3:'.$this->settings->avatar_s3_storage_id + : 'local'; + $this->avatar_storage_options = [ + ['value' => 'local', 'label' => 'Local storage'], + ...S3Storage::query() + ->whereTeamId(0) + ->where('is_usable', true) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (S3Storage $storage): array => [ + 'value' => 's3:'.$storage->id, + 'label' => $storage->name.' (S3)', + ])->all(), + ]; } public function submit() @@ -190,6 +210,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { @@ -197,6 +218,29 @@ class Advanced extends Component } } + private function saveAvatarStorageSetting(): void + { + if ($this->avatar_storage === 'local') { + $this->settings->avatar_storage_type = 'local'; + $this->settings->avatar_s3_storage_id = null; + + return; + } + + $storageId = (int) str($this->avatar_storage)->after('s3:')->value(); + $storage = S3Storage::query() + ->whereTeamId(0) + ->where('is_usable', true) + ->find($storageId); + + if (! $storage || $this->avatar_storage !== 's3:'.$storage->id) { + throw new \InvalidArgumentException('The selected avatar storage is not available.'); + } + + $this->settings->avatar_storage_type = 's3'; + $this->settings->avatar_s3_storage_id = $storage->id; + } + public function clearDomainConnectPrivateKey(): void { try { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 20e3484db..877fc5b12 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -49,6 +49,8 @@ class InstanceSettings extends Model 'is_mcp_server_enabled', 'webhook_allowed_internal_hosts', 'webhook_allow_localhost', + 'avatar_storage_type', + 'avatar_s3_storage_id', ]; protected $hidden = [ diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 518159168..e8e1788e3 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -7,6 +7,7 @@ use App\Rules\ValidS3BucketName; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; @@ -184,19 +185,7 @@ class S3Storage extends BaseModel throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket')); } - $disk = Storage::build([ - 'driver' => 's3', - 'region' => $this['region'], - 'key' => $this['key'], - 'secret' => $this['secret'], - 'bucket' => $this['bucket'], - 'endpoint' => $this['endpoint'], - 'use_path_style_endpoint' => true, - 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ - 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, - 'timeout' => self::REQUEST_TIMEOUT_SECONDS, - ]), - ]); + $disk = $this->filesystem(); // Test the connection by listing files with ListObjectsV2 (S3) $disk->files(); @@ -235,6 +224,23 @@ class S3Storage extends BaseModel } } + public function filesystem(): FilesystemAdapter + { + return Storage::build([ + 'driver' => 's3', + 'region' => $this['region'], + 'key' => $this['key'], + 'secret' => $this['secret'], + 'bucket' => $this['bucket'], + 'endpoint' => $this['endpoint'], + 'use_path_style_endpoint' => true, + 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ + 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ]), + ]); + } + /** * The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination. * diff --git a/app/Models/User.php b/app/Models/User.php index b59b553d9..5b3847396 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,6 +53,9 @@ class User extends Authenticatable implements SendsEmail 'pending_email', 'email_change_code', 'email_change_code_expires_at', + 'avatar_path', + 'avatar_storage_type', + 'avatar_s3_storage_id', ]; protected $hidden = [ diff --git a/app/Services/AvatarStorageService.php b/app/Services/AvatarStorageService.php new file mode 100644 index 000000000..3266d0719 --- /dev/null +++ b/app/Services/AvatarStorageService.php @@ -0,0 +1,147 @@ +avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local'; + $s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null; + $disk = $this->disk($storageType, $s3StorageId); + $path = "avatars/{$user->id}/avatar.jpg"; + $contents = $this->compress($upload); + + if (! $disk->put($path, $contents)) { + throw new RuntimeException('Unable to store the profile picture.'); + } + + $oldStorageType = $user->avatar_storage_type; + $oldS3StorageId = $user->avatar_s3_storage_id; + $oldPath = $user->avatar_path; + + $user->update([ + 'avatar_path' => $path, + 'avatar_storage_type' => $storageType, + 'avatar_s3_storage_id' => $s3StorageId, + ]); + + if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) { + $this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath); + } + } + + public function contents(User $user): ?string + { + if (! $user->avatar_path) { + return null; + } + + try { + $disk = $this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id); + } catch (RuntimeException) { + return null; + } + + return $disk->exists($user->avatar_path) ? $disk->get($user->avatar_path) : null; + } + + public function delete(User $user): void + { + if ($user->avatar_path) { + $this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id) + ->delete($user->avatar_path); + } + + $user->update([ + 'avatar_path' => null, + 'avatar_storage_type' => null, + 'avatar_s3_storage_id' => null, + ]); + } + + private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter + { + if ($storageType !== 's3') { + return Storage::disk('local'); + } + + $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first(); + if (! $storage) { + throw new RuntimeException('The configured S3 storage is not available.'); + } + + return $storage->filesystem(); + } + + private function compress(UploadedFile $upload): string + { + $imageInfo = getimagesize($upload->getRealPath()); + if ($imageInfo && $imageInfo['mime'] === 'image/jpeg' && $imageInfo[0] <= 256 && $imageInfo[1] <= 256) { + return file_get_contents($upload->getRealPath()); + } + + if (extension_loaded('imagick')) { + $image = new \Imagick($upload->getRealPath()); + $image->setIteratorIndex(0); + $image->autoOrient(); + $image->cropThumbnailImage(256, 256); + $image->stripImage(); + $image->setImageBackgroundColor('white'); + $image = $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); + $image->setImageFormat('jpeg'); + $image->setImageCompressionQuality(80); + $contents = $image->getImagesBlob(); + $image->clear(); + + return $contents; + } + + if (! function_exists('imagecreatefromstring') || ! function_exists('imagejpeg')) { + throw new RuntimeException('ImageMagick or GD is required to process profile pictures that were not compressed by the browser.'); + } + + $source = imagecreatefromstring(file_get_contents($upload->getRealPath())); + if (! $source) { + throw new RuntimeException('Unable to read the uploaded profile picture.'); + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + $cropSize = min($sourceWidth, $sourceHeight); + $target = imagecreatetruecolor(256, 256); + imagefill($target, 0, 0, imagecolorallocate($target, 255, 255, 255)); + imagecopyresampled( + $target, + $source, + 0, + 0, + (int) (($sourceWidth - $cropSize) / 2), + (int) (($sourceHeight - $cropSize) / 2), + 256, + 256, + $cropSize, + $cropSize, + ); + + ob_start(); + imagejpeg($target, null, 80); + $contents = ob_get_clean(); + imagedestroy($source); + imagedestroy($target); + + if (! is_string($contents)) { + throw new RuntimeException('Unable to compress the profile picture.'); + } + + return $contents; + } +} diff --git a/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php b/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php new file mode 100644 index 000000000..c4ab23282 --- /dev/null +++ b/database/migrations/2026_08_07_185535_add_avatar_columns_to_users_table.php @@ -0,0 +1,31 @@ +string('avatar_path')->nullable(); + $table->string('avatar_storage_type')->nullable(); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn(['avatar_path', 'avatar_storage_type']); + }); + } +}; diff --git a/database/migrations/2026_08_07_185535_add_avatar_storage_settings_to_instance_settings_table.php b/database/migrations/2026_08_07_185535_add_avatar_storage_settings_to_instance_settings_table.php new file mode 100644 index 000000000..501a1e4a9 --- /dev/null +++ b/database/migrations/2026_08_07_185535_add_avatar_storage_settings_to_instance_settings_table.php @@ -0,0 +1,30 @@ +string('avatar_storage_type')->default('local'); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn('avatar_storage_type'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 4e9a12d1f..372e0000c 100644 --- a/openapi.json +++ b/openapi.json @@ -366,6 +366,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -935,6 +945,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -1504,6 +1524,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" @@ -3122,6 +3152,16 @@ "domain": { "type": "string", "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index dd98f718d..4ff4b12a8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -260,7 +260,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -638,7 +638,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -1016,7 +1016,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' @@ -2100,7 +2100,7 @@ paths: docker_compose_domains: type: array description: 'Array of URLs to be applied to containers of a dockercompose application.' - items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' } }, type: object } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } watch_paths: type: string description: 'The watch paths.' diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index 58d1fe4a4..d661825f4 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -13,10 +13,16 @@ appearanceOpen: false, theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), themeColor: localStorage.getItem('themeColor') || '#6b16ed', - setTheme(type) { + avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null), + setTheme(type, closeMenu = true) { this.theme = type; localStorage.setItem('theme', type); + if (closeMenu) { + this.appearanceOpen = false; + this.open = false; + } + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const isDark = type === 'dark' || type === 'custom' || (type === 'system' && prefersDark); document.documentElement.classList.toggle('dark', isDark); @@ -27,9 +33,9 @@ }, setThemeColor() { localStorage.setItem('themeColor', this.themeColor); - this.setTheme('custom'); + this.setTheme('custom', false); }, -}" @keydown.escape.window="open = false; appearanceOpen = false" +}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false" @click.outside="open = false; appearanceOpen = false"> -
! $sidebar, - 'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar, - ])> +
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index a5aec250e..090be9fd9 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -8,6 +8,102 @@ @close-email-change-modal.window="emailModalOpen = false"> Profile | Coolify
+
+
+
+

Profile picture

+

Upload a JPG, PNG, or WebP image.

+
+
+
+
+ Profile picture preview + @if (auth()->user()->avatar_path) + {{ auth()->user()->name }} + @else + + {{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }} + + @endif +
+
+ +

+ @error('avatar') +

{{ $message }}

+ @enderror +
+ + Upload picture + Compressing… + + @if (auth()->user()->avatar_path) + Remove + @endif +
+
+
+
+
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index ae70c8536..607c5c4a2 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -134,6 +134,19 @@ ]" />
+ + +
+ +
+ @if (count($avatar_storage_options) === 1) + + Add and test an S3-compatible storage under Storages before selecting it here. + + @endif +
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 6939049f9..4512f48a3 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -98,12 +98,11 @@ this.targetOpen = false; this.targetSearch = ''; await $wire.set('selected_uuid', target.value); - await $wire.connectToContainer(); } }"> @if ($selected_uuid === 'default')
@@ -111,8 +110,8 @@ :theme-accents="$consoleThemeAccents" />
-
+ class="terminal-target-picker z-10 flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]"> +

Start a terminal session

{{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }} @@ -126,7 +125,7 @@

@endif
-
+
@if ($isLoadingContainers)
diff --git a/routes/web.php b/routes/web.php index 49a87790c..40869b4b9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\OauthController; +use App\Http\Controllers\ProfileAvatarController; use App\Http\Controllers\UploadController; use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Boarding\Index as BoardingIndex; @@ -159,6 +160,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); + Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); Route::prefix('tags')->group(function () { diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 71d7da29c..a4d1fb655 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -421,7 +421,7 @@ "buzz": { "documentation": "https://github.com/block/buzz?utm_source=coolify.io", "slogan": "Self-hostable workspace where humans and AI agents collaborate on a Nostr relay you own.", - "compose": "c2VydmljZXM6CiAgYnV6ei1naXQtaW5pdDoKICAgIGltYWdlOiAnYWxwaW5lOjMuMjEnCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LWdpdC1kYXRhOi9kYXRhL2dpdCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctZWMnCiAgICAgIC0gJ2Nob3duIC1SIDEwMDA6MTAwMCAvZGF0YS9naXQnCiAgICByZXN0YXJ0OiAnbm8nCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBidXp6OgogICAgaW1hZ2U6ICdnaGNyLmlvL2Jsb2NrL2J1eno6JHtCVVpaX1RBRzotbWFpbn0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CVVpaXzMwMDAKICAgICAgLSAnQlVaWl9CSU5EX0FERFI9MC4wLjAuMDozMDAwJwogICAgICAtIEJVWlpfSEVBTFRIX1BPUlQ9ODA4MAogICAgICAtIEJVWlpfTUVUUklDU19QT1JUPTkxMDIKICAgICAgLSAnUkVMQVlfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdCVVpaX01FRElBX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfQlVaWn0vbWVkaWEnCiAgICAgIC0gJ0JVWlpfTUVESUFfU0VSVkVSX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9DT1JTX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9CVVpafScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQlVaWl9TM19FTkRQT0lOVD1odHRwOi8vbWluaW86OTAwMCcKICAgICAgLSAnQlVaWl9TM19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQlVaWl9TM19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfQlVDS0VUPSR7QlVaWl9TM19CVUNLRVQ6LWJ1enotbWVkaWF9JwogICAgICAtIEJVWlpfR0lUX1JFUE9fUEFUSD0vZGF0YS9naXQKICAgICAgLSAnQlVaWl9SRUxBWV9QUklWQVRFX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X1JFTEFZS0VZfScKICAgICAgLSAnQlVaWl9HSVRfSE9PS19ITUFDX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X0dJVEhNQUN9JwogICAgICAtICdCVVpaX0FVVE9fTUlHUkFURT0ke0JVWlpfQVVUT19NSUdSQVRFOi10cnVlfScKICAgICAgLSAnQlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU9JHtCVVpaX0dJVF9DT05GT1JNQU5DRV9QUk9CRTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOPSR7QlVaWl9SRVFVSVJFX0FVVEhfVE9LRU46LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUD0ke0JVWlpfUkVRVUlSRV9SRUxBWV9NRU1CRVJTSElQOi1mYWxzZX0nCiAgICAgIC0gJ0JVWlpfQUxMT1dfTklQX09BX0FVVEg9JHtCVVpaX0FMTE9XX05JUF9PQV9BVVRIOi10cnVlfScKICAgICAgLSAnUkVMQVlfT1dORVJfUFVCS0VZPSR7UkVMQVlfT1dORVJfUFVCS0VZfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotYnV6el9yZWxheT1pbmZvLGJ1enpfZGI9aW5mbyxidXp6X2F1dGg9aW5mbyxidXp6X3B1YnN1Yj1pbmZvLHRvd2VyX2h0dHA9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LWdpdC1kYXRhOi9kYXRhL2dpdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvLWluaXQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgYnV6ei1naXQtaW5pdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTo4MDgwL19yZWFkaW5lc3MgPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA0NXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSBQR0RBVEE9L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhL3BnZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tYXBwZW5kb25seScKICAgICAgLSAneWVzJwogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3JlZGlzLWNsaSAtYSAiJCR7UkVESVNfUEFTU1dPUkR9IiBwaW5nIHwgZ3JlcCAtcSBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIG1pbmlvOgogICAgaW1hZ2U6ICdtaW5pby9taW5pbzpSRUxFQVNFLjIwMjUtMDktMDdUMTYtMTMtMDlaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwL21pbmlvL2hlYWx0aC9saXZlJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpSRUxFQVNFLjIwMjUtMDgtMTNUMDgtMzUtNDFaJwogICAgZGVwZW5kc19vbjoKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdCVVpaX1MzX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdCVVpaX1MzX1NFQ1JFVF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSAnQlVaWl9TM19CVUNLRVQ9JHtCVVpaX1MzX0JVQ0tFVDotYnV6ei1tZWRpYX0nCiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWV1YycKICAgICAgLSAibWMgYWxpYXMgc2V0IGxvY2FsIGh0dHA6Ly9taW5pbzo5MDAwIFwiJCR7QlVaWl9TM19BQ0NFU1NfS0VZfVwiIFwiJCR7QlVaWl9TM19TRUNSRVRfS0VZfVwiXG5tYyBtYiAtLWlnbm9yZS1leGlzdGluZyBcImxvY2FsLyQke0JVWlpfUzNfQlVDS0VUfVwiXG5tYyBhbm9ueW1vdXMgc2V0IG5vbmUgXCJsb2NhbC8kJHtCVVpaX1MzX0JVQ0tFVH1cIiIKICAgIHJlc3RhcnQ6ICdubycKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQo=", + "compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQlVaWl8zMDAwCiAgICAgIC0gJ0JVWlpfQklORF9BRERSPTAuMC4wLjA6MzAwMCcKICAgICAgLSBCVVpaX0hFQUxUSF9QT1JUPTgwODAKICAgICAgLSBCVVpaX01FVFJJQ1NfUE9SVD05MTAyCiAgICAgIC0gJ1JFTEFZX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9NRURJQV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0JVWlp9L21lZGlhJwogICAgICAtICdCVVpaX01FRElBX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfQ09SU19PUklHSU5TPSR7U0VSVklDRV9VUkxfQlVaWn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotYnV6en0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0JVWlpfUzNfRU5EUE9JTlQ9aHR0cDovL21pbmlvOjkwMDAnCiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgICAgLSBCVVpaX0dJVF9SRVBPX1BBVEg9L2RhdGEvZ2l0CiAgICAgIC0gJ0JVWlpfUkVMQVlfUFJJVkFURV9LRVk9JHtTRVJWSUNFX0hFWF82NF9SRUxBWUtFWX0nCiAgICAgIC0gJ0JVWlpfR0lUX0hPT0tfSE1BQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9HSVRITUFDfScKICAgICAgLSAnQlVaWl9BVVRPX01JR1JBVEU9JHtCVVpaX0FVVE9fTUlHUkFURTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFPSR7QlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU6LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTj0ke0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA9JHtCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUDotZmFsc2V9JwogICAgICAtICdCVVpaX0FMTE9XX05JUF9PQV9BVVRIPSR7QlVaWl9BTExPV19OSVBfT0FfQVVUSDotdHJ1ZX0nCiAgICAgIC0gJ1JFTEFZX09XTkVSX1BVQktFWT0ke1JFTEFZX09XTkVSX1BVQktFWX0nCiAgICAgIC0gJ1JVU1RfTE9HPSR7UlVTVF9MT0c6LWJ1enpfcmVsYXk9aW5mbyxidXp6X2RiPWluZm8sYnV6el9hdXRoPWluZm8sYnV6el9wdWJzdWI9aW5mbyx0b3dlcl9odHRwPWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1naXQtZGF0YTovZGF0YS9naXQnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pby1pbml0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWZzUyBodHRwOi8vMTI3LjAuMC4xOjgwODAvX3JlYWRpbmVzcyA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDQ1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtIFBHREFUQT0vdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEvcGdkYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEyCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1hcHBlbmRvbmx5JwogICAgICAtICd5ZXMnCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICIkJHtSRURJU19QQVNTV09SRH0iIHBpbmcgfCBncmVwIC1xIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgbWluaW86CiAgICBpbWFnZTogJ21pbmlvL21pbmlvOlJFTEVBU0UuMjAyNS0wOS0wN1QxNi0xMy0wOVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9kYXRhIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1taW5pby1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjkwMDAvbWluaW8vaGVhbHRoL2xpdmUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogJ21pbmlvL21jOlJFTEVBU0UuMjAyNS0wOC0xM1QwOC0zNS00MVonCiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctZXVjJwogICAgICAtICJtYyBhbGlhcyBzZXQgbG9jYWwgaHR0cDovL21pbmlvOjkwMDAgXCIkJHtCVVpaX1MzX0FDQ0VTU19LRVl9XCIgXCIkJHtCVVpaX1MzX1NFQ1JFVF9LRVl9XCJcbm1jIG1iIC0taWdub3JlLWV4aXN0aW5nIFwibG9jYWwvJCR7QlVaWl9TM19CVUNLRVR9XCJcbm1jIGFub255bW91cyBzZXQgbm9uZSBcImxvY2FsLyQke0JVWlpfUzNfQlVDS0VUfVwiIgogICAgcmVzdGFydDogJ25vJwogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCg==", "tags": [ "buzz", "chat", @@ -435,7 +435,7 @@ "category": "messaging", "logo": "svgs/buzz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-07-23T18:52:29+02:00", + "template_last_updated_at": "2026-07-23T17:25:53+02:00", "port": "3000" }, "calibre-web-automated-book-downloader": { @@ -2764,7 +2764,7 @@ "category": "devtools", "logo": "svgs/librespeed.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15+00:00", + "template_last_updated_at": "2026-02-25T23:48:15Z", "port": "82" }, "libretranslate": { @@ -2901,7 +2901,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47+00:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", diff --git a/templates/service-templates.json b/templates/service-templates.json index 002017348..d9500219f 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -421,7 +421,7 @@ "buzz": { "documentation": "https://github.com/block/buzz?utm_source=coolify.io", "slogan": "Self-hostable workspace where humans and AI agents collaborate on a Nostr relay you own.", - "compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JVWlpfMzAwMAogICAgICAtICdCVVpaX0JJTkRfQUREUj0wLjAuMC4wOjMwMDAnCiAgICAgIC0gQlVaWl9IRUFMVEhfUE9SVD04MDgwCiAgICAgIC0gQlVaWl9NRVRSSUNTX1BPUlQ9OTEwMgogICAgICAtICdSRUxBWV9VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfTUVESUFfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9CVVpafS9tZWRpYScKICAgICAgLSAnQlVaWl9NRURJQV9TRVJWRVJfRE9NQUlOPSR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdCVVpaX0NPUlNfT1JJR0lOUz1odHRwczovLyR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly86JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdCVVpaX1MzX0VORFBPSU5UPWh0dHA6Ly9taW5pbzo5MDAwJwogICAgICAtICdCVVpaX1MzX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdCVVpaX1MzX1NFQ1JFVF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSAnQlVaWl9TM19CVUNLRVQ9JHtCVVpaX1MzX0JVQ0tFVDotYnV6ei1tZWRpYX0nCiAgICAgIC0gQlVaWl9HSVRfUkVQT19QQVRIPS9kYXRhL2dpdAogICAgICAtICdCVVpaX1JFTEFZX1BSSVZBVEVfS0VZPSR7U0VSVklDRV9IRVhfNjRfUkVMQVlLRVl9JwogICAgICAtICdCVVpaX0dJVF9IT09LX0hNQUNfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfR0lUSE1BQ30nCiAgICAgIC0gJ0JVWlpfQVVUT19NSUdSQVRFPSR7QlVaWl9BVVRPX01JR1JBVEU6LXRydWV9JwogICAgICAtICdCVVpaX0dJVF9DT05GT1JNQU5DRV9QUk9CRT0ke0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX0FVVEhfVE9LRU49JHtCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTjotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfUkVRVUlSRV9SRUxBWV9NRU1CRVJTSElQPSR7QlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA6LWZhbHNlfScKICAgICAgLSAnQlVaWl9BTExPV19OSVBfT0FfQVVUSD0ke0JVWlpfQUxMT1dfTklQX09BX0FVVEg6LXRydWV9JwogICAgICAtICdSRUxBWV9PV05FUl9QVUJLRVk9JHtSRUxBWV9PV05FUl9QVUJLRVl9JwogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1idXp6X3JlbGF5PWluZm8sYnV6el9kYj1pbmZvLGJ1enpfYXV0aD1pbmZvLGJ1enpfcHVic3ViPWluZm8sdG93ZXJfaHR0cD1pbmZvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotZ2l0LWRhdGE6L2RhdGEvZ2l0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW8taW5pdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTo4MDgwL19yZWFkaW5lc3MgPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA0NXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSBQR0RBVEE9L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhL3BnZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tYXBwZW5kb25seScKICAgICAgLSAneWVzJwogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3JlZGlzLWNsaSAtYSAiJCR7UkVESVNfUEFTU1dPUkR9IiBwaW5nIHwgZ3JlcCAtcSBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIG1pbmlvOgogICAgaW1hZ2U6ICdtaW5pby9taW5pbzpSRUxFQVNFLjIwMjUtMDktMDdUMTYtMTMtMDlaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwL21pbmlvL2hlYWx0aC9saXZlJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpSRUxFQVNFLjIwMjUtMDgtMTNUMDgtMzUtNDFaJwogICAgZGVwZW5kc19vbjoKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdCVVpaX1MzX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdCVVpaX1MzX1NFQ1JFVF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSAnQlVaWl9TM19CVUNLRVQ9JHtCVVpaX1MzX0JVQ0tFVDotYnV6ei1tZWRpYX0nCiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWV1YycKICAgICAgLSAibWMgYWxpYXMgc2V0IGxvY2FsIGh0dHA6Ly9taW5pbzo5MDAwIFwiJCR7QlVaWl9TM19BQ0NFU1NfS0VZfVwiIFwiJCR7QlVaWl9TM19TRUNSRVRfS0VZfVwiXG5tYyBtYiAtLWlnbm9yZS1leGlzdGluZyBcImxvY2FsLyQke0JVWlpfUzNfQlVDS0VUfVwiXG5tYyBhbm9ueW1vdXMgc2V0IG5vbmUgXCJsb2NhbC8kJHtCVVpaX1MzX0JVQ0tFVH1cIiIKICAgIHJlc3RhcnQ6ICdubycKICAgIGV4Y2x1ZGVfZnJvbV9oYzogdHJ1ZQo=", + "compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JVWlpfMzAwMAogICAgICAtICdCVVpaX0JJTkRfQUREUj0wLjAuMC4wOjMwMDAnCiAgICAgIC0gQlVaWl9IRUFMVEhfUE9SVD04MDgwCiAgICAgIC0gQlVaWl9NRVRSSUNTX1BPUlQ9OTEwMgogICAgICAtICdSRUxBWV9VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfTUVESUFfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fQlVaWn0vbWVkaWEnCiAgICAgIC0gJ0JVWlpfTUVESUFfU0VSVkVSX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9DT1JTX09SSUdJTlM9JHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotYnV6en0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0JVWlpfUzNfRU5EUE9JTlQ9aHR0cDovL21pbmlvOjkwMDAnCiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgICAgLSBCVVpaX0dJVF9SRVBPX1BBVEg9L2RhdGEvZ2l0CiAgICAgIC0gJ0JVWlpfUkVMQVlfUFJJVkFURV9LRVk9JHtTRVJWSUNFX0hFWF82NF9SRUxBWUtFWX0nCiAgICAgIC0gJ0JVWlpfR0lUX0hPT0tfSE1BQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9HSVRITUFDfScKICAgICAgLSAnQlVaWl9BVVRPX01JR1JBVEU9JHtCVVpaX0FVVE9fTUlHUkFURTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFPSR7QlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU6LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTj0ke0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA9JHtCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUDotZmFsc2V9JwogICAgICAtICdCVVpaX0FMTE9XX05JUF9PQV9BVVRIPSR7QlVaWl9BTExPV19OSVBfT0FfQVVUSDotdHJ1ZX0nCiAgICAgIC0gJ1JFTEFZX09XTkVSX1BVQktFWT0ke1JFTEFZX09XTkVSX1BVQktFWX0nCiAgICAgIC0gJ1JVU1RfTE9HPSR7UlVTVF9MT0c6LWJ1enpfcmVsYXk9aW5mbyxidXp6X2RiPWluZm8sYnV6el9hdXRoPWluZm8sYnV6el9wdWJzdWI9aW5mbyx0b3dlcl9odHRwPWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1naXQtZGF0YTovZGF0YS9naXQnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pby1pbml0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWZzUyBodHRwOi8vMTI3LjAuMC4xOjgwODAvX3JlYWRpbmVzcyA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDQ1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtIFBHREFUQT0vdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEvcGdkYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEyCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1hcHBlbmRvbmx5JwogICAgICAtICd5ZXMnCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICIkJHtSRURJU19QQVNTV09SRH0iIHBpbmcgfCBncmVwIC1xIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgbWluaW86CiAgICBpbWFnZTogJ21pbmlvL21pbmlvOlJFTEVBU0UuMjAyNS0wOS0wN1QxNi0xMy0wOVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9kYXRhIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1taW5pby1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjkwMDAvbWluaW8vaGVhbHRoL2xpdmUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogJ21pbmlvL21jOlJFTEVBU0UuMjAyNS0wOC0xM1QwOC0zNS00MVonCiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctZXVjJwogICAgICAtICJtYyBhbGlhcyBzZXQgbG9jYWwgaHR0cDovL21pbmlvOjkwMDAgXCIkJHtCVVpaX1MzX0FDQ0VTU19LRVl9XCIgXCIkJHtCVVpaX1MzX1NFQ1JFVF9LRVl9XCJcbm1jIG1iIC0taWdub3JlLWV4aXN0aW5nIFwibG9jYWwvJCR7QlVaWl9TM19CVUNLRVR9XCJcbm1jIGFub255bW91cyBzZXQgbm9uZSBcImxvY2FsLyQke0JVWlpfUzNfQlVDS0VUfVwiIgogICAgcmVzdGFydDogJ25vJwogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCg==", "tags": [ "buzz", "chat", @@ -435,7 +435,7 @@ "category": "messaging", "logo": "svgs/buzz.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-23T17:25:53+02:00", "port": "3000" }, "calibre-web-automated-book-downloader": { @@ -2764,7 +2764,7 @@ "category": "devtools", "logo": "svgs/librespeed.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15+00:00", + "template_last_updated_at": "2026-02-25T23:48:15Z", "port": "82" }, "libretranslate": { @@ -2901,7 +2901,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47+00:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php new file mode 100644 index 000000000..04c158a34 --- /dev/null +++ b/tests/Feature/ProfileAvatarTest.php @@ -0,0 +1,131 @@ + InstanceSettings::create([ + 'id' => 0, + 'avatar_storage_type' => 'local', + ])); +}); + +it('compresses and stores an uploaded profile picture on the configured local storage', function () { + Storage::fake('local'); + $user = User::factory()->create(['name' => 'Test User']); + $this->actingAs($user); + + Livewire::test(Index::class) + ->set('avatar', UploadedFile::fake()->image('profile.png', 1200, 900)) + ->call('uploadAvatar') + ->assertHasNoErrors(); + + $user->refresh(); + + expect($user->avatar_path) + ->toBe("avatars/{$user->id}/avatar.jpg") + ->and($user->avatar_storage_type)->toBe('local') + ->and($user->avatar_s3_storage_id)->toBeNull(); + + Storage::disk('local')->assertExists($user->avatar_path); + + $image = getimagesizefromstring(Storage::disk('local')->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); +}); + +it('stores an already compressed browser JPEG without server image extensions', function () { + Storage::fake('local'); + $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'); + file_put_contents($path, $contents); + $upload = new UploadedFile($path, 'avatar.jpg', 'image/jpeg', null, true); + + app(AvatarStorageService::class)->store($user, $upload); + + expect(Storage::disk('local')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents); +}); + +it('serves the authenticated users profile picture', function () { + Storage::fake('local'); + $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'); + + $this->withoutMiddleware()->actingAs($user) + ->get(route('profile.avatar')) + ->assertSuccessful() + ->assertHeader('content-type', 'image/jpeg'); +}); + +it('removes the current profile picture', function () { + Storage::fake('local'); + $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'); + $this->actingAs($user); + + Livewire::test(Index::class) + ->call('removeAvatar') + ->assertHasNoErrors(); + + expect($user->refresh()->avatar_path)->toBeNull(); + Storage::disk('local')->assertMissing('avatars/1/avatar.jpg'); +}); + +it('falls back cleanly when the avatars S3 storage no longer exists', function () { + $user = User::factory()->create([ + 'name' => 'Test User', + 'avatar_path' => 'avatars/1/avatar.webp', + 'avatar_storage_type' => 's3', + 'avatar_s3_storage_id' => null, + ]); + + $this->withoutMiddleware()->actingAs($user) + ->get(route('profile.avatar')) + ->assertNotFound(); +}); + +it('renders the profile upload and user menu avatar', function () { + $profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php')); + $menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php')); + + expect($profile) + ->toContain("this.\$wire.upload('avatar', compressed") + ->toContain('canvas.toBlob') + ->toContain('wire:click="uploadAvatar"') + ->and($menu) + ->toContain("route('profile.avatar',"); +}); + +it('offers runtime local or existing S3 profile picture storage', function () { + $component = file_get_contents(app_path('Livewire/Settings/Advanced.php')); + $view = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php')); + + expect($component) + ->toContain("['value' => 'local', 'label' => 'Local storage']") + ->toContain("'value' => 's3:'.\$storage->id") + ->toContain('->whereTeamId(0)') + ->toContain("->where('is_usable', true)") + ->and($view) + ->toContain('id="avatar_storage"') + ->toContain('Use S3 for multi-instance or cloud deployments'); +}); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index 2295531cc..1de4296aa 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -144,6 +144,17 @@ it('shows connection progress in the terminal body instead of the header', funct ->toContain("this.starting = this.\$el.dataset.autoStart === 'true';"); }); +it('starts the global terminal only once when a target is selected', function () { + $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php')); + $component = file_get_contents(app_path('Livewire/Terminal/Index.php')); + + expect($view) + ->toContain("await \$wire.set('selected_uuid', target.value);") + ->not->toContain('await $wire.connectToContainer();') + ->and($component) + ->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s'); +}); + it('uses the redesigned terminal canvas and controls on resource terminal pages', function () { $view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php')); diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php index 7a545f92c..e19cfef92 100644 --- a/tests/Feature/SentinelUnsavedBarFlashTest.php +++ b/tests/Feature/SentinelUnsavedBarFlashTest.php @@ -108,7 +108,7 @@ test('settings advanced unsaved bar scopes dirty tracking away from instantSave expect($contents) ->toContain('x-unsaved-bar') - ->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost"') + ->toContain('targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key"') ->toContain('onChange="instantSave"') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); diff --git a/tests/Feature/TerminalPageHeaderTest.php b/tests/Feature/TerminalPageHeaderTest.php index e9fdb88a3..d4bfd925a 100644 --- a/tests/Feature/TerminalPageHeaderTest.php +++ b/tests/Feature/TerminalPageHeaderTest.php @@ -23,6 +23,15 @@ it('shows a centered themed target canvas before loading xterm', function () { ->toContain("@else\n
toContain('data-terminal-target-picker="page"') + ->toContain('flex max-h-full w-full max-w-lg flex-col') + ->toContain('class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2"'); +}); + it('loads targets inside the themed session picker with an accent scrollbar', function () { $view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php')); $styles = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/TopUserMenuTest.php b/tests/Feature/TopUserMenuTest.php index 16488f58d..04aef2415 100644 --- a/tests/Feature/TopUserMenuTest.php +++ b/tests/Feature/TopUserMenuTest.php @@ -25,8 +25,15 @@ it('changes appearance from a submenu instead of navigating to a separate page', expect($menu) ->toContain('appearanceOpen: false') ->toContain('@click.outside="open = false; appearanceOpen = false"') - ->toContain("theme: localStorage.getItem('theme') || 'dark'") - ->toContain('setTheme(type)') + ->toContain("theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark')") + ->toContain('setTheme(type, closeMenu = true)') + ->toContain("this.setTheme('custom', false)") + ->not->toContain('@change="appearanceOpen = false; open = false"') + ->toContain('this.appearanceOpen = false;') + ->toContain('this.open = false;') + ->toContain('