mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
Merge remote-tracking branch 'origin/next' into api-application-preview-deployments
This commit is contained in:
@@ -99,7 +99,7 @@ function sharedDataApplications()
|
||||
'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(),
|
||||
@@ -205,5 +205,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
||||
$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');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Spatie\Url\Url;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
@@ -192,7 +191,7 @@ function next_after_cancel(?Server $server = null)
|
||||
|
||||
function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
|
||||
{
|
||||
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
|
||||
$uuid = $overrides['uuid'] ?? new_public_id();
|
||||
$server = $destination->server;
|
||||
|
||||
if ($server->team_id !== currentTeam()->id) {
|
||||
@@ -259,7 +258,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'uuid' => new_public_id(),
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
@@ -274,7 +273,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'uuid' => new_public_id(),
|
||||
'application_id' => $newApplication->id,
|
||||
'status' => 'exited',
|
||||
'fqdn' => null,
|
||||
@@ -322,7 +321,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
deployment_uuid: new_public_id(),
|
||||
application: $source,
|
||||
server: $sourceServer,
|
||||
destination: $source->destination,
|
||||
|
||||
@@ -17,12 +17,11 @@ use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'postgresql-database-'.$database->uuid;
|
||||
$database->image = $databaseImage;
|
||||
$database->postgres_password = Str::password(length: 64, symbols: false);
|
||||
@@ -40,7 +39,7 @@ function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDock
|
||||
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
|
||||
{
|
||||
$database = new StandaloneRedis;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'redis-database-'.$database->uuid;
|
||||
|
||||
$redis_password = Str::password(length: 64, symbols: false);
|
||||
@@ -79,7 +78,7 @@ function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $
|
||||
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
|
||||
{
|
||||
$database = new StandaloneMongodb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mongodb-database-'.$database->uuid;
|
||||
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
@@ -96,7 +95,7 @@ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker
|
||||
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
|
||||
{
|
||||
$database = new StandaloneMysql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mysql-database-'.$database->uuid;
|
||||
$database->mysql_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mysql_password = Str::password(length: 64, symbols: false);
|
||||
@@ -114,7 +113,7 @@ function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $
|
||||
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
|
||||
{
|
||||
$database = new StandaloneMariadb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mariadb-database-'.$database->uuid;
|
||||
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_password = Str::password(length: 64, symbols: false);
|
||||
@@ -132,7 +131,7 @@ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker
|
||||
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
|
||||
{
|
||||
$database = new StandaloneKeydb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'keydb-database-'.$database->uuid;
|
||||
$database->keydb_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
@@ -149,7 +148,7 @@ function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $
|
||||
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
|
||||
{
|
||||
$database = new StandaloneDragonfly;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'dragonfly-database-'.$database->uuid;
|
||||
$database->dragonfly_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
@@ -166,7 +165,7 @@ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDock
|
||||
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
|
||||
{
|
||||
$database = new StandaloneClickhouse;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'clickhouse-database-'.$database->uuid;
|
||||
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
||||
{
|
||||
@@ -73,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);
|
||||
@@ -459,7 +488,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
foreach ($domains as $loop => $domain) {
|
||||
try {
|
||||
if ($generate_unique_uuid) {
|
||||
$uuid = new Cuid2;
|
||||
$uuid = new_public_id();
|
||||
}
|
||||
|
||||
$url = Url::fromString($domain);
|
||||
@@ -1248,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;
|
||||
@@ -1364,7 +1413,7 @@ function generateDockerBuildArgs($variables): Collection
|
||||
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
||||
|
||||
// Only return the key - Docker will get the value from the environment
|
||||
return "--build-arg {$key}";
|
||||
return '--build-arg '.escapeshellarg((string) $key);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
/**
|
||||
* Validates a Docker Compose YAML string for command injection vulnerabilities.
|
||||
@@ -371,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) {
|
||||
@@ -504,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,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
|
||||
@@ -704,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
|
||||
@@ -757,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
|
||||
@@ -1240,7 +1265,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$random = new Cuid2;
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
|
||||
@@ -1489,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');
|
||||
@@ -2071,7 +2095,6 @@ function serviceParser(Service $resource): Collection
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
$fileStorages = $savedService->fileStorages();
|
||||
if ($savedService->image !== $image) {
|
||||
$savedService->image = $image;
|
||||
$savedService->save();
|
||||
@@ -2091,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
|
||||
@@ -2144,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
|
||||
@@ -2748,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');
|
||||
|
||||
@@ -202,6 +202,11 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
|
||||
$serverTimezone = getServerTimezone(data_get($application, 'destination.server'));
|
||||
|
||||
// Members should never see debug logs, even if an admin enabled debug mode
|
||||
if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) {
|
||||
$is_debug_enabled = false;
|
||||
}
|
||||
|
||||
$logs = data_get($application_deployment_queue, 'logs');
|
||||
if (empty($logs)) {
|
||||
return collect([]);
|
||||
|
||||
+235
-21
@@ -64,7 +64,6 @@ use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function base_configuration_dir(): string
|
||||
{
|
||||
@@ -115,6 +114,13 @@ function sanitize_string(?string $input = null): ?string
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
function new_public_id(int $length = 24): string
|
||||
{
|
||||
$length = max(1, $length);
|
||||
|
||||
return Str::lower(Str::random($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path or identifier is safe for use in shell commands.
|
||||
*
|
||||
@@ -160,7 +166,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
/**
|
||||
* Validate that a filename is safe for use as a plain file name (no path components).
|
||||
*
|
||||
* Prevents path traversal attacks by rejecting directory separators, traversal
|
||||
* Prevents unsafe parent directory paths by rejecting directory separators, parent directory
|
||||
* sequences, and null bytes, in addition to all shell metacharacters blocked by
|
||||
* validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
|
||||
* init script names that are later written to a specific directory on the host.
|
||||
@@ -169,7 +175,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
* @param string $context Descriptive name for error messages (e.g., 'init script filename')
|
||||
* @return string The validated input (unchanged if valid)
|
||||
*
|
||||
* @throws Exception If dangerous characters or path traversal sequences are detected
|
||||
* @throws Exception If dangerous characters or parent directory sequences are detected
|
||||
*/
|
||||
function validateFilenameSafe(string $input, string $context = 'filename'): string
|
||||
{
|
||||
@@ -192,10 +198,10 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri
|
||||
);
|
||||
}
|
||||
|
||||
// Reject path traversal sequences (catches encoded or unusual forms)
|
||||
// Reject parent directory sequences (catches encoded or unusual forms)
|
||||
if (str_contains($input, '..')) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: path traversal sequence ('..') is not allowed."
|
||||
"Invalid {$context}: parent directory sequence ('..') is not allowed."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,6 +230,197 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a user supplied file mount path.
|
||||
*
|
||||
* File mount paths are container paths supplied by tenants. They may look like
|
||||
* absolute paths (for example /etc/nginx/nginx.conf), but are later joined to a
|
||||
* Coolify-managed configuration directory on the host. Therefore shell safety is
|
||||
* not enough: every path segment must also be unable to traverse out of that
|
||||
* managed directory.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function validateFileMountPath(string $input, string $context = 'file mount path'): string
|
||||
{
|
||||
validateShellSafePath($input, $context);
|
||||
|
||||
if (str_contains($input, "\0")) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: contains null byte. ".
|
||||
'Null bytes are not allowed in file mount paths for security reasons.'
|
||||
);
|
||||
}
|
||||
|
||||
if (str_contains($input, '\\')) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: backslash directory separators are not allowed."
|
||||
);
|
||||
}
|
||||
|
||||
$path = str($input)->trim()->start('/')->replaceMatches('#/+#', '/')->value();
|
||||
|
||||
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||
if ($segment === '' || ($segment !== '.' && $segment !== '..')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
"Invalid {$context}: relative path segments ('.' or '..') are not allowed."
|
||||
);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a host file path used as a bind-only source.
|
||||
*
|
||||
* Unlike managed file mounts, this path is not re-based under the Coolify
|
||||
* configuration directory and must never be written by Coolify. It still needs
|
||||
* to be shell-safe because other storage code may pass paths through remote
|
||||
* shell commands.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function validateHostFileMountPath(string $input, string $context = 'host file path'): string
|
||||
{
|
||||
validateShellSafePath($input, $context);
|
||||
|
||||
if (str_contains($input, "\0")) {
|
||||
throw new Exception("Invalid {$context}: contains null byte.");
|
||||
}
|
||||
|
||||
if (str_contains($input, '\\')) {
|
||||
throw new Exception("Invalid {$context}: backslash directory separators are not allowed.");
|
||||
}
|
||||
|
||||
$path = str($input)->trim()->replaceMatches('#/+#', '/')->value();
|
||||
|
||||
if ($path === '' || ! str_starts_with($path, '/')) {
|
||||
throw new Exception("Invalid {$context}: must be an absolute path.");
|
||||
}
|
||||
|
||||
if ($path === '/' || str_ends_with($path, '/')) {
|
||||
throw new Exception("Invalid {$context}: must point to a file, not a directory.");
|
||||
}
|
||||
|
||||
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||
if ($segment === '' || ($segment !== '.' && $segment !== '..')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception("Invalid {$context}: relative path segments ('.' or '..') are not allowed.");
|
||||
}
|
||||
|
||||
return normalizeUnixPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tenant file mount path under a Coolify-managed base directory.
|
||||
*
|
||||
* This performs lexical normalization only; the target file does not need to
|
||||
* exist yet. The normalized result must remain inside the given base directory.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function confineFileMountPath(string $baseDirectory, string $path, string $context = 'file mount path'): string
|
||||
{
|
||||
$baseDirectory = normalizeUnixPath($baseDirectory);
|
||||
$mountPath = validateFileMountPath($path, $context);
|
||||
$resolvedPath = normalizeUnixPath($baseDirectory.'/'.$mountPath);
|
||||
|
||||
if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: resolved path must stay inside the resource configuration directory."
|
||||
);
|
||||
}
|
||||
|
||||
return $resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an existing host path and assert it remains inside a base directory.
|
||||
*
|
||||
* Dot-relative paths are resolved against the base directory for legacy
|
||||
* LocalFileVolume rows. Absolute paths must already point inside the base.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function confinePathToBase(string $baseDirectory, string $path, string $context = 'path'): string
|
||||
{
|
||||
$baseDirectory = normalizeUnixPath($baseDirectory);
|
||||
$path = trim($path);
|
||||
|
||||
if (str_starts_with($path, '.')) {
|
||||
$path = $baseDirectory.'/'.str($path)->after('.')->value();
|
||||
} elseif (! str_starts_with($path, '/')) {
|
||||
$path = $baseDirectory.'/'.$path;
|
||||
}
|
||||
|
||||
$resolvedPath = normalizeUnixPath($path);
|
||||
|
||||
if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: resolved path must stay inside the resource configuration directory."
|
||||
);
|
||||
}
|
||||
|
||||
return $resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Unix path lexically without consulting the remote filesystem.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
function normalizeUnixPath(string $path): string
|
||||
{
|
||||
validateShellSafePath($path, 'path');
|
||||
|
||||
if (str_contains($path, "\0")) {
|
||||
throw new Exception('Invalid path: contains null byte.');
|
||||
}
|
||||
|
||||
if (str_contains($path, '\\')) {
|
||||
throw new Exception('Invalid path: backslash directory separators are not allowed.');
|
||||
}
|
||||
|
||||
$isAbsolute = str_starts_with($path, '/');
|
||||
$segments = [];
|
||||
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..') {
|
||||
if ($segments === [] || end($segments) === '..') {
|
||||
if ($isAbsolute) {
|
||||
throw new Exception('Invalid path: resolved path escapes the base directory.');
|
||||
}
|
||||
$segments[] = $segment;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
array_pop($segments);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
$normalized = implode('/', $segments);
|
||||
|
||||
if ($isAbsolute) {
|
||||
return $normalized === '' ? '/' : '/'.$normalized;
|
||||
}
|
||||
|
||||
return $normalized === '' ? '.' : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a databases_to_backup input string is safe from command injection.
|
||||
*
|
||||
@@ -455,7 +652,7 @@ function generate_random_name(?string $cuid = null): string
|
||||
]
|
||||
);
|
||||
if (is_null($cuid)) {
|
||||
$cuid = new Cuid2;
|
||||
$cuid = new_public_id();
|
||||
}
|
||||
|
||||
return Str::kebab("{$generator->getName()}-$cuid");
|
||||
@@ -491,7 +688,7 @@ function formatPrivateKey(string $privateKey)
|
||||
function generate_application_name(string $git_repository, string $git_branch, ?string $cuid = null): string
|
||||
{
|
||||
if (is_null($cuid)) {
|
||||
$cuid = new Cuid2;
|
||||
$cuid = new_public_id();
|
||||
}
|
||||
|
||||
$repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository;
|
||||
@@ -1057,7 +1254,6 @@ function sslip(Server $server)
|
||||
|
||||
function get_service_templates(bool $force = false): Collection
|
||||
{
|
||||
|
||||
if ($force) {
|
||||
try {
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
@@ -1068,15 +1264,16 @@ function get_service_templates(bool $force = false): Collection
|
||||
|
||||
return collect($services);
|
||||
} catch (Throwable) {
|
||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
||||
|
||||
return collect(json_decode($services))->sortKeys();
|
||||
return get_service_templates();
|
||||
}
|
||||
} else {
|
||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
||||
|
||||
return collect(json_decode($services))->sortKeys();
|
||||
}
|
||||
|
||||
$path = base_path('templates/'.config('constants.services.file_name'));
|
||||
$mtime = filemtime($path) ?: 0;
|
||||
|
||||
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
|
||||
return collect(json_decode(File::get($path)))->sortKeys();
|
||||
});
|
||||
}
|
||||
|
||||
function getResourceByUuid(string $uuid, ?int $teamId = null)
|
||||
@@ -1567,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) {
|
||||
@@ -3259,7 +3455,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
$template = $resource->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$random = new Cuid2;
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn);
|
||||
@@ -3552,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();
|
||||
@@ -3569,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');
|
||||
}
|
||||
@@ -3813,7 +4027,7 @@ function formatBytes(?int $bytes, int $precision = 2): string
|
||||
|
||||
/**
|
||||
* Validates that a file path is safely within the /tmp/ directory.
|
||||
* Protects against path traversal attacks by resolving the real path
|
||||
* Protects against unsafe parent directory paths by resolving the real path
|
||||
* and verifying it stays within /tmp/.
|
||||
*
|
||||
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
|
||||
|
||||
Reference in New Issue
Block a user