Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai committed 2026-07-07 12:56:19 +02:00
commit ff976a134f
136 files changed
+6142 -1733

No files matched your search

@@ -143,8 +143,6 @@ class StartDatabaseProxy
)
);
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return;
}
+1 -1
View File
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
-21
View File
@@ -26,22 +26,14 @@ class DeleteServer
);
}
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try {
$server->forceDelete();
} catch (\Throwable $e) {
ray('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
@@ -66,10 +58,6 @@ class DeleteServer
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
@@ -77,16 +65,7 @@ class DeleteServer
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [
+1 -1
View File
@@ -26,7 +26,7 @@ class StartSentinel
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
+5 -1
View File
@@ -118,10 +118,14 @@ class UpdateCoolify
{
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
'bash /data/coolify/source/upgrade.sh '.
escapeshellarg($this->latestVersion).' '.
escapeshellarg($latestHelperImageVersion).' '.
escapeshellarg($registryUrl),
], $this->server);
}
}
@@ -215,6 +215,7 @@ class ApplicationsController extends Controller
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -382,6 +383,7 @@ class ApplicationsController extends Controller
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -548,6 +550,7 @@ class ApplicationsController extends Controller
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@@ -741,6 +744,7 @@ class ApplicationsController extends Controller
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@@ -875,6 +879,7 @@ class ApplicationsController extends Controller
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@@ -959,7 +964,7 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -997,6 +1002,10 @@ class ApplicationsController extends Controller
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
@@ -1005,6 +1014,7 @@ class ApplicationsController extends Controller
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled;
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
@@ -1076,7 +1086,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1132,7 +1142,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1182,15 +1192,15 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$repository_url_parsed = Url::fromString($request->git_repository);
$git_host = $repository_url_parsed->getHost();
if ($git_host === 'github.com') {
$application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id;
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
}
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
$application->fqdn = $fqdn;
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
@@ -1212,6 +1222,10 @@ class ApplicationsController extends Controller
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -1284,7 +1298,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -1373,7 +1387,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1423,7 +1437,7 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$application->fqdn = $fqdn;
$application->git_repository = str($gitRepository)->trim()->toString();
@@ -1457,6 +1471,10 @@ class ApplicationsController extends Controller
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -1524,7 +1542,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
@@ -1586,7 +1604,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -1636,7 +1654,7 @@ class ApplicationsController extends Controller
$request->offsetUnset('docker_compose_domains');
}
if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson;
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
}
$application->fqdn = $fqdn;
$application->private_key_id = $privateKey->id;
@@ -1666,6 +1684,10 @@ class ApplicationsController extends Controller
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -1790,6 +1812,10 @@ class ApplicationsController extends Controller
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -1909,6 +1935,10 @@ class ApplicationsController extends Controller
$application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -2058,6 +2088,13 @@ class ApplicationsController extends Controller
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
@@ -2121,8 +2158,9 @@ class ApplicationsController extends Controller
], 400);
}
$lines = $request->query->get('lines', 100) ?: 100;
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines);
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
@@ -2269,6 +2307,7 @@ class ApplicationsController extends Controller
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'],
'start_command' => ['type' => 'string', 'description' => 'The start command.'],
@@ -2328,6 +2367,7 @@ class ApplicationsController extends Controller
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
],
)
),
@@ -2413,7 +2453,7 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'include_source_commit_in_build'];
$validationRules = [
'name' => 'string|max:255',
@@ -2423,11 +2463,13 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string',
'include_source_commit_in_build' => 'boolean',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -2527,29 +2569,7 @@ class ApplicationsController extends Controller
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
@@ -2557,6 +2577,9 @@ class ApplicationsController extends Controller
'errors' => $errors,
], 422);
}
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -2601,7 +2624,7 @@ class ApplicationsController extends Controller
$errors = [];
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
return $url;
@@ -2660,10 +2683,12 @@ class ApplicationsController extends Controller
$isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled;
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
$includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build');
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -2689,6 +2714,11 @@ class ApplicationsController extends Controller
$application->settings->save();
}
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save();
@@ -2702,6 +2732,10 @@ class ApplicationsController extends Controller
$application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
$application->settings->save();
}
if ($request->has('include_source_commit_in_build')) {
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
}
removeUnnecessaryFieldsFromRequest($request);
$data = $request->only($allowedFields);
@@ -3669,7 +3703,7 @@ class ApplicationsController extends Controller
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
@@ -3677,7 +3711,7 @@ class ApplicationsController extends Controller
return response()->json(
[
'message' => 'Deployment request queued.',
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
],
200
);
@@ -3863,13 +3897,13 @@ class ApplicationsController extends Controller
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
]);
return response()->json(
[
'message' => 'Restart request queued.',
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
],
);
}
@@ -3916,36 +3950,16 @@ class ApplicationsController extends Controller
}
if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -2335,6 +2335,116 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Invalid database type requested.'], 400);
}
#[OA\Get(
summary: 'Get database logs.',
description: 'Get database logs by UUID.',
path: '/databases/{uuid}/logs',
operationId: 'get-database-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get database logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id);
if ($containers->count() == 0) {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$container = $containers->first();
$status = getContainerStatus($database->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete database by UUID.',
@@ -369,7 +369,7 @@ class DeployController extends Controller
$uuids = $request->input('uuid');
$tags = $request->input('tag');
$force = $request->input('force') ?? false;
$force = $request->boolean('force');
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
@@ -429,7 +429,7 @@ class DeployController extends Controller
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
} else {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
}
@@ -475,7 +475,7 @@ class DeployController extends Controller
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
}
$message = $message->merge($return_message);
}
@@ -533,7 +533,7 @@ class DeployController extends Controller
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(),
'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
+22 -5
View File
@@ -136,7 +136,7 @@ class GithubController extends Controller
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
@@ -212,10 +212,14 @@ class GithubController extends Controller
'is_system_wide',
];
$request->merge([
'organization' => normalizeGithubOrganization($request->input('organization')),
]);
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
@@ -259,7 +263,9 @@ class GithubController extends Controller
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'api_url' => filled($request->input('api_url'))
? $request->input('api_url')
: githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
@@ -596,13 +602,17 @@ class GithubController extends Controller
$payload = $request->only($allowedFields);
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
}
if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl];
@@ -646,6 +656,13 @@ class GithubController extends Controller
], 422);
}
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)
+126 -18
View File
@@ -112,19 +112,10 @@ class ServicesController extends Controller
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
$urls = collect(ValidationPatterns::applicationDomainList(
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@@ -153,10 +144,10 @@ class ServicesController extends Controller
}
if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) {
$errors[] = $result['error'];
@@ -168,8 +159,6 @@ class ServicesController extends Controller
return;
}
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else {
$containerUrls = null;
}
@@ -803,6 +792,125 @@ class ServicesController extends Controller
return response()->json($this->removeSensitiveData($service));
}
#[OA\Get(
summary: 'Get service logs.',
description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.',
path: '/services/{uuid}/logs',
operationId: 'get-service-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'sub_service_name',
in: 'query',
description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.',
required: true,
schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get service logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$subServiceName = $request->query->get('sub_service_name');
if (! $subServiceName) {
return response()->json(['message' => 'Sub service name is required.'], 400);
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$name = "{$subServiceName}-{$service->uuid}";
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name);
$container = $containers->first();
if (! $container) {
return response()->json(['message' => 'Container not found.'], 404);
}
$status = getContainerStatus($service->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Container is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete service by UUID.',
+17 -4
View File
@@ -98,7 +98,7 @@ class Controller extends BaseController
public function link()
{
$token = request()->get('token');
if ($token) {
if (is_string($token) && $token !== '') {
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
@@ -126,9 +126,8 @@ class Controller extends BaseController
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
@@ -139,10 +138,11 @@ class Controller extends BaseController
}
$invitation->delete();
Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
@@ -152,6 +152,19 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
{
$query = parse_url($invitation->link, PHP_URL_QUERY);
if (! is_string($query)) {
return false;
}
parse_str($query, $parameters);
$storedToken = $parameters['token'] ?? null;
return is_string($storedToken) && hash_equals($storedToken, $token);
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
+1 -1
View File
@@ -162,7 +162,7 @@ class Bitbucket extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
'repository' => $full_name ?? null,
]);
+1 -1
View File
@@ -148,7 +148,7 @@ class Gitea extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
+1 -1
View File
@@ -190,7 +190,7 @@ class Gitlab extends Controller
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
+94 -12
View File
@@ -52,6 +52,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1;
public $timeout = 3600;
@@ -1701,6 +1716,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1752,6 +1771,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -2124,7 +2147,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
@@ -2229,7 +2252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
}
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
@@ -2241,17 +2264,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} ";
$this->coolify_variables .= "COOLIFY_FQDN={$url} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
}
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@@ -2327,7 +2366,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
);
}
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
// Note: Git warnings can be on the same line as the result (no newline)
@@ -2359,6 +2398,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function shouldResolveBranchHeadCommit(): bool
{
$commit = trim($this->commit);
return $commit === '' || $commit === 'HEAD';
}
private function clone_repository()
{
$importCommands = $this->generate_git_import_commands();
@@ -2566,6 +2612,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function is_reserved_docker_client_env_key(?string $key): bool
{
if (blank($key)) {
return false;
}
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
}
private function without_reserved_docker_client_variables(Collection $variables): Collection
{
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
}
private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
@@ -2686,6 +2746,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function railpack_build_command(string $imageName, Collection $variables): string
{
$variables = $this->without_reserved_docker_client_variables($variables);
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
@@ -2712,7 +2774,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
$buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack"
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
@@ -2723,7 +2785,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
." -t {$imageName}"
." {$this->workdir}";
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
}
@@ -2878,9 +2940,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
}
private function ensure_helper_docker_buildx_available_for_railpack(): void
{
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
'hidden' => true,
'save' => 'railpack_helper_buildx_available',
]);
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
}
private function build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$this->ensure_helper_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
@@ -4080,6 +4158,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$variables = $this->env_args;
if ($this->build_pack === 'railpack') {
$variables = $this->without_reserved_docker_client_variables($variables);
}
if ($variables->isEmpty()) {
return '';
}
@@ -4220,7 +4302,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
@@ -4242,7 +4324,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
+1 -1
View File
@@ -36,7 +36,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
+7 -2
View File
@@ -16,6 +16,7 @@ use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -716,8 +717,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedSecret = escapeshellarg($secret);
$escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
@@ -734,7 +739,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
+13 -2
View File
@@ -51,13 +51,24 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
}
}
+24 -7
View File
@@ -44,15 +44,26 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
if ($this->isSlackWebhook()) {
$this->sendToSlack();
$this->sendToSlack($httpOptions);
return;
}
@@ -62,7 +73,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$this->sendToMattermost();
$this->sendToMattermost($httpOptions);
}
private function isSlackWebhook(): bool
@@ -79,9 +90,12 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
return $scheme === 'https' && $host === 'hooks.slack.com';
}
private function sendToSlack(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToSlack(array $httpOptions): void
{
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'text' => $this->message->title,
'blocks' => [
[
@@ -119,11 +133,14 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
/**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/
private function sendToMattermost(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToMattermost(array $httpOptions): void
{
$username = config('app.name');
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
+10 -14
View File
@@ -50,28 +50,24 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
'payload' => $this->payload,
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
'status' => $response->status(),
'body' => $response->body(),
'successful' => $response->successful(),
]);
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
}
}
-1
View File
@@ -179,7 +179,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
$this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off');
}
}
-2
View File
@@ -251,7 +251,6 @@ class GlobalSearch extends Component
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
ray()->showQueries();
$items = collect();
$team = auth()->user()->currentTeam();
@@ -530,7 +529,6 @@ class GlobalSearch extends Component
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
];
});
ray($servers);
// Get all projects
$projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services'])
-14
View File
@@ -168,13 +168,6 @@ class Webhook extends Component
$this->syncData(true);
refreshSession();
if (isDev()) {
ray('Webhook settings saved', [
'webhook_enabled' => $this->settings->webhook_enabled,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->dispatch('success', 'Settings saved.');
}
@@ -183,13 +176,6 @@ class Webhook extends Component
try {
$this->authorize('sendTest', $this->settings);
if (isDev()) {
ray('Sending test webhook notification', [
'team_id' => $this->team->id,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->team->notify(new Test(channel: 'webhook'));
$this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) {
+6 -12
View File
@@ -12,7 +12,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
class General extends Component
{
@@ -142,7 +141,8 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@@ -771,16 +771,7 @@ class General extends Component
$oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
@@ -863,6 +854,9 @@ class General extends Component
}
}
if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -6,6 +6,7 @@ use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -117,12 +118,14 @@ class Previews extends Component
});
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
@@ -33,6 +34,11 @@ class PreviewsCompose extends Component
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@@ -73,9 +79,13 @@ class PreviewsCompose extends Component
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string);
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
+3 -1
View File
@@ -14,6 +14,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -598,6 +599,7 @@ EOD;
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]);
// Check if file exists
@@ -678,7 +680,7 @@ EOD;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
+9 -3
View File
@@ -112,14 +112,17 @@ class Select extends Component
$default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [
'name' => str($key)->headline(),
'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + (array) $service;
})->all();
@@ -336,7 +339,10 @@ class Select extends Component
public function setType(string $type)
{
$type = str($type)->lower()->slug()->value();
if (! str($type)->startsWith('one-click-service-')) {
$type = str($type)->lower()->slug()->value();
}
if ($this->loading) {
return;
}
+10 -13
View File
@@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
@@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null;
#[Validate(['nullable'])]
#[Validate]
public ?string $fqdn = null;
protected $rules = [
'fqdn' => 'nullable',
];
protected function rules(): array
{
return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount()
{
@@ -82,15 +85,9 @@ class EditDomain extends Component
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate();
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+5 -9
View File
@@ -8,11 +8,11 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -480,15 +480,11 @@ class Index extends Component
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate([
'fqdn' => ValidationPatterns::applicationDomainRules(),
]);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
@@ -93,6 +93,10 @@ class All extends Component
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -119,12 +123,21 @@ class All extends Component
return trim($this->search);
}
private function supportsPreviewEnvironmentVariables(): bool
{
return $this->showPreview && $this->resource instanceof Application;
}
public function getHasEnvironmentVariablesProperty(): bool
{
return $this->environmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
$hasPreviewEnvironmentVariables;
}
private function nullLockedValues($envs)
@@ -158,6 +171,10 @@ class All extends Component
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect([]);
}
// Only for services and docker-compose applications
if ($this->resource->type() !== 'service' &&
($this->resourceClass !== 'App\Models\Application' ||
-1
View File
@@ -90,7 +90,6 @@ class Logs extends Component
}
} catch (\Exception $e) {
// Log error but don't fail the entire operation
ray("Error loading containers for server {$server->name}: ".$e->getMessage());
return [];
}
+14
View File
@@ -537,6 +537,20 @@ class Show extends Component
->get();
}
#[Computed]
public function limaStartCommand(): ?string
{
if (! isDev()) {
return null;
}
return match ($this->server->uuid) {
'lima-ubuntu-2404' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml',
'lima-ubuntu-2604' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml',
default => null,
};
}
public function searchHetznerServer(): void
{
$this->hetznerSearchError = null;
+64 -2
View File
@@ -43,6 +43,11 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_mcp_server_enabled;
public ?string $webhook_allowed_internal_hosts = null;
#[Validate('boolean')]
public bool $webhook_allow_localhost;
public function rules()
{
return [
@@ -56,6 +61,8 @@ class Advanced extends Component
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
];
}
@@ -75,6 +82,8 @@ class Advanced extends Component
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
}
public function submit()
@@ -141,13 +150,21 @@ class Advanced extends Component
$this->allowed_ips = implode(',', $validEntries);
}
$this->instantSave();
$webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts();
if ($webhookAllowedInternalHosts === false) {
return;
}
$this->instantSave($webhookAllowedInternalHosts);
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
/**
* @param array<int, string>|null $webhookAllowedInternalHosts
*/
public function instantSave(?array $webhookAllowedInternalHosts = null)
{
try {
$this->authorize('update', $this->settings);
@@ -161,6 +178,8 @@ class Advanced extends Component
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$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->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
@@ -168,6 +187,49 @@ class Advanced extends Component
}
}
/**
* @return array<int, string>|false
*/
private function normalizeWebhookAllowedInternalHosts(): array|false
{
$entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: [])
->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.'))
->filter()
->unique()
->values();
$invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry));
if ($invalidEntries->isNotEmpty()) {
$this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', '));
return false;
}
$this->webhook_allowed_internal_hosts = $entries->implode(',');
return $entries->all();
}
private function isValidWebhookAllowlistEntry(string $entry): bool
{
if (filter_var($entry, FILTER_VALIDATE_IP)) {
return true;
}
if (str_contains($entry, '/')) {
[$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null);
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$maxMask = $isIpv6 ? 128 : 32;
return filter_var($ip, FILTER_VALIDATE_IP) !== false
&& is_numeric($mask)
&& (int) $mask >= 0
&& (int) $mask <= $maxMask;
}
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
public function toggleRegistration($password): bool
{
if (! verifyPasswordConfirmation($password, $this)) {
-2
View File
@@ -47,8 +47,6 @@ class Index extends Component
public bool $forceSaveDomains = false;
public $buildActivityId = null;
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
+43
View File
@@ -6,6 +6,8 @@ use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -26,6 +28,9 @@ class Updates extends Component
#[Validate('boolean')]
public bool $is_auto_update_enabled;
#[Validate('required|string|in:docker.io,ghcr.io')]
public string $docker_registry_url;
public function mount()
{
if (! isInstanceAdmin()) {
@@ -39,6 +44,7 @@ class Updates extends Component
$this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency;
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
$this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io';
}
public function instantSave()
@@ -50,16 +56,51 @@ class Updates extends Component
'auto_update_frequency' => ['required', 'string'],
]);
}
$validated = $this->validate([
'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'],
]);
$this->settings->auto_update_frequency = $this->auto_update_frequency;
$this->settings->update_check_frequency = $this->update_check_frequency;
$this->settings->is_auto_update_enabled = $this->is_auto_update_enabled;
$this->settings->docker_registry_url = $validated['docker_registry_url'];
$this->syncRegistryUrlToEnv($validated['docker_registry_url']);
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
return handleError($e, $this);
}
}
protected function syncRegistryUrlToEnv(string $registryUrl): void
{
if (! $this->server) {
return;
}
try {
instant_remote_process([
$this->registryEnvSyncCommand($registryUrl),
], $this->server);
} catch (\Exception $e) {
Log::warning('Failed to sync REGISTRY_URL to .env', [
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Failed to sync REGISTRY_URL to .env. Settings were not saved.', previous: $e);
}
}
private function registryEnvSyncCommand(string $registryUrl): string
{
$envFile = '/data/coolify/source/.env';
$sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|");
$registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}");
return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi";
}
public function submit()
{
try {
@@ -89,6 +130,8 @@ class Updates extends Component
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
return handleError($e, $this);
}
+51 -53
View File
@@ -8,11 +8,8 @@ use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Change extends Component
@@ -82,11 +79,13 @@ class Change extends Component
public string $activeTab = 'general';
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
protected function rules(): array
{
return [
'name' => 'required|string',
'organization' => 'nullable|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'customUser' => 'required|string',
@@ -107,6 +106,19 @@ class Change extends Component
];
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|| $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
}
}
public function boot()
{
if ($this->github_app) {
@@ -123,6 +135,11 @@ class Change extends Component
{
if ($toModel) {
// Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name;
$this->github_app->organization = $this->organization;
$this->github_app->api_url = $this->apiUrl;
@@ -208,6 +225,8 @@ class Change extends Component
return;
}
syncGithubAppName($this->github_app);
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false);
@@ -296,31 +315,14 @@ class Change extends Component
public function getGithubAppNameUpdatePath()
{
if (str($this->github_app->organization)->isNotEmpty()) {
return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}";
$name = encodeGithubPathSegment($this->github_app->name);
$organization = normalizeGithubOrganization($this->github_app->organization);
if (filled($organization)) {
return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
}
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}";
}
private function generateGithubJwt($private_key, $app_id): string
{
$configuration = Configuration::forAsymmetricSigner(
new Sha256,
InMemory::plainText($private_key),
InMemory::plainText($private_key)
);
$now = time();
return $configuration->builder()
->issuedBy((string) $app_id)
->permittedFor('https://api.github.com')
->identifiedBy((string) $now)
->issuedAt(new \DateTimeImmutable("@{$now}"))
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
->getToken($configuration->signer(), $configuration->signingKey())
->toString();
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
}
public function updateGithubAppName()
@@ -328,39 +330,29 @@ class Change extends Component
try {
$this->authorize('update', $this->github_app);
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id);
$this->github_app->app_id = $this->appId;
$this->github_app->private_key_id = $this->privateKeyId;
$this->github_app->unsetRelation('privateKey');
if (! $privateKey) {
if (! $this->appId) {
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
return;
}
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
$this->dispatch('error', 'No private key found for this GitHub App.');
return;
}
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id);
$appSlug = syncGithubAppName($this->github_app, true);
$response = Http::withHeaders([
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
'Authorization' => "Bearer {$jwt}",
])->get("{$this->github_app->api_url}/app");
if ($response->successful()) {
$app_data = $response->json();
$app_slug = $app_data['slug'] ?? null;
if ($app_slug) {
$this->github_app->name = $app_slug;
$this->name = str($app_slug)->kebab();
$privateKey->name = "github-app-{$app_slug}";
$privateKey->save();
$this->github_app->save();
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
} else {
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
}
if ($appSlug) {
$this->name = str($appSlug)->kebab();
$this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
} else {
$error_message = $response->json()['message'] ?? 'Unknown error';
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -373,11 +365,17 @@ class Change extends Component
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate();
$this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
+24 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Source\Github;
use App\Models\GithubApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Create extends Component
@@ -25,19 +26,39 @@ class Create extends Component
public bool $is_system_wide = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount()
{
$this->name = substr(generate_random_name(), 0, 30);
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
|| $this->api_url === githubApiUrlFromHtmlUrl($this->html_url);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
}
}
public function createGitHubApp()
{
try {
$this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization);
$this->api_url = filled($this->api_url)
? $this->api_url
: githubApiUrlFromHtmlUrl($this->html_url);
$this->validate([
'name' => 'required|string',
'organization' => 'nullable|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'required|string',
@@ -60,6 +81,8 @@ class Create extends Component
}
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
+12 -2
View File
@@ -39,6 +39,16 @@ class InviteLink extends Component
$this->generateInviteLink(sendEmail: false);
}
private function invitationUrl(string $routeName, array $parameters): string
{
$fqdn = instanceSettings()->fqdn;
if (filled($fqdn)) {
return rtrim($fqdn, '/').route($routeName, $parameters, false);
}
return route($routeName, $parameters);
}
private function generateInviteLink(bool $sendEmail = false)
{
try {
@@ -61,7 +71,7 @@ class InviteLink extends Component
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
}
$uuid = new_public_id(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid;
$link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
$user = User::whereEmail($this->email)->first();
if (is_null($user)) {
@@ -73,7 +83,7 @@ class InviteLink extends Component
'force_password_reset' => true,
]);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]);
$link = $this->invitationUrl('auth.link', ['token' => $token]);
}
$invitation = TeamInvitation::whereEmail($this->email)->first();
if (! is_null($invitation)) {
+1 -1
View File
@@ -366,7 +366,7 @@ class EnvironmentVariable extends BaseModel
private function set_environment_variables(?string $environment_variable = null): ?string
{
if (is_null($environment_variable) && $environment_variable === '') {
if (is_null($environment_variable)) {
return null;
}
$environment_variable = trim($environment_variable);
+8
View File
@@ -46,6 +46,8 @@ class InstanceSettings extends Model
'dev_helper_version',
'is_wire_navigate_enabled',
'is_mcp_server_enabled',
'webhook_allowed_internal_hosts',
'webhook_allow_localhost',
];
protected $hidden = [
@@ -80,10 +82,16 @@ class InstanceSettings extends Model
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
];
protected static function booted(): void
{
static::created(function () {
Once::flush();
});
static::updated(function ($settings) {
// Clear once() cache so subsequent calls get fresh data
Once::flush();
-1
View File
@@ -370,7 +370,6 @@ class LocalFileVolume extends BaseModel
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only volume');
return false;
}
-1
View File
@@ -187,7 +187,6 @@ class LocalPersistentVolume extends BaseModel
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only persistent volume');
return false;
}
+2 -3
View File
@@ -178,11 +178,10 @@ class S3Storage extends BaseModel
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'http' => [
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
'allow_redirects' => false,
],
]),
]);
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
-2
View File
@@ -1533,7 +1533,6 @@ $schema://$host {
public function generateCaCertificate()
{
try {
ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $this->id,
@@ -1541,7 +1540,6 @@ $schema://$host {
validityDays: 10 * 365
);
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
-1
View File
@@ -1594,7 +1594,6 @@ class Service extends BaseModel
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
}
} catch (\Exception $e) {
ray($e->getMessage());
}
}
@@ -15,23 +15,11 @@ class WebhookChannel
$webhookSettings = $notifiable->webhookNotificationSettings;
if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) {
if (isDev()) {
ray('Webhook notification skipped - not enabled or no URL configured');
}
return;
}
$payload = $notification->toWebhook();
if (isDev()) {
ray('Dispatching webhook notification', [
'notification' => get_class($notification),
'url' => $webhookSettings->webhook_url,
'payload' => $payload,
]);
}
SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url);
}
}
@@ -17,8 +17,6 @@ class HetznerDeletionFailed extends CustomEmailNotification
public function via(object $notifiable): array
{
ray('hello');
ray($notifiable);
return $notifiable->getEnabledChannels('hetzner_deletion_failed');
}
+433 -47
View File
@@ -2,9 +2,13 @@
namespace App\Rules;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Throwable;
class SafeWebhookUrl implements ValidationRule
{
@@ -18,8 +22,8 @@ class SafeWebhookUrl implements ValidationRule
*
* Validates that a webhook URL is safe for server-side requests.
* Blocks loopback addresses, cloud metadata endpoints (link-local),
* and dangerous hostnames while allowing private network IPs
* for self-hosted deployments.
* private/reserved ranges, and dangerous hostnames unless the
* instance operator explicitly allowlists the intranet target.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
@@ -43,12 +47,17 @@ class SafeWebhookUrl implements ValidationRule
return;
}
if (str_ends_with($host, '.')) {
$fail('The :attribute host must not end with a trailing dot.');
return;
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
@@ -56,9 +65,9 @@ class SafeWebhookUrl implements ValidationRule
}
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if ($this->isBlockedIp($hostForIpCheck)) {
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to loopback or link-local addresses.');
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
@@ -67,16 +76,124 @@ class SafeWebhookUrl implements ValidationRule
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.');
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
}
}
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
* @return array<string, mixed>
*/
public static function httpClientOptions(string $url): array
{
$options = ['allow_redirects' => false];
if (! defined('CURLOPT_RESOLVE')) {
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
}
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return $options;
}
$options['curl'] = [
CURLOPT_RESOLVE => array_map(
fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
),
];
return $options;
}
/**
* Build mc --resolve mappings that pin the endpoint host for S3 backups.
*
* @return array<int, string>
*/
public static function minioClientResolveOptions(string $url): array
{
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return [];
}
return array_map(
fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
);
}
public static function redactedUrlForLog(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || ! is_string($host)) {
return '[invalid-url]';
}
return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : '');
}
/**
* @return array{host: string, port: int, ips: array<int, string>}
*/
private static function resolveUrlForRequest(string $url): array
{
$rule = new self;
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
if (str_ends_with($host, '.')) {
throw new \RuntimeException('Webhook URL host must not end with a trailing dot.');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
$port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
if (! $rule->isAllowedIp($hostForDns, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => []];
}
$resolvedIps = $rule->resolveHost($hostForDns);
if ($resolvedIps === []) {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
foreach ($resolvedIps as $resolvedIp) {
if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
}
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
@@ -93,6 +210,15 @@ class SafeWebhookUrl implements ValidationRule
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
if ($host === 'localhost') {
return ['127.0.0.1', '::1'];
}
$customDnsServers = $this->customDnsServers();
if ($customDnsServers !== []) {
return $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
@@ -119,60 +245,320 @@ class SafeWebhookUrl implements ValidationRule
return array_values(array_unique($ips));
}
private function isBlockedIp(string $ip): bool
/**
* @param array<int, string> $dnsServers
* @return array<int, string>
*/
private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
{
$ips = [];
foreach ($dnsServers as $dnsServer) {
foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
try {
$query = new DNSQuery($dnsServer, 53, 5);
$records = $query->query($host, $type);
if ($records === false || $query->hasError()) {
continue;
}
foreach ($records as $record) {
if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) {
$ips[] = $record->getData();
}
}
} catch (Throwable) {
continue;
}
}
}
return array_values(array_unique($ips));
}
/**
* @return array<int, string>
*/
private function customDnsServers(): array
{
$servers = $this->instanceSettings()?->custom_dns_servers ?? '';
if (! is_string($servers)) {
return [];
}
return array_values(array_filter(array_map(
fn (string $server): string => trim($server),
explode(',', $servers),
), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
}
private function isAllowedIp(string $ip, string $host): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4);
$ip = $embeddedIpv4;
}
if ($this->isPublicIp($ip)) {
return true;
}
if ($this->isLocalhostIp($ip)) {
return $this->allowLocalhost()
&& ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip));
}
if ($this->isPrivateIp($ip)) {
return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
}
return $this->isAllowlistedIp($ip);
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIpv4($embeddedIpv4);
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIp($ip);
}
private function isLocalhostIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isLocalhostIp($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip);
return $this->ipv4InCidr($ip, '127.0.0.0/8');
}
return $this->isBlockedIpv6($ip);
return @inet_pton($ip) === @inet_pton('::1');
}
private function isBlockedIpv4(string $ip): bool
private function isPrivateIp(string $ip): bool
{
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
&& filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
private function isSpecialUseIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isSpecialUseIpv4($ip);
}
return $this->isSpecialUseIpv6($ip);
}
private function isSpecialUseIpv4(string $ip): bool
{
foreach ([
'0.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'192.0.0.0/24',
'192.0.2.0/24',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255/32',
] as $cidr) {
if ($this->ipv4InCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function isSpecialUseIpv6(string $ip): bool
{
$ipBytes = @inet_pton($ip);
if ($ipBytes === false) {
return false;
}
foreach ([
'::/128',
'::1/128',
'::ffff:0:0/96',
'64:ff9b::/96',
'100::/64',
'2001::/23',
'2001:2::/48',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'ff00::/8',
] as $cidr) {
[$network, $prefix] = explode('/', $cidr, 2);
$networkBytes = @inet_pton($network);
if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) {
return true;
}
}
return false;
}
private function isBlockedHostname(string $host): bool
{
return in_array($host, ['localhost'], true)
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')
|| str_ends_with($host, '.cluster.local');
}
private function isAllowedHostname(string $host): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
return true;
}
}
return false;
}
private function isAllowlistedIp(string $ip): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (str_contains($entry, '/')) {
if ($this->ipInCidr($ip, $entry)) {
return true;
}
continue;
}
if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) {
return true;
}
}
return false;
}
/**
* @return array<int, string>
*/
private function allowlistEntries(): array
{
$entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
if (is_string($entries)) {
$entries = explode(',', $entries);
}
if (! is_array($entries)) {
return [];
}
return array_values(array_filter(array_map(
fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
$entries,
)));
}
private function allowLocalhost(): bool
{
return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
}
private function instanceSettings(): ?InstanceSettings
{
try {
return InstanceSettings::query()->find(0);
} catch (Throwable) {
return null;
}
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, $cidr);
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 128) {
return false;
}
$ipBytes = @inet_pton($ip);
$networkBytes = @inet_pton($network);
if ($ipBytes === false || $networkBytes === false) {
return false;
}
return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
}
private function ipv4InCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 32) {
return false;
}
$ipLong = ip2long($ip);
$networkLong = ip2long($network);
if ($ipLong === false || $networkLong === false) {
return false;
}
$mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
return ($ipLong & $mask) === ($networkLong & $mask);
}
private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
{
$bytes = intdiv($prefix, 8);
$bits = $prefix % 8;
if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
return false;
}
if ($bits === 0) {
return true;
}
$long = ip2long($ip);
if ($long === false) {
return false;
}
$mask = 0xFF << (8 - $bits) & 0xFF;
$unsigned = sprintf('%u', $long);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
+2 -9
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class HetznerService
@@ -24,7 +25,7 @@ class HetznerService
->timeout(30)
->retry(3, function (int $attempt, \Exception $exception) {
// Handle rate limiting (429 Too Many Requests)
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
if ($exception instanceof RequestException) {
$response = $exception->response;
if ($response && $response->status() === 429) {
@@ -129,17 +130,9 @@ class HetznerService
public function createServer(array $params): array
{
ray('Hetzner createServer request', [
'endpoint' => '/servers',
'params' => $params,
]);
$response = $this->request('post', '/servers', $params);
ray('Hetzner createServer response', [
'response' => $response,
]);
return $response['server'] ?? [];
}
+156
View File
@@ -108,6 +108,12 @@ class ValidationPatterns
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u';
/**
* Characters that are valid in some URL positions but unsafe for values
* that are later reused in shell assignment contexts.
*/
public const APPLICATION_DOMAIN_FORBIDDEN_PATTERN = '/[`$;&|<>()\\\\\r\n]/';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
@@ -511,6 +517,156 @@ class ValidationPatterns
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for comma-separated application URL fields.
*/
public static function applicationDomainRules(int $maxLength = 2048): array
{
return [
'nullable',
'string',
'max:'.$maxLength,
function (string $attribute, mixed $value, \Closure $fail): void {
foreach (self::validateApplicationDomains($value) as $error) {
$fail($error);
}
},
];
}
/**
* Validate a comma-separated list of application URLs.
*
* @return array<int, string>
*/
public static function validateApplicationDomains(mixed $value): array
{
if (blank($value)) {
return [];
}
if (! is_string($value)) {
return ['The domains field must be a string.'];
}
$errors = [];
foreach (self::applicationDomainList($value) as $url) {
if (preg_match(self::APPLICATION_DOMAIN_FORBIDDEN_PATTERN, $url) === 1) {
$errors[] = "Invalid URL: {$url}";
continue;
}
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
continue;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
continue;
}
if (blank(parse_url($url, PHP_URL_HOST))) {
$errors[] = "Invalid URL: {$url}";
}
}
return $errors;
}
/**
* Normalize a comma-separated application URL list for storage.
*/
public static function normalizeApplicationDomains(?string $value): ?string
{
$urls = self::applicationDomainList($value);
if ($urls === []) {
return null;
}
return collect($urls)
->map(fn (string $url) => self::normalizeApplicationDomainUrl($url))
->implode(',');
}
/**
* Normalize URL components that are case-insensitive while preserving
* case-sensitive path, query, and fragment components.
*/
private static function normalizeApplicationDomainUrl(string $url): string
{
$components = parse_url($url);
if ($components === false) {
return $url;
}
$normalized = '';
if (isset($components['scheme'])) {
$normalized .= strtolower($components['scheme']).'://';
}
if (isset($components['user'])) {
$normalized .= $components['user'];
if (isset($components['pass'])) {
$normalized .= ':'.$components['pass'];
}
$normalized .= '@';
}
if (isset($components['host'])) {
$normalized .= strtolower($components['host']);
}
if (isset($components['port'])) {
$normalized .= ':'.$components['port'];
}
if (isset($components['path'])) {
$normalized .= $components['path'];
}
if (array_key_exists('query', $components)) {
$normalized .= '?'.$components['query'];
}
if (array_key_exists('fragment', $components)) {
$normalized .= '#'.$components['fragment'];
}
return $normalized;
}
/**
* Split a comma-separated application URL list into trimmed URL strings.
*
* @return array<int, string>
*/
public static function applicationDomainList(?string $value): array
{
if (blank($value)) {
return [];
}
return str($value)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn (string $url) => trim($url))
->filter(fn (string $url) => filled($url))
->values()
->all();
}
/**
* Get validation rules for Docker volume name fields
*/
+12 -12
View File
@@ -3,6 +3,11 @@
namespace App\Traits;
use App\Livewire\GlobalSearch;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
trait ClearsGlobalSearchCache
@@ -20,7 +25,6 @@ trait ClearsGlobalSearchCache
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the save operation
ray('Failed to clear global search cache on saving: '.$e->getMessage());
}
});
@@ -33,7 +37,6 @@ trait ClearsGlobalSearchCache
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the create operation
ray('Failed to clear global search cache on creation: '.$e->getMessage());
}
});
@@ -46,7 +49,6 @@ trait ClearsGlobalSearchCache
}
} catch (\Throwable $e) {
// Silently fail cache clearing - don't break the delete operation
ray('Failed to clear global search cache on deletion: '.$e->getMessage());
}
});
}
@@ -58,14 +60,14 @@ trait ClearsGlobalSearchCache
$searchableFields = ['name', 'description'];
// Add model-specific searchable fields
if ($this instanceof \App\Models\Application) {
if ($this instanceof Application) {
$searchableFields[] = 'fqdn';
$searchableFields[] = 'docker_compose_domains';
} elseif ($this instanceof \App\Models\Server) {
} elseif ($this instanceof Server) {
$searchableFields[] = 'ip';
} elseif ($this instanceof \App\Models\Service) {
} elseif ($this instanceof Service) {
// Services don't have direct fqdn, but name and description are covered
} elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) {
} elseif ($this instanceof Project || $this instanceof Environment) {
// Projects and environments only have name and description as searchable
}
// Database models only have name and description as searchable
@@ -81,7 +83,6 @@ trait ClearsGlobalSearchCache
return false;
} catch (\Throwable $e) {
// If checking changes fails, assume changes exist to be safe
ray('Failed to check searchable changes: '.$e->getMessage());
return true;
}
@@ -91,18 +92,18 @@ trait ClearsGlobalSearchCache
{
try {
// For Project models (has direct team_id)
if ($this instanceof \App\Models\Project) {
if ($this instanceof Project) {
return $this->team_id ?? null;
}
// For Environment models (get team_id through project)
if ($this instanceof \App\Models\Environment) {
if ($this instanceof Environment) {
return $this->project?->team_id;
}
// For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) {
if ($this instanceof \App\Models\Server) {
if ($this instanceof Server) {
$team = $this->team;
} else {
$team = $this->team();
@@ -120,7 +121,6 @@ trait ClearsGlobalSearchCache
return null;
} catch (\Throwable $e) {
// If we can't determine team ID, return null
ray('Failed to get team ID for cache: '.$e->getMessage());
return null;
}
-1
View File
@@ -82,7 +82,6 @@ trait SshRetryable
$lastErrorMessage = '';
// Randomly fail the command with a key exchange error for testing
// if (random_int(1, 10) === 1) { // 10% chance to fail
// ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// }
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {