mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(api): write file storage content to server on PATCH (#12248)
This commit is contained in:
@@ -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
@@ -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
@@ -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']);
|
||||
|
||||
@@ -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'])])
|
||||
|
||||
Reference in New Issue
Block a user