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->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
if ($this->fileStorage->is_too_large) {
return;
}
$this->validate();
// Sync to model
@ -172,6 +175,12 @@ public function submit()
{
$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();
try {
$this->validate();
@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$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->dispatch('success', 'File updated.');
}

View file

@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
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');
}

View file

@ -10,6 +10,12 @@
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',
@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
public $appends = ['is_binary'];
public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
get: function () {
return $this->content === '[binary file]';
}
get: fn () => $this->content === self::BINARY_PLACEHOLDER
);
}
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);
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);
// 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)) {
$content = '[binary file]';
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$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()
{
$this->load(['service']);
@ -173,9 +203,12 @@ public function saveStorageOnServer()
$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) {
$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->content = $content;
$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.');

View file

@ -1,6 +1,10 @@
<div>
<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">
@if ($fileStorage->is_directory)
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"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
@if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'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' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
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 && !$fileStorage->is_too_large)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif
@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();
});