fix(service): block UI editing of file volumes exceeding 5 MiB

Large host files mounted via Docker volumes caused the storages page to
become unusable — full file content was stored in the encrypted mediumText
column and serialised into the Livewire payload, crashing the browser.

- Add MAX_CONTENT_SIZE (5 MiB), BINARY_PLACEHOLDER, and TOO_LARGE_PLACEHOLDER
  constants to LocalFileVolume
- Check remote file size via stat/wc before cat in loadStorageOnServer and
  saveStorageOnServer; store placeholder instead of content when limit exceeded
- Expose is_too_large computed attribute (appended for Livewire serialisation)
- Guard submit, instantSave, and syncData in FileStorage Livewire component
- Truncate oversized content in Storage::refreshStorages to prevent payload bloat
- Show distinct warning banner in file-storage blade; mark textarea readonly and
  hide Save/Convert buttons for too-large files
- Add unit tests covering constants, computed flags, and toArray serialisation

Fixes #4701
This commit is contained in:
Andras Bacsai 2026-04-28 22:36:56 +02:00
parent 092ea3bb7f
commit eaaf258f25
5 changed files with 134 additions and 13 deletions

View file

@ -63,13 +63,16 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path; $this->fs_path = $this->fileStorage->fs_path;
} }
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData(); $this->syncData();
} }
public function syncData(bool $toModel = false): void public function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
if ($this->fileStorage->is_too_large) {
return;
}
$this->validate(); $this->validate();
// Sync to model // Sync to model
@ -172,6 +175,12 @@ public function submit()
{ {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$original = $this->fileStorage->getOriginal(); $original = $this->fileStorage->getOriginal();
try { try {
$this->validate(); $this->validate();
@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void public function instantSave(): void
{ {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'File updated.'); $this->dispatch('success', 'File updated.');
} }

View file

@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages() public function refreshStorages()
{ {
$this->fileStorage = $this->resource->fileStorages()->get(); $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
$this->resource->load('persistentStorages.resource'); $this->resource->load('persistentStorages.resource');
} }

View file

@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel 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 = [ protected $casts = [
// 'fs_path' => 'encrypted', // 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted', // 'mount_path' => 'encrypted',
@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled', 'is_preview_suffix_enabled',
]; ];
public $appends = ['is_binary']; public $appends = ['is_binary', 'is_too_large'];
protected static function booted() protected static function booted()
{ {
@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute protected function isBinary(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: function () { get: fn () => $this->content === self::BINARY_PLACEHOLDER
return $this->content === '[binary file]'; );
} }
protected function isTooLarge(): Attribute
{
return Attribute::make(
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
); );
} }
@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') { if ($isFile === 'OK') {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
$this->is_directory = false;
$this->save();
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false); $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters // Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]'; $content = self::BINARY_PLACEHOLDER;
} }
$this->content = $content; $this->content = $content;
$this->is_directory = false; $this->is_directory = false;
@ -92,6 +110,18 @@ public function loadStorageOnServer()
} }
} }
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;
}
public function deleteStorageOnServer() public function deleteStorageOnServer()
{ {
$this->load(['service']); $this->load(['service']);
@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$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 ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat {$escapedPath}"], $server, false); if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
}
$this->is_directory = false; $this->is_directory = false;
$this->content = $content;
$this->save(); $this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id')); 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.'); 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.');

View file

@ -1,6 +1,10 @@
<div> <div>
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200"> <div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly) @if ($fileStorage->is_too_large)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
</div>
@elseif ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning"> <div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory) @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 and cannot be modified from the UI.
@ -44,7 +48,7 @@
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" /> shortConfirmationLabel="Filepath" />
@else @else
@if (!$fileStorage->is_binary) @if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?" <x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[ buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.', 'The selected file will be permanently deleted and an empty directory will be created in its place.',
@ -76,8 +80,8 @@
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version." helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content" rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea> readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-forms.button class="w-full" type="submit">Save</x-forms.button> <x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif @endif
@else @else

View file

@ -0,0 +1,66 @@
<?php
/**
* Unit tests for LocalFileVolume content size handling.
*
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
* mount large host files. Coolify previously stored full file content in the
* encrypted `content` mediumText column, then serialized it to the Livewire
* payload, crashing the browser.
*/
use App\Models\LocalFileVolume;
use Tests\TestCase;
uses(TestCase::class);
it('exposes a 5 MiB content size limit', function () {
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
});
it('exposes binary and too-large placeholder constants', function () {
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
});
it('flags is_too_large when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
expect($volume->is_too_large)->toBeTrue();
expect($volume->is_binary)->toBeFalse();
});
it('flags is_binary when content matches the placeholder', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
expect($volume->is_binary)->toBeTrue();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag normal content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = "hello\nworld\n";
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('does not flag empty content as binary or too large', function () {
$volume = new LocalFileVolume;
$volume->content = null;
expect($volume->is_binary)->toBeFalse();
expect($volume->is_too_large)->toBeFalse();
});
it('exposes the too-large flag via toArray for Livewire serialization', function () {
$volume = new LocalFileVolume;
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
$array = $volume->toArray();
expect($array)->toHaveKey('is_too_large');
expect($array['is_too_large'])->toBeTrue();
});