:` for each path argument, in order. It only reads. A state is `file` * (also a symlink to a file), `missing`, `empty-directory`, `directory` (not empty) or `other` * (another symlink or a special file). A directory that it cannot read is `directory`. */ private const REMOTE_FILE_STATE_SCRIPT = <<<'SH' i=0 for path in "$@"; do i=$((i + 1)) if [ -L "$path" ]; then if [ -f "$path" ]; then state=file; else state=other; fi elif [ -f "$path" ]; then state=file elif [ -d "$path" ]; then state=directory if entries=$(ls -A "$path" 2>/dev/null); then if [ -z "$entries" ]; then state=empty-directory; fi fi elif [ -e "$path" ]; then state=other else state=missing fi printf "%s:%s\n" "$i" "$state" done SH; /** * The pull request whose preview this copy writes. Not saved: see forPullRequest(). */ protected int $previewPullRequestId = 0; protected $casts = [ // 'fs_path' => 'encrypted', // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'is_preview_suffix_enabled' => 'boolean', ]; protected $hidden = [ 'content', ]; use HasFactory; protected $fillable = [ 'fs_path', 'mount_path', 'content', 'resource_type', 'resource_id', 'is_directory', 'is_host_file', 'is_based_on_git', 'is_preview_suffix_enabled', ]; public $appends = ['is_binary', 'is_too_large']; protected static function booted() { static::created(function (LocalFileVolume $fileVolume) { if ($fileVolume->is_host_file) { return; } // A token with only the write permission can save a Compose file. A mount outside the resource // directory is written when the resource is deployed or started, not when it is saved. if ($fileVolume->isOutsideResourceDirectory()) { return; } ServerStorageSaveJob::dispatch($fileVolume)->afterCommit(); }); static::deleting(function (LocalFileVolume $fileVolume): void { if ($fileVolume->scheduledBackups()->exists()) { throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.'); } }); } protected function isBinary(): Attribute { return Attribute::make( get: fn () => $this->content === self::BINARY_PLACEHOLDER ); } protected function isTooLarge(): Attribute { return Attribute::make( get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER ); } public function resource(): MorphTo { return $this->morphTo(); } public function service(): MorphTo { return $this->morphTo('resource'); } public function scheduledBackups(): MorphMany { return $this->morphMany(ScheduledVolumeBackup::class, 'backupable'); } public function abortIfScheduledBackupsExist(): void { if ($this->scheduledBackups()->exists()) { abort(422, 'Delete this directory backup schedule and its archives before deleting the directory.'); } } public function loadStorageOnServer() { if ($this->is_host_file) { return; } $this->load(['service']); $isService = data_get($this->resource, 'service'); if ($isService) { $workdir = $this->resource->service->workdir(); $server = $this->resource->service->server; } else { $workdir = $this->resource->workdir(); $server = $this->resource->destination->server; } $commands = collect([]); $path = $this->resolvedFsPath($workdir); if (! $this->isAdminControlledComposeMount()) { [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); if ($resourceDirectory !== null) { $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); } $path = str($hostPath); } // Validate and escape path to prevent command injection validateShellSafePath($path, 'storage path'); $escapedPath = escapeshellarg($path); $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; $this->is_directory = false; $this->save(); return; } $content = $this->readRemoteFileContent($escapedPath, $server); // Check if content contains binary data by looking for null bytes or non-printable characters if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; $this->is_directory = false; $this->save(); } } protected function remoteFileExceedsLimit(string $escapedPath, $server): bool { $sizeOutput = instant_remote_process( ["stat -c%s {$escapedPath} 2>/dev/null"], $server, false, ); $size = (int) trim((string) $sizeOutput); return $size > self::MAX_CONTENT_SIZE; } /** * Cap the remote read itself so a file that grows after the size check * cannot be fully slurped into PHP memory. */ protected function readRemoteFileContent(string $escapedPath, $server): string { $readLimit = self::MAX_CONTENT_SIZE + 1; $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); return self::contentFromBoundedRead($content); } public static function contentFromBoundedRead(?string $content): string { if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { return self::TOO_LARGE_PLACEHOLDER; } return (string) $content; } /** * Without a server, Coolify deletes the file on every server of the resource. * Host paths outside the resource directory are never deleted. */ public function deleteStorageOnServer(?Server $server = null) { if ($this->is_host_file) { return; } if (is_null($server)) { return $this->runOnEveryServer(fn (Server $server) => $this->deleteStorageOnServer($server)); } $this->load(['service']); $isService = data_get($this->resource, 'service'); $workdir = $isService ? $this->resource->service->workdir() : $this->resource->workdir(); $commands = collect([]); $path = $this->resolvedFsPath($workdir); if ($this->isOutsideResourceDirectory()) { return null; } if (! $this->isAdminControlledComposeMount()) { $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); $this->assertRemotePathIsConfined($workdir, $path->value(), $server); } // Validate and escape path to prevent command injection validateShellSafePath($path, 'storage path'); $escapedPath = escapeshellarg($path); $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($path && $path != '/' && $path != '.' && $path != '..') { if ($isFile === 'OK') { $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); } elseif ($isDir === 'OK') { $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); $commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true"); } } if ($commands->count() > 0) { return instant_remote_process($commands, $server); } } /** * Without a server, Coolify writes the file on every server of the resource. * Outside the resource directory, nothing is deleted or written through a symlink. */ public function saveStorageOnServer(?Server $server = null) { if ($this->is_host_file) { return; } if (is_null($server)) { return $this->runOnEveryServer(fn (Server $server) => $this->saveStorageOnServer($server)); } $this->load(['service']); $isService = data_get($this->resource, 'service'); $workdir = $isService ? $this->resource->service->workdir() : $this->resource->workdir(); $commands = collect([]); $escapedWorkdir = escapeshellarg($workdir); $path = $this->resolvedFsPath($workdir); $content = data_get($this, 'content'); // A Compose bind mount (also with `content:`) uses the host path that the administrator wrote. if (! $this->isAdminControlledComposeMount()) { [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); if ($resourceDirectory === null) { if (! $this->is_directory) { self::assertRemotePathIsNotSymlink($hostPath, $server); } } else { $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); } $path = str($hostPath); } $isOutsideResourceDirectory = $this->isOutsideResourceDirectory(); if ($this->is_directory) { validateShellSafePath($path, 'storage path'); $commands->push('mkdir -p '.escapeshellarg($path).' > /dev/null 2>&1 || true'); $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); } if ($path->startsWith('/') || $path->startsWith('~')) { $parent_dir = $path->beforeLast('/'); if ($parent_dir != '') { $escapedParentDir = escapeshellarg($parent_dir); $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } // Validate and escape resolved path (may differ from fs_path if relative) validateShellSafePath($path, 'storage path'); $escapedPath = escapeshellarg($path); $state = self::remoteFileStates([(string) $path], $server)[0]; if ($state === 'unknown') { throw new \Exception("Coolify cannot check the file {$path} on the server."); } if ($state === 'other' && ! $this->is_directory) { throw new \Exception("A symbolic link or a special file is at {$path} on the server. Coolify did not write the file."); } $isFile = $state === 'file'; $isDir = in_array($state, ['directory', 'empty-directory'], true); $replacesEmptyDirectory = false; if ($isFile && $this->is_directory) { if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); 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 && ! $this->is_directory) { if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) { $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.'); } // Docker creates a missing bind source as an empty directory. Replace only an empty // directory inside the resource directory; never delete files that are on the server. if ($isOutsideResourceDirectory || $state !== 'empty-directory') { throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}

Please delete the directory on the server or mark it as directory."); } $replacesEmptyDirectory = true; } if ((! $isDir || $replacesEmptyDirectory) && ! $this->is_directory) { if ($replacesEmptyDirectory) { $commands->push("rmdir {$escapedPath}"); } $chmod = data_get($this, 'chmod'); $chown = data_get($this, 'chown'); if ($content) { $content = base64_encode($content); $commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null"); } else { $commands->push("touch {$escapedPath}"); } $commands->push("chmod +x {$escapedPath}"); if ($chown) { $commands->push('chown -- '.escapeshellarg($chown)." {$escapedPath}"); } if ($chmod) { $commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}"); } } elseif (! $isDir && $this->is_directory) { $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true"); } $result = instant_remote_process($commands, $server); if ($replacesEmptyDirectory) { FileStorageChanged::dispatch(data_get($server, 'team_id')); } return $result; } /** * One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts sudo in * front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output. * * @param list $paths * @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg(). */ public static function remoteFileStateCommand(array $paths, ?Closure $quote = null): string { return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map($quote ?? escapeshellarg(...), $paths)); } /** * Gets the state of each path with one server command. A path without a state in the output is `unknown`. * * @param list $paths * @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg(). * @return list */ public static function remoteFileStates(array $paths, Server $server, ?Closure $quote = null): array { $states = []; $output = (string) instant_remote_process([self::remoteFileStateCommand($paths, $quote)], $server, false); foreach (preg_split('/\R/', trim($output)) as $line) { if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) { $states[(int) $matches[1] - 1] = $matches[2]; } } return array_map(fn (int $index) => $states[$index] ?? 'unknown', array_keys($paths)); } /** * The path where saveStorageOnServer() writes the content of this file on the server. * * @throws \Exception If the path is not allowed */ public function contentPathOnServer(): string { if ($this->isAdminControlledComposeMount()) { return $this->resolvedFsPath($this->ownerResource()->workdir())->value(); } return $this->hostPathAndResourceDirectory()[0]; } /** * `~` is not allowed because it depends on the home directory of the SSH user. * * @param string|list $resourceDirectories The first directory resolves relative paths. * * @throws \Exception If the path is not allowed */ public static function resolveHostPath(string|array $resourceDirectories, string $path, string $context = 'storage path'): string { $resourceDirectories = array_map(normalizeUnixPath(...), (array) $resourceDirectories); $path = trim($path); if ($path === '') { throw new \Exception("Invalid {$context}: the path is empty."); } if (str_starts_with($path, '~')) { throw new \Exception("Invalid {$context}: use an absolute path instead of ~."); } validateShellSafePath($path, $context); $isAbsolute = str_starts_with($path, '/'); $resolvedPath = normalizeUnixPath($isAbsolute ? $path : $resourceDirectories[0].'/'.$path); if (self::resourceDirectoryContaining($resolvedPath, $resourceDirectories) !== null) { return $resolvedPath; } if (! $isAbsolute) { throw new \Exception("Invalid {$context}: a relative path must stay inside the resource directory."); } if (array_intersect(explode('/', $path), ['.', '..']) !== []) { throw new \Exception("Invalid {$context}: '.' and '..' segments are not allowed."); } if ($resolvedPath === '/') { throw new \Exception("Invalid {$context}: the root directory cannot be mounted."); } return $resolvedPath; } /** * @throws \RuntimeException If Coolify must not use the path */ public static function assertHostPathOnServer(string $resourceDirectory, string $path, Server $server, bool $isDirectory): void { if (self::resourceDirectoryContaining($path, [normalizeUnixPath($resourceDirectory)]) !== null) { self::assertRemotePathIsConfined($resourceDirectory, $path, $server); } elseif (! $isDirectory) { self::assertRemotePathIsNotSymlink($path, $server); } } /** * @throws \RuntimeException If the path is a symbolic link on the server */ public static function assertRemotePathIsNotSymlink(string $path, Server $server): void { $escapedPath = escapeshellarg($path); $result = instant_remote_process(["test -L {$escapedPath} && echo LINK || echo OK"], $server); if (trim((string) $result) !== 'OK') { throw new \RuntimeException("{$path} is a symbolic link on the server. Coolify does not write a file through a symbolic link. Remove the link or use another path."); } } /** * A path that cannot be resolved counts as outside. */ public function isOutsideResourceDirectory(): bool { $directories = $this->contentBaseDirectories(); try { $path = normalizeUnixPath($this->resolvedFsPath($directories[0])->value()); $directories = array_map(normalizeUnixPath(...), $directories); } catch (\Throwable) { return true; } return str_starts_with($path, '~') || self::resourceDirectoryContaining($path, $directories) === null; } /** * The resource directory is null when the host path is outside it. * * @return array{0: string, 1: string|null} * * @throws \Exception If the path is not allowed */ protected function hostPathAndResourceDirectory(): array { $directories = $this->contentBaseDirectories(); $path = self::resolveHostPath($directories, $this->resolvedFsPath($directories[0])->value()); return [$path, self::resourceDirectoryContaining($path, array_map(normalizeUnixPath(...), $directories))]; } /** * @param list $directories Normalized directories */ protected static function resourceDirectoryContaining(string $path, array $directories): ?string { foreach ($directories as $directory) { if ($path === $directory || str_starts_with($path, $directory.'/')) { return $directory; } } return null; } /** * The host path of this mount. Like a relative bind source in Compose, a relative path is inside * the resource directory. Remote commands run in the home directory of the SSH user, so they * must never get the relative path. A `~` path stays in the home directory. */ public function resolvedFsPath(string $workdir): Stringable { $path = trim((string) $this->fs_path); if (! str_starts_with($path, '/') && ! str_starts_with($path, '~')) { if (str_starts_with($path, '.')) { $path = substr($path, 1); } $path = ltrim($path, '/'); $path = rtrim($workdir, '/').($path === '' ? '' : '/'.$path); } return str(addPreviewDeploymentSuffix($path, $this->previewPullRequestId)); } /** * A copy that uses the path of the preview of a pull request, as the Compose parser mounts it * (`-pr-` unless the PR suffix is off for this storage). The row keeps the path of * the application, so saving the copy never stores the preview path. */ public function forPullRequest(int $pullRequestId): static { $copy = clone $this; $copy->previewPullRequestId = $pullRequestId !== 0 && ($this->is_preview_suffix_enabled ?? true) ? $pullRequestId : 0; return $copy; } /** * Coolify writes this file from its content when the file is missing on the server. Files from * the Git repository, host files and placeholders for binary or large files are not written. */ public function hasContentToWrite(): bool { return ! $this->is_directory && ! $this->is_host_file && ! $this->is_based_on_git && ! $this->is_binary && ! $this->is_too_large && (string) $this->content !== ''; } /** * Reject symlink escapes immediately before a managed path is used remotely. */ public static function assertRemotePathIsConfined(string $baseDirectory, string $path, Server $server): void { $result = instant_remote_process([self::remotePathConfinementCommand($baseDirectory, $path)], $server, false); if (trim((string) $result) !== 'OK') { throw new \RuntimeException('Invalid storage path: resolved path must stay inside the resource configuration directory.'); } } /** * One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts * sudo in front of it and never changes the script. */ public static function remotePathConfinementCommand(string $baseDirectory, string $path): string { return 'sh -c '.escapeshellarg(self::REMOTE_PATH_CONFINEMENT_SCRIPT).' sh '.escapeshellarg($baseDirectory).' '.escapeshellarg($path); } /** * The resource workdir, and the directory where the Compose parser resolves `./` sources. They * differ only for services that use parser version 3. * * @return list */ public function contentBaseDirectories(): array { return array_values(array_unique([$this->ownerResource()->workdir(), $this->composeSourceDirectory()])); } protected function composeSourceDirectory(): string { $owner = $this->ownerResource(); return $owner instanceof Application || $owner instanceof Service ? composeResourceDirectory($owner) : $owner->workdir(); } /** * The main server of the resource, and the additional servers of an application. * * @return Collection */ public function servers(): Collection { $this->load(['service']); if (data_get($this->resource, 'service')) { return collect([$this->resource->service->server]); } $servers = collect([$this->resource->destination->server]); if ($this->resource instanceof Application) { $servers = $servers->merge($this->resource->additional_servers); } return $servers->filter()->unique('id')->values(); } /** * Runs the callback on every server, also when one server fails, and then throws the first error. * * @param Closure(Server): mixed $callback */ protected function runOnEveryServer(Closure $callback): mixed { $result = null; $firstError = null; foreach ($this->servers() as $server) { try { $result = $callback($server); } catch (\Throwable $e) { $firstError ??= $e; } } if ($firstError) { throw $firstError; } return $result; } /** * The Application, Service or standalone database that owns the storage directory. */ protected function ownerResource(): mixed { $resource = $this->resource; return $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service : $resource; } /** * Raw Compose bind mounts keep administrator-selected host path semantics. */ protected function isAdminControlledComposeMount(): bool { $compose = data_get($this->resource, 'docker_compose_raw') ?? data_get($this->resource, 'service.docker_compose_raw'); if (! is_string($compose) || $compose === '') { return false; } try { $services = data_get(parseDockerComposeYaml($compose), 'services', []); foreach ($services as $service) { foreach (data_get($service, 'volumes', []) as $volume) { if (is_string($volume)) { $parsed = parseDockerVolumeString($volume); $source = data_get($parsed, 'source'); $target = data_get($parsed, 'target'); } else { $source = data_get($volume, 'source'); $target = data_get($volume, 'target'); } if ((string) $target !== $this->mount_path || ! sourceIsLocal(str((string) $source))) { continue; } $sourceDirectory = str($this->composeSourceDirectory()); $fsPath = normalizeUnixPath($this->fs_path); if (normalizeUnixPath(legacyReplaceLocalSource(str((string) $source), $sourceDirectory)->value()) === $fsPath) { return true; } try { $resolvedSource = replaceLocalSource(str((string) $source), $sourceDirectory)->value(); } catch (\Throwable) { continue; } if (normalizeUnixPath($resolvedSource) === $fsPath) { return true; } } } } catch (\Throwable) { return false; } return false; } // Accessor for convenient access protected function plainMountPath(): Attribute { return Attribute::make( get: fn () => $this->mount_path, set: fn ($value) => $this->mount_path = $value ); } // Scope for searching public function scopeWherePlainMountPath($query, $path) { return $query->get()->where('plain_mount_path', $path); } // Check if this volume belongs to a service resource public function isServiceResource(): bool { return in_array($this->resource_type, [ 'App\Models\ServiceApplication', 'App\Models\ServiceDatabase', ]); } // Determine if this volume should be read-only in the UI // File/directory mounts can be edited even for services public function shouldBeReadOnlyInUI(): bool { // Check for explicit :ro flag in compose (existing logic) return $this->isReadOnlyVolume(); } // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { try { // Only check for services $service = $this->service; if (! $service || ! method_exists($service, 'service')) { return false; } $actualService = $service->service; if (! $actualService || ! $actualService->docker_compose_raw) { return false; } // Parse the docker-compose content $compose = parseDockerComposeYaml($actualService->docker_compose_raw); if (! isset($compose['services'])) { return false; } // Find the service that this volume belongs to $serviceName = $service->name; if (! isset($compose['services'][$serviceName]['volumes'])) { return false; } $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match // Note: We match on mount_path (container path) only, since fs_path gets transformed // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { $parts = explode(':', $volume); // Check if this volume matches our mount_path if (count($parts) >= 2) { $containerPath = $parts[1]; $options = $parts[2] ?? null; // Match based on mount_path // Remove leading slash from mount_path if present for comparison $mountPath = str($this->mount_path)->ltrim('/')->toString(); $containerPathClean = str($containerPath)->ltrim('/')->toString(); if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { return $options === 'ro'; } } } elseif (is_array($volume)) { // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true } $containerPath = data_get($volume, 'target'); $readOnly = data_get($volume, 'read_only', false); // Match based on mount_path // Remove leading slash from mount_path if present for comparison $mountPath = str($this->mount_path)->ltrim('/')->toString(); $containerPathClean = str($containerPath)->ltrim('/')->toString(); if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { return $readOnly === true; } } } return false; } catch (\Throwable $e) { return false; } } }