Merge remote-tracking branch 'origin/next' into feat/api/tag-management

# Conflicts:
#	app/Http/Controllers/Api/ApplicationsController.php
This commit is contained in:
Andras Bacsai committed 2026-07-07 12:49:19 +02:00
commit f617e58401
1196 files changed
+69504 -18702

No files matched your search

+32 -21
View File
@@ -3,15 +3,23 @@
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
function getTeamIdFromToken()
{
$token = auth()->user()->currentAccessToken();
$user = auth()->user();
$token = $user?->currentAccessToken();
$teamId = data_get($token, 'team_id');
return data_get($token, 'team_id');
if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
return null;
}
return $teamId;
}
function invalidTokenResponse()
{
@@ -83,30 +91,31 @@ function sharedDataApplications()
{
return [
'git_repository' => 'string',
'git_branch' => 'string',
'git_branch' => ['string', new ValidGitBranch],
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'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' => 'string|nullable',
'docker_registry_image_tag' => 'string|nullable',
'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'install_command' => ValidationPatterns::shellSafeCommandRules(),
'build_command' => ValidationPatterns::shellSafeCommandRules(),
'start_command' => ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'base_directory' => ValidationPatterns::directoryPathRules(),
'publish_directory' => ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
@@ -125,26 +134,26 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_location' => ValidationPatterns::filePathRules(),
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean'
'is_preserve_repository_enabled' => 'boolean',
];
}
@@ -190,11 +199,13 @@ 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');
$request->offsetUnset('tags');
}
+10 -7
View File
@@ -10,10 +10,10 @@ 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 = 'HEAD', 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)
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)
{
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
$deployment_url = $deployment_link->getPath();
@@ -47,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
->where('pull_request_id', $pull_request_id)
->where('docker_registry_image_tag', $docker_registry_image_tag)
->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])
->first();
@@ -72,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme
'deployment_uuid' => $deployment_uuid,
'deployment_url' => $deployment_url,
'pull_request_id' => $pull_request_id,
'docker_registry_image_tag' => $docker_registry_image_tag,
'force_rebuild' => $force_rebuild,
'is_webhook' => $is_webhook,
'is_api' => $is_api,
@@ -189,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) {
@@ -212,7 +214,7 @@ function clone_application(Application $source, $destination, array $overrides =
'updated_at',
'additional_servers_count',
'additional_networks_count',
])->forceFill(array_merge([
])->fill(array_merge([
'uuid' => $uuid,
'name' => $name,
'fqdn' => $url,
@@ -239,6 +241,7 @@ function clone_application(Application $source, $destination, array $overrides =
'application_id' => $newApplication->id,
]);
$newApplicationSettings->save();
$newApplication->setRelation('settings', $newApplicationSettings->fresh());
}
// Clone tags
@@ -255,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,
]);
@@ -270,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,
@@ -318,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,
+81
View File
@@ -0,0 +1,81 @@
<?php
use Illuminate\Support\Facades\Log;
if (! function_exists('auditLog')) {
/**
* Write a security-relevant audit entry to the dedicated `audit` log channel.
*
* Never include secrets (private keys, passwords, tokens, webhook secrets,
* signature header values, env-var values) in $context.
*
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
* @param array<string, mixed> $context Identifiers + outcome details.
* @param string $level Log level: info | warning | error.
*/
function auditLog(string $event, array $context = [], string $level = 'info'): void
{
try {
$request = app()->bound('request') ? request() : null;
$user = auth()->check() ? auth()->user() : null;
$token = $user?->currentAccessToken();
$base = [
'event' => $event,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'user_id' => $user?->id,
'user_email' => $user?->email,
'team_id' => $token ? data_get($token, 'team_id') : null,
'token_id' => $token?->id ?? null,
'token_name' => $token?->name ?? null,
'method' => $request?->method(),
'path' => $request?->path(),
];
$payload = array_merge($base, $context);
Log::channel('audit')->{$level}($event, $payload);
} catch (Throwable $e) {
// Audit logging must never break the request path.
try {
Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
} catch (Throwable) {
}
}
}
}
if (! function_exists('auditLogWebhookFailure')) {
/**
* Record a webhook signature/auth verification failure to the `audit` channel.
*/
function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
{
try {
$request = app()->bound('request') ? request() : null;
$event = "webhook.{$provider}.signature_failed";
$base = [
'event' => $event,
'reason' => $reason,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'method' => $request?->method(),
'path' => $request?->path(),
'event_header' => $request?->header('X-GitHub-Event')
?? $request?->header('X-Gitlab-Event')
?? $request?->header('X-Gitea-Event')
?? $request?->header('X-Event-Key'),
];
Log::channel('audit')->warning($event, array_merge($base, $context));
} catch (Throwable $e) {
try {
Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
} catch (Throwable) {
}
}
}
}
+23 -1
View File
@@ -1,7 +1,26 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
const REDACTED = '<REDACTED>';
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
const STANDALONE_DATABASE_MODELS = [
'postgresql' => StandalonePostgresql::class,
'redis' => StandaloneRedis::class,
'mongodb' => StandaloneMongodb::class,
'mysql' => StandaloneMysql::class,
'mariadb' => StandaloneMariadb::class,
'keydb' => StandaloneKeydb::class,
'dragonfly' => StandaloneDragonfly::class,
'clickhouse' => StandaloneClickhouse::class,
];
const VALID_CRON_STRINGS = [
'every_minute' => '* * * * *',
'hourly' => '0 * * * *',
@@ -16,6 +35,9 @@ const VALID_CRON_STRINGS = [
'@yearly' => '0 0 1 1 *',
];
const RESTART_MODE = 'unless-stopped';
const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
const MIN_STOP_GRACE_PERIOD_SECONDS = 1;
const MAX_STOP_GRACE_PERIOD_SECONDS = 3600;
const DATABASE_DOCKER_IMAGES = [
'bitnami/mariadb',
@@ -81,4 +103,4 @@ const NEEDS_TO_DISABLE_GZIP = [
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server'];
+31 -37
View File
@@ -3,6 +3,7 @@
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
@@ -12,18 +13,18 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Visus\Cuid2\Cuid2;
use Illuminate\Support\Str;
function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
$database = new StandalonePostgresql;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage;
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->postgres_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -35,14 +36,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
return $database;
}
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'redis-database-'.$database->uuid;
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$redis_password = Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
@@ -75,13 +75,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMongodb;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'mongodb-database-'.$database->uuid;
$database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -93,14 +92,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
return $database;
}
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMysql;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'mysql-database-'.$database->uuid;
$database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mysql_root_password = Str::password(length: 64, symbols: false);
$database->mysql_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -112,14 +110,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMariadb;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'mariadb-database-'.$database->uuid;
$database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
$database->mariadb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -131,13 +128,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
return $database;
}
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneKeydb;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'keydb-database-'.$database->uuid;
$database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->keydb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -149,13 +145,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
return $database;
}
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneDragonfly;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'dragonfly-database-'.$database->uuid;
$database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->dragonfly_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -167,13 +162,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
return $database;
}
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
{
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneClickhouse;
$database->uuid = (new Cuid2);
$database->uuid = new_public_id();
$database->name = 'clickhouse-database-'.$database->uuid;
$database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -279,7 +273,7 @@ function removeOldBackups($backup): void
->whereNull('s3_uploaded')
->delete();
} catch (\Exception $e) {
} catch (Exception $e) {
throw $e;
}
}
@@ -345,7 +339,7 @@ function deleteOldBackupsLocally($backup): Collection
$processedBackups = collect();
$server = null;
if ($backup->database_type === \App\Models\ServiceDatabase::class) {
if ($backup->database_type === ServiceDatabase::class) {
$server = $backup->database->service->server;
} else {
$server = $backup->database->destination->server;
+74 -23
View File
@@ -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);
@@ -86,7 +115,7 @@ function format_docker_command_output_to_json($rawOutput): Collection
return $outputLines
->reject(fn ($line) => empty($line))
->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR));
} catch (\Throwable) {
} catch (Throwable) {
return collect([]);
}
}
@@ -123,7 +152,7 @@ function format_docker_envs_to_json($rawOutput)
return [$env[0] => $env[1]];
});
} catch (\Throwable) {
} catch (Throwable) {
return collect([]);
}
}
@@ -255,12 +284,12 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st
function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
{
if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) {
if ($resource->getMorphClass() === ServiceApplication::class) {
$uuid = data_get($resource, 'uuid');
$server = data_get($resource, 'service.server');
$environment_variables = data_get($resource, 'service.environment_variables');
$type = $resource->serviceType();
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
} elseif ($resource->getMorphClass() === Application::class) {
$uuid = data_get($resource, 'uuid');
$server = data_get($resource, 'destination.server');
$environment_variables = data_get($resource, 'environment_variables');
@@ -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);
@@ -641,7 +670,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
}
}
}
} catch (\Throwable) {
} catch (Throwable) {
continue;
}
}
@@ -1000,6 +1029,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--ulimit',
'--device',
'--shm-size',
'--dns',
]);
$mapping = collect([
'--cap-add' => 'cap_add',
@@ -1013,6 +1043,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--ip' => 'ip',
'--ip6' => 'ip6',
'--shm-size' => 'shm_size',
'--dns' => 'dns',
'--gpus' => 'gpus',
'--hostname' => 'hostname',
'--entrypoint' => 'entrypoint',
@@ -1219,7 +1250,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
$server = Server::ownedByCurrentTeam()->find($server_id);
try {
if (! $server) {
throw new \Exception('Server not found');
throw new Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
@@ -1235,7 +1266,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
], $server);
return 'OK';
} catch (\Throwable $e) {
} catch (Throwable $e) {
return $e->getMessage();
} finally {
if (filled($server)) {
@@ -1246,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;
@@ -1351,10 +1402,10 @@ function escapeBashDoubleQuoted(?string $value): string
* Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export
*
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @return Collection Collection of formatted --build-arg strings (keys only)
*/
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
function generateDockerBuildArgs($variables): Collection
{
$variables = collect($variables);
@@ -1362,14 +1413,14 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\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);
});
}
/**
* Generate Docker environment flags from environment variables collection
*
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @return string Space-separated environment flags
*/
function generateDockerEnvFlags($variables): string
+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 = [];
+242 -17
View File
@@ -2,8 +2,10 @@
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Encoding\ChainedFormatter;
@@ -12,15 +14,150 @@ 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));
if ($timeDiff > 50) {
throw new \Exception(
throw new Exception(
'System time is out of sync with GitHub API time:<br>'.
'- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC<br>'.
'- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC<br>'.
@@ -28,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;
@@ -60,7 +202,7 @@ function generateGithubToken(GithubApp $source, string $type)
return $response->json()['token'];
})(),
default => throw new \InvalidArgumentException("Unsupported token type: {$type}")
default => throw new InvalidArgumentException("Unsupported token type: {$type}")
};
}
@@ -77,11 +219,11 @@ function generateGithubJwt(GithubApp $source)
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
{
if (is_null($source)) {
throw new \Exception('Source is required for API calls');
throw new Exception('Source is required for API calls');
}
if ($source->getMorphClass() !== GithubApp::class) {
throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
}
if ($source->is_public) {
@@ -100,7 +242,7 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
$errorMessage = data_get($response->json(), 'message', 'no error message found');
$remainingCalls = $response->header('X-RateLimit-Remaining', '0');
throw new \Exception(
throw new Exception(
'GitHub API call failed:<br>'.
"Error: {$errorMessage}<br>".
'Rate Limit Status:<br>'.
@@ -116,21 +258,106 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
];
}
function getInstallationPath(GithubApp $source)
function generateGithubAppJwt(string $privateKey, string|int $appId): string
{
$github = GithubApp::where('uuid', $source->uuid)->first();
$name = str(Str::kebab($github->name));
$installation_path = $github->html_url === 'https://github.com' ? 'apps' : 'github-apps';
$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 "$github->html_url/$installation_path/$name/installations/new";
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 = 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',
'github_app_id' => $source->id,
'team_id' => $source->team_id,
], now()->addMinutes(60));
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)
@@ -182,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 [];
}
@@ -208,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) {
}
}
+90 -69
View File
@@ -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.
@@ -22,25 +21,25 @@ use Visus\Cuid2\Cuid2;
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws \Exception If the compose file contains command injection attempts
* @throws Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
} catch (\Exception $e) {
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
} catch (Exception $e) {
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new \Exception('Docker Compose file must contain a "services" section');
throw new Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
@@ -68,8 +67,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -84,8 +83,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -105,7 +104,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
*
* @param string $volumeString The volume string to validate
*
* @throws \Exception If the volume string contains command injection attempts
* @throws Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
@@ -325,9 +324,9 @@ function parseDockerVolumeString(string $volumeString): array
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
} catch (Exception $e) {
// Re-throw with more context about the volume string
throw new \Exception(
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -343,8 +342,8 @@ function parseDockerVolumeString(string $volumeString): array
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -371,11 +370,9 @@ 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) {
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
@@ -409,8 +406,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -465,7 +462,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
}
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
@@ -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
@@ -738,8 +766,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -749,20 +777,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$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');
@@ -1519,7 +1543,7 @@ function serviceParser(Service $resource): Collection
try {
$yaml = Yaml::parse($compose);
} catch (\Exception) {
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
@@ -1566,8 +1590,8 @@ function serviceParser(Service $resource): Collection
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -1593,20 +1617,25 @@ function serviceParser(Service $resource): Collection
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
$databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($databaseFound) {
$savedService = $databaseFound;
} else {
$savedService = ServiceDatabase::firstOrCreate([
$savedService = ServiceDatabase::create([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
$savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
} else {
$savedService = ServiceApplication::create([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
}
}
// Update image if it changed
@@ -1772,7 +1801,7 @@ function serviceParser(Service $resource): Collection
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
@@ -2066,7 +2095,6 @@ function serviceParser(Service $resource): Collection
'service_id' => $resource->id,
]);
}
$fileStorages = $savedService->fileStorages();
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
@@ -2086,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
@@ -2120,8 +2145,8 @@ function serviceParser(Service $resource): Collection
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -2131,20 +2156,17 @@ function serviceParser(Service $resource): Collection
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$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
@@ -2741,9 +2763,8 @@ function serviceParser(Service $resource): Collection
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
} 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');
+9 -4
View File
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml;
@@ -110,6 +111,7 @@ function connectProxyToNetworks(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
@@ -119,6 +121,7 @@ function connectProxyToNetworks(Server $server)
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
@@ -135,7 +138,7 @@ function connectProxyToNetworks(Server $server)
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
* @param Server $server The server to ensure networks on
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
* @return Collection Commands to create networks if they don't exist
*/
function ensureProxyNetworksExist(Server $server)
{
@@ -144,6 +147,7 @@ function ensureProxyNetworksExist(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
@@ -152,6 +156,7 @@ function ensureProxyNetworksExist(Server $server)
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
@@ -211,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
$custom_commands[] = $command;
}
}
} catch (\Exception $e) {
} catch (Exception $e) {
// If we can't parse the config, return empty array
// Silently fail to avoid breaking the proxy regeneration
}
@@ -432,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
return null;
} catch (\Exception $e) {
} catch (Exception $e) {
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
return null;
@@ -479,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
return null;
} catch (\Exception $e) {
} catch (Exception $e) {
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
return null;
+14 -2
View File
@@ -200,6 +200,12 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
}
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
$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)) {
@@ -240,8 +246,14 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
return $formatted
->sortBy(fn ($i) => data_get($i, 'order'))
->map(function ($i) {
data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u'));
->map(function ($i) use ($serverTimezone) {
$timestamp = Carbon::parse(data_get($i, 'timestamp'));
try {
$timestamp->setTimezone($serverTimezone);
} catch (Exception) {
$timestamp->setTimezone('UTC');
}
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
return $i;
})
+400 -131
View File
@@ -18,6 +18,7 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@@ -25,6 +26,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
@@ -62,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
{
@@ -113,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.
*
@@ -155,6 +163,264 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
/**
* Validate that a filename is safe for use as a plain file name (no path components).
*
* 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.
*
* @param string $input The filename to validate
* @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 parent directory sequences are detected
*/
function validateFilenameSafe(string $input, string $context = 'filename'): string
{
// First apply shell-metachar checks
validateShellSafePath($input, $context);
// Reject NUL bytes (can be used to truncate path strings in some contexts)
if (str_contains($input, "\0")) {
throw new Exception(
"Invalid {$context}: contains null byte. ".
'Null bytes are not allowed in filenames for security reasons.'
);
}
// Reject directory separators — filename must be a single path component
if (str_contains($input, '/') || str_contains($input, '\\')) {
throw new Exception(
"Invalid {$context}: directory separators ('/' or '\\') are not allowed. ".
'Provide a plain filename without path components.'
);
}
// Reject parent directory sequences (catches encoded or unusual forms)
if (str_contains($input, '..')) {
throw new Exception(
"Invalid {$context}: parent directory sequence ('..') is not allowed."
);
}
// Reject shell globbing / expansion metacharacters and whitespace that would
// split the filename into additional shell arguments if ever interpolated
// unquoted (defence in depth on top of escapeshellarg() at call sites).
$shellExpansionChars = [
' ' => 'whitespace',
'*' => 'glob wildcard',
'?' => 'glob wildcard',
'[' => 'glob character class',
']' => 'glob character class',
'~' => 'tilde expansion',
'"' => 'double quote',
"'" => 'single quote',
];
foreach ($shellExpansionChars as $char => $description) {
if (str_contains($input, $char)) {
throw new Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description})."
);
}
}
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.
*
@@ -259,6 +525,16 @@ function currentTeam()
return Auth::user()?->currentTeam() ?? null;
}
function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
if (blank($uuid) || ! currentTeam()) {
return null;
}
return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first()
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function showBoarding(): bool
{
if (isDev()) {
@@ -274,14 +550,30 @@ function showBoarding(): bool
function refreshSession(?Team $team = null): void
{
if (! $team) {
if (Auth::user()->currentTeam()) {
$team = Team::find(Auth::user()->currentTeam()->id);
} else {
$team = User::find(Auth::id())->teams->first();
$currentTeam = Auth::user()->currentTeam();
if ($currentTeam) {
// currentTeam() can resolve a stale (just-deleted) team from the
// session/cache, so Team::find() may still return null here.
$team = Team::find($currentTeam->id);
}
if (! $team) {
// Fall back to any team the user still belongs to.
$team = User::query()->find(Auth::id())?->teams()->first();
}
}
// Clear old cache key format for backwards compatibility
Cache::forget('team:'.Auth::id());
if (! $team) {
// The user has no team left (e.g. just deleted their current team and
// belongs to no other): clear the stale session reference instead of
// dereferencing null.
session()->forget('currentTeam');
return;
}
// Use new cache key format that includes team ID
Cache::forget('user:'.Auth::id().':team:'.$team->id);
Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) {
@@ -360,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");
@@ -396,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;
@@ -513,6 +805,39 @@ function isCloud(): bool
return ! config('constants.coolify.self_hosted');
}
/**
* Resolve the queue used for application deployments, database starts and service starts.
*
* On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an
* isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing
* is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching
* process needs no special env — only the worker must be configured to drain `deployments`.
*
* IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise
* these jobs are never processed.
*/
function deployment_queue(): string
{
return isCloud() ? 'deployments' : 'high';
}
/**
* Resolve the queue used for scheduled jobs — the scheduler dispatcher, scheduled tasks and
* scheduled database backups, whether triggered automatically or manually.
*
* On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated
* Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided
* by `isCloud()` (config-based), so the dispatching process needs no special env — only the
* worker must be configured to drain `crons`.
*
* IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these
* jobs are never processed.
*/
function crons_queue(): string
{
return isCloud() ? 'crons' : 'high';
}
function translate_cron_expression($expression_to_validate): string
{
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
@@ -929,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'));
@@ -940,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)
@@ -979,44 +1304,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
{
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql && $postgresql->team()->id == $teamId) {
return $postgresql->unsetRelation('environment');
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis && $redis->team()->id == $teamId) {
return $redis->unsetRelation('environment');
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb && $mongodb->team()->id == $teamId) {
return $mongodb->unsetRelation('environment');
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql && $mysql->team()->id == $teamId) {
return $mysql->unsetRelation('environment');
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb && $mariadb->team()->id == $teamId) {
return $mariadb->unsetRelation('environment');
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb && $keydb->team()->id == $teamId) {
return $keydb->unsetRelation('environment');
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly && $dragonfly->team()->id == $teamId) {
return $dragonfly->unsetRelation('environment');
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse && $clickhouse->team()->id == $teamId) {
return $clickhouse->unsetRelation('environment');
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database && $database->team()->id == $teamId) {
return $database->unsetRelation('environment');
}
}
return null;
}
function queryResourcesByUuid(string $uuid)
{
$resource = null;
$application = Application::whereUuid($uuid)->first();
if ($application) {
return $application;
@@ -1025,37 +1323,11 @@ function queryResourcesByUuid(string $uuid)
if ($service) {
return $service;
}
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql) {
return $postgresql;
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis) {
return $redis;
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb) {
return $mongodb;
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql) {
return $mysql;
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb) {
return $mariadb;
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb) {
return $keydb;
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly) {
return $dragonfly;
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse) {
return $clickhouse;
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database) {
return $database;
}
}
// Check for ServiceDatabase by its own UUID
@@ -1064,7 +1336,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
return $resource;
return null;
}
function generateTagDeployWebhook($tag_name)
{
@@ -1374,23 +1646,23 @@ function generateEnvValue(string $command, Service|Application|null $service = n
break;
// This is base64,
case 'REALBASE64_64':
$generatedValue = base64_encode(Str::random(64));
$generatedValue = base64_encode(random_bytes(64));
break;
case 'REALBASE64_128':
$generatedValue = base64_encode(Str::random(128));
$generatedValue = base64_encode(random_bytes(128));
break;
case 'REALBASE64':
case 'REALBASE64_32':
$generatedValue = base64_encode(Str::random(32));
$generatedValue = base64_encode(random_bytes(32));
break;
case 'HEX_32':
$generatedValue = bin2hex(Str::random(32));
$generatedValue = bin2hex(random_bytes(16));
break;
case 'HEX_64':
$generatedValue = bin2hex(Str::random(64));
$generatedValue = bin2hex(random_bytes(32));
break;
case 'HEX_128':
$generatedValue = bin2hex(Str::random(128));
$generatedValue = bin2hex(random_bytes(64));
break;
case 'USER':
$generatedValue = Str::random(16);
@@ -1492,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) {
@@ -1790,15 +2061,15 @@ function isBase64Encoded($strValue)
{
return base64_encode(base64_decode($strValue, true)) === $strValue;
}
function customApiValidator(Collection|array $item, array $rules)
function customApiValidator(Collection|array $item, array $rules, array $messages = [])
{
if (is_array($item)) {
$item = collect($item);
}
return Validator::make($item->toArray(), $rules, [
return Validator::make($item->toArray(), $rules, array_merge([
'required' => 'This field is required.',
]);
], $messages));
}
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
{
@@ -3184,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);
@@ -3453,10 +3724,10 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
// Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
return 'wire:navigate.hover';
return 'wire:navigate';
}
}
@@ -3477,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();
@@ -3489,42 +3781,11 @@ function getHelperVersion(): string
return config('constants.coolify.helper_version');
}
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();
if (! $server) {
return;
}
$uuid = new Cuid2;
$cloneCommand = "git clone --no-checkout -b $branch $repository .";
$workdir = rtrim($base_directory, '/');
$fileList = collect([".$workdir/coolify.json"]);
$commands = collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir/coolify.json",
'rm -rf /tmp/{$uuid}',
]);
try {
return instant_remote_process($commands, $server);
} catch (Exception) {
// continue
}
}
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');
}
@@ -3660,13 +3921,21 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
}
}
preg_match('/(?<=:)\d+(?=\/)/', $gitRepository, $matches);
$normalizedRepository = $repository;
if (count($matches) === 1) {
$providerInfo['port'] = $matches[0];
$gitHost = str($gitRepository)->before(':');
$gitRepo = str($gitRepository)->after('/');
$repository = "$gitHost:$gitRepo";
if (str($normalizedRepository)->contains('://')) {
$parsedRepository = parse_url($normalizedRepository);
if ($parsedRepository !== false && array_key_exists('port', $parsedRepository)) {
$providerInfo['port'] = (string) $parsedRepository['port'];
}
} else {
preg_match('/^(?<host>[^:]+):(?<port>\d+)\/(?<path>.+)$/', $normalizedRepository, $matches);
if (! empty($matches['port'])) {
$providerInfo['port'] = $matches['port'];
$repository = "{$matches['host']}:{$matches['path']}";
}
}
return [
@@ -3758,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.