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

This commit is contained in:
Andras Bacsai
2026-07-07 12:56:19 +02:00
136 changed files with 6142 additions and 1733 deletions
+4 -1
View File
@@ -112,8 +112,9 @@ function sharedDataApplications()
'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_preview_deployments_enabled' => 'boolean',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string|nullable',
'domains' => ValidationPatterns::applicationDomainRules(),
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
@@ -213,10 +214,12 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled');
$request->offsetUnset('is_preview_deployments_enabled');
$request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('docker_compose_raw');
}
+59 -9
View File
@@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection
return $containers;
}
function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
{
$containers = collect([]);
if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
return $containers->filter();
}
return $containers;
}
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
{
return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
}
function filterServiceSubContainersByName(Collection $containers, string $name): Collection
{
return $containers->filter(function ($container) use ($name) {
$labels = data_get($container, 'Labels', []);
if (is_string($labels)) {
$labels = format_docker_labels_to_json($labels);
}
return collect($labels)->get('coolify.name') === $name;
})->values();
}
function format_docker_command_output_to_json($rawOutput): Collection
{
$outputLines = explode(PHP_EOL, $rawOutput);
@@ -1247,18 +1277,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
}
function getContainerLogs(Server $server, string $container_id, int $lines = 100): string
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
{
if ($server->isSwarm()) {
$output = instant_remote_process([
"docker service logs -n {$lines} {$container_id} 2>&1",
], $server);
} else {
$output = instant_remote_process([
"docker logs -n {$lines} {$container_id} 2>&1",
], $server);
$lines = filter_var($lines, FILTER_VALIDATE_INT);
if ($lines === false || $lines <= 0) {
return $default;
}
return min($lines, $max);
}
function parseLogTimestampFlag(mixed $showTimestamps): bool
{
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
{
$command = "docker logs -n {$lines}";
if ($server->isSwarm()) {
$command = "docker service logs -n {$lines}";
}
if ($showTimestamps) {
$command .= ' --timestamps';
}
return "{$command} ".escapeshellarg($container_id).' 2>&1';
}
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
{
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
$output = removeAnsiColors($output);
return $output;
+48
View File
@@ -4,6 +4,54 @@ use App\Models\Application;
use App\Models\ServiceApplication;
use Illuminate\Support\Collection;
function isValidDomainUrl(string $url): bool
{
$components = parse_url($url);
if ($components === false) {
return false;
}
$scheme = $components['scheme'] ?? '';
$host = $components['host'] ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') {
return false;
}
$urlToValidate = $scheme.'://';
if (isset($components['user'])) {
$urlToValidate .= $components['user'];
if (isset($components['pass'])) {
$urlToValidate .= ':'.$components['pass'];
}
$urlToValidate .= '@';
}
$urlToValidate .= str_replace('_', '-', $host);
if (isset($components['port'])) {
$urlToValidate .= ':'.$components['port'];
}
if (isset($components['path'])) {
$urlToValidate .= $components['path'];
}
if (isset($components['query'])) {
$urlToValidate .= '?'.$components['query'];
}
if (isset($components['fragment'])) {
$urlToValidate .= '#'.$components['fragment'];
}
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{
$conflicts = [];
+228 -10
View File
@@ -2,6 +2,7 @@
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
@@ -13,9 +14,144 @@ use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder;
function generateGithubToken(GithubApp $source, string $type)
/**
* Extract and normalize the hostname from a GitHub URL.
*
* @param string|null $url The URL to parse
* @return string|null The lowercase hostname, or null if the URL is blank or has no parseable host
*/
function githubUrlHost(?string $url): ?string
{
$response = Http::get("{$source->api_url}/zen");
if (blank($url)) {
return null;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || blank($host)) {
return null;
}
return strtolower($host);
}
/**
* Build the scheme://host[:port] origin for a GitHub URL.
*
* This helper fails explicitly for blank, scheme-less, or malformed input when
* githubUrlHost() cannot parse a host, because returning the original input
* would not be a valid origin. Callers should pass already-validated URLs.
*
* @param string $url The URL to derive the origin from
* @return string The normalized origin
*
* @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
*/
function githubUrlOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || blank($scheme) || ! $host) {
throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
}
/**
* Determine whether the URL points at github.com.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubDotComHost(?string $htmlUrl): bool
{
return githubUrlHost($htmlUrl) === 'github.com';
}
/**
* Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGheDotComHost(?string $htmlUrl): bool
{
$host = githubUrlHost($htmlUrl);
return is_string($host)
&& Str::endsWith($host, '.ghe.com')
&& ! Str::startsWith($host, 'api.');
}
/**
* Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com).
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubCloudFamilyHost(?string $htmlUrl): bool
{
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
}
/**
* Determine whether the URL belongs to a self-hosted GitHub Enterprise Server.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
{
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
}
/**
* Derive the GitHub REST API base URL from a GitHub HTML URL.
*
* @param string $htmlUrl The GitHub HTML URL
* @return string The API base URL (api.github.com, api.<host> for *.ghe.com, or <origin>/api/v3 for GHES)
*/
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
{
if (isGithubDotComHost($htmlUrl)) {
return 'https://api.github.com';
}
if (isGheDotComHost($htmlUrl)) {
return 'https://api.'.githubUrlHost($htmlUrl);
}
return githubUrlOrigin($htmlUrl).'/api/v3';
}
/**
* Normalize a GitHub organization slug by trimming surrounding slashes and whitespace.
*
* @param string|null $organization The raw organization value
* @return string|null The trimmed organization, or null when blank
*/
function normalizeGithubOrganization(?string $organization): ?string
{
if (blank($organization)) {
return null;
}
return trim((string) $organization, "/ \t\n\r\0\x0B");
}
/**
* URL-encode a single GitHub path segment.
*
* @param string $segment The raw path segment
* @return string The raw-URL-encoded segment
*/
function encodeGithubPathSegment(string $segment): string
{
return rawurlencode($segment);
}
function assertGithubClockInSync(string $apiUrl): void
{
$response = Http::get("{$apiUrl}/zen");
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
$githubTime = Carbon::parse($response->header('date'));
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
@@ -29,6 +165,11 @@ function generateGithubToken(GithubApp $source, string $type)
'Please synchronize your system clock.'
);
}
}
function generateGithubToken(GithubApp $source, string $type)
{
assertGithubClockInSync($source->api_url);
$signingKey = InMemory::plainText($source->privateKey->private_key);
$algorithm = new Sha256;
@@ -117,11 +258,86 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
];
}
function generateGithubAppJwt(string $privateKey, string|int $appId): string
{
$algorithm = new Sha256;
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
$now = CarbonImmutable::now()->setTimezone('UTC');
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
return $tokenBuilder
->issuedBy((string) $appId)
->issuedAt($now->modify('-1 minute'))
->expiresAt($now->modify('+8 minutes'))
->getToken($algorithm, InMemory::plainText($privateKey))
->toString();
}
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
{
try {
if (blank($source->app_id) || blank($source->private_key_id)) {
return null;
}
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
if (! $privateKey) {
return null;
}
assertGithubClockInSync($source->api_url);
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
$response = Http::withHeaders([
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
'Authorization' => "Bearer {$jwt}",
])->get("{$source->api_url}/app");
if (! $response->successful()) {
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
}
$appSlug = data_get($response->json(), 'slug');
if (blank($appSlug)) {
return null;
}
$source->name = $appSlug;
if ($source->exists) {
$source->save();
}
$privateKey->name = "github-app-{$appSlug}";
$privateKey->save();
return $appSlug;
} catch (Throwable $e) {
if ($throw) {
throw $e;
}
return null;
}
}
function getInstallationPath(GithubApp $source): string
{
$name = str(Str::kebab($source->name));
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
$name = encodeGithubPathSegment(Str::kebab($source->name));
$state = Str::random(64);
$organization = normalizeGithubOrganization($source->organization);
if (isGithubEnterpriseServerHost($source->html_url)) {
$path = "github-apps/{$name}";
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
} else {
$path = "apps/{$name}";
}
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => 'install',
@@ -129,15 +345,19 @@ function getInstallationPath(GithubApp $source): string
'team_id' => $source->team_id,
], now()->addMinutes(60));
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
}
function getPermissionsPath(GithubApp $source)
{
$github = GithubApp::where('uuid', $source->uuid)->first();
$name = str(Str::kebab($github->name));
$name = encodeGithubPathSegment(Str::kebab($source->name));
$organization = normalizeGithubOrganization($source->organization);
return "$github->html_url/settings/apps/$name/permissions";
if (filled($organization)) {
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
}
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
}
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
@@ -189,7 +409,6 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) {
ray('Error fetching GitHub commit range files: '.$e->getMessage());
return [];
}
@@ -215,7 +434,6 @@ function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) {
ray('Error fetching GitHub PR files: '.$e->getMessage());
return [];
}
+1 -2
View File
@@ -18,8 +18,7 @@ function send_internal_notification(string $message): void
try {
$team = Team::find(0);
$team?->notify(new GeneralNotification($message));
} catch (\Throwable $e) {
ray($e->getMessage());
} catch (Throwable) {
}
}
+44 -27
View File
@@ -370,8 +370,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$pullRequestId = $pull_request_id;
$isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server');
$fileStorages = $resource->fileStorages();
try {
$yaml = Yaml::parse($compose);
} catch (Exception) {
@@ -503,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
]);
}
}
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
$parsed = parseServiceEnvironmentVariable($key->value());
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
$serviceExists = false;
foreach (array_keys($services) as $serviceNameKey) {
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
$serviceExists = true;
break;
}
}
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
if (is_null($domainExists)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$domainValue = "$domainValue$path";
}
}
if ($parsed['port'] && is_numeric($parsed['port'])) {
$domainValue = "$domainValue:{$parsed['port']}";
}
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
}
}
}
}
@@ -610,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Only add domain if the service exists
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($serviceName), 'domain');
// Update domain using URL with port if applicable
@@ -703,14 +735,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
$foundConfig = $fileStorages->whereMountPath($target)->first();
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
@@ -756,12 +785,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
@@ -1488,9 +1514,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (Exception $e) {
} catch (Exception) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
@@ -2070,7 +2095,6 @@ function serviceParser(Service $resource): Collection
'service_id' => $resource->id,
]);
}
$fileStorages = $savedService->fileStorages();
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
@@ -2090,14 +2114,11 @@ function serviceParser(Service $resource): Collection
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
$foundConfig = $fileStorages->whereMountPath($target)->first();
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
@@ -2143,12 +2164,9 @@ function serviceParser(Service $resource): Collection
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
@@ -2747,7 +2765,6 @@ function serviceParser(Service $resource): Collection
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
+21 -4
View File
@@ -1764,7 +1764,6 @@ function validateDNSEntry(string $fqdn, Server $server)
$query = new DNSQuery($dns_server);
$results = $query->query($host, $type);
if ($results === false || $query->hasError()) {
ray('Error: '.$query->getLasterror());
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
@@ -3749,6 +3748,27 @@ function redirectRoute(Component $component, string $name, array $parameters = [
return $component->redirectRoute($name, $parameters, navigate: $navigate);
}
function coolifyRegistryUrl(): string
{
try {
return instanceSettings()->docker_registry_url ?: 'docker.io';
} catch (Throwable) {
return config('constants.coolify.registry_url', 'docker.io');
}
}
function coolifyHelperImage(): string
{
$configuredHelperImage = config('constants.coolify.helper_image');
$configuredDefaultHelperImage = config('constants.coolify.registry_url', 'docker.io').'/coollabsio/coolify-helper';
if ($configuredHelperImage !== $configuredDefaultHelperImage) {
return $configuredHelperImage;
}
return coolifyRegistryUrl().'/coollabsio/coolify-helper';
}
function getHelperVersion(): string
{
$settings = instanceSettings();
@@ -3766,9 +3786,6 @@ function loggy($message = null, array $context = [])
if (! isDev()) {
return;
}
if (function_exists('ray') && config('app.debug')) {
ray($message, $context);
}
if (is_null($message)) {
return app('log');
}