diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index e869ca91bd..2adaad476e 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -17,6 +17,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; +use Symfony\Component\Yaml\Yaml; class FileStorage extends Component { @@ -30,6 +31,8 @@ class FileStorage extends Component public ?string $workdir = null; + public ?string $resolvedFromEnvVar = null; + public bool $permanently_delete = true; public bool $isReadOnly = false; @@ -64,6 +67,7 @@ public function mount() } $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large; + $this->resolvedFromEnvVar = $this->isV6Parser() ? $this->detectEnvVarSource() : null; $this->syncData(); } @@ -89,6 +93,69 @@ public function syncData(bool $toModel = false): void } } + private function isV6Parser(): bool + { + $version = $this->resource instanceof Application + ? $this->resource->compose_parsing_version + : ($this->resource->service->compose_parsing_version ?? null); + + return (int) $version >= 6; + } + + /** + * Check the raw docker-compose for env var references in this volume's source. + * Returns the variable name if found, null otherwise. + */ + private function detectEnvVarSource(): ?string + { + try { + $resource = $this->resource; + $dockerComposeRaw = $resource->service?->docker_compose_raw + ?? $resource->docker_compose_raw + ?? null; + + if (! $dockerComposeRaw) { + return null; + } + + $compose = Yaml::parse($dockerComposeRaw); + + if (! isset($compose['services'])) { + return null; + } + + $mountPath = $this->fileStorage->mount_path; + + // Search all services in the compose for a volume matching this mount path + foreach ($compose['services'] as $serviceVolumes) { + $volumes = data_get($serviceVolumes, 'volumes', []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + + if (is_string($volume)) { + $parts = explode(':', $volume); + if (count($parts) >= 2) { + $source = $parts[0]; + $target = $parts[1]; + } + } elseif (is_array($volume)) { + $source = data_get($volume, 'source'); + $target = data_get($volume, 'target'); + } + + if ($target === $mountPath && $source && preg_match('/\$\{([a-zA-Z_][a-zA-Z0-9_]*)/', $source, $matches)) { + return $matches[1]; + } + } + } + } catch (\Throwable $e) { + // Silently fail — this is just a UI hint + } + + return null; + } + public function convertToDirectory() { try { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index b45d9aba3c..3681333104 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -270,6 +271,9 @@ public function submit($data = null) $this->updateOrder(); $this->getDevView(); + + // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path}) + LocalFileVolume::reResolveVolumePaths($this->resource); } catch (\Throwable $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 094320bdd4..860769f929 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Models\LocalFileVolume; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -227,6 +228,13 @@ public function submit() $this->serialize(); $this->syncData(true); $this->syncData(false); + + // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path}) + $resourceable = $this->env->resourceable ?? null; + if ($resourceable) { + LocalFileVolume::reResolveVolumePaths($resourceable); + } + $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); @@ -378,7 +386,14 @@ public function delete() } } + $resourceable = $this->env->resourceable ?? null; $this->env->delete(); + + // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path}) + if ($resourceable) { + LocalFileVolume::reResolveVolumePaths($resourceable); + } + $this->dispatch('environmentVariableDeleted'); $this->dispatch('success', 'Environment variable deleted successfully.'); } catch (\Exception $e) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 2c408483e0..c5776f9f84 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -118,7 +118,7 @@ class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '5'; + private static $parserVersion = '6'; protected $fillable = [ 'name', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b521ede3d6..8455c4aa58 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -6,6 +6,9 @@ use App\Jobs\ServerStorageSaveJob; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel @@ -55,6 +58,63 @@ protected static function booted() }); } + /** + * Resolve env var default syntax from fs_path (e.g., ${VAR:-./path} -> ./path). + * Resolves against the owning resource's environment variables so that + * Coolify-defined values take precedence over defaults. + * + * @throws \RuntimeException if the resolved path is bare '.' or empty (unresolvable variable with no default) + */ + public function resolvedFsPath(): string + { + $envVars = $this->getResourceEnvironmentVariables(); + $resolved = resolveEnvVarDefault(str($this->fs_path), $envVars); + $value = $resolved->value(); + + if ($value === '.' || $value === '' || str_contains($value, '${')) { + throw new \RuntimeException( + 'Cannot resolve storage path: environment variable has no value and no default was provided. ' + .'Original path: '.$this->fs_path + ); + } + + return $value; + } + + /** + * Gather environment variables from the owning resource (and its parent + * service when the resource is a ServiceApplication or ServiceDatabase). + */ + private function getResourceEnvironmentVariables(): Collection + { + if ($this->exists) { + $this->loadMissing(['service']); + } + $resource = $this->resource; + + if (! $resource) { + return collect(); + } + + $envVars = collect(); + + // Service children (ServiceApplication / ServiceDatabase) — merge parent service env vars + $parentService = data_get($resource, 'service'); + if ($parentService && method_exists($parentService, 'environment_variables')) { + $envVars = $parentService->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + } + + // Resource's own env vars (overrides parent service vars) + if (method_exists($resource, 'environment_variables')) { + $resourceEnvVars = $resource->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + $envVars = $envVars->merge($resourceEnvVars); + } + + return $envVars; + } + protected function isBinary(): Attribute { return Attribute::make( @@ -74,6 +134,194 @@ public function service() return $this->morphTo('resource'); } + /** + * Re-resolve fs_path for file volumes whose raw compose source references + * environment variables. Call this after env vars are updated. + * + * Works for Application and Service resources. + */ + public static function reResolveVolumePaths($resource): void + { + try { + if ($resource instanceof Application) { + static::reResolveForApplication($resource); + } elseif ($resource instanceof Service) { + static::reResolveForService($resource); + } + } catch (\Throwable) { + // Best-effort — don't break env var saves + } + } + + private static function reResolveForApplication(Application $application): void + { + if ((int) $application->compose_parsing_version < 6) { + return; + } + + $dockerComposeRaw = $application->docker_compose_raw; + if (! $dockerComposeRaw) { + return; + } + + $compose = Yaml::parse($dockerComposeRaw); + if (! isset($compose['services'])) { + return; + } + + $envVars = $application->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + + $mainDirectory = str(base_configuration_dir().'/applications/'.$application->uuid); + + // Build raw source map from ALL compose services (Application owns all volumes) + $rawSources = []; + foreach ($compose['services'] as $service) { + $volumes = data_get($service, 'volumes', []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + if (is_string($volume)) { + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + } elseif (is_array($volume)) { + $source = data_get_str($volume, 'source'); + $target = data_get_str($volume, 'target'); + } + if ($source && $target) { + $targetValue = $target instanceof Stringable ? $target->value() : (string) $target; + $rawSources[$targetValue] = $source; + } + } + } + + static::updateFileVolumePaths($application, $rawSources, $envVars, $mainDirectory); + } + + private static function reResolveForService(Service $service): void + { + if ((int) $service->compose_parsing_version < 6) { + return; + } + + $dockerComposeRaw = $service->docker_compose_raw; + if (! $dockerComposeRaw) { + return; + } + + $compose = Yaml::parse($dockerComposeRaw); + if (! isset($compose['services'])) { + return; + } + + $serviceEnvVars = $service->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + + $mainDirectory = str(base_configuration_dir().'/services/'.$service->uuid); + + $volumeOwners = collect() + ->merge($service->applications ?? collect()) + ->merge($service->databases ?? collect()); + + foreach ($volumeOwners as $owner) { + $childEnvVars = $owner->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + $mergedEnvVars = $serviceEnvVars->merge($childEnvVars); + + $serviceName = $owner->name ?? null; + if (! $serviceName) { + continue; + } + + $rawSources = []; + $volumes = data_get($compose, "services.{$serviceName}.volumes", []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + if (is_string($volume)) { + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + } elseif (is_array($volume)) { + $source = data_get_str($volume, 'source'); + $target = data_get_str($volume, 'target'); + } + if ($source && $target) { + $targetValue = $target instanceof Stringable ? $target->value() : (string) $target; + $rawSources[$targetValue] = $source; + } + } + + static::updateFileVolumePaths($owner, $rawSources, $mergedEnvVars, $mainDirectory); + } + } + + private static function updateFileVolumePaths($resource, array $rawSources, $envVars, Stringable $mainDirectory): void + { + foreach ($resource->fileStorages()->get() as $fileVolume) { + $rawSource = $rawSources[$fileVolume->mount_path] ?? null; + if (! $rawSource) { + continue; + } + + $sourceStr = $rawSource instanceof Stringable ? $rawSource : str($rawSource); + if (! str_contains($sourceStr->value(), '${')) { + continue; + } + + $resolved = resolveEnvVarDefault($sourceStr, $envVars); + if (sourceIsLocal($resolved)) { + $resolved = replaceLocalSource($resolved, $mainDirectory); + if ($fileVolume->fs_path !== $resolved->value()) { + $fileVolume->update(['fs_path' => $resolved->value()]); + } + } else { + // Env var removed or no longer resolves to local path — convert back to named volume + $slugWithoutUuid = Str::slug($resolved, '-'); + $name = $resource->uuid.'_'.$slugWithoutUuid; + LocalPersistentVolume::updateOrCreate( + [ + 'mount_path' => $fileVolume->mount_path, + 'resource_id' => $resource->id, + 'resource_type' => get_class($resource), + ], + [ + 'name' => $name, + ] + ); + $fileVolume->delete(); + } + } + + // Reclassify: when env var now resolves to a local path, create LocalFileVolume + // and remove stale LocalPersistentVolume + foreach ($rawSources as $mountPath => $rawSource) { + $sourceStr = $rawSource instanceof Stringable ? $rawSource : str($rawSource); + if (! str_contains($sourceStr->value(), '${')) { + continue; + } + + $resolved = resolveEnvVarDefault($sourceStr, $envVars); + if (! sourceIsLocal($resolved)) { + continue; + } + + if (! $resource->fileStorages()->where('mount_path', $mountPath)->exists()) { + $fsPath = replaceLocalSource($resolved, $mainDirectory); + LocalFileVolume::create([ + 'mount_path' => $mountPath, + 'fs_path' => $fsPath, + 'is_directory' => true, + 'resource_id' => $resource->id, + 'resource_type' => get_class($resource), + ]); + } + + $resource->persistentStorages()->where('mount_path', $mountPath)->delete(); + } + } + public function loadStorageOnServer() { if ($this->is_host_file) { @@ -90,7 +338,7 @@ public function loadStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); - $path = data_get_str($this, 'fs_path'); + $path = str($this->resolvedFsPath()); if ($path->startsWith('.')) { $path = $path->after('.'); $path = $workdir.$path; @@ -148,7 +396,7 @@ public function deleteStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); - $path = data_get_str($this, 'fs_path'); + $path = str($this->resolvedFsPath()); if ($path->startsWith('.')) { $path = $path->after('.'); $path = $workdir.$path; @@ -189,19 +437,28 @@ public function saveStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); + + // Resolve env var syntax (e.g., ${VAR:-./path} -> ./path) for legacy records. + $fsPath = $this->resolvedFsPath(); + $path = str($fsPath); + if ($path->startsWith('.')) { + $path = $path->after('.'); + $path = $workdir.$path; + } + + // Validate and escape resolved path before any shell interpolation. + $pathValue = $path->value(); + validateShellSafePath($pathValue, 'storage path'); + $escapedPath = escapeshellarg($pathValue); $escapedWorkdir = escapeshellarg($workdir); if ($this->is_directory) { - // Validate fs_path early before any shell interpolation - validateShellSafePath($this->fs_path, 'storage path'); - $escapedFsPath = escapeshellarg($this->fs_path); - $commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true"); + $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true"); $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); $commands->push("cd {$escapedWorkdir}"); } - $path = data_get_str($this, 'fs_path'); - $content = data_get($this, 'content'); - $pathForParentDirectory = str($this->fs_path); + + $pathForParentDirectory = str($pathValue); if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) { $parent_dir = $pathForParentDirectory->beforeLast('/'); if ($parent_dir != '') { @@ -209,14 +466,8 @@ public function saveStorageOnServer() $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } - if ($path->startsWith('.')) { - $path = $path->after('.'); - $path = $workdir.$path; - } - // Validate and escape resolved path (may differ from fs_path if relative) - validateShellSafePath($path, 'storage path'); - $escapedPath = escapeshellarg($path); + $content = data_get($this, 'content'); $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); @@ -231,7 +482,7 @@ public function saveStorageOnServer() FileStorageChanged::dispatch(data_get($server, 'team_id')); throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.'); } elseif ($isDir === 'OK' && ! $this->is_directory) { - if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) { + if ($pathValue === '/' || $pathValue === '.' || $pathValue === '..' || $pathValue === '' || str($pathValue)->isEmpty()) { $this->is_directory = true; $this->save(); throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.

Please delete the directory on the server or mark it as directory.'); diff --git a/app/Models/Service.php b/app/Models/Service.php index 72b4676578..53cff64d35 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -45,7 +45,7 @@ class Service extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '5'; + private static $parserVersion = '6'; protected $fillable = [ 'uuid', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ea99e99931..e8f801aca7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -112,7 +112,7 @@ function validateVolumeStringForInjection(string $volumeString): void parseDockerVolumeString($volumeString); } -function parseDockerVolumeString(string $volumeString): array +function parseDockerVolumeString(string $volumeString, bool $preserveEnvDefaults = false): array { $volumeString = trim($volumeString); $source = null; @@ -287,25 +287,24 @@ function parseDockerVolumeString(string $volumeString): array } // Handle environment variable expansion in source - // Example: ${VOLUME_DB_PATH:-db} should extract default value if present if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) { $varContent = $matches[1]; - - // Check if there's a default value with :- if (strpos($varContent, ':-') !== false) { $parts = explode(':-', $varContent, 2); $varName = $parts[0]; - $defaultValue = isset($parts[1]) ? $parts[1] : ''; - - // If there's a non-empty default value, use it for source + $defaultValue = $parts[1] ?? ''; if ($defaultValue !== '') { - $source = $defaultValue; + if ($preserveEnvDefaults) { + // v6+: keep ${VAR:-default} so resolveEnvVarDefault() can apply env overrides + } else { + // v5 and below: strip to just the default value (v4.x behavior) + $source = $defaultValue; + } } else { - // Empty default value, keep the variable reference for env resolution + // Empty default — clean up to simple variable reference $source = '${'.$varName.'}'; } } - // Otherwise keep the variable as-is for later expansion (no default value) } // Validate source path for command injection attempts @@ -315,11 +314,11 @@ function parseDockerVolumeString(string $volumeString): array // Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path) $sourceStr = is_string($source) ? $source : $source; - // Skip validation for simple environment variable references - // Pattern 1: ${WORD_CHARS} with no special characters inside - // Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation) - $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr); - $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr); + // Skip validation for environment variable references (with or without defaults) + // Pattern 1: ${VAR} or ${VAR:-safe_default} + // Pattern 2: ${VAR}/path or ${VAR:-safe_default}/path + $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceStr); + $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceStr); if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { @@ -432,6 +431,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { return [$item['key'] => $item['value']]; }); + // For preview deployments, merge preview env vars (they override normal ones) + if ($isPullRequest) { + $previewEnvironments = $resource->environment_variables_preview()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + $allEnvironments = $allEnvironments->merge($previewEnvironments); + } // filter and add magic environments foreach ($environment as $key => $value) { // Get all SERVICE_ variables from keys and values @@ -688,6 +693,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $predefinedPort = null; $originalResource = $resource; + $isV6 = (int) $resource->compose_parsing_version >= 6; if ($volumes->count() > 0) { foreach ($volumes as $index => $volume) { @@ -697,9 +703,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $content = null; $isDirectory = false; if (is_string($volume)) { - $parsed = parseDockerVolumeString($volume); + $parsed = parseDockerVolumeString($volume, $isV6); $source = $parsed['source']; $target = $parsed['target']; + // v6+: resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars) + if ($isV6) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } // Mode is available in $parsed['mode'] if needed $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { @@ -721,15 +731,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + // v6+: resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path) + if ($isV6 && $source !== null && ! empty($source->value())) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } + // Validate source and target for command injection (array/long syntax) if ($source !== null && ! empty($source->value())) { $sourceValue = $source->value(); - // Allow environment variable references and env vars with path concatenation - $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue); - $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue); - $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue); + // Allow environment variable references (with or without defaults) and path concatenation + $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceValue); + $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceValue); - if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { + if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); } catch (Exception $e) { @@ -2068,6 +2082,7 @@ function serviceParser(Service $resource): Collection } $originalResource = $savedService; + $isV6 = (int) $resource->compose_parsing_version >= 6; if ($volumes->count() > 0) { foreach ($volumes as $index => $volume) { @@ -2077,9 +2092,13 @@ function serviceParser(Service $resource): Collection $content = null; $isDirectory = false; if (is_string($volume)) { - $parsed = parseDockerVolumeString($volume); + $parsed = parseDockerVolumeString($volume, $isV6); $source = $parsed['source']; $target = $parsed['target']; + // v6+: resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars) + if ($isV6) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } // Mode is available in $parsed['mode'] if needed $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first(); if (sourceIsLocal($source)) { @@ -2101,15 +2120,19 @@ function serviceParser(Service $resource): Collection $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + // v6+: resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path) + if ($isV6 && $source !== null && ! empty($source->value())) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } + // Validate source and target for command injection (array/long syntax) if ($source !== null && ! empty($source->value())) { $sourceValue = $source->value(); - // Allow environment variable references and env vars with path concatenation - $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue); - $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue); - $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue); + // Allow environment variable references (with or without defaults) and path concatenation + $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceValue); + $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceValue); - if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { + if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); } catch (Exception $e) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 39c23d2bb5..e68ea62935 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1506,9 +1506,45 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } } +function resolveEnvVarDefault(Stringable $source, ?Collection $environmentVariables = null): Stringable +{ + $value = $source->value(); + if (preg_match('/^\$\{([^}]+)\}$/', $value, $matches)) { + $varContent = $matches[1]; + + // Extract variable name and optional default from ${VAR:-default} or ${VAR} + $varName = $varContent; + $defaultValue = null; + if (strpos($varContent, ':-') !== false) { + $parts = explode(':-', $varContent, 2); + $varName = $parts[0]; + $defaultValue = $parts[1] ?? ''; + } + + // 1. If env var is set in Coolify, use its value + if ($environmentVariables !== null && $environmentVariables->has($varName)) { + $envValue = $environmentVariables->get($varName); + if ($envValue !== null && $envValue !== '') { + return str($envValue); + } + } + + // 2. If a non-empty default exists, use it + if ($defaultValue !== null && $defaultValue !== '') { + return str($defaultValue); + } + + // 3. No env var and no default — return original reference unchanged so callers + // (e.g. sourceIsLocal()) don't misclassify it as a local bind mount. + return $source; + } + + return $source; +} + function sourceIsLocal(Stringable $source) { - if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) { + if ($source->value() === '.' || $source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) { return true; } @@ -3725,7 +3761,7 @@ function wireNavigate(): string try { $settings = instanceSettings(); - // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled + // Return wire:navigate for SPA navigation without hover-prefetching, or empty string if disabled return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : ''; } catch (Exception $e) { return 'wire:navigate'; diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index e47d290b88..6e1b5a12dc 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -11,15 +11,16 @@ @elseif ($isReadOnly)
@if ($fileStorage->is_directory) - This directory is mounted as read-only and cannot be modified from the UI. + This directory is mounted as read-only inside the container. Content editing is disabled, but you can still convert or delete it. @else - This file is mounted as read-only and cannot be modified from the UI. + This file is mounted as read-only inside the container. Content editing is disabled, but you can still convert or delete it. @endif
@endif
- +
@@ -33,53 +34,54 @@ @endcan @endif
- @if (!$isReadOnly) - @can('update', $resource) -
- @if ($fileStorage->is_host_file) - - @elseif ($fileStorage->is_directory) - - + @if ($fileStorage->is_host_file) + + @elseif ($fileStorage->is_directory) + + + @else + @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) + - @else - @if (!$fileStorage->is_binary && !$fileStorage->is_too_large) - - @endif - Load from - server - + shortConfirmationLabel="Filepath" :confirmWithPassword="false" + step2ButtonText="Convert to directory" /> @endif -
- @endcan - @if (!$fileStorage->is_directory && !$fileStorage->is_host_file) + Load from + server + + @endif + + @endcan + + @if (!$fileStorage->is_directory && !$fileStorage->is_host_file) + @if (!$isReadOnly) @can('update', $resource) @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
@@ -96,6 +98,29 @@ Save @endif @else + @can('view', $resource) + @if (data_get($resource, 'settings.is_preserve_repository_enabled')) +
+ +
+ @endif + + @endcan + @endcan + @else + @can('update', $resource) + @if (!$fileStorage->is_too_large) +
+ Load from + server +
+ @endif + @endcan + @can('view', $resource) @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
@endcan @endif - @else - {{-- Read-only view --}} - @if (!$fileStorage->is_directory && !$fileStorage->is_host_file) - @can('update', $resource) -
- Load from - server -
- @endcan - @if (data_get($resource, 'settings.is_preserve_repository_enabled')) -
- -
- @endif - - @endif @endif
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 0a2dfda9d7..19c2137642 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -31,7 +31,7 @@ "category": "finance", "logo": "svgs/actualbudget.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5006" }, "affine": { @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-05T13:36:24+02:00", + "template_last_updated_at": "2026-01-27T21:03:32+01:00", "port": "8200" }, "anythingllm": { @@ -175,7 +175,7 @@ "category": "ai", "logo": "svgs/argilla.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "6900" }, "audiobookshelf": { @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:09:36+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "authentik": { @@ -231,7 +231,7 @@ "category": "database", "logo": "svgs/autobase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-19T18:13:51+01:00", "port": "80" }, "babybuddy": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T02:22:08+05:30" + "template_last_updated_at": "2026-02-21T23:17:23+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T02:21:33+05:30", + "template_last_updated_at": "2026-02-21T23:17:23+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:19:48+05:30", + "template_last_updated_at": "2026-02-03T22:12:21+01:00", "port": "3000" }, "bookstack": { @@ -377,7 +377,7 @@ "category": "finance", "logo": "svgs/budge.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", @@ -438,7 +438,7 @@ "category": "media", "logo": "svgs/calibre-web-automated-with-downloader.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-08T21:32:32+01:00", "port": "8083" }, "calibre-web": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T01:07:14+05:30", + "template_last_updated_at": null, "port": "3000" }, "cap": { @@ -493,7 +493,7 @@ "category": "media", "logo": "svgs/cap.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-14T23:42:45+02:00", "port": "5679" }, "castopod": { @@ -552,7 +552,7 @@ "category": "helpdesk", "logo": "svgs/chaskiq.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "chatwoot": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-27T09:31:29-03:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "checkmate": { @@ -605,7 +605,7 @@ "category": "storage", "logo": "svgs/chibisafe.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-12T22:51:57+01:00", "port": "80" }, "chroma": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-18T15:42:31+10:00" + "template_last_updated_at": null }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-12T10:45:52+02:00", + "template_last_updated_at": "2025-11-10T14:16:14+01:00", "port": "6791" }, "cryptgeon": { @@ -903,7 +903,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T19:25:51+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8055" }, "directus": { @@ -919,7 +919,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T19:26:06+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8055" }, "diun": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-15T13:36:02+02:00", + "template_last_updated_at": "2025-09-27T04:00:56+02:00", "port": "3000" }, "documenso": { @@ -1044,7 +1044,7 @@ "category": "productivity", "logo": "svgs/dolibarr.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-12-18T10:27:24+01:00", "port": "80" }, "dozzle-with-auth": { @@ -1093,7 +1093,7 @@ "category": "devtools", "logo": "svgs/drizzle.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4983" }, "drupal-with-postgresql": { @@ -1161,7 +1161,7 @@ "category": "monitoring", "logo": "svgs/elasticsearch.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-09T00:43:59-05:00", + "template_last_updated_at": "2026-01-27T21:34:40+05:30", "port": "5601" }, "elasticsearch": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-27T09:25:48+03:00", + "template_last_updated_at": null, "port": "18083" }, "ente-photos-with-s3": { @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-03-16T18:23:47+02:00", "port": "80" }, "evolution-api": { @@ -1436,7 +1436,7 @@ "category": "finance", "logo": "svgs/firefly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "firefox": { @@ -1625,7 +1625,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-27T21:12:57+01:00", "port": "3000" }, "foundryvtt": { @@ -1642,7 +1642,7 @@ "category": "games", "logo": "svgs/foundryvtt.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "30000" }, "freescout": { @@ -1658,7 +1658,7 @@ "category": "helpdesk", "logo": "svgs/freescout.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-mariadb": { @@ -1672,7 +1672,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-mysql": { @@ -1686,7 +1686,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-postgresql": { @@ -1700,7 +1700,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-04T17:41:25+01:00", "port": "80" }, "freshrss": { @@ -1714,7 +1714,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "garage": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-31T23:57:46+02:00", + "template_last_updated_at": "2025-12-10T12:57:34+01:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:19:29+05:30", + "template_last_updated_at": "2025-12-16T14:12:15+07:00", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-06T00:11:24+02:00" + "template_last_updated_at": null }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -1910,7 +1910,7 @@ "category": "productivity", "logo": "svgs/glance.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-18T19:18:07+02:00", "port": "8080" }, "glances": { @@ -2000,7 +2000,7 @@ "category": "messaging", "logo": "svgs/gotify.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-08T11:43:21+02:00", "port": "80" }, "gowa": { @@ -2017,7 +2017,7 @@ "category": "messaging", "logo": "svgs/gowa.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "grafana-with-postgresql": { @@ -2080,7 +2080,7 @@ "category": null, "logo": "svgs/grimmory.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:59:26+05:30", + "template_last_updated_at": null, "port": "80" }, "grist": { @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:10:44+02:00" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2129,7 +2129,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:56+01:00", "port": "80" }, "healthchecks": { @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-25T13:06:59-04:00", + "template_last_updated_at": null, "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-29T12:14:17+05:30", + "template_last_updated_at": null, "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:30:07+05:30", + "template_last_updated_at": "2025-10-07T12:19:45+02:00", "port": "7575" }, "home-assistant": { @@ -2253,7 +2253,7 @@ "category": "productivity", "logo": "svgs/homebox.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "7745" }, "homepage": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-07-02T13:25:47+02:00", + "template_last_updated_at": null, "port": "8288" }, "invoice-ninja": { @@ -2378,7 +2378,7 @@ "category": "finance", "logo": "svgs/invoiceninja.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "9000" }, "it-tools": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T08:58:05+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T09:40:01+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T18:08:40+02:00", + "template_last_updated_at": "2026-03-28T16:21:57+01:00", "port": "3000" }, "leantime": { @@ -2713,7 +2713,7 @@ "category": "ai", "logo": "svgs/librechat.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:29:44+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3080" }, "libreoffice": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47Z" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3005,7 +3005,7 @@ "category": "messaging", "logo": "svgs/mattermost.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T13:57:40-05:00", + "template_last_updated_at": "2025-09-04T16:02:48+03:30", "port": "8065" }, "mealie": { @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:22:15+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "9000" }, "mediawiki": { @@ -3149,7 +3149,7 @@ "category": "games", "logo": "svgs/minecraft.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T15:06:17-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "25565" }, "miniflux": { @@ -3166,7 +3166,7 @@ "category": "RSS", "logo": "svgs/miniflux.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "mixpost": { @@ -3242,7 +3242,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-03-08T01:08:18-05:00", "port": "5678" }, "n8n-with-postgresql": { @@ -3261,7 +3261,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-02-27T22:41:52-05:00", "port": "5678" }, "n8n": { @@ -3280,7 +3280,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-02-27T22:41:52-05:00", "port": "5678" }, "navidrome": { @@ -3328,7 +3328,7 @@ "category": "vpn", "logo": "svgs/netbird.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-09T00:00:38+05:30" + "template_last_updated_at": "2025-11-16T10:33:10+05:30" }, "newapi": { "documentation": "https://docs.newapi.pro/en/getting-started/?utm_source=coolify.io", @@ -3360,7 +3360,7 @@ "category": "proxy", "logo": "svgs/pangolin-logo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-11-12T11:59:36-03:00" }, "next-image-transformation": { "documentation": "https://github.com/coollabsio/next-image-transformation?utm_source=coolify.io", @@ -3392,7 +3392,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud-with-mysql": { @@ -3409,7 +3409,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud-with-postgres": { @@ -3426,7 +3426,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud": { @@ -3443,7 +3443,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nexus-arm": { @@ -3554,7 +3554,7 @@ "category": "productivity", "logo": "svgs/nocodb.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "nodebb": { @@ -3621,7 +3621,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8069" }, "ollama-with-open-webui": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:35:15+05:30", + "template_last_updated_at": "2026-01-12T23:41:36+01:00", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-19T16:40:18+05:30", + "template_last_updated_at": null, "port": "5080" }, "openpanel": { @@ -3789,7 +3789,7 @@ "category": "analytics", "logo": "svgs/openpanel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-27T20:48:57+01:00", "port": "3000" }, "opnform": { @@ -3810,7 +3810,7 @@ "category": "productivity", "logo": "svg/opnform.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-11-24T10:13:08+01:00", "port": "80" }, "orangehrm": { @@ -3828,7 +3828,7 @@ "category": "productivity", "logo": "svgs/orangehrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "organizr": { @@ -3857,7 +3857,7 @@ "category": "helpdesk", "logo": "svgs/osticket.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "overseerr": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-02T12:12:42+03:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "pairdrop": { @@ -3918,7 +3918,7 @@ "category": "games", "logo": "svgs/default.webp", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-11-15T21:55:50+01:00" }, "paperless": { "documentation": "https://docs.paperless-ngx.com/configuration/?utm_source=coolify.io", @@ -3959,7 +3959,7 @@ "category": "finance", "logo": "svgs/paymenter.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-11-23T12:08:21+01:00", "port": "80" }, "penpot-with-s3": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T00:12:17+05:30", + "template_last_updated_at": "2026-02-24T02:34:35+05:30", "port": "80" }, "plex": { @@ -4108,7 +4108,7 @@ "category": "email", "logo": "svgs/plunk.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "pocket-id-with-postgresql": { @@ -4265,7 +4265,7 @@ "category": "proxy", "logo": "svgs/hoppscotch.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-13T19:24:05+02:00", "port": "9159" }, "qbittorrent": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-19T16:11:50+01:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "reactive-resume": { @@ -4428,7 +4428,7 @@ "category": "productivity", "logo": "svgs/redmine.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:56+01:00", "port": "3000" }, "rivet-engine": { @@ -4446,7 +4446,7 @@ "category": "development", "logo": "svgs/rivet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T19:58:30+02:00", + "template_last_updated_at": "2025-10-22T16:07:42+02:00", "port": "6420" }, "rocketchat": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T08:34:57+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8000" }, "satisfactory": { @@ -4621,7 +4621,7 @@ "category": "storage", "logo": "svgs/sftpgo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-12T23:44:06+01:00", "port": "8080" }, "shlink": { @@ -4660,7 +4660,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-11T17:51:10+01:00", "port": "8080" }, "silverbullet": { @@ -4675,7 +4675,7 @@ "category": "productivity", "logo": "svgs/silverbullet.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-05T15:42:12+01:00", "port": "3000" }, "siyuan": { @@ -4759,7 +4759,7 @@ "category": "devtools", "logo": "svgs/soketi-app-manager.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:17+01:00", "port": "8080" }, "soketi": { @@ -4898,7 +4898,7 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-04-05T20:21:11+02:00", + "template_last_updated_at": "2026-01-20T19:10:07+01:00", "port": "8000" }, "superset-with-postgresql": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-16T23:18:19+05:30", + "template_last_updated_at": "2026-01-08T18:51:23+01:00", "port": "3000" }, "typesense": { @@ -5224,7 +5224,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-04T19:45:35+01:00", "port": "4242" }, "unleash-without-database": { @@ -5241,7 +5241,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4242" }, "unstructured": { @@ -5339,7 +5339,7 @@ "category": "email", "logo": "svgs/usesend.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-12-07T17:32:23+11:00", "port": "3000" }, "vaultwarden": { @@ -5774,7 +5774,7 @@ "category": "storage", "logo": "svgs/cells.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-02-13T18:47:54+01:00", "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index 4d97d8d5e2..e5c56c5dcf 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -31,7 +31,7 @@ "category": "finance", "logo": "svgs/actualbudget.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5006" }, "affine": { @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-05T13:36:24+02:00", + "template_last_updated_at": "2026-01-27T21:03:32+01:00", "port": "8200" }, "anythingllm": { @@ -175,7 +175,7 @@ "category": "ai", "logo": "svgs/argilla.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "6900" }, "audiobookshelf": { @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:09:36+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "authentik": { @@ -231,7 +231,7 @@ "category": "database", "logo": "svgs/autobase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-19T18:13:51+01:00", "port": "80" }, "babybuddy": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T02:22:08+05:30" + "template_last_updated_at": "2026-02-21T23:17:23+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T02:21:33+05:30", + "template_last_updated_at": "2026-02-21T23:17:23+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:19:48+05:30", + "template_last_updated_at": "2026-02-03T22:12:21+01:00", "port": "3000" }, "bookstack": { @@ -377,7 +377,7 @@ "category": "finance", "logo": "svgs/budge.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", @@ -438,7 +438,7 @@ "category": "media", "logo": "svgs/calibre-web-automated-with-downloader.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-08T21:32:32+01:00", "port": "8083" }, "calibre-web": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T01:07:14+05:30", + "template_last_updated_at": null, "port": "3000" }, "cap": { @@ -493,7 +493,7 @@ "category": "media", "logo": "svgs/cap.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-14T23:42:45+02:00", "port": "5679" }, "castopod": { @@ -552,7 +552,7 @@ "category": "helpdesk", "logo": "svgs/chaskiq.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "chatwoot": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-27T09:31:29-03:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "checkmate": { @@ -605,7 +605,7 @@ "category": "storage", "logo": "svgs/chibisafe.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-12T22:51:57+01:00", "port": "80" }, "chroma": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-18T15:42:31+10:00" + "template_last_updated_at": null }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-12T10:45:52+02:00", + "template_last_updated_at": "2025-11-10T14:16:14+01:00", "port": "6791" }, "cryptgeon": { @@ -903,7 +903,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T19:25:51+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8055" }, "directus": { @@ -919,7 +919,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T19:26:06+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8055" }, "diun": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-15T13:36:02+02:00", + "template_last_updated_at": "2025-09-27T04:00:56+02:00", "port": "3000" }, "documenso": { @@ -1044,7 +1044,7 @@ "category": "productivity", "logo": "svgs/dolibarr.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-12-18T10:27:24+01:00", "port": "80" }, "dozzle-with-auth": { @@ -1093,7 +1093,7 @@ "category": "devtools", "logo": "svgs/drizzle.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4983" }, "drupal-with-postgresql": { @@ -1161,7 +1161,7 @@ "category": "monitoring", "logo": "svgs/elasticsearch.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-09T00:43:59-05:00", + "template_last_updated_at": "2026-01-27T21:34:40+05:30", "port": "5601" }, "elasticsearch": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-27T09:25:48+03:00", + "template_last_updated_at": null, "port": "18083" }, "ente-photos-with-s3": { @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-03-16T18:23:47+02:00", "port": "80" }, "evolution-api": { @@ -1436,7 +1436,7 @@ "category": "finance", "logo": "svgs/firefly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "firefox": { @@ -1625,7 +1625,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-27T21:12:57+01:00", "port": "3000" }, "foundryvtt": { @@ -1642,7 +1642,7 @@ "category": "games", "logo": "svgs/foundryvtt.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "30000" }, "freescout": { @@ -1658,7 +1658,7 @@ "category": "helpdesk", "logo": "svgs/freescout.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-mariadb": { @@ -1672,7 +1672,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-mysql": { @@ -1686,7 +1686,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "freshrss-with-postgresql": { @@ -1700,7 +1700,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-04T17:41:25+01:00", "port": "80" }, "freshrss": { @@ -1714,7 +1714,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "garage": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-31T23:57:46+02:00", + "template_last_updated_at": "2025-12-10T12:57:34+01:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:19:29+05:30", + "template_last_updated_at": "2025-12-16T14:12:15+07:00", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-06T00:11:24+02:00" + "template_last_updated_at": null }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -1910,7 +1910,7 @@ "category": "productivity", "logo": "svgs/glance.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-18T19:18:07+02:00", "port": "8080" }, "glances": { @@ -2000,7 +2000,7 @@ "category": "messaging", "logo": "svgs/gotify.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-08T11:43:21+02:00", "port": "80" }, "gowa": { @@ -2017,7 +2017,7 @@ "category": "messaging", "logo": "svgs/gowa.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "grafana-with-postgresql": { @@ -2080,7 +2080,7 @@ "category": null, "logo": "svgs/grimmory.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:59:26+05:30", + "template_last_updated_at": null, "port": "80" }, "grist": { @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:10:44+02:00" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2129,7 +2129,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:56+01:00", "port": "80" }, "healthchecks": { @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-25T13:06:59-04:00", + "template_last_updated_at": null, "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-29T12:14:17+05:30", + "template_last_updated_at": null, "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:30:07+05:30", + "template_last_updated_at": "2025-10-07T12:19:45+02:00", "port": "7575" }, "home-assistant": { @@ -2253,7 +2253,7 @@ "category": "productivity", "logo": "svgs/homebox.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "7745" }, "homepage": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-07-02T13:25:47+02:00", + "template_last_updated_at": null, "port": "8288" }, "invoice-ninja": { @@ -2378,7 +2378,7 @@ "category": "finance", "logo": "svgs/invoiceninja.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "9000" }, "it-tools": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T08:58:05+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T09:40:01+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T18:08:40+02:00", + "template_last_updated_at": "2026-03-28T16:21:57+01:00", "port": "3000" }, "leantime": { @@ -2713,7 +2713,7 @@ "category": "ai", "logo": "svgs/librechat.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-03T18:29:44+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3080" }, "libreoffice": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T13:19:47Z" + "template_last_updated_at": "2025-08-17T18:23:57+02:00" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3005,7 +3005,7 @@ "category": "messaging", "logo": "svgs/mattermost.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T13:57:40-05:00", + "template_last_updated_at": "2025-09-04T16:02:48+03:30", "port": "8065" }, "mealie": { @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T09:22:15+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "9000" }, "mediawiki": { @@ -3149,7 +3149,7 @@ "category": "games", "logo": "svgs/minecraft.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T15:06:17-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "25565" }, "miniflux": { @@ -3166,7 +3166,7 @@ "category": "RSS", "logo": "svgs/miniflux.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "mixpost": { @@ -3242,7 +3242,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-03-08T01:08:18-05:00", "port": "5678" }, "n8n-with-postgresql": { @@ -3261,7 +3261,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-02-27T22:41:52-05:00", "port": "5678" }, "n8n": { @@ -3280,7 +3280,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-30T20:40:07+02:00", + "template_last_updated_at": "2026-02-27T22:41:52-05:00", "port": "5678" }, "navidrome": { @@ -3328,7 +3328,7 @@ "category": "vpn", "logo": "svgs/netbird.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-09T00:00:38+05:30" + "template_last_updated_at": "2025-11-16T10:33:10+05:30" }, "newapi": { "documentation": "https://docs.newapi.pro/en/getting-started/?utm_source=coolify.io", @@ -3360,7 +3360,7 @@ "category": "proxy", "logo": "svgs/pangolin-logo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-11-12T11:59:36-03:00" }, "next-image-transformation": { "documentation": "https://github.com/coollabsio/next-image-transformation?utm_source=coolify.io", @@ -3392,7 +3392,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud-with-mysql": { @@ -3409,7 +3409,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud-with-postgres": { @@ -3426,7 +3426,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nextcloud": { @@ -3443,7 +3443,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "nexus-arm": { @@ -3554,7 +3554,7 @@ "category": "productivity", "logo": "svgs/nocodb.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "nodebb": { @@ -3621,7 +3621,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8069" }, "ollama-with-open-webui": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:35:15+05:30", + "template_last_updated_at": "2026-01-12T23:41:36+01:00", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-19T16:40:18+05:30", + "template_last_updated_at": null, "port": "5080" }, "openpanel": { @@ -3789,7 +3789,7 @@ "category": "analytics", "logo": "svgs/openpanel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-27T20:48:57+01:00", "port": "3000" }, "opnform": { @@ -3810,7 +3810,7 @@ "category": "productivity", "logo": "svg/opnform.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-11-24T10:13:08+01:00", "port": "80" }, "orangehrm": { @@ -3828,7 +3828,7 @@ "category": "productivity", "logo": "svgs/orangehrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "organizr": { @@ -3857,7 +3857,7 @@ "category": "helpdesk", "logo": "svgs/osticket.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, "overseerr": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-02T12:12:42+03:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, "pairdrop": { @@ -3918,7 +3918,7 @@ "category": "games", "logo": "svgs/default.webp", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00" + "template_last_updated_at": "2025-11-15T21:55:50+01:00" }, "paperless": { "documentation": "https://docs.paperless-ngx.com/configuration/?utm_source=coolify.io", @@ -3959,7 +3959,7 @@ "category": "finance", "logo": "svgs/paymenter.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-11-23T12:08:21+01:00", "port": "80" }, "penpot-with-s3": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-24T00:12:17+05:30", + "template_last_updated_at": "2026-02-24T02:34:35+05:30", "port": "80" }, "plex": { @@ -4108,7 +4108,7 @@ "category": "email", "logo": "svgs/plunk.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "pocket-id-with-postgresql": { @@ -4265,7 +4265,7 @@ "category": "proxy", "logo": "svgs/hoppscotch.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-10-13T19:24:05+02:00", "port": "9159" }, "qbittorrent": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-19T16:11:50+01:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, "reactive-resume": { @@ -4428,7 +4428,7 @@ "category": "productivity", "logo": "svgs/redmine.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:56+01:00", "port": "3000" }, "rivet-engine": { @@ -4446,7 +4446,7 @@ "category": "development", "logo": "svgs/rivet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-01T19:58:30+02:00", + "template_last_updated_at": "2025-10-22T16:07:42+02:00", "port": "6420" }, "rocketchat": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-12T08:34:57+02:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8000" }, "satisfactory": { @@ -4621,7 +4621,7 @@ "category": "storage", "logo": "svgs/sftpgo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-12T23:44:06+01:00", "port": "8080" }, "shlink": { @@ -4660,7 +4660,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-11T17:51:10+01:00", "port": "8080" }, "silverbullet": { @@ -4675,7 +4675,7 @@ "category": "productivity", "logo": "svgs/silverbullet.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-05T15:42:12+01:00", "port": "3000" }, "siyuan": { @@ -4759,7 +4759,7 @@ "category": "devtools", "logo": "svgs/soketi-app-manager.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-16T22:27:17+01:00", "port": "8080" }, "soketi": { @@ -4898,7 +4898,7 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-04-05T20:21:11+02:00", + "template_last_updated_at": "2026-01-20T19:10:07+01:00", "port": "8000" }, "superset-with-postgresql": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-16T23:18:19+05:30", + "template_last_updated_at": "2026-01-08T18:51:23+01:00", "port": "3000" }, "typesense": { @@ -5224,7 +5224,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-01-04T19:45:35+01:00", "port": "4242" }, "unleash-without-database": { @@ -5241,7 +5241,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4242" }, "unstructured": { @@ -5339,7 +5339,7 @@ "category": "email", "logo": "svgs/usesend.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2025-12-07T17:32:23+11:00", "port": "3000" }, "vaultwarden": { @@ -5774,7 +5774,7 @@ "category": "storage", "logo": "svgs/cells.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-02-13T18:47:54+01:00", "port": "8080" } } diff --git a/tests/Unit/ParseDockerVolumeStringTest.php b/tests/Unit/ParseDockerVolumeStringTest.php index 6d31725e31..9ab09df5f1 100644 --- a/tests/Unit/ParseDockerVolumeStringTest.php +++ b/tests/Unit/ParseDockerVolumeStringTest.php @@ -61,9 +61,13 @@ }); test('parses volumes with environment variables', function () { - // Variable with default value + // v5 and below: default is stripped $result = parseDockerVolumeString('${VOLUME_DB_PATH:-db}:/data/db'); expect($result['source']->value())->toBe('db'); + + // v6+: preserved for env-aware resolution + $result = parseDockerVolumeString('${VOLUME_DB_PATH:-db}:/data/db', preserveEnvDefaults: true); + expect($result['source']->value())->toBe('${VOLUME_DB_PATH:-db}'); expect($result['target']->value())->toBe('/data/db'); expect($result['mode'])->toBeNull(); @@ -79,9 +83,13 @@ expect($result['target']->value())->toBe('/data'); expect($result['mode'])->toBeNull(); - // Variable with mode + // Variable with mode — v5: default is stripped $result = parseDockerVolumeString('${DATA_PATH:-./data}:/app/data:ro'); expect($result['source']->value())->toBe('./data'); + + // Variable with mode — v6+: preserved for env-aware resolution + $result = parseDockerVolumeString('${DATA_PATH:-./data}:/app/data:ro', preserveEnvDefaults: true); + expect($result['source']->value())->toBe('${DATA_PATH:-./data}'); expect($result['target']->value())->toBe('/app/data'); expect($result['mode']->value())->toBe('ro'); }); @@ -160,6 +168,28 @@ expect($result['mode']->value())->toBe('ro'); }); +test('preserved env var syntax allows resolveEnvVarDefault to apply env overrides', function () { + // Regression: parseDockerVolumeString used to eagerly replace ${VAR:-default} with the default, + // preventing resolveEnvVarDefault() from checking $allEnvironments for an override. + $parsed = parseDockerVolumeString('${AO_CONFIG_FILE:-./agent-orchestrator.yaml}:/app/agent-orchestrator.yaml:ro', preserveEnvDefaults: true); + + // Source must still contain the full env var syntax + expect($parsed['source']->value())->toBe('${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'); + + // When an env override IS provided, resolveEnvVarDefault should use it + $envOverrides = collect(['AO_CONFIG_FILE' => './custom-config.yaml']); + $resolved = resolveEnvVarDefault($parsed['source'], $envOverrides); + expect($resolved->value())->toBe('./custom-config.yaml'); + + // When no env override is provided, resolveEnvVarDefault should fall back to the default + $resolved = resolveEnvVarDefault($parsed['source'], collect()); + expect($resolved->value())->toBe('./agent-orchestrator.yaml'); + + // When env override is empty string, resolveEnvVarDefault should fall back to the default + $resolved = resolveEnvVarDefault($parsed['source'], collect(['AO_CONFIG_FILE' => ''])); + expect($resolved->value())->toBe('./agent-orchestrator.yaml'); +}); + test('parses all valid Docker volume modes', function () { $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; @@ -173,9 +203,13 @@ }); test('parses complex real-world examples', function () { - // MongoDB volume with environment variable + // MongoDB volume with environment variable — v5: default is stripped $result = parseDockerVolumeString('${VOLUME_DB_PATH:-./data/db}:/data/db'); expect($result['source']->value())->toBe('./data/db'); + + // v6+: preserved for env-aware resolution + $result = parseDockerVolumeString('${VOLUME_DB_PATH:-./data/db}:/data/db', preserveEnvDefaults: true); + expect($result['source']->value())->toBe('${VOLUME_DB_PATH:-./data/db}'); expect($result['target']->value())->toBe('/data/db'); expect($result['mode'])->toBeNull(); diff --git a/tests/Unit/ServiceVolumePathTest.php b/tests/Unit/ServiceVolumePathTest.php new file mode 100644 index 0000000000..7a50cd8c5e --- /dev/null +++ b/tests/Unit/ServiceVolumePathTest.php @@ -0,0 +1,41 @@ +not->toBeFalse('reResolveForService method should exist in LocalFileVolume'); + + // Find the next method boundary to scope our search + $nextMethod = strpos($source, 'private static function ', $methodStart + 1); + $methodBody = substr($source, $methodStart, $nextMethod ? $nextMethod - $methodStart : null); + + // The mainDirectory for services must use /services/, not /applications/ + expect($methodBody)->toContain("base_configuration_dir().'/services/'") + ->and($methodBody)->not->toContain("base_configuration_dir().'/applications/'"); +}); + +it('service and application re-resolve methods use distinct storage roots', function () { + // Verify that the service_configuration_dir helper returns /services/ + expect(service_configuration_dir())->toBe(base_configuration_dir().'/services'); + + // Verify that the application_configuration_dir helper returns /applications/ + expect(application_configuration_dir())->toBe(base_configuration_dir().'/applications'); + + // These must be different + expect(service_configuration_dir())->not->toBe(application_configuration_dir()); +}); diff --git a/tests/Unit/SourceIsLocalTest.php b/tests/Unit/SourceIsLocalTest.php new file mode 100644 index 0000000000..d47436f808 --- /dev/null +++ b/tests/Unit/SourceIsLocalTest.php @@ -0,0 +1,42 @@ +toBeTrue(); +}); + +test('sourceIsLocal recognizes dot-slash as local path', function () { + expect(sourceIsLocal(str('./data')))->toBeTrue(); +}); + +test('sourceIsLocal recognizes absolute paths as local', function () { + expect(sourceIsLocal(str('/var/data')))->toBeTrue(); +}); + +test('sourceIsLocal recognizes tilde paths as local', function () { + expect(sourceIsLocal(str('~/data')))->toBeTrue(); + expect(sourceIsLocal(str('~')))->toBeTrue(); +}); + +test('sourceIsLocal recognizes double-dot paths as local', function () { + expect(sourceIsLocal(str('../data')))->toBeTrue(); + expect(sourceIsLocal(str('..')))->toBeTrue(); +}); + +test('sourceIsLocal rejects named volumes', function () { + expect(sourceIsLocal(str('my_volume')))->toBeFalse(); + expect(sourceIsLocal(str('data')))->toBeFalse(); +}); + +test('resolveEnvVarDefault unresolvable bare env var is NOT local', function () { + // When no env var is set and no default exists, resolveEnvVarDefault returns the + // original string — sourceIsLocal must NOT treat it as a local bind mount. + $resolved = resolveEnvVarDefault(str('${UNDEFINED_VAR}'), collect()); + expect(sourceIsLocal($resolved))->toBeFalse(); +}); + +test('resolveEnvVarDefault with fallback path is recognized as local', function () { + // ${VAR:-./agent-orchestrator.yaml} with no env var set should resolve to the default + $resolved = resolveEnvVarDefault(str('${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'), collect()); + expect($resolved->value())->toBe('./agent-orchestrator.yaml'); + expect(sourceIsLocal($resolved))->toBeTrue(); +}); diff --git a/tests/Unit/VolumeArrayFormatSecurityTest.php b/tests/Unit/VolumeArrayFormatSecurityTest.php index 08174fff3c..1ab3caa73c 100644 --- a/tests/Unit/VolumeArrayFormatSecurityTest.php +++ b/tests/Unit/VolumeArrayFormatSecurityTest.php @@ -1,5 +1,6 @@ validateShellSafePath($target, 'volume target')) ->toThrow(Exception::class, 'backtick'); }); + +// Issue #8854: resolveEnvVarDefault tests — without env vars (backward compat) + +test('resolveEnvVarDefault resolves variable with default path', function () { + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./default-config.yaml}')); + expect($result->value())->toBe('./default-config.yaml'); +}); + +test('resolveEnvVarDefault resolves variable with absolute default path', function () { + $result = resolveEnvVarDefault(str('${DATA_PATH:-/var/lib/data}')); + expect($result->value())->toBe('/var/lib/data'); +}); + +test('resolveEnvVarDefault returns original string when no default and no env vars', function () { + $result = resolveEnvVarDefault(str('${DATA_PATH}')); + expect($result->value())->toBe('${DATA_PATH}'); +}); + +test('resolveEnvVarDefault returns original string when empty default and no env vars', function () { + $result = resolveEnvVarDefault(str('${DATA_PATH:-}')); + expect($result->value())->toBe('${DATA_PATH:-}'); +}); + +test('resolveEnvVarDefault keeps non-env-var string unchanged', function () { + $result = resolveEnvVarDefault(str('./data')); + expect($result->value())->toBe('./data'); +}); + +test('resolveEnvVarDefault resolved path passes validateShellSafePath', function () { + $source = str('${CONFIG_FILE:-./default-config.yaml}'); + $resolved = resolveEnvVarDefault($source); + + expect(fn () => validateShellSafePath($resolved->value(), 'storage path')) + ->not->toThrow(Exception::class); +}); + +test('resolveEnvVarDefault with malicious default still fails validation', function () { + $source = str('${VAR:-/tmp/evil`whoami`}'); + $resolved = resolveEnvVarDefault($source); + + expect(fn () => validateShellSafePath($resolved->value(), 'storage path')) + ->toThrow(Exception::class, 'backtick'); +}); + +// Issue #8854: resolveEnvVarDefault tests — with env vars + +test('resolveEnvVarDefault uses env var value when set', function () { + $envVars = collect(['CONFIG_FILE' => '/custom/config.yaml']); + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./default-config.yaml}'), $envVars); + expect($result->value())->toBe('/custom/config.yaml'); +}); + +test('resolveEnvVarDefault uses env var for simple variable reference', function () { + $envVars = collect(['DATA_PATH' => '/custom/data']); + $result = resolveEnvVarDefault(str('${DATA_PATH}'), $envVars); + expect($result->value())->toBe('/custom/data'); +}); + +test('resolveEnvVarDefault falls back to default when env var not in collection', function () { + $envVars = collect(['OTHER_VAR' => '/other/path']); + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./default-config.yaml}'), $envVars); + expect($result->value())->toBe('./default-config.yaml'); +}); + +test('resolveEnvVarDefault falls back to default when env var is empty string', function () { + $envVars = collect(['CONFIG_FILE' => '']); + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./default-config.yaml}'), $envVars); + expect($result->value())->toBe('./default-config.yaml'); +}); + +test('resolveEnvVarDefault returns original string when env var not set and no default', function () { + $envVars = collect(['OTHER_VAR' => '/other/path']); + $result = resolveEnvVarDefault(str('${DATA_PATH}'), $envVars); + expect($result->value())->toBe('${DATA_PATH}'); +}); + +test('resolveEnvVarDefault preview env var overrides normal env var', function () { + // Simulate merged collection where preview overrides normal + $normalEnvs = collect(['CONFIG_FILE' => '/normal/config.yaml']); + $previewEnvs = collect(['CONFIG_FILE' => '/preview/config.yaml']); + $merged = $normalEnvs->merge($previewEnvs); + + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./default.yaml}'), $merged); + expect($result->value())->toBe('/preview/config.yaml'); +}); + +test('resolveEnvVarDefault with malicious env var value still fails validation', function () { + $envVars = collect(['CONFIG_FILE' => '/tmp/evil`whoami`']); + $resolved = resolveEnvVarDefault(str('${CONFIG_FILE:-./safe.yaml}'), $envVars); + + // Env var value is used, but it contains backticks so validation catches it + expect($resolved->value())->toBe('/tmp/evil`whoami`'); + expect(fn () => validateShellSafePath($resolved->value(), 'storage path')) + ->toThrow(Exception::class, 'backtick'); +}); + +// Issue #8854: LocalFileVolume::resolvedFsPath tests + +test('LocalFileVolume resolvedFsPath resolves env var with default path', function () { + $volume = new LocalFileVolume; + $volume->fs_path = '${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'; + + expect($volume->resolvedFsPath())->toBe('./agent-orchestrator.yaml'); +}); + +test('LocalFileVolume resolvedFsPath passes through normal paths unchanged', function () { + $volume = new LocalFileVolume; + $volume->fs_path = '/data/coolify/services/abc123/config.yaml'; + + expect($volume->resolvedFsPath())->toBe('/data/coolify/services/abc123/config.yaml'); +}); + +test('LocalFileVolume resolvedFsPath with relative path unchanged', function () { + $volume = new LocalFileVolume; + $volume->fs_path = './data/config.yaml'; + + expect($volume->resolvedFsPath())->toBe('./data/config.yaml'); +}); + +test('LocalFileVolume resolvedFsPath resolved value passes validateShellSafePath', function () { + $volume = new LocalFileVolume; + $volume->fs_path = '${AO_CONFIG_FILE:-./agent-orchestrator.yaml}'; + + $resolved = $volume->resolvedFsPath(); + + // This should NOT throw — the resolved path is safe + expect(fn () => validateShellSafePath($resolved, 'storage path')) + ->not->toThrow(Exception::class); +}); + +test('LocalFileVolume resolvedFsPath with unresolvable env var throws RuntimeException', function () { + $volume = new LocalFileVolume; + $volume->fs_path = '${DATA_PATH}'; + + expect(fn () => $volume->resolvedFsPath()) + ->toThrow(RuntimeException::class, 'Cannot resolve storage path'); +}); + +// Regression: bare ${VAR} must NOT be misclassified as a local bind mount +test('bare unresolved env var is not treated as local path by sourceIsLocal', function () { + $result = resolveEnvVarDefault(str('${DATA_VOLUME}')); + expect(sourceIsLocal($result))->toBeFalse(); +}); + +test('bare unresolved env var with empty default is not treated as local path', function () { + $result = resolveEnvVarDefault(str('${DATA_VOLUME:-}')); + expect(sourceIsLocal($result))->toBeFalse(); +}); + +test('env var with local default IS treated as local path by sourceIsLocal', function () { + $result = resolveEnvVarDefault(str('${CONFIG_FILE:-./config.yaml}')); + expect(sourceIsLocal($result))->toBeTrue(); +}); diff --git a/tests/Unit/WireNavigateHelperTest.php b/tests/Unit/WireNavigateHelperTest.php new file mode 100644 index 0000000000..21d92e8fc7 --- /dev/null +++ b/tests/Unit/WireNavigateHelperTest.php @@ -0,0 +1,10 @@ +toContain("return (\$settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';") + ->toContain("return 'wire:navigate';") + ->not->toContain('wire:navigate.hover'); +});