feat(profile): add uploadable avatars with local/S3 storage

Users can upload and remove profile pictures on the profile page.
Admins choose local or instance S3 storage in advanced settings.
Avatars are compressed to JPEG and served via a private cached route.
This commit is contained in:
Andras Bacsai committed 2026-08-07 21:39:56 +02:00
1 parent e50108a905
commit 51561e4bee
23 files changed
+677 -44

No files matched your search

@@ -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();
+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;
}
}
@@ -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.'
@@ -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>
@@ -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">
@@ -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 () {
+4 -4
View File
@@ -421,7 +421,7 @@
"buzz": {
"documentation": "https://github.com/block/buzz?utm_source=coolify.io",
"slogan": "Self-hostable workspace where humans and AI agents collaborate on a Nostr relay you own.",
"compose": "c2VydmljZXM6CiAgYnV6ei1naXQtaW5pdDoKICAgIGltYWdlOiAnYWxwaW5lOjMuMjEnCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LWdpdC1kYXRhOi9kYXRhL2dpdCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctZWMnCiAgICAgIC0gJ2Nob3duIC1SIDEwMDA6MTAwMCAvZGF0YS9naXQnCiAgICByZXN0YXJ0OiAnbm8nCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBidXp6OgogICAgaW1hZ2U6ICdnaGNyLmlvL2Jsb2NrL2J1eno6JHtCVVpaX1RBRzotbWFpbn0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CVVpaXzMwMDAKICAgICAgLSAnQlVaWl9CSU5EX0FERFI9MC4wLjAuMDozMDAwJwogICAgICAtIEJVWlpfSEVBTFRIX1BPUlQ9ODA4MAogICAgICAtIEJVWlpfTUVUUklDU19QT1JUPTkxMDIKICAgICAgLSAnUkVMQVlfVVJMPXdzczovLyR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdCVVpaX01FRElBX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfQlVaWn0vbWVkaWEnCiAgICAgIC0gJ0JVWlpfTUVESUFfU0VSVkVSX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9DT1JTX09SSUdJTlM9JHtTRVJWSUNFX1VSTF9CVVpafScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQlVaWl9TM19FTkRQT0lOVD1odHRwOi8vbWluaW86OTAwMCcKICAgICAgLSAnQlVaWl9TM19BQ0NFU1NfS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQlVaWl9TM19TRUNSRVRfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfQlVDS0VUPSR7QlVaWl9TM19CVUNLRVQ6LWJ1enotbWVkaWF9JwogICAgICAtIEJVWlpfR0lUX1JFUE9fUEFUSD0vZGF0YS9naXQKICAgICAgLSAnQlVaWl9SRUxBWV9QUklWQVRFX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X1JFTEFZS0VZfScKICAgICAgLSAnQlVaWl9HSVRfSE9PS19ITUFDX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X0dJVEhNQUN9JwogICAgICAtICdCVVpaX0FVVE9fTUlHUkFURT0ke0JVWlpfQVVUT19NSUdSQVRFOi10cnVlfScKICAgICAgLSAnQlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU9JHtCVVpaX0dJVF9DT05GT1JNQU5DRV9QUk9CRTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOPSR7QlVaWl9SRVFVSVJFX0FVVEhfVE9LRU46LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUD0ke0JVWlpfUkVRVUlSRV9SRUxBWV9NRU1CRVJTSElQOi1mYWxzZX0nCiAgICAgIC0gJ0JVWlpfQUxMT1dfTklQX09BX0FVVEg9JHtCVVpaX0FMTE9XX05JUF9PQV9BVVRIOi10cnVlfScKICAgICAgLSAnUkVMQVlfT1dORVJfUFVCS0VZPSR7UkVMQVlfT1dORVJfUFVCS0VZfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotYnV6el9yZWxheT1pbmZvLGJ1enpfZGI9aW5mbyxidXp6X2F1dGg9aW5mbyxidXp6X3B1YnN1Yj1pbmZvLHRvd2VyX2h0dHA9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LWdpdC1kYXRhOi9kYXRhL2dpdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvLWluaXQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgYnV6ei1naXQtaW5pdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTo4MDgwL19yZWFkaW5lc3MgPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA0NXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSBQR0RBVEE9L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhL3BnZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tYXBwZW5kb25seScKICAgICAgLSAneWVzJwogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3JlZGlzLWNsaSAtYSAiJCR7UkVESVNfUEFTU1dPUkR9IiBwaW5nIHwgZ3JlcCAtcSBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIG1pbmlvOgogICAgaW1hZ2U6ICdtaW5pby9taW5pbzpSRUxFQVNFLjIwMjUtMDktMDdUMTYtMTMtMDlaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwL21pbmlvL2hlYWx0aC9saXZlJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBtaW5pby1pbml0OgoLine truncated
"compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQlVaWl8zMDAwCiAgICAgIC0gJ0JVWlpfQklORF9BRERSPTAuMC4wLjA6MzAwMCcKICAgICAgLSBCVVpaX0hFQUxUSF9QT1JUPTgwODAKICAgICAgLSBCVVpaX01FVFJJQ1NfUE9SVD05MTAyCiAgICAgIC0gJ1JFTEFZX1VSTD13c3M6Ly8ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9NRURJQV9CQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX0JVWlp9L21lZGlhJwogICAgICAtICdCVVpaX01FRElBX1NFUlZFUl9ET01BSU49JHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfQ09SU19PUklHSU5TPSR7U0VSVklDRV9VUkxfQlVaWn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotYnV6en0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0JVWlpfUzNfRU5EUE9JTlQ9aHR0cDovL21pbmlvOjkwMDAnCiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgICAgLSBCVVpaX0dJVF9SRVBPX1BBVEg9L2RhdGEvZ2l0CiAgICAgIC0gJ0JVWlpfUkVMQVlfUFJJVkFURV9LRVk9JHtTRVJWSUNFX0hFWF82NF9SRUxBWUtFWX0nCiAgICAgIC0gJ0JVWlpfR0lUX0hPT0tfSE1BQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9HSVRITUFDfScKICAgICAgLSAnQlVaWl9BVVRPX01JR1JBVEU9JHtCVVpaX0FVVE9fTUlHUkFURTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFPSR7QlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU6LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTj0ke0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA9JHtCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUDotZmFsc2V9JwogICAgICAtICdCVVpaX0FMTE9XX05JUF9PQV9BVVRIPSR7QlVaWl9BTExPV19OSVBfT0FfQVVUSDotdHJ1ZX0nCiAgICAgIC0gJ1JFTEFZX09XTkVSX1BVQktFWT0ke1JFTEFZX09XTkVSX1BVQktFWX0nCiAgICAgIC0gJ1JVU1RfTE9HPSR7UlVTVF9MT0c6LWJ1enpfcmVsYXk9aW5mbyxidXp6X2RiPWluZm8sYnV6el9hdXRoPWluZm8sYnV6el9wdWJzdWI9aW5mbyx0b3dlcl9odHRwPWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1naXQtZGF0YTovZGF0YS9naXQnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pby1pbml0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWZzUyBodHRwOi8vMTI3LjAuMC4xOjgwODAvX3JlYWRpbmVzcyA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDQ1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtIFBHREFUQT0vdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEvcGdkYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEyCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1hcHBlbmRvbmx5JwogICAgICAtICd5ZXMnCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICIkJHtSRURJU19QQVNTV09SRH0iIHBpbmcgfCBncmVwIC1xIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgbWluaW86CiAgICBpbWFnZTogJ21pbmlvL21pbmlvOlJFTEVBU0UuMjAyNS0wOS0wN1QxNi0xMy0wOVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9kYXRhIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1taW5pby1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjkwMDAvbWluaW8vaGVhbHRoL2xpdmUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogJ21pbmlvL21jOlJFTEVBU0UuMjAyNS0wOC0xM1QwOC0zNS00MVonCiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1Line truncated
"tags": [
"buzz",
"chat",
@@ -435,7 +435,7 @@
"category": "messaging",
"logo": "svgs/buzz.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-07-23T18:52:29+02:00",
"template_last_updated_at": "2026-07-23T17:25:53+02:00",
"port": "3000"
},
"calibre-web-automated-book-downloader": {
@@ -2764,7 +2764,7 @@
"category": "devtools",
"logo": "svgs/librespeed.png",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-25T23:48:15+00:00",
"template_last_updated_at": "2026-02-25T23:48:15Z",
"port": "82"
},
"libretranslate": {
@@ -2901,7 +2901,7 @@
"category": "auth",
"logo": "svgs/logto_dark.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-01T13:19:47+00:00"
"template_last_updated_at": "2026-04-01T13:19:47Z"
},
"lowcoder": {
"documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io",
+4 -4
View File
@@ -421,7 +421,7 @@
"buzz": {
"documentation": "https://github.com/block/buzz?utm_source=coolify.io",
"slogan": "Self-hostable workspace where humans and AI agents collaborate on a Nostr relay you own.",
"compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JVWlpfMzAwMAogICAgICAtICdCVVpaX0JJTkRfQUREUj0wLjAuMC4wOjMwMDAnCiAgICAgIC0gQlVaWl9IRUFMVEhfUE9SVD04MDgwCiAgICAgIC0gQlVaWl9NRVRSSUNTX1BPUlQ9OTEwMgogICAgICAtICdSRUxBWV9VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfTUVESUFfQkFTRV9VUkw9aHR0cHM6Ly8ke1NFUlZJQ0VfRlFETl9CVVpafS9tZWRpYScKICAgICAgLSAnQlVaWl9NRURJQV9TRVJWRVJfRE9NQUlOPSR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdCVVpaX0NPUlNfT1JJR0lOUz1odHRwczovLyR7U0VSVklDRV9GUUROX0JVWlp9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly86JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdCVVpaX1MzX0VORFBPSU5UPWh0dHA6Ly9taW5pbzo5MDAwJwogICAgICAtICdCVVpaX1MzX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdCVVpaX1MzX1NFQ1JFVF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSAnQlVaWl9TM19CVUNLRVQ9JHtCVVpaX1MzX0JVQ0tFVDotYnV6ei1tZWRpYX0nCiAgICAgIC0gQlVaWl9HSVRfUkVQT19QQVRIPS9kYXRhL2dpdAogICAgICAtICdCVVpaX1JFTEFZX1BSSVZBVEVfS0VZPSR7U0VSVklDRV9IRVhfNjRfUkVMQVlLRVl9JwogICAgICAtICdCVVpaX0dJVF9IT09LX0hNQUNfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfR0lUSE1BQ30nCiAgICAgIC0gJ0JVWlpfQVVUT19NSUdSQVRFPSR7QlVaWl9BVVRPX01JR1JBVEU6LXRydWV9JwogICAgICAtICdCVVpaX0dJVF9DT05GT1JNQU5DRV9QUk9CRT0ke0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX0FVVEhfVE9LRU49JHtCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTjotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfUkVRVUlSRV9SRUxBWV9NRU1CRVJTSElQPSR7QlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA6LWZhbHNlfScKICAgICAgLSAnQlVaWl9BTExPV19OSVBfT0FfQVVUSD0ke0JVWlpfQUxMT1dfTklQX09BX0FVVEg6LXRydWV9JwogICAgICAtICdSRUxBWV9PV05FUl9QVUJLRVk9JHtSRUxBWV9PV05FUl9QVUJLRVl9JwogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1idXp6X3JlbGF5PWluZm8sYnV6el9kYj1pbmZvLGJ1enpfYXV0aD1pbmZvLGJ1enpfcHVic3ViPWluZm8sdG93ZXJfaHR0cD1pbmZvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotZ2l0LWRhdGE6L2RhdGEvZ2l0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW8taW5pdDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfY29tcGxldGVkX3N1Y2Nlc3NmdWxseQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgaHR0cDovLzEyNy4wLjAuMTo4MDgwL19yZWFkaW5lc3MgPi9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA0NXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1idXp6fScKICAgICAgLSBQR0RBVEE9L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhL3BnZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tYXBwZW5kb25seScKICAgICAgLSAneWVzJwogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3JlZGlzLWNsaSAtYSAiJCR7UkVESVNfUEFTU1dPUkR9IiBwaW5nIHwgZ3JlcCAtcSBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIG1pbmlvOgogICAgaW1hZ2U6ICdtaW5pby9taW5pbzpSRUxFQVNFLjIwMjUtMDktMDdUMTYtMTMtMDlaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01JTklPX1JPT1RfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ01JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5MDAwL21pbmlvL2hlYWx0aC9saXZlJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTIKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpSRUxFQVNFLjIwMjUtMDgtMTNUMDgtMzUtNDFaJwogICAgZGVwZW5kc19vbjoKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdCVVpaX1MzX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdCVVpaX1MzX1NFQ1JFVF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSAnQlVaWl9TM19CVUNLRVQ9JHtCVVpLine truncated
"compose": "c2VydmljZXM6CiAgYnV6ejoKICAgIGltYWdlOiAnZ2hjci5pby9ibG9jay9idXp6OiR7QlVaWl9UQUc6LW1haW59JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JVWlpfMzAwMAogICAgICAtICdCVVpaX0JJTkRfQUREUj0wLjAuMC4wOjMwMDAnCiAgICAgIC0gQlVaWl9IRUFMVEhfUE9SVD04MDgwCiAgICAgIC0gQlVaWl9NRVRSSUNTX1BPUlQ9OTEwMgogICAgICAtICdSRUxBWV9VUkw9d3NzOi8vJHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0JVWlpfTUVESUFfQkFTRV9VUkw9JHtTRVJWSUNFX0ZRRE5fQlVaWn0vbWVkaWEnCiAgICAgIC0gJ0JVWlpfTUVESUFfU0VSVkVSX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9CVVpafScKICAgICAgLSAnQlVaWl9DT1JTX09SSUdJTlM9JHtTRVJWSUNFX0ZRRE5fQlVaWn0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotYnV6en0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0JVWlpfUzNfRU5EUE9JTlQ9aHR0cDovL21pbmlvOjkwMDAnCiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXp6LW1lZGlhfScKICAgICAgLSBCVVpaX0dJVF9SRVBPX1BBVEg9L2RhdGEvZ2l0CiAgICAgIC0gJ0JVWlpfUkVMQVlfUFJJVkFURV9LRVk9JHtTRVJWSUNFX0hFWF82NF9SRUxBWUtFWX0nCiAgICAgIC0gJ0JVWlpfR0lUX0hPT0tfSE1BQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9HSVRITUFDfScKICAgICAgLSAnQlVaWl9BVVRPX01JR1JBVEU9JHtCVVpaX0FVVE9fTUlHUkFURTotdHJ1ZX0nCiAgICAgIC0gJ0JVWlpfR0lUX0NPTkZPUk1BTkNFX1BST0JFPSR7QlVaWl9HSVRfQ09ORk9STUFOQ0VfUFJPQkU6LXRydWV9JwogICAgICAtICdCVVpaX1JFUVVJUkVfQVVUSF9UT0tFTj0ke0JVWlpfUkVRVUlSRV9BVVRIX1RPS0VOOi10cnVlfScKICAgICAgLSAnQlVaWl9SRVFVSVJFX1JFTEFZX01FTUJFUlNISVA9JHtCVVpaX1JFUVVJUkVfUkVMQVlfTUVNQkVSU0hJUDotZmFsc2V9JwogICAgICAtICdCVVpaX0FMTE9XX05JUF9PQV9BVVRIPSR7QlVaWl9BTExPV19OSVBfT0FfQVVUSDotdHJ1ZX0nCiAgICAgIC0gJ1JFTEFZX09XTkVSX1BVQktFWT0ke1JFTEFZX09XTkVSX1BVQktFWX0nCiAgICAgIC0gJ1JVU1RfTE9HPSR7UlVTVF9MT0c6LWJ1enpfcmVsYXk9aW5mbyxidXp6X2RiPWluZm8sYnV6el9hdXRoPWluZm8sYnV6el9wdWJzdWI9aW5mbyx0b3dlcl9odHRwPWluZm99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1naXQtZGF0YTovZGF0YS9naXQnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pby1pbml0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWZzUyBodHRwOi8vMTI3LjAuMC4xOjgwODAvX3JlYWRpbmVzcyA+L2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDQ1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWJ1enp9JwogICAgICAtIFBHREFUQT0vdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEvcGdkYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdidXp6LXBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEyCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSByZWRpcy1zZXJ2ZXIKICAgICAgLSAnLS1hcHBlbmRvbmx5JwogICAgICAtICd5ZXMnCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2J1enotcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICIkJHtSRURJU19QQVNTV09SRH0iIHBpbmcgfCBncmVwIC1xIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgbWluaW86CiAgICBpbWFnZTogJ21pbmlvL21pbmlvOlJFTEVBU0UuMjAyNS0wOS0wN1QxNi0xMy0wOVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9kYXRhIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgdm9sdW1lczoKICAgICAgLSAnYnV6ei1taW5pby1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjkwMDAvbWluaW8vaGVhbHRoL2xpdmUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMgogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogJ21pbmlvL21jOlJFTEVBU0UuMjAyNS0wOC0xM1QwOC0zNS00MVonCiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0JVWlpfUzNfQUNDRVNTX0tFWT0ke1NFUlZJQ0VfVVNFUl9NSU5JT30nCiAgICAgIC0gJ0JVWlpfUzNfU0VDUkVUX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdCVVpaX1MzX0JVQ0tFVD0ke0JVWlpfUzNfQlVDS0VUOi1idXpLine truncated
"tags": [
"buzz",
"chat",
@@ -435,7 +435,7 @@
"category": "messaging",
"logo": "svgs/buzz.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-07-23T17:25:53+02:00",
"port": "3000"
},
"calibre-web-automated-book-downloader": {
@@ -2764,7 +2764,7 @@
"category": "devtools",
"logo": "svgs/librespeed.png",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-25T23:48:15+00:00",
"template_last_updated_at": "2026-02-25T23:48:15Z",
"port": "82"
},
"libretranslate": {
@@ -2901,7 +2901,7 @@
"category": "auth",
"logo": "svgs/logto_dark.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-01T13:19:47+00:00"
"template_last_updated_at": "2026-04-01T13:19:47Z"
},
"lowcoder": {
"documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io",
+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'")