Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot] committed 2026-10-07 11:34:15 +00:00
commit 41a264a2be
46 files changed
+1008 -366

No files matched your search

+1 -2
View File
@@ -11,7 +11,6 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
@@ -61,7 +60,7 @@ class StartService
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
$safeNetwork = escapeshellarg($service->destination->network);
$serviceNames = data_get(Yaml::parse($compose), 'services', []);
$serviceNames = data_get(parseDockerComposeYaml($compose), 'services', []);
foreach ($serviceNames as $serviceName => $serviceConfig) {
$containerName = escapeshellarg("{$serviceName}-{$service->uuid}");
$commands[] = "docker network connect --alias {$containerName} {$safeNetwork} {$containerName} >/dev/null 2>&1 || true";
@@ -36,7 +36,6 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
class ApplicationsController extends Controller
{
@@ -2787,7 +2786,7 @@ class ApplicationsController extends Controller
$dockerComposeDomainsResponse = null;
if ($isCompose) {
try {
$compose = Yaml::parse($application->docker_compose_raw ?? '');
$compose = parseDockerComposeYaml($application->docker_compose_raw ?? '');
} catch (\Throwable) {
return response()->json([
'message' => 'Validation failed.',
@@ -3519,7 +3518,7 @@ class ApplicationsController extends Controller
}
}
$yaml = Yaml::parse($application->docker_compose_raw);
$yaml = parseDockerComposeYaml($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
@@ -26,7 +26,6 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
class ServicesController extends Controller
{
@@ -729,7 +728,7 @@ class ServicesController extends Controller
],
], 422);
}
Yaml::parse($dockerComposeRaw);
parseDockerComposeYaml($dockerComposeRaw);
// Validate for command injection BEFORE saving to database
try {
@@ -1278,7 +1277,7 @@ class ServicesController extends Controller
],
], 422);
}
Yaml::parse($dockerComposeRaw);
parseDockerComposeYaml($dockerComposeRaw);
// Validate for command injection BEFORE saving to database
try {
+19 -15
View File
@@ -163,7 +163,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $docker_compose;
private $docker_compose_base64;
private ?string $generated_docker_compose = null;
private ?string $nixpacks_plan = null;
@@ -840,11 +840,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$yaml = Yaml::dump(convertToArray($composeFile), 10);
}
$this->docker_compose_base64 = base64_encode($yaml);
$this->generated_docker_compose = $yaml;
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"),
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}{$this->docker_compose_location} > /dev/null",
'input' => $yaml,
'hidden' => true,
'skip_command_log' => true,
]);
// Modify Dockerfiles for ARGs and build secrets
@@ -1262,7 +1262,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
}
if (isset($this->docker_compose_base64)) {
if (isset($this->generated_docker_compose)) {
if ($this->use_build_server) {
$this->server = $this->mainServer;
}
@@ -1283,8 +1283,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
"mkdir -p $mainDir",
],
[
"echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null",
'skip_command_log' => true,
"tee $composeFileName > /dev/null",
'input' => $this->generated_docker_compose,
],
[
"echo '{$readme}' | tee $mainDir/README.md > /dev/null",
@@ -1726,9 +1726,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
@@ -1796,7 +1796,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_NAME for dockercompose services
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
@@ -2035,9 +2035,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
@@ -2061,7 +2061,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
// Generate SERVICE_NAME for preview deployments
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
@@ -4109,8 +4109,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
$this->docker_compose = Yaml::dump($docker_compose, 10);
$this->docker_compose_base64 = base64_encode($this->docker_compose);
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true, 'skip_command_log' => true]);
$this->generated_docker_compose = $this->docker_compose;
$this->execute_remote_command([
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null",
'input' => $this->docker_compose,
'hidden' => true,
]);
}
private function generate_local_persistent_volumes()
+307 -137
View File
@@ -3,7 +3,10 @@
namespace App\Livewire;
use App\Models\Application;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -16,6 +19,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
@@ -252,7 +256,7 @@ class GlobalSearch extends Component
// Try to get from Redis cache first
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
$resources = Cache::remember($cacheKey, 300, function () {
$items = collect();
$team = auth()->user()->currentTeam();
@@ -617,143 +621,8 @@ class GlobalSearch extends Component
];
});
// Add navigation routes
$navigation = collect([
[
'name' => 'Dashboard',
'type' => 'navigation',
'description' => 'Go to main dashboard',
'link' => route('dashboard'),
'search_text' => 'dashboard home main overview',
],
[
'name' => 'Servers',
'type' => 'navigation',
'description' => 'View all servers',
'link' => route('server.index'),
'search_text' => 'servers all list view',
],
[
'name' => 'Projects',
'type' => 'navigation',
'description' => 'View all projects',
'link' => route('project.index'),
'search_text' => 'projects all list view',
],
[
'name' => 'Destinations',
'type' => 'navigation',
'description' => 'View all destinations',
'link' => route('destination.index'),
'search_text' => 'destinations docker networks',
],
[
'name' => 'Security',
'type' => 'navigation',
'description' => 'Manage private keys and API tokens',
'link' => route('security.private-key.index'),
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
],
[
'name' => 'Cloud-Init Scripts',
'type' => 'navigation',
'description' => 'Manage reusable cloud-init scripts',
'link' => route('security.cloud-init-scripts'),
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
],
[
'name' => 'Sources',
'type' => 'navigation',
'description' => 'Manage GitHub apps and Git sources',
'link' => route('source.all'),
'search_text' => 'sources github apps git repositories',
],
[
'name' => 'Storages',
'type' => 'navigation',
'description' => 'Manage S3 storage for backups',
'link' => route('storage.index'),
'search_text' => 'storages s3 backups',
],
[
'name' => 'Shared Variables',
'type' => 'navigation',
'description' => 'View all shared variables',
'link' => route('shared-variables.index'),
'search_text' => 'shared variables environment all',
],
[
'name' => 'Team Shared Variables',
'type' => 'navigation',
'description' => 'Manage team-wide shared variables',
'link' => route('shared-variables.team.index'),
'search_text' => 'shared variables team environment',
],
[
'name' => 'Project Shared Variables',
'type' => 'navigation',
'description' => 'Manage project shared variables',
'link' => route('shared-variables.project.index'),
'search_text' => 'shared variables project environment',
],
[
'name' => 'Environment Shared Variables',
'type' => 'navigation',
'description' => 'Manage environment shared variables',
'link' => route('shared-variables.environment.index'),
'search_text' => 'shared variables environment',
],
[
'name' => 'Tags',
'type' => 'navigation',
'description' => 'View resources by tags',
'link' => route('tags.show'),
'search_text' => 'tags labels organize',
],
[
'name' => 'Terminal',
'type' => 'navigation',
'description' => 'Access server terminal',
'link' => route('terminal'),
'search_text' => 'terminal ssh console shell command line',
],
[
'name' => 'Profile',
'type' => 'navigation',
'description' => 'Manage your profile and preferences',
'link' => route('profile'),
'search_text' => 'profile account user settings preferences',
],
[
'name' => 'Team',
'type' => 'navigation',
'description' => 'Manage team members and settings',
'link' => route('team.index'),
'search_text' => 'team settings members users invitations',
],
[
'name' => 'Notifications',
'type' => 'navigation',
'description' => 'Configure email, Discord, Telegram notifications',
'link' => route('notifications.email'),
'search_text' => 'notifications alerts email discord telegram slack pushover',
],
]);
// Add instance settings only for self-hosted and root team
if (! isCloud() && $team->id === 0) {
$navigation->push([
'name' => 'Settings',
'type' => 'navigation',
'description' => 'Instance settings and configuration',
'link' => route('settings.index'),
'search_text' => 'settings configuration instance',
]);
}
// Merge all collections
$items = $items->merge($navigation)
->merge($applications)
$items = $items->merge($applications)
->merge($services)
->merge($databases)
->merge($servers)
@@ -762,6 +631,307 @@ class GlobalSearch extends Component
return $items->toArray();
});
// Navigation depends on the user's permissions, so it is not part of the team-wide cache
$this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all();
}
private function navigationItems(): Collection
{
$user = auth()->user();
$team = $user->currentTeam();
// Add navigation routes
$navigation = collect([
[
'name' => 'Dashboard',
'type' => 'navigation',
'description' => 'Go to main dashboard',
'link' => route('dashboard'),
'search_text' => 'dashboard home main overview',
],
[
'name' => 'Servers',
'type' => 'navigation',
'description' => 'View all servers',
'link' => route('server.index'),
'search_text' => 'servers all list view',
],
[
'name' => 'Projects',
'type' => 'navigation',
'description' => 'View all projects',
'link' => route('project.index'),
'search_text' => 'projects all list view',
],
[
'name' => 'Destinations',
'type' => 'navigation',
'description' => 'View all destinations',
'link' => route('destination.index'),
'search_text' => 'destinations docker networks',
],
[
'name' => 'Security',
'type' => 'navigation',
'description' => 'Manage private keys and API tokens',
'link' => route('security.private-key.index'),
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
],
$user->can('viewAny', CloudInitScript::class) ? [
'name' => 'Cloud-Init Scripts',
'type' => 'navigation',
'description' => 'Manage reusable cloud-init scripts',
'link' => route('security.cloud-init-scripts'),
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
] : null,
[
'name' => 'Sources',
'type' => 'navigation',
'description' => 'Manage GitHub apps and Git sources',
'link' => route('source.all'),
'search_text' => 'sources github apps git repositories',
],
[
'name' => 'Storages',
'type' => 'navigation',
'description' => 'Manage S3 storage for backups',
'link' => route('storage.index'),
'search_text' => 'storages s3 backups',
],
[
'name' => 'Shared Variables',
'type' => 'navigation',
'description' => 'View all shared variables',
'link' => route('shared-variables.index'),
'search_text' => 'shared variables environment all',
],
[
'name' => 'Team Shared Variables',
'type' => 'navigation',
'description' => 'Manage team-wide shared variables',
'link' => route('shared-variables.team.index'),
'search_text' => 'shared variables team environment',
],
[
'name' => 'Project Shared Variables',
'type' => 'navigation',
'description' => 'Manage project shared variables',
'link' => route('shared-variables.project.index'),
'search_text' => 'shared variables project environment',
],
[
'name' => 'Environment Shared Variables',
'type' => 'navigation',
'description' => 'Manage environment shared variables',
'link' => route('shared-variables.environment.index'),
'search_text' => 'shared variables environment',
],
[
'name' => 'Tags',
'type' => 'navigation',
'description' => 'View resources by tags',
'link' => route('tags.show'),
'search_text' => 'tags labels organize',
],
$user->can('canAccessTerminal') ? [
'name' => 'Terminal',
'type' => 'navigation',
'description' => 'Access server terminal',
'link' => route('terminal'),
'search_text' => 'terminal ssh console shell command line',
] : null,
[
'name' => 'Profile',
'type' => 'navigation',
'description' => 'Manage your profile and preferences',
'link' => route('profile'),
'search_text' => 'profile account user settings preferences',
],
[
'name' => 'Team',
'type' => 'navigation',
'description' => 'Manage team members and settings',
'link' => route('team.index'),
'search_text' => 'team settings members users invitations',
],
[
'name' => 'Notifications',
'type' => 'navigation',
'description' => 'Configure email, Discord, Telegram notifications',
'link' => route('notifications.email'),
'search_text' => 'notifications alerts email discord telegram slack pushover',
],
[
'name' => 'Analytics',
'type' => 'navigation',
'description' => 'View traffic analytics for your servers',
'link' => route('analytics'),
'search_text' => 'analytics traffic requests visitors metrics',
],
$user->isAdmin() ? [
'name' => 'Registries',
'type' => 'navigation',
'description' => 'Manage Docker registry credentials',
'link' => route('registries.index'),
'search_text' => 'registries docker registry credentials login private images',
] : null,
[
'name' => 'Server Shared Variables',
'type' => 'navigation',
'description' => 'Manage server shared variables',
'link' => route('shared-variables.server.index'),
'search_text' => 'shared variables server environment',
],
[
'name' => 'Team Members',
'type' => 'navigation',
'description' => 'Manage team members and invitations',
'link' => route('team.member.index'),
'search_text' => 'team members users invitations invite roles',
],
$user->isAdminOfTeam($team->id) ? [
'name' => 'Team Audit Log',
'type' => 'navigation',
'description' => 'View team activity history',
'link' => route('team.audit-log'),
'search_text' => 'team audit log activity history events',
] : null,
isInstanceAdmin() ? [
'name' => 'Team Admin View',
'type' => 'navigation',
'description' => 'Manage all users of this instance',
'link' => route('team.admin-view'),
'search_text' => 'team admin view users instance delete',
] : null,
$user->can('delete', $team) ? [
'name' => 'Team Danger Zone',
'type' => 'navigation',
'description' => 'Delete this team',
'link' => route('team.danger-zone'),
'search_text' => 'team danger zone delete remove',
] : null,
[
'name' => 'API Tokens',
'type' => 'navigation',
'description' => 'Manage API tokens',
'link' => route('security.api-tokens'),
'search_text' => 'security api tokens personal access token',
],
$user->can('viewAny', CloudProviderToken::class) ? [
'name' => 'Cloud Tokens',
'type' => 'navigation',
'description' => 'Manage cloud provider tokens',
'link' => route('security.cloud-tokens'),
'search_text' => 'security cloud provider tokens hetzner digitalocean',
] : null,
$user->can('viewAny', IntegrationToken::class) ? [
'name' => 'Integration Tokens',
'type' => 'navigation',
'description' => 'Manage integration tokens',
'link' => route('security.integration-tokens'),
'search_text' => 'security integration tokens',
] : null,
[
'name' => 'Discord Notifications',
'type' => 'navigation',
'description' => 'Configure Discord notifications',
'link' => route('notifications.discord'),
'search_text' => 'notifications alerts discord webhook',
],
[
'name' => 'Telegram Notifications',
'type' => 'navigation',
'description' => 'Configure Telegram notifications',
'link' => route('notifications.telegram'),
'search_text' => 'notifications alerts telegram bot',
],
[
'name' => 'Slack Notifications',
'type' => 'navigation',
'description' => 'Configure Slack notifications',
'link' => route('notifications.slack'),
'search_text' => 'notifications alerts slack webhook',
],
[
'name' => 'Pushover Notifications',
'type' => 'navigation',
'description' => 'Configure Pushover notifications',
'link' => route('notifications.pushover'),
'search_text' => 'notifications alerts pushover',
],
[
'name' => 'Webhook Notifications',
'type' => 'navigation',
'description' => 'Configure webhook notifications',
'link' => route('notifications.webhook'),
'search_text' => 'notifications alerts webhook http',
],
[
'name' => 'Appearance',
'type' => 'navigation',
'description' => 'Change theme and display preferences',
'link' => route('profile.appearance'),
'search_text' => 'profile appearance theme dark light mode preferences',
],
isCloud() && $user->isAdmin() ? [
'name' => 'Subscription',
'type' => 'navigation',
'description' => 'Manage your subscription and billing',
'link' => route('subscription.show'),
'search_text' => 'subscription billing plan payment invoices',
] : null,
])->filter();
// Add instance settings only for self-hosted and root team
if (! isCloud() && $team->id === 0) {
$navigation->push(
[
'name' => 'Settings',
'type' => 'navigation',
'description' => 'Instance settings and configuration',
'link' => route('settings.index'),
'search_text' => 'settings general configuration instance url domain name timezone public ip https redirect',
],
[
'name' => 'Advanced Settings',
'type' => 'navigation',
'description' => 'Registration, API access, MCP server, DNS validation and telemetry',
'link' => route('settings.advanced'),
'search_text' => 'settings advanced registration api access allowed ips mcp server dns validation custom dns domain connect confirmation telemetry sponsorship cdn',
],
[
'name' => 'Update Settings',
'type' => 'navigation',
'description' => 'Configure Coolify auto updates',
'link' => route('settings.updates'),
'search_text' => 'settings updates auto update upgrade version frequency',
],
[
'name' => 'Instance Backup',
'type' => 'navigation',
'description' => 'Back up the Coolify instance database',
'link' => route('settings.backup'),
'search_text' => 'settings instance backup coolify database restore',
],
[
'name' => 'Instance Email',
'type' => 'navigation',
'description' => 'Configure SMTP or Resend for system emails',
'link' => route('settings.email'),
'search_text' => 'settings instance email smtp resend transactional mail',
],
[
'name' => 'Authentication',
'type' => 'navigation',
'description' => 'Configure OAuth login providers',
'link' => route('settings.oauth'),
'search_text' => 'settings authentication oauth sso login providers github google gitlab azure',
],
);
}
return $navigation;
}
private function search()
@@ -241,7 +241,7 @@ class ConnectApplication extends Component
private function composeVolumeSnippet(Application $application): string
{
try {
$services = data_get(Yaml::parse((string) $application->docker_compose_raw), 'services');
$services = data_get(parseDockerComposeYaml((string) $application->docker_compose_raw), 'services');
} catch (\Throwable) {
$services = null;
}
+1 -2
View File
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class DockerCompose extends Component
{
@@ -38,7 +37,7 @@ class DockerCompose extends Component
$this->validate([
'dockerComposeRaw' => 'required',
]);
Yaml::parse($this->dockerComposeRaw);
parseDockerComposeYaml($this->dockerComposeRaw);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
+1 -4
View File
@@ -2189,10 +2189,7 @@ class Application extends BaseModel
public function oldRawParser()
{
try {
$yaml = Yaml::parse(
$this->docker_compose_raw,
maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES,
);
$yaml = parseDockerComposeYaml($this->docker_compose_raw);
} catch (\Exception $e) {
throw new RuntimeException($e->getMessage());
}
+2 -3
View File
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
@@ -691,7 +690,7 @@ class LocalFileVolume extends BaseModel
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
foreach ($services as $service) {
foreach (data_get($service, 'volumes', []) as $volume) {
if (is_string($volume)) {
@@ -777,7 +776,7 @@ class LocalFileVolume extends BaseModel
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
+4 -5
View File
@@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
{
@@ -191,7 +190,7 @@ class LocalPersistentVolume extends BaseModel
return true;
}
$compose = Yaml::parse($composeContent);
$compose = parseDockerComposeYaml($composeContent);
$services = data_get($compose, 'services', []);
$topLevelVolumes = collect(data_get($compose, 'volumes') ?? []);
@@ -246,7 +245,7 @@ class LocalPersistentVolume extends BaseModel
return null;
}
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
$key = (string) $key;
if (! isComposeExternalVolume($declaration)) {
continue;
@@ -293,7 +292,7 @@ class LocalPersistentVolume extends BaseModel
}
$resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid');
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
if (! is_array($declaration) || isComposeExternalVolume($declaration)) {
continue;
}
@@ -334,7 +333,7 @@ class LocalPersistentVolume extends BaseModel
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
+1 -2
View File
@@ -18,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
#[OA\Schema(
description: 'Service model',
@@ -1629,7 +1628,7 @@ class Service extends BaseModel
// Generate SERVICE_NAME_* environment variables from docker-compose services
if ($this->docker_compose) {
try {
$dockerCompose = Yaml::parse($this->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->docker_compose);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
+2 -3
View File
@@ -10,7 +10,6 @@ use App\Traits\ReleasesManagedDnsRecords;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Symfony\Component\Yaml\Yaml;
class ServiceApplication extends BaseModel
{
@@ -367,7 +366,7 @@ class ServiceApplication extends BaseModel
return $this->service->getRequiredPort();
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
$serviceConfig = $dockerCompose['services'][$this->name] ?? null;
if (! $serviceConfig) {
return $this->service->getRequiredPort();
@@ -448,7 +447,7 @@ class ServiceApplication extends BaseModel
*/
private function getSavedLegacyRoutingPort(array $serviceConfig): ?int
{
$savedCompose = Yaml::parse($this->service->docker_compose ?? '');
$savedCompose = parseDockerComposeYaml($this->service->docker_compose ?? '');
$savedService = $savedCompose['services'][$this->name] ?? null;
$image = $serviceConfig['image'] ?? null;
if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) {
+1 -1
View File
@@ -112,7 +112,7 @@ trait CalculatesExcludedStatus
}
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
// Validate structure
if (! is_array($dockerCompose)) {
+1 -1
View File
@@ -31,7 +31,7 @@ trait EnvironmentVariableProtection
}
try {
$dockerComposeData = Yaml::parse($dockerCompose);
$dockerComposeData = parseDockerComposeYaml($dockerCompose);
$dockerEnvVars = data_get($dockerComposeData, 'services.*.environment');
foreach ($dockerEnvVars as $serviceEnvs) {
+10 -5
View File
@@ -95,6 +95,7 @@ trait ExecuteRemoteCommand
$append = data_get($single_command, 'append', true);
$command_hidden = data_get($single_command, 'command_hidden', false);
$skip_command_log = data_get($single_command, 'skip_command_log', false);
$input = data_get($single_command, 'input');
$this->save = data_get($single_command, 'save');
if ($this->server->isNonRoot()) {
if (str($command)->startsWith('docker exec')) {
@@ -119,7 +120,7 @@ trait ExecuteRemoteCommand
while ($attempt < $maxRetries && ! $commandExecuted) {
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log);
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log, $input);
$commandExecuted = true;
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
@@ -167,16 +168,20 @@ trait ExecuteRemoteCommand
}
/**
* Execute the actual command with process handling
* Execute the actual command with process handling. With $input, the content goes to the command over
* SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole
* script into one argument.
*/
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false)
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null)
{
if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue)) {
$this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
}
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
$remote_command = $input === null
? SshMultiplexingHelper::generateSshCommand($this->server, $command)
: SshMultiplexingHelper::generateSshStdinCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
$sanitized_output = sanitize_utf8_text($output);
$log_output = str($sanitized_output)->trim();
+2 -2
View File
@@ -908,7 +908,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
} catch (Throwable) {
return [];
}
@@ -1813,7 +1813,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
if (! $server) {
throw new Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
$yaml_compose = parseDockerComposeYaml($compose);
// Remove Coolify's custom fields before Docker validation
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
+20 -8
View File
@@ -15,8 +15,20 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* Parses Docker Compose YAML with a higher collection alias limit than the Symfony default (128),
* because large Compose files (for example Sentry self-hosted) reuse YAML anchors many times.
*
* @throws ParseException If the YAML is invalid or exceeds the alias limit
*/
function parseDockerComposeYaml(string $compose): mixed
{
return Yaml::parse($compose, maxAliasesForCollections: Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES);
}
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
@@ -28,7 +40,7 @@ use Symfony\Component\Yaml\Yaml;
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
$parsed = parseDockerComposeYaml($composeYaml);
} catch (Exception $e) {
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
@@ -515,7 +527,7 @@ function composeExternalVolumeDockerNames(?string $compose): array
return [];
}
try {
$volumes = data_get(Yaml::parse($compose), 'volumes');
$volumes = data_get(parseDockerComposeYaml($compose), 'volumes');
} catch (Throwable) {
return [];
}
@@ -542,7 +554,7 @@ function composeExternalVolumeMounts(?string $compose): array
return [];
}
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Throwable) {
return [];
}
@@ -985,7 +997,7 @@ function removeComposeVolumeFieldsPreservingComments(string $source, array $clea
$candidate = implode('', $result);
try {
if (Yaml::parse($candidate) === $cleanedYaml) {
if (parseDockerComposeYaml($candidate) === $cleanedYaml) {
return $candidate;
}
} catch (Exception) {
@@ -1010,7 +1022,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server');
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
@@ -2150,7 +2162,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
@@ -2197,7 +2209,7 @@ function serviceParser(Service $resource): Collection
$server = data_get($resource, 'server');
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
@@ -3383,7 +3395,7 @@ function serviceParser(Service $resource): Collection
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
+1 -1
View File
@@ -269,7 +269,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
if (! $dockerComposeRaw) {
throw new Exception('No compose file found or not a valid YAML file.');
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
// Switch Image
$updatedImage = data_get_str($resource, 'image');
+51 -7
View File
@@ -1736,7 +1736,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
if ($resource->getMorphClass() === Service::class) {
if ($resource->docker_compose_raw) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
@@ -1802,7 +1802,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
}
} elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
@@ -2742,7 +2742,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
@@ -3534,7 +3534,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception) {
return;
}
@@ -4406,8 +4406,9 @@ function convertToKeyValueCollection($environment)
$key = $parts[0];
$realValue = $parts[1] ?? '';
$changedEnvironment->put($key, $realValue);
} else {
$changedEnvironment->put($key, $value);
} elseif (is_string($value) && $value !== '') {
// A bare name (for example a list-style build arg) is the same as `NAME:` without a value.
$changedEnvironment->put($value, $changedEnvironment->get($value));
}
} else {
$changedEnvironment->put($key, $value);
@@ -4450,6 +4451,49 @@ function wireNavigate(): string
}
}
/**
* Flattens a grouped settings sidebar into the items that the command palette
* shows for the current page: every page, its child pages, and its in-page sections.
*
* @param iterable<string, iterable<array{label: string, route: string, navigate?: bool, visible?: bool, children?: array<int, array{label: string, route: string, navigate?: bool, visible?: bool}>}>> $groupedItems
* @param array<string, string> $routeParameters
* @param array<string, array<int, array{id: string, label: string}>> $pageSections In-page sections keyed by page route
* @return array<int, array{label: string, breadcrumb: string, search_text: string, href: string, navigate: bool}>
*/
function settingsSearchItems(iterable $groupedItems, array $routeParameters, array $pageSections = []): array
{
$spaNavigation = wireNavigate() !== '';
$items = [];
$add = function (string $label, string $breadcrumb, string $href, bool $navigate) use (&$items, $spaNavigation): void {
$items[] = [
'label' => $label,
'breadcrumb' => $breadcrumb,
'search_text' => $label.' '.$breadcrumb,
'href' => $href,
'navigate' => $navigate && $spaNavigation,
];
};
foreach ($groupedItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
$href = route($item['route'], $routeParameters);
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);
foreach ($item['children'] ?? [] as $child) {
if ($child['visible'] ?? true) {
$add($child['label'], $groupLabel.' · '.$item['label'], route($child['route'], $routeParameters), $child['navigate'] ?? true);
}
}
foreach ($pageSections[$item['route']] ?? [] as $section) {
$add($section['label'], $groupLabel.' · '.$item['label'], $href.'#'.$section['id'], true);
}
}
}
return $items;
}
/**
* Redirect to a named route with SPA navigation support.
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
@@ -5052,7 +5096,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
}
try {
$yaml = Yaml::parse($dockerComposeRaw);
$yaml = parseDockerComposeYaml($dockerComposeRaw);
} catch (Exception $e) {
// Malformed YAML - return empty collection
return collect([]);
+22 -25
View File
@@ -4509,7 +4509,7 @@ html[data-theme="custom"] .runtime-log-columns {
.command-palette {
display: flex;
width: 100%;
max-width: 36rem;
max-width: 42rem;
flex-direction: column;
overflow: hidden;
border-radius: 10px;
@@ -4566,47 +4566,44 @@ html[data-theme="custom"] .runtime-log-columns {
border: 0 !important;
}
.command-palette-shortcuts {
display: flex;
/* Search scope switch (Global / This page), shown on resource settings pages */
.command-palette-scope {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.375rem;
gap: 0.125rem;
padding: 0.125rem;
border-radius: 0.5rem;
border: 1px solid var(--coollabs-hairline);
}
.command-palette-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
.command-palette-scope-option {
height: 1.375rem;
padding: 0 0.375rem;
padding: 0 0.5rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: var(--coollabs-recessed);
color: var(--coollabs-subtle);
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
font-family: inherit;
}
.dark .command-palette-kbd {
border-color: rgb(255 255 255 / 0.05);
background: rgb(255 255 255 / 0.06);
}
button.command-palette-kbd {
white-space: nowrap;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
button.command-palette-kbd:hover {
.command-palette-scope-option:hover {
color: #171717;
}
.command-palette-scope-option[aria-pressed='true'] {
color: #171717;
background: var(--coollabs-fill);
}
.dark button.command-palette-kbd:hover {
.dark .command-palette-scope-option:hover,
.dark .command-palette-scope-option[aria-pressed='true'] {
color: var(--color-fg);
}
.dark .command-palette-scope-option[aria-pressed='true'] {
background: rgb(255 255 255 / 0.1);
}
+44 -3
View File
@@ -34,10 +34,10 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo
* after the scroll has settled. Starting the flash immediately makes long
* jumps (top → bottom) finish scrolling after the animation has already ended.
*
* @param {string} id
* @param {string|HTMLElement} target Section id or element
*/
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
const el = document.getElementById(id);
window.scrollToSettingsSection = function scrollToSettingsSection(target) {
const el = typeof target === 'string' ? document.getElementById(target) : target;
if (!el) {
return;
}
@@ -136,6 +136,47 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) {
rafId = window.requestAnimationFrame(tick);
};
/**
* Items for the "This page" scope of the command palette: the pages of the
* settings sidebar (rendered by the server), then the visible sections of the
* current page. Sections that the sidebar already lists are skipped.
*
* @returns {Array<{label: string, breadcrumb: string, search_text: string, href: string, navigate?: boolean, element?: HTMLElement}>}
*/
window.currentPageSearchItems = function currentPageSearchItems() {
let items = [];
try {
items = JSON.parse(document.querySelector('[data-settings-search-items]')?.dataset.settingsSearchItems || '[]');
} catch (e) {
items = [];
}
const toPath = (href) => {
const url = new URL(href, window.location.href);
return url.pathname + url.hash;
};
const sidebarPaths = new Set(items.map((item) => toPath(item.href)));
const headingSelector = ':scope > header :is(h1, h2, h3), :scope > .application-settings-section-header :is(h1, h2, h3)';
document.querySelectorAll('.application-settings-section').forEach((section, index) => {
if (section.closest('[role=dialog], .command-palette') || section.getClientRects().length === 0) {
return;
}
if (section.id && sidebarPaths.has(window.location.pathname + '#' + section.id)) {
return;
}
const label = (section.dataset.settingsSectionTitle ?? section.querySelector(headingSelector)?.textContent ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!label) {
return;
}
items.push({ label, breadcrumb: 'Section', search_text: label + ' section', href: 'section-' + index, element: section });
});
return items;
};
// When a settings sub-section link navigates across pages (href="route#section-id"),
// scroll to that section once the destination page has rendered.
function scrollToHashSettingsSection() {
@@ -9,21 +9,6 @@ export function initializeSettingsSidebarAccordionComponent() {
activeGroup: config.activeGroup || '',
storageKey: config.storageKey || 'coolify.settings-sidebar',
groups: {},
// Optional client-side filter (sidebars that render a search box).
search: '',
labels: Array.isArray(config.labels) ? config.labels : [],
get searching() {
return this.search.trim() !== '';
},
matches(label) {
if (!this.searching) {
return true;
}
return String(label).toLowerCase().includes(this.search.trim().toLowerCase());
},
get hasResults() {
return !this.searching || this.labels.some((label) => this.matches(label));
},
init() {
let stored = {};
try {
@@ -243,81 +243,23 @@
],
];
// Flat, searchable index: every page plus its in-page sub-sections. Each
// entry carries a breadcrumb (its category, and parent page for a
// sub-section) and combined text so the query matches sub-pages too.
$searchIndex = [];
foreach ($groupedMenuItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
$searchIndex[] = [
'label' => $item['label'],
'breadcrumb' => $groupLabel,
'searchText' => $item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters),
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => $item['navigate'] ?? true,
];
foreach ($pageSections[$item['route']] ?? [] as $section) {
$searchIndex[] = [
'label' => $section['label'],
'breadcrumb' => $groupLabel.' · '.$item['label'],
'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'],
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => true,
];
}
}
}
$searchTexts = array_column($searchIndex, 'searchText');
// Pages and in-page sections that the command palette (⌘K) can search on this page
$settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections);
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])>
]) data-settings-search-items="{{ json_encode($settingsSearchItems) }}">
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application', labels: @js($searchTexts) })"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
{{-- A quiet inline filter, deliberately lighter than the global ⌘K
search so the two don't read as duplicate search bars. --}}
<div class="relative col-span-full mb-1.5 xl:mb-2">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.100ms="search" type="search" placeholder="Filter settings"
aria-label="Filter settings"
class="h-7 w-full rounded-md border-0 bg-black/[0.035] py-0 pr-7 pl-7 text-[12px] text-nav-text shadow-none outline-none ring-0 transition-colors placeholder:text-neutral-400 focus:bg-black/[0.05] focus-visible:ring-1 focus-visible:ring-accent/40 dark:bg-white/[0.04] dark:text-fg dark:placeholder:text-fg-faint dark:focus:bg-white/[0.06]">
<button x-cloak x-show="searching" type="button" @click="search = ''" aria-label="Clear filter"
class="absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
<x-reicon name="x" class="size-3" />
</button>
</div>
<p x-cloak x-show="searching && !hasResults"
class="col-span-full px-2.5 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
No settings match “<span x-text="search"></span>”.
</p>
{{-- Flat search results (pages + sub-sections), each with its category/parent. --}}
<div x-cloak x-show="searching" class="col-span-full flex flex-col gap-0.5">
@foreach ($searchIndex as $entry)
<a href="{{ $entry['href'] }}" @if ($entry['navigate']) {{ wireNavigate() }} @endif
x-show="matches(@js($entry['searchText']))"
class="group flex flex-col gap-0.5 rounded-md px-2.5 py-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.05]">
<span class="flex min-w-0 items-center gap-2.5">
<x-reicon :name="$entry['icon']" class="size-[18px] shrink-0 text-nav-text opacity-90" />
<span class="truncate text-[13px] font-medium text-nav-text">{{ $entry['label'] }}</span>
</span>
<span class="truncate pl-[28px] text-[11px] text-neutral-400 dark:text-fg-faint">{{ $entry['breadcrumb'] }}</span>
</a>
@endforeach
</div>
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
x-show="!searching" aria-hidden="true"></div>
aria-hidden="true"></div>
@endunless
<button type="button" class="nav-section-toggle hidden xl:flex" x-show="!searching"
<button type="button" class="nav-section-toggle hidden xl:flex"
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
@@ -329,8 +271,7 @@
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
x-show="!searching">
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
@@ -351,7 +292,7 @@
{{-- Sub-sections belong to the current page only; collapse them for
every other item so the sidebar stays short. --}}
@if ($menuItem['active'] && filled($sections))
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-show="!searching"
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($sections as $section)
<button type="button" class="menu-subitem"
@@ -5,7 +5,7 @@
'flush' => false,
])
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
<section {{ $attributes->merge(['class' => 'application-settings-section', 'data-settings-section-title' => $title]) }}>
<header>
<div class="min-w-0 py-0.5">
@if (filled($description ?? $helper))
@@ -55,7 +55,8 @@
};
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Backup' => collect($items)->map(fn (array $item): array => [...$item, 'route' => $routes[$item['key']]])], $parameters)) }}">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
@@ -63,7 +63,8 @@
: [];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $databaseRouteParameters, ['project.database.configuration' => $pageSections])) }}">
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
+8 -2
View File
@@ -55,12 +55,18 @@
{{-- Search is only useful when workspace resources are available --}}
@if (isSubscribed() || ! isCloud())
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0'">
{{-- On pages with settings or sections the palette also searches the current page --}}
<button @click="$dispatch('open-global-search')" type="button"
:title="'Search (Press / or ' + modKeyLabel + 'K)'"
x-data="{ hasPageSearch: false }"
x-init="$nextTick(() => hasPageSearch = (window.currentPageSearchItems?.() ?? []).length > 0)"
:title="hasPageSearch
? 'Search this page and everything (Press / or ' + modKeyLabel + 'K, then Tab to switch)'
: 'Search (Press / or ' + modKeyLabel + 'K)'"
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Search</span>
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search<span
x-show="hasPageSearch" x-cloak class="text-[11px] opacity-70"> page &amp; global</span></span>
</span>
<kbd class="px-1.5 py-0.5 text-[11px] font-medium text-fg-faint bg-neutral-200 dark:bg-white/[0.06] rounded-md border border-transparent dark:border-white/5"
:class="collapsed && 'lg:hidden'" x-text="modKeyLabel + 'K'"></kbd>
@@ -15,7 +15,8 @@
<p class="settings-mobile-description">Configure how your team receives deployment and system alerts.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Notifications' => $notificationMenuItems], [])) }}">
<nav aria-label="Notification settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Notifications</div>
@@ -39,7 +39,8 @@
<p class="settings-mobile-description">Manage SSH keys, cloud credentials, and API access tokens.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Keys & Tokens' => $securityMenuItems], [])) }}">
<nav aria-label="Keys and tokens"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Keys & Tokens</div>
@@ -223,6 +223,7 @@
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedServerMenuItems, $serverRouteParameters)) }}"
x-data="{
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
@@ -52,7 +52,8 @@
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -19,7 +19,8 @@
<p class="settings-mobile-description">Configure global settings for this Coolify instance.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($settingsMenuSections, [])) }}">
<nav aria-label="Instance settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($settingsMenuSections as $section => $menuItems)
@@ -15,7 +15,8 @@
</header>
<div class="grid min-w-0 gap-8 xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
@@ -40,7 +40,8 @@
<p class="settings-mobile-description">Manage your team, members, and access settings.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Team' => $teamMenuItems], [])) }}">
<nav aria-label="Team settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Team</div>
@@ -22,7 +22,8 @@
])->filter();
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Destination' => $destinationMenuItems], $destinationRouteParameters)) }}">
<nav aria-label="Destination settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
+113 -19
View File
@@ -11,12 +11,10 @@
searchQuery: '',
creatableItems: [],
isCreateMode: false,
// macOS/iOS use ⌘; Windows/Linux use Ctrl+
modKeyLabel: (() => {
const platform = navigator.userAgentData?.platform || navigator.platform || '';
const ua = navigator.userAgent || '';
return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';
})(),
// Items of the open page: its sidebar pages and its sections. 'global' shows them
// above the global results; 'page' shows only them. Tab switches the scope.
pageItems: [],
scope: 'global',
serverTimingHudEnabled: localStorage.getItem('coolify.serverTimingHud.enabled') !== '0',
developerCommandsEnabled: @js(app()->environment('local')),
@@ -33,9 +31,60 @@
this.closeModal();
},
get pageResults() {
const query = this.searchQuery.toLowerCase().trim();
if (this.scope !== 'page' && !query) {
return [];
}
const results = this.pageItems.filter(item => item.search_text.toLowerCase().includes(query));
return this.scope === 'page' ? results : results.slice(0, 5);
},
setScope(scope) {
this.scope = scope;
this.selectedIndex = -1;
this.$refs.searchInput?.focus();
},
toggleScope() {
this.setScope(this.scope === 'page' ? 'global' : 'page');
},
// Mobile browsers (iOS Safari) open the keyboard only when focus happens inside the
// tap handler. The palette is still hidden here, so focus a temporary input now and
// move the focus to the search input when the palette shows. The keyboard stays open.
holdKeyboardFocus() {
const holder = document.createElement('input');
holder.setAttribute('aria-hidden', 'true');
holder.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;';
document.body.appendChild(holder);
holder.focus();
setTimeout(() => holder.remove(), 1000);
},
openPageItem(item) {
this.closeModal();
if (item.element) {
if (item.element.id) history.replaceState(null, '', '#' + item.element.id);
window.scrollToSettingsSection?.(item.element);
return;
}
const url = new URL(item.href, window.location.href);
if (url.pathname === window.location.pathname && url.hash) {
history.replaceState(null, '', url.hash);
window.scrollToSettingsSection?.(url.hash.slice(1));
} else if (item.navigate && window.Livewire?.navigate) {
window.Livewire.navigate(url.href);
} else {
window.location.assign(url.href);
}
},
// Client-side search function
get searchResults() {
if (!this.searchQuery || this.searchQuery.length < 1) {
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
return [];
}
@@ -55,7 +104,7 @@
},
get filteredCreatableItems() {
if (!this.searchQuery || this.searchQuery.length < 1) {
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
return [];
}
@@ -102,11 +151,14 @@
}
clearTimeout(this.closeResetTimer);
clearTimeout(this.spinnerTimer);
this.holdKeyboardFocus();
this.modalOpen = true;
this.selectedIndex = -1;
this.isLoadingInitialData = true;
this.showLoadingSpinner = false;
this.searchQuery = '';
this.pageItems = window.currentPageSearchItems?.() ?? [];
this.scope = 'global';
// Only show the spinner when loading takes longer than 150ms, so fast (cached) loads do not flash the icon
this.spinnerTimer = setTimeout(() => {
if (this.isLoadingInitialData) this.showLoadingSpinner = true;
@@ -207,7 +259,7 @@
'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse', 'new sqlite'
];
if (exactMatchCommands.includes(trimmed)) {
if (this.scope === 'global' && exactMatchCommands.includes(trimmed)) {
const matchingItem = this.creatableItems.find(item => {
const itemSearchText = `new ${item.name}`.toLowerCase();
const itemType = `new ${item.type}`.toLowerCase();
@@ -271,6 +323,14 @@
}
}
};
// Capture phase, so the focus trap of the open palette does not move focus first
const tabKeyHandler = (e) => {
if (e.key !== 'Tab' || !this.modalOpen || this.pageItems.length === 0) return;
if (document.activeElement !== this.$refs.searchInput) return;
e.preventDefault();
e.stopImmediatePropagation();
this.toggleScope();
};
const arrowKeyHandler = (e) => {
if (!this.modalOpen) return;
if (e.key === 'ArrowDown') {
@@ -288,6 +348,7 @@
document.addEventListener('keydown', cmdKHandler);
document.addEventListener('keydown', escapeKeyHandler);
document.addEventListener('keydown', arrowKeyHandler);
document.addEventListener('keydown', tabKeyHandler, true);
// Cleanup on component destroy
this.$el.addEventListener('alpine:destroy', () => {
@@ -296,6 +357,7 @@
document.removeEventListener('keydown', cmdKHandler);
document.removeEventListener('keydown', escapeKeyHandler);
document.removeEventListener('keydown', arrowKeyHandler);
document.removeEventListener('keydown', tabKeyHandler, true);
});
// Watch for auto-open resource (only if $wire is available)
@@ -358,20 +420,21 @@
</svg>
</span>
<input type="text" x-model="searchQuery"
placeholder="Search resources, paths, everything (type new for create)..." x-ref="searchInput"
:placeholder="scope === 'page' ? 'Search this page…' : 'Search resources, paths, everything (type new for create)...'"
x-ref="searchInput"
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })"
class="command-palette-input" autocomplete="off" spellcheck="false" />
<div class="command-palette-shortcuts">
<span class="command-palette-kbd">/</span>
<span class="command-palette-kbd" x-text="modKeyLabel + 'K'"></span>
<button type="button" @click="closeModal()" class="command-palette-kbd" title="Close">
ESC
</button>
<div x-show="pageItems.length > 0" x-cloak class="command-palette-scope" role="group"
aria-label="Search scope" title="Press Tab to switch between global search and this page">
<button type="button" @click="setScope('global')" :aria-pressed="scope === 'global'"
class="command-palette-scope-option">Global</button>
<button type="button" @click="setScope('page')" :aria-pressed="scope === 'page'"
class="command-palette-scope-option">This page</button>
</div>
</div>
<!-- Search results -->
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body relative">
<div x-show="searchQuery.length >= 1 || scope === 'page'" x-cloak class="command-palette-body relative">
@if (app()->environment('local'))
<div x-show="showServerTimingCommand && !$wire.isSelectingResource"
class="command-palette-section">
@@ -602,9 +665,31 @@
@endif
<div wire:ignore>
<template x-if="pageResults.length > 0 && !$wire.isSelectingResource">
<div class="command-palette-section">
<div class="command-palette-group-label">This page</div>
<template x-for="item in pageResults" :key="item.href">
<a :href="item.element ? (item.element.id ? '#' + item.element.id : '#') : item.href" @click.prevent="openPageItem(item)"
class="search-result-item command-palette-item">
<div class="command-palette-item-main">
<div class="command-palette-item-title">
<span class="command-palette-item-name" x-text="item.label"></span>
</div>
<div class="command-palette-item-meta" x-text="item.breadcrumb"></div>
</div>
<svg class="command-palette-item-chevron" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</template>
</div>
</template>
<template x-if="searchQuery.length >= 1 && searchResults.length > 0 && !$wire.isSelectingResource">
<div class="command-palette-section">
<template x-if="filteredCreatableItems.length > 0">
<template x-if="filteredCreatableItems.length > 0 || pageResults.length > 0">
<div class="command-palette-group-label">Existing resources</div>
</template>
<template x-for="(result, index) in searchResults" :key="index">
@@ -704,7 +789,7 @@
</template>
<template
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
x-if="scope === 'global' && searchQuery.length >= 2 && pageResults.length === 0 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
<div class="command-palette-empty">
<p class="command-palette-empty-title">No results found</p>
<p class="command-palette-empty-desc">
@@ -712,6 +797,15 @@
</p>
</div>
</template>
<template x-if="scope === 'page' && pageResults.length === 0">
<div class="command-palette-empty">
<p class="command-palette-empty-title">Nothing on this page</p>
<p class="command-palette-empty-desc">
Press <span class="font-medium">Tab</span> to search everything.
</p>
</div>
</template>
</div>
</div>
</div>
@@ -61,7 +61,8 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@@ -43,7 +43,8 @@
@if ($showSettingsSidebar)
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['GitHub App' => $settingsMenuItems], $githubAppRouteParameters)) }}">
<nav aria-label="GitHub App settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
@@ -36,7 +36,8 @@
@if ($showSettingsSidebar)
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['S3 storage' => $settingsMenuItems], $storageRouteParameters)) }}">
<nav aria-label="S3 storage settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
@@ -373,7 +373,7 @@ test('the Compose deployment validates the repository file before any command us
->and($body)->not->toContain('$this->application->loadComposeFile(')
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->oldRawParser()'))
->and($loadPosition)->toBeLessThan(strpos($body, '$this->parseComposeFileForDeployment()'))
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
->and($loadPosition)->toBeLessThan(strpos($body, "'input' =>"))
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
});
@@ -411,7 +411,7 @@ it('hides the Nixpacks plan write command that contains build-time values', func
'not static' => [false],
]);
it('hides the compose file write command because compose files can contain secrets', function () {
it('streams the compose file over stdin so its content stays out of commands, errors, and logs', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
setSensitiveFailureJobProperties($job, $reflection, [
@@ -419,12 +419,10 @@ it('hides the compose file write command because compose files can contain secre
'production_image_name' => 'example/app:latest',
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $process->command, $matches);
$composePayloads = array_filter($matches[0], fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), 'services:'));
if ($composePayloads !== []) {
$payloads = array_merge($payloads, $composePayloads);
$composeWrite = null;
Process::fake(function (PendingProcess $process) use (&$composeWrite) {
if (str_contains((string) $process->input, 'services:')) {
$composeWrite = $process;
return Process::result(errorOutput: 'tee: docker-compose.yaml: No space left on device', exitCode: 1);
}
@@ -434,15 +432,14 @@ it('hides the compose file write command because compose files can contain secre
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_compose_file'));
expect($payloads)->not->toBeEmpty();
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->toContain('No space left on device');
$storedLogs = (string) $queue->fresh()->logs;
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
expect($storedLogs)->not->toContain($payload);
}
$compose = (string) readSensitiveFailureJobProperty($job, $reflection, 'docker_compose');
expect($composeWrite)->not->toBeNull()
->and($composeWrite->input)->toBe($compose)
->and($composeWrite->command)->toEndWith(escapeshellarg('docker exec -i sensitive-failure-deployment tee /artifacts/sensitive-failure/docker-compose.yaml > /dev/null'))
->and($composeWrite->command)->not->toContain('services:')
->and($exception->getMessage())->toContain('No space left on device')
->and($exception->getMessage())->not->toContain('services:')
->and((string) $queue->fresh()->logs)->not->toContain('services:');
});
function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
@@ -0,0 +1,93 @@
<?php
use App\Livewire\GlobalSearch;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Cache::flush();
});
function globalSearchNavigationLinks(Team $team, string $role = 'owner'): array
{
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
test()->actingAs($user);
session(['currentTeam' => $team]);
return collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems'))
->where('type', 'navigation')
->pluck('link')
->all();
}
function adminOnlyNavigationLinks(): array
{
return [
route('registries.index'),
route('team.audit-log'),
route('team.danger-zone'),
route('security.cloud-tokens'),
route('security.integration-tokens'),
route('security.cloud-init-scripts'),
route('terminal'),
];
}
it('lists every instance settings page for the root team', function () {
$links = globalSearchNavigationLinks(Team::factory()->create(['id' => 0]));
expect($links)->toContain(
route('settings.index'),
route('settings.advanced'),
route('settings.updates'),
route('settings.backup'),
route('settings.email'),
route('settings.oauth'),
);
});
it('does not list instance settings pages for other teams', function () {
$links = globalSearchNavigationLinks(Team::factory()->create());
expect(collect($links)->filter(fn (string $link) => str_contains($link, '/settings')))->toBeEmpty();
});
it('lists team, security, notification and profile pages for a team owner', function () {
$links = globalSearchNavigationLinks(Team::factory()->create());
expect($links)->toContain(
route('analytics'),
route('shared-variables.server.index'),
route('team.member.index'),
route('security.api-tokens'),
route('notifications.discord'),
route('notifications.telegram'),
route('notifications.slack'),
route('notifications.pushover'),
route('notifications.webhook'),
route('profile.appearance'),
...adminOnlyNavigationLinks(),
);
});
it('hides admin-only pages from team members', function () {
$links = globalSearchNavigationLinks(Team::factory()->create(), 'member');
expect($links)->toContain(route('team.member.index'), route('security.api-tokens'))
->not->toContain(...adminOnlyNavigationLinks());
});
it('does not leak admin-only pages to a member through the team search cache', function () {
$team = Team::factory()->create();
expect(globalSearchNavigationLinks($team))->toContain(...adminOnlyNavigationLinks());
expect(globalSearchNavigationLinks($team, 'member'))->not->toContain(...adminOnlyNavigationLinks());
});
+52
View File
@@ -0,0 +1,52 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function serverSettingsSearchGroups(): array
{
return [
'Security' => [
[
'label' => 'Security',
'route' => 'server.security.patches',
'children' => [
['label' => 'Server Patching', 'route' => 'server.security.patches'],
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'navigate' => false],
['label' => 'Hidden', 'route' => 'server.security', 'visible' => false],
],
],
],
'General' => [
['label' => 'Proxy', 'route' => 'server.proxy'],
],
];
}
it('lists pages, visible child pages and in-page sections with their breadcrumbs', function () {
InstanceSettings::forceCreate(['id' => 0]);
$items = settingsSearchItems(
serverSettingsSearchGroups(),
['server_uuid' => 'abc'],
['server.proxy' => [['id' => 'proxy-config-section', 'label' => 'Configuration']]],
);
expect($items)->toBe([
['label' => 'Security', 'breadcrumb' => 'Security', 'search_text' => 'Security Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Server Patching', 'breadcrumb' => 'Security · Security', 'search_text' => 'Server Patching Security · Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Terminal Access', 'breadcrumb' => 'Security · Security', 'search_text' => 'Terminal Access Security · Security', 'href' => route('server.security.terminal-access', ['server_uuid' => 'abc']), 'navigate' => false],
['label' => 'Proxy', 'breadcrumb' => 'General', 'search_text' => 'Proxy General', 'href' => route('server.proxy', ['server_uuid' => 'abc']), 'navigate' => true],
['label' => 'Configuration', 'breadcrumb' => 'General · Proxy', 'search_text' => 'Configuration General · Proxy', 'href' => route('server.proxy', ['server_uuid' => 'abc']).'#proxy-config-section', 'navigate' => true],
]);
});
it('uses full page loads when SPA navigation is disabled', function () {
InstanceSettings::forceCreate(['id' => 0, 'is_wire_navigate_enabled' => false]);
$items = settingsSearchItems(serverSettingsSearchGroups(), ['server_uuid' => 'abc']);
expect(collect($items)->pluck('navigate')->unique()->all())->toBe([false]);
});
@@ -0,0 +1,13 @@
<?php
it('keeps bare list-style build args as named keys when merged into a map environment', function () {
$environment = collect(['SENTRY_CONF' => '/etc/sentry', 'SENTRY_IMAGE' => 'getsentry/sentry'])
->merge(['SENTRY_IMAGE', 'DOCKER_PLATFORM', 'FOO=bar']);
expect(convertToKeyValueCollection($environment)->all())->toBe([
'SENTRY_CONF' => '/etc/sentry',
'SENTRY_IMAGE' => 'getsentry/sentry',
'DOCKER_PLATFORM' => null,
'FOO' => 'bar',
]);
});
@@ -49,3 +49,22 @@ it('keeps a finite collection alias limit for Docker Compose files', function ()
$application->oldRawParser();
})->throws(RuntimeException::class, 'Maximum number of collection aliases (256) exceeded');
it('accepts Docker Compose files with more than 128 collection aliases during the safety check', function () {
$services = collect(range(1, 129))
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
->implode('');
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
expect(parseDockerComposeYaml("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}"))
->toHaveKey('services.service-129.image', 'alpine:latest');
});
it('keeps a finite collection alias limit during the Docker Compose safety check', function () {
$services = collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1))
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
->implode('');
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
})->throws(Exception::class, 'Maximum number of collection aliases (256) exceeded');
@@ -0,0 +1,167 @@
<?php
use App\Models\GithubApp;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->stack = seedBrowserResourceStack();
$this->application = createBrowserApplication($this->stack, [
'uuid' => 'app-palette-settings',
'name' => 'Palette App',
]);
});
it('shows the settings of the open resource in the command palette and switches scope with Tab', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
$page->assertSee('Palette App')
->assertMissing('[aria-label="Filter settings"]')
->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-input')
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'true')
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'false')
->type('.command-palette-input', 'runtime')
->assertSeeIn('.command-palette-body', 'This page')
->assertSeeIn('.command-palette-body', 'Runtime Logs')
->assertSeeIn('.command-palette-body', 'Settings · General')
->keys('.command-palette-input', 'Tab')
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'true')
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'false')
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
->type('.command-palette-input', 'zzz-nothing')
->assertSeeIn('.command-palette-body', 'Nothing on this page')
->type('.command-palette-input', 'runtime logs')
->screenshot(filename: 'command-palette-this-page-scope')
->click('.command-palette-body a.search-result-item')
->assertPathEndsWith('/logs')
->screenshot(filename: 'command-palette-opened-runtime-logs');
});
it('does not show the scope switch on pages without a settings sidebar', function () {
loginAndSkipBoarding();
$page = visit('/dashboard');
$page->assertDontSee('page & global');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-input')
->assertMissing('.command-palette-scope')
->screenshot(filename: 'command-palette-without-scope');
});
it('focuses the search input when the palette is opened from the mobile menu', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application))
->on()->mobile();
$page->assertSee('Palette App')
->click('button:visible:has-text("Open sidebar")')
->assertSee('Search page & global')
->screenshot(filename: 'command-palette-mobile-menu')
->click('button:visible:has-text("Search")')
->assertVisible('.command-palette-input')
->wait(0.3)
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
->assertVisible('.command-palette-scope')
->screenshot(filename: 'command-palette-mobile');
});
function commandPaletteSettingsPageUrl(string $page, array $stack): string
{
return match ($page) {
'instance settings' => '/settings/oauth',
'team' => '/team',
'keys and tokens' => '/security/private-key',
'notifications' => '/notifications/email',
'shared variables' => '/shared-variables',
'destination' => '/destination/'.$stack['destination']->uuid,
's3 storage' => '/storages/'.S3Storage::create([
'team_id' => 0, 'name' => 'Palette S3', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret',
'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com',
])->uuid,
'github app' => '/source/github/'.GithubApp::create([
'name' => 'palette-app', 'organization' => 'acme', 'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => 1234,
'installation_id' => 5678, 'webhook_secret' => 'secret', 'private_key_id' => $stack['privateKey']->id,
'team_id' => 0, 'is_system_wide' => false,
])->uuid,
'database backup' => (function () use ($stack): string {
$database = createBrowserPostgresql($stack, ['uuid' => 'db-palette-backup']);
$backup = ScheduledDatabaseBackup::create([
'team_id' => 0, 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
]);
return "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/database/{$database->uuid}/backups/{$backup->uuid}";
})(),
};
}
it('searches the sidebar pages of other settings pages', function (string $pageType, string $expectedLabel) {
$url = commandPaletteSettingsPageUrl($pageType, $this->stack);
loginAndSkipBoarding();
$page = visit($url);
$page->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-scope')
->keys('.command-palette-input', 'Tab')
->assertSeeIn('.command-palette-body', $expectedLabel)
->screenshot(filename: 'command-palette-page-'.str($pageType)->slug());
})->with([
['instance settings', 'Updates'],
['team', 'Members'],
['keys and tokens', 'API Tokens'],
['notifications', 'Telegram'],
['shared variables', 'Environments'],
['destination', 'Danger Zone'],
['s3 storage', 'Resources'],
['github app', 'Permissions'],
['database backup', 'Retention'],
]);
it('searches the sections of a page without a settings sidebar and scrolls to them', function () {
loginAndSkipBoarding();
$page = visit('/profile');
$page->assertVisible('button:visible:has-text("Search page & global")');
$page->script("window.dispatchEvent(new Event('open-global-search'))");
$page->assertVisible('.command-palette-scope')
->keys('.command-palette-input', 'Tab')
->type('.command-palette-input', 'two-factor')
->assertSeeIn('.command-palette-body', 'Two-factor authentication')
->click('.command-palette-body a.search-result-item')
->assertMissing('.command-palette-input')
->wait(1)
->assertScript(<<<'JS'
() => {
const heading = [...document.querySelectorAll('.application-settings-section h2')]
.find(el => el.textContent.includes('Two-factor authentication'));
const top = heading.getBoundingClientRect().top;
return top >= 0 && top < window.innerHeight;
}
JS)
->screenshot(filename: 'command-palette-profile-section');
});
it('lists a section once when the sidebar already links to it', function () {
loginAndSkipBoarding();
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
$page->assertSee('Palette App');
expect($page->script("window.currentPageSearchItems().filter(item => item.label === 'Container labels').length"))->toBe(1);
});