fix(api): write file storage content to server on PATCH (#12248)

This commit is contained in:
Andras Bacsai
2026-10-26 12:54:11 +01:00
committed by GitHub
parent afde2cb471
commit 435645ce15
8 changed files with 95 additions and 16 deletions
@@ -10,6 +10,7 @@ use App\Enums\BuildPackTypes;
use App\Enums\HttpBasicAuthHashAlgorithm;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
@@ -5202,7 +5203,7 @@ class ApplicationsController extends Controller
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'],
],
additionalProperties: false,
),
@@ -5337,7 +5338,7 @@ class ApplicationsController extends Controller
}
}
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) {
return $forbidden;
}
@@ -5367,6 +5368,10 @@ class ApplicationsController extends Controller
$storage->save();
if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) {
ServerStorageSaveJob::dispatch($storage);
}
auditLog('api.application.storage_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
@@ -24,15 +24,18 @@ trait RequiresDeployForOutsideHostPaths
return $this->deployAbilityForbiddenResponse($request, 'A mount outside the resource directory needs a token with the deploy permission.');
}
private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse
/**
* Coolify writes changed file content on the server immediately, also inside the resource directory.
*/
private function contentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse
{
if (! $storage instanceof LocalFileVolume || $storage->is_directory || ! $request->has('content')) {
if (! $storage instanceof LocalFileVolume || $storage->is_directory || $storage->is_host_file || ! $request->has('content')) {
return null;
}
if ((string) $request->input('content') === (string) $storage->content) {
return null;
}
return $this->outsideHostPathForbiddenResponse($request, $storage);
return $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
}
}
@@ -11,6 +11,7 @@ use App\Enums\NewDatabaseTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ServerStorageSaveJob;
use App\Jobs\VolumeCloneJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
@@ -4792,7 +4793,7 @@ class DatabasesController extends Controller
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'],
],
additionalProperties: false,
),
@@ -4924,7 +4925,7 @@ class DatabasesController extends Controller
}
}
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) {
return $forbidden;
}
@@ -4954,6 +4955,10 @@ class DatabasesController extends Controller
$storage->save();
if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) {
ServerStorageSaveJob::dispatch($storage);
}
auditLog('api.database.storage_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
@@ -7,6 +7,7 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ServerStorageSaveJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
@@ -2793,7 +2794,7 @@ class ServicesController extends Controller
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'],
],
additionalProperties: false,
),
@@ -2955,7 +2956,7 @@ class ServicesController extends Controller
}
}
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) {
return $forbidden;
}
@@ -2985,6 +2986,10 @@ class ServicesController extends Controller
$storage->save();
if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) {
ServerStorageSaveJob::dispatch($storage);
}
auditLog('api.service.storage_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
+3 -3
View File
@@ -5449,7 +5449,7 @@
"content": {
"type": "string",
"nullable": true,
"description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission."
"description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission."
}
},
"type": "object",
@@ -10340,7 +10340,7 @@
"content": {
"type": "string",
"nullable": true,
"description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission."
"description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission."
}
},
"type": "object",
@@ -21789,7 +21789,7 @@
"content": {
"type": "string",
"nullable": true,
"description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission."
"description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission."
}
},
"type": "object",
+3 -3
View File
@@ -3586,7 +3586,7 @@ paths:
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'
description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'
type: object
additionalProperties: false
responses:
@@ -6779,7 +6779,7 @@ paths:
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'
description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'
type: object
additionalProperties: false
responses:
@@ -13753,7 +13753,7 @@ paths:
content:
type: string
nullable: true
description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'
description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'
type: object
additionalProperties: false
responses:
@@ -111,3 +111,64 @@ test('write and deploy token creates a file storage with content', function (str
expect($resource->fileStorages()->where('mount_path', '/etc/app.conf')->value('content'))->toBe('listen 8080;');
Queue::assertPushed(ServerStorageSaveJob::class);
})->with(['applications', 'services', 'databases']);
function createSavedFileStorage(mixed $resource): LocalFileVolume
{
$storage = LocalFileVolume::create([
'fs_path' => '/data/coolify/app.conf',
'mount_path' => '/etc/app.conf',
'content' => 'listen 80;',
'resource_id' => $resource->id,
'resource_type' => $resource->getMorphClass(),
]);
Queue::fake();
return $storage;
}
test('updating file storage content writes the file on the server', function (string $type) {
[$url, , $resource] = $this->targets[$type];
$storage = createSavedFileStorage($resource);
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 8080;'])
->assertOk();
expect($storage->fresh()->content)->toBe('listen 8080;');
Queue::assertPushed(ServerStorageSaveJob::class, fn (ServerStorageSaveJob $job) => $job->localFileVolume->is($storage));
})->with(['applications', 'services', 'databases']);
test('write-only token cannot change file storage content', function (string $type) {
[$url, , $resource] = $this->targets[$type];
$storage = createSavedFileStorage($resource);
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write']))
->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 8080;'])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
expect($storage->fresh()->content)->toBe('listen 80;');
Queue::assertNotPushed(ServerStorageSaveJob::class);
})->with(['applications', 'services', 'databases']);
test('write-only token can update a file storage without changing its content', function (string $type) {
[$url, , $resource] = $this->targets[$type];
$storage = createSavedFileStorage($resource);
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write']))
->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 80;', 'mount_path' => '/etc/app2.conf'])
->assertOk();
expect($storage->fresh()->mount_path)->toBe('/etc/app2.conf');
})->with(['applications', 'services', 'databases']);
test('updating a file storage without changing its content does not write the file', function (string $type) {
[$url, , $resource] = $this->targets[$type];
$storage = createSavedFileStorage($resource);
$this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy']))
->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 80;', 'is_preview_suffix_enabled' => false])
->assertOk();
Queue::assertNotPushed(ServerStorageSaveJob::class);
})->with(['applications', 'services', 'databases']);
+1 -1
View File
@@ -436,7 +436,7 @@ test('changing the content of a mount outside the resource directory needs deplo
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])
->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'ssh-ed25519 AAAA'])
->assertForbidden()
->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.');
->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.');
expect($storage->fresh()->content)->toBe('listen 8080;');
$this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])