mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-19 07:35:25 +00:00
Merge remote-tracking branch 'origin/next' into jean/port-exposes-improvement
This commit is contained in:
+29
-19
@@ -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,7 +91,7 @@ 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',
|
||||
@@ -93,20 +101,20 @@ function sharedDataApplications()
|
||||
'domains' => 'string|nullable',
|
||||
'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' => 'string|nullable',
|
||||
'build_command' => 'string|nullable',
|
||||
'start_command' => 'string|nullable',
|
||||
'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,25 +133,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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -193,5 +202,6 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
||||
$request->offsetUnset('force_domain_override');
|
||||
$request->offsetUnset('autogenerate_domain');
|
||||
$request->offsetUnset('is_container_label_escape_enabled');
|
||||
$request->offsetUnset('is_preserve_repository_enabled');
|
||||
$request->offsetUnset('docker_compose_raw');
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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();
|
||||
@@ -46,6 +48,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();
|
||||
|
||||
@@ -71,6 +74,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,
|
||||
@@ -192,7 +196,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
$server = $destination->server;
|
||||
|
||||
if ($server->team_id !== currentTeam()->id) {
|
||||
throw new \RuntimeException('Destination does not belong to the current team.');
|
||||
throw new RuntimeException('Destination does not belong to the current team.');
|
||||
}
|
||||
|
||||
// Prepare name and URL
|
||||
@@ -238,6 +242,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'application_id' => $newApplication->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
$newApplication->setRelation('settings', $newApplicationSettings->fresh());
|
||||
}
|
||||
|
||||
// Clone tags
|
||||
@@ -299,6 +304,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newApplication->id,
|
||||
@@ -322,8 +328,8 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
destination: $source->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +350,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
// Clone production environment variables without triggering the created hook
|
||||
$environmentVariables = $source->environment_variables()->get();
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
@@ -361,7 +367,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
// Clone preview environment variables
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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,19 @@ 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 Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
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->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 +37,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->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 +76,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->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 +93,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->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 +111,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->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 +129,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->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 +146,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->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 +163,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->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 +274,7 @@ function removeOldBackups($backup): void
|
||||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -345,7 +340,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;
|
||||
|
||||
@@ -86,7 +86,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 +123,7 @@ function format_docker_envs_to_json($rawOutput)
|
||||
|
||||
return [$env[0] => $env[1]];
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return collect([]);
|
||||
}
|
||||
}
|
||||
@@ -255,12 +255,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');
|
||||
@@ -641,7 +641,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1000,6 +1000,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
'--ulimit',
|
||||
'--device',
|
||||
'--shm-size',
|
||||
'--dns',
|
||||
]);
|
||||
$mapping = collect([
|
||||
'--cap-add' => 'cap_add',
|
||||
@@ -1013,6 +1014,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 +1221,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 +1237,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)) {
|
||||
@@ -1351,10 +1353,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);
|
||||
|
||||
@@ -1369,7 +1371,7 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
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;
|
||||
@@ -20,7 +21,7 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||
$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>'.
|
||||
@@ -60,7 +61,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 +78,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 +101,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,13 +117,19 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
];
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source)
|
||||
function getInstallationPath(GithubApp $source): 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';
|
||||
$name = str(Str::kebab($source->name));
|
||||
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
||||
$state = Str::random(64);
|
||||
|
||||
return "$github->html_url/$installation_path/$name/installations/new";
|
||||
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 "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
|
||||
}
|
||||
|
||||
function getPermissionsPath(GithubApp $source)
|
||||
|
||||
@@ -22,25 +22,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 +68,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 +84,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 +105,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 +325,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 +343,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.'
|
||||
);
|
||||
@@ -375,7 +375,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
@@ -409,8 +409,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 +465,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";
|
||||
@@ -738,8 +738,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,8 +749,8 @@ 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.'
|
||||
);
|
||||
@@ -1489,7 +1489,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
}
|
||||
$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 applicationParser: '.$e->getMessage());
|
||||
}
|
||||
@@ -1519,7 +1519,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 +1566,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 +1593,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 +1777,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)
|
||||
@@ -2120,8 +2125,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,8 +2136,8 @@ 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.'
|
||||
);
|
||||
@@ -2741,7 +2746,7 @@ 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());
|
||||
}
|
||||
|
||||
+23
-14
@@ -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;
|
||||
|
||||
@@ -109,18 +110,22 @@ function connectProxyToNetworks(Server $server)
|
||||
['networks' => $networks] = collectDockerNetworksByServer($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 $network >/dev/null",
|
||||
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to $network network.'",
|
||||
"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",
|
||||
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
|
||||
];
|
||||
});
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null",
|
||||
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to $network network.'",
|
||||
"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",
|
||||
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -133,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)
|
||||
{
|
||||
@@ -141,16 +146,20 @@ function ensureProxyNetworksExist(Server $server)
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
|
||||
];
|
||||
});
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -207,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
|
||||
}
|
||||
@@ -428,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;
|
||||
@@ -475,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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\CoolifyTask\PrepareCoolifyTask;
|
||||
use App\Data\CoolifyTaskArgs;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Helpers\SshRetryHandler;
|
||||
use App\Jobs\CoolifyTask;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -38,29 +39,46 @@ function remote_process(
|
||||
if (Auth::check()) {
|
||||
$teams = Auth::user()->teams->pluck('id');
|
||||
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
|
||||
throw new \Exception('User is not part of the team that owns this server');
|
||||
throw new Exception('User is not part of the team that owns this server');
|
||||
}
|
||||
}
|
||||
|
||||
SshMultiplexingHelper::ensureMultiplexedConnection($server);
|
||||
|
||||
return resolve(PrepareCoolifyTask::class, [
|
||||
'remoteProcessArgs' => new CoolifyTaskArgs(
|
||||
server_uuid: $server->uuid,
|
||||
command: $command_string,
|
||||
type: $type,
|
||||
type_uuid: $type_uuid,
|
||||
model: $model,
|
||||
ignore_errors: $ignore_errors,
|
||||
call_event_on_finish: $callEventOnFinish,
|
||||
call_event_data: $callEventData,
|
||||
),
|
||||
])();
|
||||
$properties = [
|
||||
'server_uuid' => $server->uuid,
|
||||
'command' => $command_string,
|
||||
'type' => $type,
|
||||
'type_uuid' => $type_uuid,
|
||||
'status' => ProcessStatus::QUEUED->value,
|
||||
'team_id' => $server->team_id,
|
||||
];
|
||||
|
||||
$activityLog = activity()
|
||||
->withProperties($properties)
|
||||
->event($type);
|
||||
|
||||
if ($model) {
|
||||
$activityLog->performedOn($model);
|
||||
}
|
||||
|
||||
$activity = $activityLog->log('[]');
|
||||
|
||||
dispatch(new CoolifyTask(
|
||||
activity: $activity,
|
||||
ignore_errors: $ignore_errors,
|
||||
call_event_on_finish: $callEventOnFinish,
|
||||
call_event_data: $callEventData,
|
||||
));
|
||||
|
||||
$activity->refresh();
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
|
||||
{
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($source, $dest, $server) {
|
||||
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
|
||||
@@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
|
||||
}
|
||||
$command_string = implode("\n", $command);
|
||||
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($server, $command_string) {
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
|
||||
$process = Process::timeout(30)->run($sshCommand);
|
||||
@@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
|
||||
$command_string = implode("\n", $command);
|
||||
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
|
||||
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
|
||||
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
|
||||
@@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
|
||||
|
||||
if ($ignored) {
|
||||
// TODO: Create new exception and disable in sentry
|
||||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
throw new RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
throw new RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
|
||||
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
|
||||
@@ -182,6 +200,7 @@ 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'));
|
||||
|
||||
$logs = data_get($application_deployment_queue, 'logs');
|
||||
if (empty($logs)) {
|
||||
@@ -194,7 +213,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\JsonException $e) {
|
||||
} catch (JsonException $e) {
|
||||
// If JSON decoding fails, try to clean up the logs and retry
|
||||
try {
|
||||
// Ensure valid UTF-8 encoding
|
||||
@@ -204,7 +223,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\JsonException $e) {
|
||||
} catch (JsonException $e) {
|
||||
// If it still fails, return empty collection to prevent crashes
|
||||
return collect([]);
|
||||
}
|
||||
@@ -222,8 +241,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;
|
||||
})
|
||||
@@ -353,7 +378,7 @@ function checkRequiredCommands(Server $server)
|
||||
}
|
||||
try {
|
||||
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
break;
|
||||
}
|
||||
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
|
||||
|
||||
+223
-161
@@ -16,7 +16,9 @@ use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
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;
|
||||
@@ -24,12 +26,15 @@ 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;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Process\Pool;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -49,10 +54,14 @@ use Lcobucci\JWT\Encoding\JoseEncoder;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Token\Builder;
|
||||
use Livewire\Component;
|
||||
use Nubs\RandomNameGenerator\All;
|
||||
use Nubs\RandomNameGenerator\Alliteration;
|
||||
use phpseclib3\Crypt\EC;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use Poliander\Cron\CronExpression;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
@@ -116,7 +125,7 @@ function sanitize_string(?string $input = null): ?string
|
||||
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
|
||||
* @return string The validated input (unchanged if valid)
|
||||
*
|
||||
* @throws \Exception If dangerous characters are detected
|
||||
* @throws Exception If dangerous characters are detected
|
||||
*/
|
||||
function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
{
|
||||
@@ -138,7 +147,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
// Check for dangerous characters
|
||||
foreach ($dangerousChars as $char => $description) {
|
||||
if (str_contains($input, $char)) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
|
||||
'Shell metacharacters are not allowed for security reasons.'
|
||||
);
|
||||
@@ -148,6 +157,73 @@ 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 path traversal attacks by rejecting directory separators, traversal
|
||||
* 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 path traversal 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 path traversal sequences (catches encoded or unusual forms)
|
||||
if (str_contains($input, '..')) {
|
||||
throw new Exception(
|
||||
"Invalid {$context}: path traversal 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 that a databases_to_backup input string is safe from command injection.
|
||||
*
|
||||
@@ -160,7 +236,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
* @param string $input The databases_to_backup string
|
||||
* @return string The validated input
|
||||
*
|
||||
* @throws \Exception If any component contains dangerous characters
|
||||
* @throws Exception If any component contains dangerous characters
|
||||
*/
|
||||
function validateDatabasesBackupInput(string $input): string
|
||||
{
|
||||
@@ -211,7 +287,7 @@ function validateDatabasesBackupInput(string $input): string
|
||||
* @param string $context Descriptive name for error messages
|
||||
* @return string The validated input (trimmed)
|
||||
*
|
||||
* @throws \Exception If the input contains disallowed characters
|
||||
* @throws Exception If the input contains disallowed characters
|
||||
*/
|
||||
function validateGitRef(string $input, string $context = 'git ref'): string
|
||||
{
|
||||
@@ -223,12 +299,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string
|
||||
|
||||
// Must not start with a hyphen (git flag injection)
|
||||
if (str_starts_with($input, '-')) {
|
||||
throw new \Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
throw new Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
}
|
||||
|
||||
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
|
||||
throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
}
|
||||
|
||||
return $input;
|
||||
@@ -252,6 +328,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()) {
|
||||
@@ -267,14 +353,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) {
|
||||
@@ -282,7 +384,7 @@ function refreshSession(?Team $team = null): void
|
||||
});
|
||||
session(['currentTeam' => $team]);
|
||||
}
|
||||
function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
|
||||
function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null)
|
||||
{
|
||||
if ($error instanceof TooManyRequestsException) {
|
||||
if (isset($livewire)) {
|
||||
@@ -299,7 +401,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
|
||||
return 'Duplicate entry found. Please use a different name.';
|
||||
}
|
||||
|
||||
if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
|
||||
if ($error instanceof ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -329,7 +431,7 @@ function get_latest_sentinel_version(): string
|
||||
$versions = $response->json();
|
||||
|
||||
return data_get($versions, 'coolify.sentinel.version');
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -339,7 +441,7 @@ function get_latest_version_of_coolify(): string
|
||||
$versions = get_versions_data();
|
||||
|
||||
return data_get($versions, 'coolify.v4.version', '0.0.0');
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
|
||||
return '0.0.0';
|
||||
}
|
||||
@@ -347,9 +449,9 @@ function get_latest_version_of_coolify(): string
|
||||
|
||||
function generate_random_name(?string $cuid = null): string
|
||||
{
|
||||
$generator = new \Nubs\RandomNameGenerator\All(
|
||||
$generator = new All(
|
||||
[
|
||||
new \Nubs\RandomNameGenerator\Alliteration,
|
||||
new Alliteration,
|
||||
]
|
||||
);
|
||||
if (is_null($cuid)) {
|
||||
@@ -448,7 +550,7 @@ function getFqdnWithoutPort(string $fqdn)
|
||||
$path = $url->getPath();
|
||||
|
||||
return "$scheme://$host$path";
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return $fqdn;
|
||||
}
|
||||
}
|
||||
@@ -478,13 +580,13 @@ function base_url(bool $withPort = true): string
|
||||
}
|
||||
if ($settings->public_ipv6) {
|
||||
if ($withPort) {
|
||||
return "http://$settings->public_ipv6:$port";
|
||||
return "http://[$settings->public_ipv6]:$port";
|
||||
}
|
||||
|
||||
return "http://$settings->public_ipv6";
|
||||
return "http://[$settings->public_ipv6]";
|
||||
}
|
||||
|
||||
return url('/');
|
||||
return config('app.url');
|
||||
}
|
||||
|
||||
function isSubscribed()
|
||||
@@ -506,6 +608,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])) {
|
||||
@@ -537,21 +672,21 @@ function validate_cron_expression($expression_to_validate): bool
|
||||
* Even if the job runs minutes late, it still catches the missed cron window.
|
||||
* Without a dedupKey, falls back to a simple isDue() check.
|
||||
*/
|
||||
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
|
||||
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool
|
||||
{
|
||||
$cron = new \Cron\CronExpression($frequency);
|
||||
$executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
|
||||
$cron = new Cron\CronExpression($frequency);
|
||||
$executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone);
|
||||
|
||||
if ($dedupKey === null) {
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
|
||||
$previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
$lastDispatched = Cache::get($dedupKey);
|
||||
|
||||
$shouldFire = $lastDispatched === null
|
||||
? $cron->isDue($executionTime)
|
||||
: $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
|
||||
: $previousDue->gt(Carbon::parse($lastDispatched));
|
||||
|
||||
// Always write: seeds on first miss, refreshes on dispatch.
|
||||
// 30-day static TTL covers all intervals; orphan keys self-clean.
|
||||
@@ -932,7 +1067,7 @@ function get_service_templates(bool $force = false): Collection
|
||||
$services = $response->json();
|
||||
|
||||
return collect($services);
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
||||
|
||||
return collect(json_decode($services))->sortKeys();
|
||||
@@ -955,7 +1090,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
|
||||
}
|
||||
|
||||
// ServiceDatabase has a different relationship path: service->environment->project->team_id
|
||||
if ($resource instanceof \App\Models\ServiceDatabase) {
|
||||
if ($resource instanceof ServiceDatabase) {
|
||||
if ($resource->service?->environment?->project?->team_id === $teamId) {
|
||||
return $resource;
|
||||
}
|
||||
@@ -972,44 +1107,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;
|
||||
@@ -1018,37 +1126,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
|
||||
@@ -1057,7 +1139,7 @@ function queryResourcesByUuid(string $uuid)
|
||||
return $serviceDatabase;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
return null;
|
||||
}
|
||||
function generateTagDeployWebhook($tag_name)
|
||||
{
|
||||
@@ -1081,7 +1163,7 @@ function generateGitManualWebhook($resource, $type)
|
||||
if ($resource->source_id !== 0 && ! is_null($resource->source_id)) {
|
||||
return null;
|
||||
}
|
||||
if ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($resource->getMorphClass() === Application::class) {
|
||||
$baseUrl = base_url();
|
||||
|
||||
return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual";
|
||||
@@ -1102,11 +1184,11 @@ function sanitizeLogsForExport(string $text): string
|
||||
|
||||
function getTopLevelNetworks(Service|Application $resource)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($resource->getMorphClass() === Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
$resource->uuid => [
|
||||
@@ -1169,10 +1251,10 @@ function getTopLevelNetworks(Service|Application $resource)
|
||||
|
||||
return $topLevelNetworks->keys();
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
$resource->uuid => [
|
||||
@@ -1367,23 +1449,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);
|
||||
@@ -1479,7 +1561,7 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
$ip = $server->ip;
|
||||
}
|
||||
$found_matching_ip = false;
|
||||
$type = \PurplePixie\PhpDns\DNSTypes::NAME_A;
|
||||
$type = DNSTypes::NAME_A;
|
||||
foreach ($dns_servers as $dns_server) {
|
||||
try {
|
||||
$query = new DNSQuery($dns_server);
|
||||
@@ -1500,7 +1582,7 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1682,7 +1764,7 @@ function get_public_ips()
|
||||
}
|
||||
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
@@ -1697,7 +1779,7 @@ function get_public_ips()
|
||||
}
|
||||
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
@@ -1795,15 +1877,15 @@ function customApiValidator(Collection|array $item, array $rules)
|
||||
}
|
||||
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($resource->getMorphClass() === Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
// Extract inline comments from raw YAML before Symfony parser discards them
|
||||
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
$allServices = get_service_templates();
|
||||
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
|
||||
@@ -2567,10 +2649,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
} else {
|
||||
return collect([]);
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
return;
|
||||
}
|
||||
$server = $resource->destination->server;
|
||||
@@ -3332,7 +3414,7 @@ function isAssociativeArray($array)
|
||||
}
|
||||
|
||||
if (! is_array($array)) {
|
||||
throw new \InvalidArgumentException('Input must be an array or a Collection.');
|
||||
throw new InvalidArgumentException('Input must be an array or a Collection.');
|
||||
}
|
||||
|
||||
if ($array === []) {
|
||||
@@ -3446,10 +3528,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' : '';
|
||||
} catch (\Exception $e) {
|
||||
return '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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3457,13 +3539,13 @@ function wireNavigate(): string
|
||||
* Redirect to a named route with SPA navigation support.
|
||||
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
|
||||
*/
|
||||
function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
|
||||
function redirectRoute(Component $component, string $name, array $parameters = []): mixed
|
||||
{
|
||||
$navigate = true;
|
||||
|
||||
try {
|
||||
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$navigate = true;
|
||||
}
|
||||
|
||||
@@ -3482,34 +3564,6 @@ 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()) {
|
||||
@@ -3636,8 +3690,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
|
||||
// If this happens, the user may have provided an HTTP URL when they needed an SSH one
|
||||
// Let's try and fix that for known Git providers
|
||||
switch ($source->getMorphClass()) {
|
||||
case \App\Models\GithubApp::class:
|
||||
case \App\Models\GitlabApp::class:
|
||||
case GithubApp::class:
|
||||
case GitlabApp::class:
|
||||
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
||||
$providerInfo['port'] = $source->custom_port;
|
||||
$providerInfo['user'] = $source->custom_user;
|
||||
@@ -3653,13 +3707,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 [
|
||||
@@ -3915,10 +3977,10 @@ function shouldSkipPasswordConfirmation(): bool
|
||||
* - User has no password (OAuth users)
|
||||
*
|
||||
* @param mixed $password The password to verify (may be array if skipped by frontend)
|
||||
* @param \Livewire\Component|null $component Optional Livewire component to add errors to
|
||||
* @param Component|null $component Optional Livewire component to add errors to
|
||||
* @return bool True if verification passed (or skipped), false if password is incorrect
|
||||
*/
|
||||
function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
|
||||
function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool
|
||||
{
|
||||
// Skip if password confirmation should be skipped
|
||||
if (shouldSkipPasswordConfirmation()) {
|
||||
@@ -3941,17 +4003,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
|
||||
* Extract hard-coded environment variables from docker-compose YAML.
|
||||
*
|
||||
* @param string $dockerComposeRaw Raw YAML content
|
||||
* @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
|
||||
* @return Collection Collection of arrays with: key, value, comment, service_name
|
||||
*/
|
||||
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
|
||||
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection
|
||||
{
|
||||
if (blank($dockerComposeRaw)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
try {
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
$yaml = Yaml::parse($dockerComposeRaw);
|
||||
} catch (Exception $e) {
|
||||
// Malformed YAML - return empty collection
|
||||
return collect([]);
|
||||
}
|
||||
@@ -4100,7 +4162,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
|
||||
if (is_null($id)) {
|
||||
continue;
|
||||
}
|
||||
$found = \App\Models\SharedEnvironmentVariable::where('type', $type)
|
||||
$found = SharedEnvironmentVariable::where('type', $type)
|
||||
->where('key', $variable)
|
||||
->where('team_id', $resource->team()->id)
|
||||
->where("{$type}_id", $id)
|
||||
|
||||
Reference in New Issue
Block a user