Merge remote-tracking branch 'origin/next' into celld-one-click-service

This commit is contained in:
Andras Bacsai
2026-08-07 22:46:22 +02:00
40 changed files with 801 additions and 96 deletions
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\AvatarStorageService;
use Illuminate\Http\Response;
class ProfileAvatarController extends Controller
{
public function __invoke(AvatarStorageService $avatarStorage): Response
{
$contents = $avatarStorage->contents(auth()->user());
abort_if($contents === null, 404);
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=300',
]);
}
}
+33
View File
@@ -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();
@@ -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<string> */
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));
+44
View File
@@ -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 {
+2
View File
@@ -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 = [
+19 -13
View File
@@ -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.
*
+3
View File
@@ -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 = [
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Services;
use App\Models\S3Storage;
use App\Models\User;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class AvatarStorageService
{
public function store(User $user, UploadedFile $upload): void
{
$settings = instanceSettings();
$storageType = $settings->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;
}
}
+2 -2
View File
@@ -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') {
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->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');
});
}
};
+40
View File
@@ -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"
+4 -4
View File
@@ -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.'
+5 -20
View File
@@ -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;
@@ -2232,6 +2234,7 @@ input[type="search"]::-webkit-search-results-decoration {
.backup-table-grid {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
min-width: 50rem;
}
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
@@ -2466,16 +2469,6 @@ input[type="search"]::-webkit-search-results-decoration {
}
@media (max-width: 900px) {
.backup-table-grid {
grid-template-columns: minmax(10rem, 1.5fr) 6rem 7.5rem 6.5rem;
}
.backup-table-grid > :nth-child(3),
.backup-table-grid > :nth-child(6),
.backup-table-grid > :nth-child(7) {
display: none;
}
.team-members-table-grid {
grid-template-columns: minmax(0, 1fr) 7rem 7rem;
}
@@ -2527,14 +2520,6 @@ input[type="search"]::-webkit-search-results-decoration {
width: 100%;
}
.backup-table-grid {
grid-template-columns: minmax(0, 1fr) 7.5rem 6.5rem;
}
.backup-table-grid > :nth-child(2) {
display: none;
}
.team-members-table-grid {
grid-template-columns: minmax(0, 1fr) 6.5rem;
}
@@ -55,10 +55,11 @@
<a
target="_blank"
rel="noopener noreferrer"
class="error-contact-link"
href="{{ config('constants.urls.contact') }}">
Contact support
<x-external-link class="inline-flex size-3 text-current" />
<x-forms.button type="button">
Contact support
<x-external-link class="inline-flex size-3 text-current" />
</x-forms.button>
</a>
@endif
</div>
@@ -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">
<button type="button" @click="open = !open"
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
@@ -38,7 +44,9 @@
'flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]',
'max-w-36' => $sidebar,
])>
<span
<img x-cloak x-show="avatarUrl" :src="avatarUrl" alt="{{ $userName }}"
class="size-5 shrink-0 rounded-full object-cover">
<span x-show="!avatarUrl"
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-[11px] font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
{{ $userInitial }}
</span>
@@ -52,12 +60,12 @@
</svg>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
@class([
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
'right-0! left-auto!' => ! $sidebar,
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
])>
<template x-if="open">
<div @class([
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
'right-0! left-auto!' => ! $sidebar,
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
])>
<div class="min-w-0 px-2 py-1.5">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $userEmail }}</div>
@@ -162,5 +170,6 @@
</span>
</button>
</form>
</div>
</div>
</template>
</div>
+3 -3
View File
@@ -11,14 +11,14 @@
:show-dashboard="false"
primary-href="/login"
primary-label="Back to login">
<details>
<summary>Using a reverse proxy or Cloudflare Tunnel?</summary>
<x-forms.collapsible title="Using a reverse proxy or Cloudflare Tunnel?" class="error-proxy-help">
<ul>
<li>Set your domain in <strong>Settings &rarr; FQDN</strong> to match the URL you use to access Coolify.</li>
<li>Cloudflare users: disable <strong>Browser Integrity Check</strong> and <strong>Under Attack Mode</strong> for your Coolify domain, as these can interrupt login sessions.</li>
<li>If you can still access Coolify via <code>localhost</code>, log in there first to configure your FQDN.</li>
</ul>
</details>
</x-forms.collapsible>
</x-error-page>
@livewireScripts
</body>
@endsection
@@ -8,6 +8,102 @@
@close-email-change-modal.window="emailModalOpen = false">
<x-slot:title>Profile | Coolify</x-slot>
<div class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
<section class="application-settings-section" x-data="{
preview: null,
processing: false,
uploadError: null,
async prepareAvatar(event) {
const file = event.target.files?.[0];
if (!file) return;
this.processing = true;
this.uploadError = null;
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const image = await new Promise((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = reject;
element.src = dataUrl;
});
const cropSize = Math.min(image.naturalWidth, image.naturalHeight);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 256, 256);
context.drawImage(
image,
(image.naturalWidth - cropSize) / 2,
(image.naturalHeight - cropSize) / 2,
cropSize,
cropSize,
0,
0,
256,
256,
);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
});
this.preview = URL.createObjectURL(blob);
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
this.$wire.upload('avatar', compressed, () => this.processing = false, () => {
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
} catch (error) {
this.processing = false;
this.uploadError = 'The image could not be processed in this browser.';
}
},
}">
<div class="application-settings-section-header">
<div>
<h2>Profile picture</h2>
<p>Upload a JPG, PNG, or WebP image.</p>
</div>
</div>
<div class="application-settings-section-body flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-200 text-2xl font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
<img x-cloak x-show="preview" :src="preview" alt="Profile picture preview"
class="h-full w-full object-cover">
@if (auth()->user()->avatar_path)
<img src="{{ route('profile.avatar', ['v' => auth()->user()->updated_at->timestamp]) }}"
x-show="!preview" alt="{{ auth()->user()->name }}" class="h-full w-full object-cover">
@else
<span x-show="!preview">
{{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }}
</span>
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<input type="file" x-on:change="prepareAvatar($event)" accept="image/jpeg,image/png,image/webp"
class="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-200 file:px-3 file:py-2 file:text-xs file:font-medium file:text-neutral-800 hover:file:bg-neutral-300 dark:text-fg-dim dark:file:bg-white/[0.08] dark:file:text-fg dark:hover:file:bg-white/[0.12]">
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('avatar')
<p class="text-xs text-red-500">{{ $message }}</p>
@enderror
<div class="flex flex-wrap gap-2">
<x-forms.button type="button" wire:click="uploadAvatar" wire:loading.attr="disabled"
wire:target="avatar,uploadAvatar" x-bind:disabled="processing || !preview" isHighlighted>
<span wire:loading.remove wire:target="uploadAvatar">Upload picture</span>
<span wire:loading wire:target="uploadAvatar">Compressing…</span>
</x-forms.button>
@if (auth()->user()->avatar_path)
<x-forms.button type="button" wire:click="removeAvatar" isError>Remove</x-forms.button>
@endif
</div>
</div>
</div>
</section>
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
@@ -172,7 +172,7 @@
</div>
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
@@ -7,8 +7,6 @@
}">
<form wire:submit='submit' class="application-settings-form flex flex-col">
<x-unsaved-bar action="submit" />
{{-- Temporarily hidden: the "Compose parser" dev hint and the "View details"
resource-details modal trigger. --}}
<div class="application-settings-grid flex flex-col gap-6">
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
@if ($buildPack === 'dockercompose')
@@ -180,8 +180,6 @@
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-1 overflow-visible xl:w-auto xl:justify-end">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
{{-- Status badge temporarily hidden will be redesigned later:
<x-status.index :resource="$application" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" /> --}}
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
<span class="px-2 text-[13px] text-neutral-500 dark:text-fg-dim">Load a Compose file to deploy.</span>
@else
@@ -1,6 +1,6 @@
<div x-data="{ raw: true, showNormalTextarea: false }"
@compose-preview-toggle.window="raw = !raw"
@compose-validate.window="$wire.validateCompose()"
@compose-validate.window="$wire.validateCompose().finally(() => $dispatch('compose-validate-finished'))"
@compose-save.window="$wire.saveEditedCompose()"
class="flex min-h-0 flex-col gap-3">
<x-callout type="info" title="Volume names">
@@ -12,7 +12,8 @@
<x-modal-input buttonTitle="Edit Compose file" title="Docker Compose" :closeOutside="false"
:isLarge="true">
<x-slot:headerActions>
<div x-data="{ preview: false, saving: false }"
<div x-data="{ preview: false, validating: false, saving: false }"
@compose-validate-finished.window="validating = false"
@compose-save-finished.window="saving = false" class="flex items-center gap-2">
<x-forms.button
@click="preview = !preview; $dispatch('compose-preview-toggle')">
@@ -20,7 +21,11 @@
<span x-text="preview ? 'Back to source Compose' : 'Preview generated Compose'"></span>
</x-forms.button>
@if (blank($service->service_type))
<x-forms.button @click="$dispatch('compose-validate')">Validate</x-forms.button>
<x-forms.button @click="validating = true; $dispatch('compose-validate')"
x-bind:disabled="validating">
<x-loading-on-button x-show="validating" x-cloak />
Validate
</x-forms.button>
@endif
<x-forms.button @click="saving = true; $dispatch('compose-save')"
x-bind:disabled="saving" isHighlighted>
@@ -173,7 +173,7 @@
</div>
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
@@ -101,12 +101,13 @@
@endif
</span>
<span class="flex items-center justify-end gap-2">
<span class="flex items-center justify-end gap-1">
@if ($execution->status === 'success' && ! $execution->local_storage_deleted)
<x-forms.button
x-on:click="download_volume_backup_file('{{ $execution->id }}')">
Download
</x-forms.button>
<button type="button" class="icon-button shrink-0"
x-on:click="download_volume_backup_file('{{ $execution->id }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
@if ($execution->status !== 'running')
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
@@ -116,7 +117,11 @@
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-forms.button isError>Delete</x-forms.button>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
@@ -134,6 +134,19 @@
]" />
</div>
</x-application.settings-section>
<x-application.settings-section id="avatar-storage-section" title="Profile picture storage"
helper="Choose where compressed user profile pictures are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
<div class="max-w-md">
<x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave"
:options="$avatar_storage_options" />
</div>
@if (count($avatar_storage_options) === 1)
<x-callout type="info" title="No usable S3 storage configured" class="mt-4">
Add and test an S3-compatible storage under Storages before selecting it here.
</x-callout>
@endif
</x-application.settings-section>
</form>
</x-settings.layout>
</div>
@@ -98,12 +98,11 @@
this.targetOpen = false;
this.targetSearch = '';
await $wire.set('selected_uuid', target.value);
await $wire.connectToContainer();
}
}">
@if ($selected_uuid === 'default')
<div wire:key="terminal-target-canvas" data-terminal-target-canvas
class="application-console-shell relative flex h-full min-h-0 w-full items-center justify-center overflow-hidden rounded-lg p-6"
class="application-console-shell relative flex h-full min-h-0 w-full items-center justify-center overflow-hidden rounded-lg p-3 sm:p-6"
:data-console-theme="consoleTheme"
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
<div class="absolute top-3 right-3 z-20">
@@ -111,8 +110,8 @@
:theme-accents="$consoleThemeAccents" />
</div>
<div data-terminal-target-picker="page"
class="terminal-target-picker z-10 w-full max-w-lg overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
<div class="border-b border-white/[0.08] p-4">
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)]">
<div class="shrink-0 border-b border-white/[0.08] p-4">
<h2 class="text-base font-semibold text-white/85">Start a terminal session</h2>
<p class="mt-1 text-sm text-white/55">
{{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }}
@@ -126,7 +125,7 @@
</div>
@endif
</div>
<div class="terminal-target-list max-h-96 overflow-y-auto p-2">
<div class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2">
@if ($isLoadingContainers)
<div class="flex min-h-28 items-center justify-center">
<div class="terminal-loading-label flex items-center gap-2">
+2
View File
@@ -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 () {
+2 -2
View File
@@ -2783,7 +2783,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": {
@@ -2920,7 +2920,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",
+2 -2
View File
@@ -2783,7 +2783,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": {
@@ -2920,7 +2920,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",
@@ -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 () {
@@ -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('<x-loading-on-button x-show="validating" x-cloak />')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->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')
@@ -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,
+13
View File
@@ -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</');
});
it('renders contact support as a button', function () {
$exception = new HttpException(404, 'Not found');
$html = view('errors.404', ['exception' => $exception])->render();
expect($html)
->toContain('href="'.config('constants.urls.contact').'"')
->toMatch('/<button[^>]*>\s*Contact support/s');
});
it('shows purified exception message on 500 page', function () {
$exception = new RuntimeException('Database connection failed');
@@ -13,6 +13,26 @@ it('keeps the volume backup executions table horizontally scrollable on mobile',
->not->toContain('.data-table-header.volume-backup-executions-grid');
});
it('uses compact icon actions for volume backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'));
expect($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('keeps storage backup schedule tables horizontally scrollable on mobile', function () {
$applicationView = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
$serviceView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($applicationView)->toContain('class="data-table w-full overflow-x-auto"')
->and($serviceView)->toContain('class="data-table w-full overflow-x-auto"')
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
});
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
+131
View File
@@ -0,0 +1,131 @@
<?php
use App\Livewire\Profile\Index;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Services\AvatarStorageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => 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');
});
@@ -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'));
@@ -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*\/>/');
});
+9
View File
@@ -23,6 +23,15 @@ it('shows a centered themed target canvas before loading xterm', function () {
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
});
it('keeps the terminal target picker within the mobile canvas', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->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'));
+9 -2
View File
@@ -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('<template x-if="open">')
->not->toContain('x-show.important="open"')
->not->toContain('<div x-show="open" x-cloak x-transition.opacity.duration.120ms')
->toContain("['value' => 'light', 'label' => 'Light'")
->toContain("['value' => 'system', 'label' => 'System'")
->toContain("['value' => 'dark', 'label' => 'Dark'")