mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 06:23:35 +00:00
Merge remote-tracking branch 'origin/next' into celld-one-click-service
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
+11
-6
@@ -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,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 () {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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*\/>/');
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'")
|
||||
|
||||
Reference in New Issue
Block a user