Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai
2026-10-21 22:56:42 +02:00
993 changed files with 71441 additions and 9286 deletions
+13 -2
View File
@@ -2,6 +2,7 @@
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Application;
@@ -202,6 +203,7 @@ function sharedDataApplications(?Application $application = null): array
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'is_pr_deployments_public_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
@@ -211,9 +213,13 @@ function sharedDataApplications(?Application $application = null): array
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'gpu_driver' => 'string|nullable',
'gpu_count' => 'string|nullable',
'gpu_count' => ['string', 'nullable', 'regex:'.ApplicationSetting::GPU_COUNT_PATTERN],
'gpu_device_ids' => 'string|nullable',
'gpu_options' => 'string|nullable',
'gpu_options' => ['string', 'nullable', function (string $attribute, mixed $value, Closure $fail): void {
if (ApplicationSetting::parseGpuOptions($value) === null) {
$fail('The gpu_options must be key=value pairs or a YAML mapping.');
}
}],
'is_consistent_container_name_enabled' => 'boolean',
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
@@ -283,6 +289,10 @@ function sharedDataApplications(?Application $application = null): array
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean',
'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class),
'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14',
'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144',
'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12',
];
}
@@ -474,6 +484,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
$request->offsetUnset('redact_all_env_values_in_logs');
$request->offsetUnset('is_pr_deployments_public_enabled');
$request->offsetUnset('stop_grace_period');
$request->offsetUnset('docker_images_to_keep');
+114 -134
View File
@@ -1,7 +1,9 @@
<?php
use App\Models\DatabaseBackupS3Replica;
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
@@ -314,110 +316,96 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void
function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
// Delete old local backups (only if local backup is NOT disabled)
// Note: When disable_local_backup is enabled, each execution already marks its own
// local_storage_deleted status at the time of backup, so we don't need to retroactively
// update old executions
if (! $backup->disable_local_backup) {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
->whereIn('id', $localBackupsToDelete->pluck('id'))
->update(['local_storage_deleted' => true]);
}
}
}
if ($backup->save_s3 && $backup->executions) {
$s3BackupsToDelete = deleteOldBackupsFromS3($backup);
if ($s3BackupsToDelete->isNotEmpty()) {
$backup->executions()
->whereIn('id', $s3BackupsToDelete->pluck('id'))
->update(['s3_storage_deleted' => true]);
}
}
// Delete execution records where all backup copies are gone
// Case 1: Both local and S3 backups are deleted
// When local backups are disabled, retain the local file until an S3 copy is available.
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
// Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
$backup->executions()
->where('local_storage_deleted', true)
->whereNull('s3_uploaded')
->delete();
} catch (Exception $e) {
throw $e;
->whereIn('id', $localBackupsToDelete->pluck('id'))
->update(['local_storage_deleted' => true]);
}
if ($backup->save_s3) {
deleteOldBackupsFromS3($backup);
}
// Delete execution records when no local file and no S3 copy is left
$backup->executions()
->where('local_storage_deleted', true)
->withoutLiveS3Copies()
->delete();
}
function deleteOldBackupsLocally($backup): Collection
/**
* Successful executions that fall outside the retention settings, newest execution first.
*
* @param Collection<int, ScheduledDatabaseBackupExecution> $executions Sorted newest first.
* @return Collection<int, ScheduledDatabaseBackupExecution>
*/
function backupExecutionsOutsideRetention(Collection $executions, ?int $retentionAmount, ?int $retentionDays, int|float|null $maxStorageGB): Collection
{
if (! $backup || ! $backup->executions) {
return collect();
}
$successfulBackups = $backup->executions()
->where('status', 'success')
->where('local_storage_deleted', false)
->orderBy('created_at', 'desc')
->get();
if ($successfulBackups->isEmpty()) {
return collect();
}
$retentionAmount = $backup->database_backup_retention_amount_locally;
$retentionDays = $backup->database_backup_retention_days_locally;
$maxStorageGB = $backup->database_backup_retention_max_storage_locally;
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
if ($executions->isEmpty()) {
return collect();
}
$backupsToDelete = collect();
if ($retentionAmount > 0) {
$byAmount = $successfulBackups->skip($retentionAmount);
$backupsToDelete = $backupsToDelete->merge($byAmount);
$backupsToDelete = $backupsToDelete->merge($executions->skip($retentionAmount));
}
if ($retentionDays > 0) {
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
$backupsToDelete = $backupsToDelete->merge($oldBackups);
$oldestAllowedDate = $executions->first()->created_at->clone()->utc()->subDays($retentionDays);
$backupsToDelete = $backupsToDelete->merge(
$executions->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate)
);
}
if ($maxStorageGB > 0) {
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
$totalSize = 0;
$backupsOverLimit = collect();
$backupsToCheck = $successfulBackups->skip(1);
foreach ($backupsToCheck as $backupExecution) {
foreach ($executions->skip(1) as $backupExecution) {
$totalSize += (int) $backupExecution->size;
if ($totalSize > $maxStorageBytes) {
$backupsOverLimit = $successfulBackups->filter(
$backupsToDelete = $backupsToDelete->merge($executions->filter(
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
)->skip(1);
)->skip(1));
break;
}
}
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
}
$backupsToDelete = $backupsToDelete->unique('id');
$processedBackups = collect();
return $backupsToDelete->unique('id')->values();
}
function deleteOldBackupsLocally($backup): Collection
{
if (! $backup) {
return collect();
}
$successfulBackups = $backup->executions()
->where('status', 'success')
->where('local_storage_deleted', false)
->when($backup->disable_local_backup, fn ($query) => $query
->whereHas('s3Replicas', fn ($query) => $query
->where('s3_uploaded', true)
->where('s3_storage_deleted', false)))
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->get();
$backupsToDelete = backupExecutionsOutsideRetention(
$successfulBackups,
$backup->database_backup_retention_amount_locally,
$backup->database_backup_retention_days_locally,
$backup->database_backup_retention_max_storage_locally,
);
if ($backupsToDelete->isEmpty()) {
return collect();
}
$server = null;
if ($backup->database_type === ServiceDatabase::class) {
$server = $backup->database->service->server;
} else {
@@ -433,89 +421,81 @@ function deleteOldBackupsLocally($backup): Collection
->pluck('filename')
->all();
if (! empty($filesToDelete)) {
deleteBackupsLocally($filesToDelete, $server);
$processedBackups = $backupsToDelete;
if (empty($filesToDelete)) {
return collect();
}
return $processedBackups;
deleteBackupsLocally($filesToDelete, $server);
return $backupsToDelete;
}
/**
* Applies the S3 retention settings to each destination separately. Copies in a storage that no longer exists are
* marked as deleted.
*
* @return Collection<int, ScheduledDatabaseBackupExecution> The executions that lost at least one S3 copy.
*/
function deleteOldBackupsFromS3($backup): Collection
{
if (! $backup || ! $backup->executions || ! $backup->s3) {
if (! $backup) {
return collect();
}
$successfulBackups = $backup->executions()
->where('status', 'success')
$liveReplicas = DatabaseBackupS3Replica::query()
->with('s3')
->whereHas('execution', fn ($query) => $query
->where('scheduled_database_backup_id', $backup->id)
->where('status', 'success'))
->where('s3_uploaded', true)
->where('s3_storage_deleted', false)
->orderBy('created_at', 'desc')
->get();
if ($successfulBackups->isEmpty()) {
return collect();
}
$deletedReplicas = $liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => ! $replica->s3);
$retentionAmount = $backup->database_backup_retention_amount_s3;
$retentionDays = $backup->database_backup_retention_days_s3;
$maxStorageGB = $backup->database_backup_retention_max_storage_s3;
foreach ($liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => $replica->s3)->groupBy('s3_storage_id') as $replicas) {
$successfulBackups = $backup->executions()
->whereIn('id', $replicas->pluck('execution_id'))
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->get();
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
return collect();
}
$backupsToDelete = backupExecutionsOutsideRetention(
$successfulBackups,
$backup->database_backup_retention_amount_s3,
$backup->database_backup_retention_days_s3,
$backup->database_backup_retention_max_storage_s3,
);
$backupsToDelete = collect();
$filesToDelete = $backupsToDelete
->filter(fn ($execution) => ! empty($execution->filename))
->pluck('filename')
->all();
if ($retentionAmount > 0) {
$byAmount = $successfulBackups->skip($retentionAmount);
$backupsToDelete = $backupsToDelete->merge($byAmount);
}
if ($retentionDays > 0) {
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
$backupsToDelete = $backupsToDelete->merge($oldBackups);
}
if ($maxStorageGB > 0) {
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
$totalSize = 0;
$backupsOverLimit = collect();
$backupsToCheck = $successfulBackups->skip(1);
foreach ($backupsToCheck as $backupExecution) {
$totalSize += (int) $backupExecution->size;
if ($totalSize > $maxStorageBytes) {
$backupsOverLimit = $successfulBackups->filter(
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
)->skip(1);
break;
}
if (empty($filesToDelete)) {
continue;
}
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
}
$backupsToDelete = $backupsToDelete->unique('id');
$processedBackups = collect();
$filesToDelete = $backupsToDelete
->filter(fn ($execution) => ! empty($execution->filename))
->pluck('filename')
->all();
if (! empty($filesToDelete)) {
try {
deleteBackupsS3($filesToDelete, $backup->s3);
$processedBackups = $backupsToDelete;
deleteBackupsS3($filesToDelete, $replicas->first()->s3);
$deletedReplicas = $deletedReplicas->merge($replicas->whereIn('execution_id', $backupsToDelete->pluck('id')));
} catch (Throwable $e) {
report($e);
}
}
return $processedBackups;
if ($deletedReplicas->isEmpty()) {
return collect();
}
DatabaseBackupS3Replica::query()
->whereKey($deletedReplicas->pluck('id'))
->update(['s3_storage_deleted' => true]);
$executions = $backup->executions()->whereIn('id', $deletedReplicas->pluck('execution_id')->unique())->get();
$executions->each(fn (ScheduledDatabaseBackupExecution $execution) => $execution->refreshS3Summary());
return $executions;
}
function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool
+98 -8
View File
@@ -774,7 +774,20 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
->contains($normalize($domain));
}
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, bool $is_permanent_redirect_enabled = false)
/**
* Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same
* address as the labels, so caddy-docker-proxy merges them into one site block.
*/
function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string
{
if ($scheme === 'https' && ! $isForceHttpsEnabled) {
return "http://{$host}, https://{$host}";
}
return "{$scheme}://{$host}";
}
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [], bool $is_permanent_redirect_enabled = false)
{
$labels = collect([]);
if ($serviceLabels) {
@@ -785,7 +798,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
if ($is_http_basic_auth_enabled) {
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
$hashedPassword = $http_basic_auth_argon2id_options
? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options)
: password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
}
$trafficAppKey = caddyTrafficAppKey($uuid, $service_name);
@@ -796,10 +811,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$path = $url->getPath();
$host_without_www = str($host)->replace('www.', '');
$schema = $url->getScheme();
$siteAddress = "{$schema}://{$host}";
if ($schema === 'https' && ! $is_force_https_enabled) {
$siteAddress = "http://{$host}, https://{$host}";
}
$siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled);
$portlessDomain = ServiceApplication::withoutPort($domain);
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
$handle = 'handle_path';
@@ -843,6 +855,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if ($is_http_basic_auth_enabled) {
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
if ($http_basic_auth_argon2id_options) {
$labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id");
}
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
}
if ($is_traffic_analytics_enabled) {
@@ -929,7 +944,7 @@ function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection
) : $label);
}
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', bool $is_permanent_redirect_enabled = false)
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', ?int $http_basic_auth_bcrypt_cost = null, bool $is_permanent_redirect_enabled = false)
{
$labels = collect([]);
$labels->push('traefik.enable=true');
@@ -951,7 +966,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
$http_basic_auth_label = "http-basic-auth-{$uuid}";
if ($is_http_basic_auth_enabled) {
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
}
if ($is_http_basic_auth_enabled) {
@@ -1222,6 +1237,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
$appUuid = $appUuid.'-pr-'.$pull_request_id;
}
$labels = collect([]);
$httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [
'memory_cost' => $application->http_basic_auth_argon2id_memory_cost,
'time_cost' => $application->http_basic_auth_argon2id_time_cost,
] : [];
if ($pull_request_id === 0) {
if ($application->fqdn) {
$domains = str(data_get($application, 'fqdn'))->explode(',');
@@ -1242,6 +1261,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
domainPortOverrides: $application->domain_port_overrides ?? [],
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
@@ -1261,6 +1281,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1282,6 +1304,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $application->domain_port_overrides ?? [],
@@ -1300,6 +1323,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1331,6 +1356,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
@@ -1350,6 +1376,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1370,6 +1398,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
@@ -1387,6 +1416,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
@@ -1752,6 +1783,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
}
}
// Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by
// the long-option regex above. Map them to compose so a single-image app can keep STDIN
// open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an
// interactive main process, e.g. game-server consoles.
// Keep quoted option values together so flags inside an entrypoint are not options.
preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens);
foreach ($tokens[0] as $token) {
if ($token === '--interactive') {
$compose_options->put('stdin_open', true);
} elseif ($token === '--tty') {
$compose_options->put('tty', true);
} elseif (preg_match('/^-[dit]+$/', $token)) {
if (str_contains($token, 'i')) {
$compose_options->put('stdin_open', true);
}
if (str_contains($token, 't')) {
$compose_options->put('tty', true);
}
}
}
return $compose_options->toArray();
}
@@ -1769,6 +1821,10 @@ function unsupportedDockerRunOptions(?string $custom_docker_run_options): array
if (! str_starts_with($word, '-') || preg_match('/^-\d/', $word)) {
continue;
}
// convertDockerRunToCompose() maps these outside DOCKER_RUN_OPTION_MAPPING
if (preg_match('/^(-[dit]+|--interactive|--tty)$/', $word)) {
continue;
}
$option = str_starts_with($word, '--') ? '--'.ltrim(strtok($word, '='), '-') : strtok($word, '=');
if (! array_key_exists($option, DOCKER_RUN_OPTION_MAPPING)) {
$unsupported[] = $option;
@@ -2171,3 +2227,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString):
return $modifiedCommand ?? $command;
}
/**
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
*
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
*/
function railpackBuilderLockedScript(string $command): string
{
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
."{$command}; status=\$?; "
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
.'exit $status';
}
/**
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
*/
function railpackBuildxMetadataVolume(Server $server): string
{
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
}
/**
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
* when no build uses it.
*/
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
{
$script = railpackBuilderLockedScript($command);
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
}
+163
View File
@@ -0,0 +1,163 @@
<?php
use App\Models\GiteaApp;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Gitea caps page sizes at its MAX_RESPONSE_ITEMS setting (default 50).
*/
const GITEA_PAGE_SIZE = 50;
function refreshGiteaToken(GiteaApp $source): void
{
if (! $source->refresh_token) {
throw new RuntimeException('Gitea source has no refresh token. Please reconnect the Gitea app.');
}
$safetyMargin = 60;
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
return;
}
$lock = Cache::lock("gitea_token_refresh_{$source->id}", 20);
if (! $lock->block(20)) {
$source->refresh();
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
return;
}
throw new RuntimeException('Gitea token refresh timed out. Please try again.');
}
try {
$source->refresh();
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
return;
}
$baseUrl = rtrim($source->html_url, '/');
$response = Http::GitSource($baseUrl)->asForm()->post("{$baseUrl}/login/oauth/access_token", [
'client_id' => $source->client_id,
'client_secret' => $source->client_secret,
'refresh_token' => $source->refresh_token,
'grant_type' => 'refresh_token',
]);
if (! $response->successful()) {
$error = data_get($response->json(), 'error_description', $response->body());
throw new RuntimeException("Failed to refresh Gitea token: {$error}");
}
$data = $response->json();
$source->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $source->refresh_token,
'expires_at' => time() + ($data['expires_in'] ?? 3600),
]);
} finally {
$lock->release();
}
}
function giteaApi(GiteaApp $source, string $endpoint, string $method = 'get', ?array $data = null): array
{
refreshGiteaToken($source);
$client = Http::Gitea($source->apiUrlBase(), $source->access_token)
->timeout(20)
->retry(3, 200, throw: false);
if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) {
$response = $client->$method($endpoint, $data);
} else {
$response = $client->$method($endpoint);
}
if (! $response->successful()) {
$errorMessage = data_get($response->json(), 'message', $response->body());
throw new RuntimeException("Gitea API call failed: {$errorMessage}");
}
return [
'data' => collect($response->json()),
'total' => (int) $response->header('x-total-count', 0),
];
}
function giteaAppPrivateKey(GiteaApp $source): ?PrivateKey
{
$privateKey = $source->privateKey;
return $privateKey?->team_id === $source->team_id ? $privateKey : null;
}
function generateGiteaCloneToken(GiteaApp $source): string
{
refreshGiteaToken($source);
return $source->access_token;
}
function loadGiteaRepositories(GiteaApp $source, int $page = 1): array
{
refreshGiteaToken($source);
$response = Http::Gitea($source->apiUrlBase(), $source->access_token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/user/repos', [
'limit' => GITEA_PAGE_SIZE,
'page' => $page,
]);
if (! $response->successful()) {
return ['total_count' => 0, 'has_more' => false, 'repositories' => []];
}
$repositories = collect($response->json());
$hasMore = count($response->json()) === GITEA_PAGE_SIZE;
$organizations = collect(explode(',', (string) $source->organization))
->map(fn ($organization) => strtolower(trim($organization)))
->filter();
if ($organizations->isNotEmpty()) {
$repositories = $repositories->filter(
fn ($repository) => $organizations->contains(strtolower((string) data_get($repository, 'owner.login')))
);
}
return [
'total_count' => $repositories->count(),
'has_more' => $hasMore,
'repositories' => $repositories->map(fn ($repository) => [
'id' => data_get($repository, 'id'),
'name' => data_get($repository, 'name'),
'full_name' => data_get($repository, 'full_name'),
'default_branch' => data_get($repository, 'default_branch', 'main'),
'html_url' => data_get($repository, 'html_url'),
'owner' => data_get($repository, 'owner.login'),
])->values()->all(),
];
}
function loadGiteaBranches(GiteaApp $source, string $fullName, int $page = 1): array
{
refreshGiteaToken($source);
$repositoryPath = collect(explode('/', $fullName, 2))->map(fn ($part) => rawurlencode($part))->implode('/');
$response = Http::Gitea($source->apiUrlBase(), $source->access_token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$repositoryPath}/branches", [
'limit' => GITEA_PAGE_SIZE,
'page' => $page,
]);
if (! $response->successful()) {
return [];
}
return $response->json();
}
+2 -2
View File
@@ -1462,7 +1462,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($server->isLogDrainEnabled()) {
if ($resource->isLogDrainEnabled()) {
$logging = generate_fluentd_configuration();
$logging = generate_log_drain_configuration($server);
}
}
$volumes = collect(data_get($service, 'volumes', []));
@@ -2848,7 +2848,7 @@ function serviceParser(Service $resource): Collection
if ($server->isLogDrainEnabled()) {
if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
$logging = generate_fluentd_configuration();
$logging = generate_log_drain_configuration($server);
}
}
$volumes = collect(data_get($service, 'volumes', []));
+13
View File
@@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
return null;
}
}
/**
* Validation rule for an HTML page that Coolify sends to the proxy in one shell command
* (see Server::PROXY_ERROR_PAGE_MAX_BYTES).
*/
function proxyPageSizeRule(string $name): Closure
{
return function (string $attribute, mixed $value, Closure $fail) use ($name): void {
if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) {
$fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.');
}
};
}
+2 -40
View File
@@ -9,6 +9,7 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\RemoteProcessCommand;
use Carbon\Carbon;
use Illuminate\Contracts\Process\ProcessResult;
@@ -370,46 +371,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
function remove_iip($text)
{
// Ensure the input is valid UTF-8 before processing
$text = sanitize_utf8_text($text);
// Git access tokens
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
$text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text);
// ANSI color codes
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.)
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
// User and password cannot contain whitespace, so a match never spans log lines.
$text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:\s@\/]+:)[^@\s]+(@)/i', '$1'.REDACTED.'$2', $text);
// Email addresses
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
// Bearer/JWT tokens
$text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text);
// GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh)
$text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text);
// GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token)
$text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text);
// AWS credentials (Access Key ID starts with AKIA, ABIA, ACCA, ASIA)
$text = preg_replace('/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/', REDACTED, $text);
// AWS Secret Access Key (40 character base64-ish string, typically follows access key)
$text = preg_replace('/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'"]?([A-Za-z0-9\/+=]{40})[\'"]?/i', '$1='.REDACTED, $text);
// API keys (common patterns)
$text = preg_replace('/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'"]?[A-Za-z0-9\-_]{16,}[\'"]?/i', '$1='.REDACTED, $text);
// Private key blocks
$text = preg_replace('/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/', REDACTED, $text);
return $text;
return app(SensitiveDataRedactor::class)->redactText((string) $text);
}
/**
+96
View File
@@ -26,15 +26,111 @@ function service_logo_urls(mixed $logo): array
}
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
/**
* The URL of a logo in public/svgs/resources. The file's modification time is the version, so
* browsers load a changed logo instead of their cached copy.
*/
function resource_logo_url(string $name): string
{
$path = "svgs/resources/{$name}.svg";
$modifiedAt = is_file(public_path($path)) ? filemtime(public_path($path)) : false;
return asset($path).($modifiedAt ? "?v={$modifiedAt}" : '');
}
/**
* The logo of a resource, the same one shown on the "New resource" page: the database engine,
* the service template, or how the application is built. Null when there is no specific logo.
*
* Applications with a domain also get the favicon URL of their first domain. The browser loads it
* and shows it in place of the logo when it loads. Coolify itself never requests it.
*
* @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null
*/
function resource_logo(Model $resource): ?array
{
$resourceLogo = function (string $name, bool $hasDarkVariant = false): array {
return [
'src' => resource_logo_url($name),
'dark' => $hasDarkVariant ? resource_logo_url("{$name}-dark") : null,
'fallback' => null,
'padded' => false,
'favicon' => null,
];
};
if ($resource instanceof Service) {
$templates = once(fn () => get_service_templates());
$type = filled($resource->service_type) ? $resource->service_type : str($resource->name)->beforeLast('-')->value();
$logo = data_get($templates->get(resolve_service_template_key($type, $templates)), 'logo');
if (! is_string($logo) || $logo === '' || basename($logo) === 'default.webp') {
return null;
}
$urls = service_logo_urls($logo);
return ['src' => $urls['logo'], 'dark' => null, 'fallback' => $urls['logo_cdn_url'], 'padded' => true, 'favicon' => null];
}
if ($resource instanceof Application) {
$logo = match (true) {
$resource->build_pack === 'dockerimage' => $resourceLogo('docker-image'),
$resource->build_pack === 'dockerfile' => $resourceLogo('dockerfile'),
$resource->build_pack === 'dockercompose' => $resourceLogo('docker-compose'),
$resource->source_type === GithubApp::class => $resourceLogo('github-app', true),
$resource->source_type === GitlabApp::class => $resourceLogo('gitlab-app'),
default => $resourceLogo('public-repo'),
};
return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)];
}
if (! method_exists($resource, 'type')) {
return null;
}
return match (str($resource->type())->after('standalone-')->value()) {
'postgresql' => $resourceLogo('postgres'),
'mysql' => $resourceLogo('mysql'),
'mariadb' => $resourceLogo('mariadb'),
'mongodb' => $resourceLogo('mongodb'),
'redis' => $resourceLogo('redis'),
'keydb' => $resourceLogo('keydb', true),
'dragonfly' => $resourceLogo('dragonfly', true),
'clickhouse' => $resourceLogo('clickhouse'),
'sqlite' => $resourceLogo('sqlite'),
default => null,
};
}
/**
* The favicon URL of the first domain in a comma-separated domain list, or null when there is no usable domain.
*/
function resource_favicon_url(?string $fqdn): ?string
{
$domain = str((string) $fqdn)->explode(',')->map(fn (string $url) => trim($url))->first(fn (string $url) => $url !== '');
$parts = parse_url((string) $domain);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (! in_array($scheme, ['http', 'https'], true) || $host === '' || str_contains($host, '*')) {
return null;
}
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
return "{$scheme}://{$host}{$port}/favicon.ico";
}
function replaceRegex(?string $name = null)
{
return "/\\\${?{$name}[^}]*}?|\\\${$name}\w+/";
+186 -14
View File
@@ -9,6 +9,7 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GiteaApp;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
@@ -38,6 +39,7 @@ use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\DnsRecordHints;
use Carbon\CarbonImmutable;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
@@ -701,14 +703,8 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string
{
try {
$response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
} catch (Throwable) {
return '0.0.0';
}
// Read the local versions.json, which CheckForUpdatesJob refreshes every hour. Do not ask the CDN for each server.
return data_get(get_versions_data(), 'coolify.sentinel.version') ?? '0.0.0';
}
function get_latest_version_of_coolify(): string
{
@@ -891,6 +887,10 @@ function profile_avatar_url(User $user): string
}
}
if (auth()->check() && $user->id !== auth()->id()) {
return route('team.member.avatar', ['user_id' => $user->id, 'v' => $user->updated_at->timestamp]);
}
return route('profile.avatar', ['v' => $user->updated_at->timestamp]);
}
@@ -909,6 +909,21 @@ function project_icon_url(Project $project): string
]);
}
function team_avatar_url(Team $team): string
{
if ($team->avatar_storage_type === 's3') {
$url = s3_image_url($team->avatar_s3_storage_id, $team->avatar_path, $team->updated_at->timestamp);
if ($url) {
return $url;
}
}
return route('team.avatar', [
'team_id' => $team->id,
'v' => $team->updated_at->timestamp,
]);
}
function s3_image_url(?int $storageId, ?string $path, int $version): ?string
{
if (! $storageId || blank($path)) {
@@ -1741,10 +1756,14 @@ function removeAnsiColors($text)
return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text);
}
function sanitizeLogsForExport(string $text): string
/**
* Redact log text before it leaves Coolify (copy, download, API, MCP).
*
* @param array{known?: array<int, string>, locked?: array<int, string>} $secrets Resource values to hide, usually from EnvironmentVariable::logRedactionSecretsFor().
*/
function sanitizeLogsForExport(string $text, array $secrets = []): string
{
// All sanitization is now handled by remove_iip()
return remove_iip($text);
return app(SensitiveDataRedactor::class)->redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []);
}
function getTopLevelNetworks(Service|Application $resource): Collection
@@ -3588,7 +3607,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) {
data_set($service, 'logging', generate_fluentd_configuration());
data_set($service, 'logging', generate_log_drain_configuration($resource->server));
}
if ($serviceLabels->count() > 0) {
if ($resource->is_container_label_escape_enabled) {
@@ -4413,7 +4432,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($server->isLogDrainEnabled()) {
if ($resource instanceof Application && $resource->isLogDrainEnabled()) {
data_set($service, 'logging', generate_fluentd_configuration());
data_set($service, 'logging', generate_log_drain_configuration($server));
}
}
if ($serviceLabels->count() > 0) {
@@ -4473,6 +4492,31 @@ function generate_fluentd_configuration(): array
];
}
function generate_log_drain_configuration(Server $server): array
{
if ($server->isFluentBitLogDrainEnabled()) {
return generate_fluentd_configuration();
}
$settings = $server->settings;
$options = [
'awslogs-region' => $settings->logdrain_cloudwatch_region,
'awslogs-group' => $settings->logdrain_cloudwatch_group,
'awslogs-create-group' => 'true',
// Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container.
'mode' => 'non-blocking',
// The stream is named after the container; awslogs-stream is never set because it would override this tag.
'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}',
];
return [
'driver' => 'awslogs',
'options' => collect($options)
->reject(fn ($value) => blank($value))
->all(),
];
}
function isAssociativeArray($array)
{
if ($array instanceof Collection) {
@@ -4578,6 +4622,20 @@ function wireNavigate(): string
}
}
/**
* Registers a resource menu that the main sidebar shows in place of its main menu on wide screens
* (see components/navbar.blade.php). The menu teleports itself into #main-nav-context-{depth}.
* Back on a menu shows the menu one depth lower, and Back at depth 0 shows the main menu.
*/
function registerMainNavContext(int $depth, string $label, string $backTitle): void
{
$levels = request()->attributes->get('mainNavContext', []);
$levels[$depth] = ['label' => $label, 'backTitle' => $backTitle];
ksort($levels);
request()->attributes->set('mainNavContext', $levels);
}
/**
* Flattens a grouped settings sidebar into the items that the command palette
* shows for the current page: every page, its child pages, and its in-page sections.
@@ -4603,6 +4661,10 @@ function settingsSearchItems(iterable $groupedItems, array $routeParameters, arr
foreach ($groupedItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
if ($item['disabled'] ?? false) {
continue;
}
$href = route($item['route'], $routeParameters);
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);
@@ -4856,7 +4918,7 @@ function gitRepositorySlug(?string $gitRepository): string
return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
}
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|GiteaApp|null $source = null): array
{
$repository = $gitRepository;
$providerInfo = [
@@ -4876,6 +4938,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
switch ($source->getMorphClass()) {
case GithubApp::class:
case GitlabApp::class:
case GiteaApp::class:
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
$providerInfo['port'] = $source->custom_port;
$providerInfo['user'] = $source->custom_user;
@@ -5714,6 +5777,115 @@ function deviceLabel(?string $device): string
};
}
/**
* Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from
* TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display,
* largest first. `name` and `detail` are the two parts of `label` ("Berlin" and
* "BE, Germany"). `CC||` (sent by older Sentinel builds) is an unknown city of
* that country. `__other__` and malformed values are merged into one "Other"
* row, which stays last. Returns [] when no row has a real city.
*
* @param iterable<int, array{value?: string, requests?: int, bytesOut?: int, lat?: float, lon?: float}> $rows
* @return array<int, array{key: string, country: string, label: string, name: string, detail: string, requests: int, bytesOut: int, lat: ?float, lon: ?float}>
*/
function trafficCityRows(iterable $rows): array
{
$out = [];
$hasCity = false;
foreach ($rows as $row) {
$requests = (int) data_get($row, 'requests', 0);
if ($requests <= 0) {
continue;
}
$value = (string) data_get($row, 'value', '');
$parts = explode('|', $value, 3);
$parts[0] = strtoupper($parts[0]);
$isCityKey = count($parts) === 3 && preg_match('/^[A-Z]{2}$/', $parts[0]) === 1;
$key = '__other__';
$country = '';
$label = 'Other';
$name = 'Other';
$detail = '';
$lat = null;
$lon = null;
if ($isCityKey) {
[$country, $subdivision, $city] = array_map('trim', $parts);
$key = $value;
if ($city === '') {
$label = 'Unknown, '.$country;
$name = 'Unknown';
$detail = countryName($country);
} else {
$hasCity = true;
$region = preg_match('/^[A-Z]{2,3}$/', $subdivision) === 1 ? $subdivision.', ' : '';
$label = $city.', '.$region.$country;
$name = $city;
$detail = $region.countryName($country);
$rawLat = data_get($row, 'lat');
$rawLon = data_get($row, 'lon');
if (is_numeric($rawLat) && is_numeric($rawLon)) {
$lat = (float) $rawLat;
$lon = (float) $rawLon;
}
}
}
$out[$key] ??= ['key' => $key, 'country' => $country, 'label' => $label, 'name' => $name, 'detail' => $detail, 'requests' => 0, 'bytesOut' => 0, 'lat' => $lat, 'lon' => $lon];
$out[$key]['requests'] += $requests;
$out[$key]['bytesOut'] += (int) data_get($row, 'bytesOut', 0);
}
if (! $hasCity) {
return [];
}
$other = $out['__other__'] ?? null;
unset($out['__other__']);
$out = array_values($out);
usort($out, fn (array $a, array $b): int => $b['requests'] <=> $a['requests']);
return $other === null ? $out : [...$out, $other];
}
/**
* Marker data for the analytics globe, one set for each view of the Locations
* card: a marker per country with a valid ISO-A2 code, and a marker per city
* that has coordinates.
*
* @param iterable<int, mixed> $countryRows
* @param iterable<int, mixed> $cityRows
* @return array{countries: array<int, array{code: string, requests: int}>, cities: array<int, array{lat: float, lon: float, requests: int}>}
*/
function trafficGeoMarkers(iterable $countryRows, iterable $cityRows): array
{
$countries = [];
foreach ($countryRows as $row) {
$code = strtoupper((string) data_get($row, 'value', ''));
$requests = (int) data_get($row, 'requests', 0);
if ($requests > 0 && preg_match('/^[A-Z]{2}$/', $code) === 1) {
$countries[] = ['code' => $code, 'requests' => $requests];
}
}
$cities = [];
foreach ($cityRows as $row) {
$requests = (int) data_get($row, 'requests', 0);
$lat = data_get($row, 'lat');
$lon = data_get($row, 'lon');
if ($requests > 0 && is_numeric($lat) && is_numeric($lon)) {
$cities[] = ['lat' => (float) $lat, 'lon' => (float) $lon, 'requests' => $requests];
}
}
return ['countries' => $countries, 'cities' => $cities];
}
/**
* Resolve an ISO 3166-1 alpha-2 country code to its English country name.
*
+6
View File
@@ -85,6 +85,12 @@ function allowedPathsForUnsubscribedAccounts()
// Account basics stay available without a paid plan.
'profile',
'profile/appearance',
'profile/passkeys/confirm',
'user/confirm-password',
'user/passkeys',
'user/passkeys/options',
'passkeys/confirm',
'passkeys/confirm/options',
];
}
function allowedPathsForBoardingAccounts()
+105
View File
@@ -1,7 +1,12 @@
<?php
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Get cached versions data from versions.json.
@@ -51,3 +56,103 @@ function invalidate_versions_cache(): void
{
Cache::forget('coolify:versions:all');
}
/**
* HTTP client for the Coolify CDN. It retries only transient failures (connection errors, 429, 5xx)
* and does not throw for a failed response.
*/
function cdn_http(int $tries = 3): PendingRequest
{
return Http::timeout(10)
->connectTimeout(10)
->retry(
$tries,
fn (int $attempt, Throwable $exception) => cdn_retry_delay_ms($attempt, $exception),
fn (Throwable $exception) => $exception instanceof ConnectionException
|| ($exception instanceof RequestException
&& ($exception->response->status() === 429 || $exception->response->serverError())),
throw: false,
);
}
/**
* Wait as long as the Retry-After header says (Cloudflare sends 60 seconds with error 1200),
* or 2^attempt seconds without it. A random part of up to the same length is added, so instances
* that failed in the same burst do not retry in the same second.
*/
function cdn_retry_delay_ms(int $attempt, Throwable $exception): int
{
$retryAfter = $exception instanceof RequestException ? (int) $exception->response->header('Retry-After') : 0;
$seconds = $retryAfter > 0 ? min($retryAfter, 60) : 2 ** $attempt;
return ($seconds + random_int(0, $seconds)) * 1000;
}
/**
* Fetch versions.json from the CDN and write it to the local file of this node.
* The Coolify version never goes below the cached or the running version.
* When the CDN fails, the local file stays as it is.
*
* @return array|null The written versions data, or null if the CDN fetch failed
*/
function refresh_versions_file(int $tries = 3): ?array
{
$response = cdn_http($tries)->get(config('constants.coolify.versions_url'));
$versions = $response->json();
if ($response->failed() || ! is_array($versions)) {
Log::warning('Could not fetch versions.json from the CDN, using the local file', ['status' => $response->status()]);
return null;
}
$latestVersion = data_get($versions, 'coolify.v4.version');
$currentVersion = config('constants.coolify.version');
$existingCoolifyVersion = null;
if (File::exists(base_path('versions.json'))) {
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
}
$bestVersion = $latestVersion;
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
Log::warning('CDN served older Coolify version than cache', [
'cdn_version' => $latestVersion,
'cached_version' => $existingCoolifyVersion,
'current_version' => $currentVersion,
]);
$bestVersion = $existingCoolifyVersion;
}
if (version_compare($bestVersion, $currentVersion, '<')) {
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
'cdn_version' => $latestVersion,
'cached_version' => $existingCoolifyVersion,
'current_version' => $currentVersion,
'attempted_best' => $bestVersion,
'using' => $currentVersion,
]);
$bestVersion = $currentVersion;
}
data_set($versions, 'coolify.v4.version', $bestVersion);
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
invalidate_versions_cache();
update_helper_version($versions);
return $versions;
}
/**
* Store the helper image version of the versions data when it is newer than the stored one.
*/
function update_helper_version(?array $versions): void
{
$latestVersion = data_get($versions, 'coolify.helper.version');
$settings = instanceSettings();
if ($latestVersion && version_compare($latestVersion, (string) $settings->helper_version, '>')) {
$settings->update(['helper_version' => $latestVersion]);
}
}