Files
coolify/app/Models/LocalFileVolume.php
T
Andras Bacsai 49c8f6cb5d fix: cap remote file and task reads after size-check races
Read local file volumes with a bounded head instead of cat so a file that grows after the size check cannot be fully slurped into PHP memory. Treat oversized bounded reads as too large and skip binary detection on that placeholder. For scheduled tasks, read one extra byte, then truncate and append the 5MB notice instead of relying on a one-byte peek.
2026-08-13 11:31:26 +02:00

399 lines
14 KiB
PHP

<?php
namespace App\Models;
use App\Events\FileStorageChanged;
use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
public const MAX_CONTENT_SIZE = 5_242_880;
public const BINARY_PLACEHOLDER = '[binary file]';
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
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',
'chown',
'chmod',
'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;
}
$fileVolume->load(['service']);
dispatch(new ServerStorageSaveJob($fileVolume));
});
}
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 service()
{
return $this->morphTo('resource');
}
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 = data_get_str($this, 'fs_path');
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
}
// 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 || wc -c < {$escapedPath}"],
$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;
}
public function deleteStorageOnServer()
{
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 = data_get_str($this, 'fs_path');
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
}
// 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);
}
}
public function saveStorageOnServer()
{
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([]);
$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 {$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);
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
$parent_dir = $pathForParentDirectory->beforeLast('/');
if ($parent_dir != '') {
$escapedParentDir = escapeshellarg($parent_dir);
$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);
$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 ($isFile === 'OK' && $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 === 'OK' && ! $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. <br><br>Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
"rm -fr {$escapedPath}",
"touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
if ($isDir === 'NOK' && ! $this->is_directory) {
$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 $chown {$escapedPath}");
}
if ($chmod) {
$commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);
}
// 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 = Yaml::parse($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;
}
}
}