Improve storage mount path handling
This commit is contained in:
parent
39ae16de42
commit
a06c1a7bf5
17 changed files with 979 additions and 55 deletions
|
|
@ -4279,10 +4279,11 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||||
'content' => 'string|nullable',
|
'content' => 'string|nullable',
|
||||||
'is_directory' => 'boolean',
|
'is_directory' => 'boolean',
|
||||||
|
'is_host_file' => 'boolean',
|
||||||
'fs_path' => 'string',
|
'fs_path' => 'string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
$errors = $validator->errors();
|
$errors = $validator->errors();
|
||||||
|
|
@ -4306,7 +4307,7 @@ public function create_storage(Request $request): JsonResponse
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||||
if (! empty($typeSpecificInvalidFields)) {
|
if (! empty($typeSpecificInvalidFields)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
|
|
@ -4337,6 +4338,14 @@ public function create_storage(Request $request): JsonResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
$isDirectory = $request->boolean('is_directory', false);
|
$isDirectory = $request->boolean('is_directory', false);
|
||||||
|
$isHostFile = $request->boolean('is_host_file', false);
|
||||||
|
|
||||||
|
if ($isDirectory && $isHostFile) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if ($isDirectory) {
|
if ($isDirectory) {
|
||||||
if (! $request->fs_path) {
|
if (! $request->fs_path) {
|
||||||
|
|
@ -4359,12 +4368,50 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'resource_id' => $application->id,
|
'resource_id' => $application->id,
|
||||||
'resource_type' => get_class($application),
|
'resource_type' => get_class($application),
|
||||||
]);
|
]);
|
||||||
|
} elseif ($isHostFile) {
|
||||||
|
if (! $request->fs_path) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('content')) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = LocalFileVolume::create([
|
||||||
|
'fs_path' => $fsPath,
|
||||||
|
'mount_path' => $mountPath,
|
||||||
|
'content' => null,
|
||||||
|
'is_directory' => false,
|
||||||
|
'is_host_file' => true,
|
||||||
|
'resource_id' => $application->id,
|
||||||
|
'resource_type' => get_class($application),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
try {
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||||
validateShellSafePath($mountPath, 'file storage path');
|
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$storage = LocalFileVolume::create([
|
$storage = LocalFileVolume::create([
|
||||||
'fs_path' => $fsPath,
|
'fs_path' => $fsPath,
|
||||||
|
|
|
||||||
|
|
@ -3696,10 +3696,11 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||||
'content' => 'string|nullable',
|
'content' => 'string|nullable',
|
||||||
'is_directory' => 'boolean',
|
'is_directory' => 'boolean',
|
||||||
|
'is_host_file' => 'boolean',
|
||||||
'fs_path' => 'string',
|
'fs_path' => 'string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
$errors = $validator->errors();
|
$errors = $validator->errors();
|
||||||
|
|
@ -3723,7 +3724,7 @@ public function create_storage(Request $request): JsonResponse
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||||
if (! empty($typeSpecificInvalidFields)) {
|
if (! empty($typeSpecificInvalidFields)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
|
|
@ -3754,6 +3755,14 @@ public function create_storage(Request $request): JsonResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
$isDirectory = $request->boolean('is_directory', false);
|
$isDirectory = $request->boolean('is_directory', false);
|
||||||
|
$isHostFile = $request->boolean('is_host_file', false);
|
||||||
|
|
||||||
|
if ($isDirectory && $isHostFile) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if ($isDirectory) {
|
if ($isDirectory) {
|
||||||
if (! $request->fs_path) {
|
if (! $request->fs_path) {
|
||||||
|
|
@ -3776,12 +3785,50 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'resource_id' => $database->id,
|
'resource_id' => $database->id,
|
||||||
'resource_type' => get_class($database),
|
'resource_type' => get_class($database),
|
||||||
]);
|
]);
|
||||||
|
} elseif ($isHostFile) {
|
||||||
|
if (! $request->fs_path) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('content')) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = LocalFileVolume::create([
|
||||||
|
'fs_path' => $fsPath,
|
||||||
|
'mount_path' => $mountPath,
|
||||||
|
'content' => null,
|
||||||
|
'is_directory' => false,
|
||||||
|
'is_host_file' => true,
|
||||||
|
'resource_id' => $database->id,
|
||||||
|
'resource_type' => get_class($database),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
try {
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||||
validateShellSafePath($mountPath, 'file storage path');
|
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$storage = LocalFileVolume::create([
|
$storage = LocalFileVolume::create([
|
||||||
'fs_path' => $fsPath,
|
'fs_path' => $fsPath,
|
||||||
|
|
|
||||||
|
|
@ -2115,10 +2115,11 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||||
'content' => 'string|nullable',
|
'content' => 'string|nullable',
|
||||||
'is_directory' => 'boolean',
|
'is_directory' => 'boolean',
|
||||||
|
'is_host_file' => 'boolean',
|
||||||
'fs_path' => 'string',
|
'fs_path' => 'string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || ! empty($extraFields)) {
|
||||||
$errors = $validator->errors();
|
$errors = $validator->errors();
|
||||||
|
|
@ -2150,7 +2151,7 @@ public function create_storage(Request $request): JsonResponse
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||||
if (! empty($typeSpecificInvalidFields)) {
|
if (! empty($typeSpecificInvalidFields)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
|
|
@ -2181,6 +2182,14 @@ public function create_storage(Request $request): JsonResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
$isDirectory = $request->boolean('is_directory', false);
|
$isDirectory = $request->boolean('is_directory', false);
|
||||||
|
$isHostFile = $request->boolean('is_host_file', false);
|
||||||
|
|
||||||
|
if ($isDirectory && $isHostFile) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if ($isDirectory) {
|
if ($isDirectory) {
|
||||||
if (! $request->fs_path) {
|
if (! $request->fs_path) {
|
||||||
|
|
@ -2203,12 +2212,50 @@ public function create_storage(Request $request): JsonResponse
|
||||||
'resource_id' => $subResource->id,
|
'resource_id' => $subResource->id,
|
||||||
'resource_type' => get_class($subResource),
|
'resource_type' => get_class($subResource),
|
||||||
]);
|
]);
|
||||||
|
} elseif ($isHostFile) {
|
||||||
|
if (! $request->fs_path) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('content')) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = LocalFileVolume::create([
|
||||||
|
'fs_path' => $fsPath,
|
||||||
|
'mount_path' => $mountPath,
|
||||||
|
'content' => null,
|
||||||
|
'is_directory' => false,
|
||||||
|
'is_host_file' => true,
|
||||||
|
'resource_id' => $subResource->id,
|
||||||
|
'resource_type' => get_class($subResource),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
try {
|
||||||
|
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||||
validateShellSafePath($mountPath, 'file storage path');
|
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['mount_path' => $e->getMessage()],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$storage = LocalFileVolume::create([
|
$storage = LocalFileVolume::create([
|
||||||
'fs_path' => $fsPath,
|
'fs_path' => $fsPath,
|
||||||
|
|
|
||||||
|
|
@ -1031,7 +1031,7 @@ private function write_deployment_configurations()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
foreach ($this->application->fileStorages as $fileStorage) {
|
foreach ($this->application->fileStorages as $fileStorage) {
|
||||||
if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
||||||
$fileStorage->saveStorageOnServer();
|
$fileStorage->saveStorageOnServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ public function convertToDirectory()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if ($this->fileStorage->is_host_file) {
|
||||||
|
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->fileStorage->deleteStorageOnServer();
|
$this->fileStorage->deleteStorageOnServer();
|
||||||
$this->fileStorage->is_directory = true;
|
$this->fileStorage->is_directory = true;
|
||||||
$this->fileStorage->content = null;
|
$this->fileStorage->content = null;
|
||||||
|
|
@ -112,6 +116,10 @@ public function loadStorageOnServer()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if ($this->fileStorage->is_host_file) {
|
||||||
|
throw new \Exception('Host file mounts are bind-only and cannot be loaded from the server.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->fileStorage->loadStorageOnServer();
|
$this->fileStorage->loadStorageOnServer();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
$this->dispatch('success', 'File storage loaded from server.');
|
$this->dispatch('success', 'File storage loaded from server.');
|
||||||
|
|
@ -127,6 +135,10 @@ public function convertToFile()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if ($this->fileStorage->is_host_file) {
|
||||||
|
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->fileStorage->deleteStorageOnServer();
|
$this->fileStorage->deleteStorageOnServer();
|
||||||
$this->fileStorage->is_directory = false;
|
$this->fileStorage->is_directory = false;
|
||||||
$this->fileStorage->content = null;
|
$this->fileStorage->content = null;
|
||||||
|
|
@ -154,8 +166,10 @@ public function delete($password, $selectedActions = [])
|
||||||
$message = 'File deleted.';
|
$message = 'File deleted.';
|
||||||
if ($this->fileStorage->is_directory) {
|
if ($this->fileStorage->is_directory) {
|
||||||
$message = 'Directory deleted.';
|
$message = 'Directory deleted.';
|
||||||
|
} elseif ($this->fileStorage->is_host_file) {
|
||||||
|
$message = 'Host file mount removed.';
|
||||||
}
|
}
|
||||||
if ($this->permanently_delete) {
|
if ($this->permanently_delete && ! $this->fileStorage->is_host_file) {
|
||||||
$message = 'Directory deleted from the server.';
|
$message = 'Directory deleted from the server.';
|
||||||
$this->fileStorage->deleteStorageOnServer();
|
$this->fileStorage->deleteStorageOnServer();
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +188,12 @@ public function submit()
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if ($this->fileStorage->is_host_file) {
|
||||||
|
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->fileStorage->is_too_large) {
|
if ($this->fileStorage->is_too_large) {
|
||||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||||
|
|
||||||
|
|
@ -205,6 +225,12 @@ 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_host_file) {
|
||||||
|
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->fileStorage->is_too_large) {
|
if ($this->fileStorage->is_too_large) {
|
||||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||||
|
|
||||||
|
|
@ -223,6 +249,9 @@ public function render()
|
||||||
'fileDeletionCheckboxes' => [
|
'fileDeletionCheckboxes' => [
|
||||||
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
|
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
|
||||||
],
|
],
|
||||||
|
'hostFileDeletionCheckboxes' => [
|
||||||
|
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ class Storage extends Component
|
||||||
|
|
||||||
public ?string $file_storage_content = null;
|
public ?string $file_storage_content = null;
|
||||||
|
|
||||||
|
public string $host_file_storage_source = '';
|
||||||
|
|
||||||
|
public string $host_file_storage_destination = '';
|
||||||
|
|
||||||
public string $file_storage_directory_source = '';
|
public string $file_storage_directory_source = '';
|
||||||
|
|
||||||
public string $file_storage_directory_destination = '';
|
public string $file_storage_directory_destination = '';
|
||||||
|
|
@ -146,19 +150,9 @@ public function submitFileStorage()
|
||||||
'file_storage_content' => 'nullable|string',
|
'file_storage_content' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->file_storage_path = trim($this->file_storage_path);
|
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
|
||||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
|
||||||
|
|
||||||
// Validate path to prevent command injection
|
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
|
||||||
validateShellSafePath($this->file_storage_path, 'file storage path');
|
|
||||||
|
|
||||||
if ($this->resource->getMorphClass() === Application::class) {
|
|
||||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
|
||||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
|
||||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
|
||||||
} else {
|
|
||||||
throw new \Exception('No valid resource type for file mount storage type!');
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalFileVolume::create([
|
LocalFileVolume::create([
|
||||||
'fs_path' => $fs_path,
|
'fs_path' => $fs_path,
|
||||||
|
|
@ -178,6 +172,38 @@ public function submitFileStorage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function submitHostFileStorage()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'host_file_storage_source' => 'required|string',
|
||||||
|
'host_file_storage_destination' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
|
||||||
|
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
|
||||||
|
|
||||||
|
LocalFileVolume::create([
|
||||||
|
'fs_path' => $this->host_file_storage_source,
|
||||||
|
'mount_path' => $this->host_file_storage_destination,
|
||||||
|
'content' => null,
|
||||||
|
'is_directory' => false,
|
||||||
|
'is_host_file' => true,
|
||||||
|
'resource_id' => $this->resource->id,
|
||||||
|
'resource_type' => get_class($this->resource),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'Host file mount added successfully');
|
||||||
|
$this->dispatch('closeStorageModal', 'host-file');
|
||||||
|
$this->clearForm();
|
||||||
|
$this->refreshStorages();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function submitFileStorageDirectory()
|
public function submitFileStorageDirectory()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -222,6 +248,8 @@ public function clearForm()
|
||||||
$this->file_storage_path = '';
|
$this->file_storage_path = '';
|
||||||
$this->file_storage_content = null;
|
$this->file_storage_content = null;
|
||||||
$this->file_storage_directory_destination = '';
|
$this->file_storage_directory_destination = '';
|
||||||
|
$this->host_file_storage_source = '';
|
||||||
|
$this->host_file_storage_destination = '';
|
||||||
|
|
||||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||||
|
|
@ -230,6 +258,34 @@ public function clearForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fileStorageHostPath(): string
|
||||||
|
{
|
||||||
|
if (method_exists($this->resource, 'workdir')) {
|
||||||
|
return $this->resource->workdir();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->resource->getMorphClass() === Application::class) {
|
||||||
|
return application_configuration_dir().'/'.$this->resource->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||||
|
return database_configuration_dir().'/'.$this->resource->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \Exception('No valid resource type for file mount storage type!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fileStoragePreviewPath(): string
|
||||||
|
{
|
||||||
|
$path = str($this->file_storage_path)->trim();
|
||||||
|
|
||||||
|
if ($path->isEmpty()) {
|
||||||
|
return $this->fileStorageHostPath().'/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fileStorageHostPath().$path->start('/')->value();
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project.service.storage');
|
return view('livewire.project.service.storage');
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class LocalFileVolume extends BaseModel
|
||||||
// 'mount_path' => 'encrypted',
|
// 'mount_path' => 'encrypted',
|
||||||
'content' => 'encrypted',
|
'content' => 'encrypted',
|
||||||
'is_directory' => 'boolean',
|
'is_directory' => 'boolean',
|
||||||
|
'is_host_file' => 'boolean',
|
||||||
'is_preview_suffix_enabled' => 'boolean',
|
'is_preview_suffix_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ class LocalFileVolume extends BaseModel
|
||||||
'resource_type',
|
'resource_type',
|
||||||
'resource_id',
|
'resource_id',
|
||||||
'is_directory',
|
'is_directory',
|
||||||
|
'is_host_file',
|
||||||
'chown',
|
'chown',
|
||||||
'chmod',
|
'chmod',
|
||||||
'is_based_on_git',
|
'is_based_on_git',
|
||||||
|
|
@ -44,6 +46,10 @@ class LocalFileVolume extends BaseModel
|
||||||
protected static function booted()
|
protected static function booted()
|
||||||
{
|
{
|
||||||
static::created(function (LocalFileVolume $fileVolume) {
|
static::created(function (LocalFileVolume $fileVolume) {
|
||||||
|
if ($fileVolume->is_host_file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$fileVolume->load(['service']);
|
$fileVolume->load(['service']);
|
||||||
dispatch(new ServerStorageSaveJob($fileVolume));
|
dispatch(new ServerStorageSaveJob($fileVolume));
|
||||||
});
|
});
|
||||||
|
|
@ -70,6 +76,10 @@ public function service()
|
||||||
|
|
||||||
public function loadStorageOnServer()
|
public function loadStorageOnServer()
|
||||||
{
|
{
|
||||||
|
if ($this->is_host_file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->load(['service']);
|
$this->load(['service']);
|
||||||
$isService = data_get($this->resource, 'service');
|
$isService = data_get($this->resource, 'service');
|
||||||
if ($isService) {
|
if ($isService) {
|
||||||
|
|
@ -124,6 +134,10 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
|
||||||
|
|
||||||
public function deleteStorageOnServer()
|
public function deleteStorageOnServer()
|
||||||
{
|
{
|
||||||
|
if ($this->is_host_file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->load(['service']);
|
$this->load(['service']);
|
||||||
$isService = data_get($this->resource, 'service');
|
$isService = data_get($this->resource, 'service');
|
||||||
if ($isService) {
|
if ($isService) {
|
||||||
|
|
@ -161,6 +175,10 @@ public function deleteStorageOnServer()
|
||||||
|
|
||||||
public function saveStorageOnServer()
|
public function saveStorageOnServer()
|
||||||
{
|
{
|
||||||
|
if ($this->is_host_file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->load(['service']);
|
$this->load(['service']);
|
||||||
$isService = data_get($this->resource, 'service');
|
$isService = data_get($this->resource, 'service');
|
||||||
if ($isService) {
|
if ($isService) {
|
||||||
|
|
@ -171,26 +189,26 @@ public function saveStorageOnServer()
|
||||||
$server = $this->resource->destination->server;
|
$server = $this->resource->destination->server;
|
||||||
}
|
}
|
||||||
$commands = collect([]);
|
$commands = collect([]);
|
||||||
|
|
||||||
// Validate fs_path early before any shell interpolation
|
|
||||||
validateShellSafePath($this->fs_path, 'storage path');
|
|
||||||
$escapedFsPath = escapeshellarg($this->fs_path);
|
|
||||||
$escapedWorkdir = escapeshellarg($workdir);
|
$escapedWorkdir = escapeshellarg($workdir);
|
||||||
|
|
||||||
if ($this->is_directory) {
|
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 {$escapedFsPath} > /dev/null 2>&1 || true");
|
||||||
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
||||||
$commands->push("cd {$escapedWorkdir}");
|
$commands->push("cd {$escapedWorkdir}");
|
||||||
}
|
}
|
||||||
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
|
$path = data_get_str($this, 'fs_path');
|
||||||
$parent_dir = str($this->fs_path)->beforeLast('/');
|
$content = data_get($this, 'content');
|
||||||
|
$pathForParentDirectory = str($this->fs_path);
|
||||||
|
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
|
||||||
|
$parent_dir = $pathForParentDirectory->beforeLast('/');
|
||||||
if ($parent_dir != '') {
|
if ($parent_dir != '') {
|
||||||
$escapedParentDir = escapeshellarg($parent_dir);
|
$escapedParentDir = escapeshellarg($parent_dir);
|
||||||
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$path = data_get_str($this, 'fs_path');
|
|
||||||
$content = data_get($this, 'content');
|
|
||||||
if ($path->startsWith('.')) {
|
if ($path->startsWith('.')) {
|
||||||
$path = $path->after('.');
|
$path = $path->after('.');
|
||||||
$path = $workdir.$path;
|
$path = $workdir.$path;
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||||
/**
|
/**
|
||||||
* Validate that a filename is safe for use as a plain file name (no path components).
|
* Validate that a filename is safe for use as a plain file name (no path components).
|
||||||
*
|
*
|
||||||
* Prevents path traversal attacks by rejecting directory separators, traversal
|
* Prevents unsafe parent directory paths by rejecting directory separators, parent directory
|
||||||
* sequences, and null bytes, in addition to all shell metacharacters blocked by
|
* sequences, and null bytes, in addition to all shell metacharacters blocked by
|
||||||
* validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
|
* validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
|
||||||
* init script names that are later written to a specific directory on the host.
|
* init script names that are later written to a specific directory on the host.
|
||||||
|
|
@ -175,7 +175,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||||
* @param string $context Descriptive name for error messages (e.g., 'init script filename')
|
* @param string $context Descriptive name for error messages (e.g., 'init script filename')
|
||||||
* @return string The validated input (unchanged if valid)
|
* @return string The validated input (unchanged if valid)
|
||||||
*
|
*
|
||||||
* @throws Exception If dangerous characters or path traversal sequences are detected
|
* @throws Exception If dangerous characters or parent directory sequences are detected
|
||||||
*/
|
*/
|
||||||
function validateFilenameSafe(string $input, string $context = 'filename'): string
|
function validateFilenameSafe(string $input, string $context = 'filename'): string
|
||||||
{
|
{
|
||||||
|
|
@ -198,10 +198,10 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject path traversal sequences (catches encoded or unusual forms)
|
// Reject parent directory sequences (catches encoded or unusual forms)
|
||||||
if (str_contains($input, '..')) {
|
if (str_contains($input, '..')) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
"Invalid {$context}: path traversal sequence ('..') is not allowed."
|
"Invalid {$context}: parent directory sequence ('..') is not allowed."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +230,197 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri
|
||||||
return $input;
|
return $input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalize a user supplied file mount path.
|
||||||
|
*
|
||||||
|
* File mount paths are container paths supplied by tenants. They may look like
|
||||||
|
* absolute paths (for example /etc/nginx/nginx.conf), but are later joined to a
|
||||||
|
* Coolify-managed configuration directory on the host. Therefore shell safety is
|
||||||
|
* not enough: every path segment must also be unable to traverse out of that
|
||||||
|
* managed directory.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function validateFileMountPath(string $input, string $context = 'file mount path'): string
|
||||||
|
{
|
||||||
|
validateShellSafePath($input, $context);
|
||||||
|
|
||||||
|
if (str_contains($input, "\0")) {
|
||||||
|
throw new Exception(
|
||||||
|
"Invalid {$context}: contains null byte. ".
|
||||||
|
'Null bytes are not allowed in file mount paths for security reasons.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($input, '\\')) {
|
||||||
|
throw new Exception(
|
||||||
|
"Invalid {$context}: backslash directory separators are not allowed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str($input)->trim()->start('/')->replaceMatches('#/+#', '/')->value();
|
||||||
|
|
||||||
|
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||||
|
if ($segment === '' || ($segment !== '.' && $segment !== '..')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception(
|
||||||
|
"Invalid {$context}: relative path segments ('.' or '..') are not allowed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a host file path used as a bind-only source.
|
||||||
|
*
|
||||||
|
* Unlike managed file mounts, this path is not re-based under the Coolify
|
||||||
|
* configuration directory and must never be written by Coolify. It still needs
|
||||||
|
* to be shell-safe because other storage code may pass paths through remote
|
||||||
|
* shell commands.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function validateHostFileMountPath(string $input, string $context = 'host file path'): string
|
||||||
|
{
|
||||||
|
validateShellSafePath($input, $context);
|
||||||
|
|
||||||
|
if (str_contains($input, "\0")) {
|
||||||
|
throw new Exception("Invalid {$context}: contains null byte.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($input, '\\')) {
|
||||||
|
throw new Exception("Invalid {$context}: backslash directory separators are not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str($input)->trim()->replaceMatches('#/+#', '/')->value();
|
||||||
|
|
||||||
|
if ($path === '' || ! str_starts_with($path, '/')) {
|
||||||
|
throw new Exception("Invalid {$context}: must be an absolute path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/' || str_ends_with($path, '/')) {
|
||||||
|
throw new Exception("Invalid {$context}: must point to a file, not a directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||||
|
if ($segment === '' || ($segment !== '.' && $segment !== '..')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Invalid {$context}: relative path segments ('.' or '..') are not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeUnixPath($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a tenant file mount path under a Coolify-managed base directory.
|
||||||
|
*
|
||||||
|
* This performs lexical normalization only; the target file does not need to
|
||||||
|
* exist yet. The normalized result must remain inside the given base directory.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function confineFileMountPath(string $baseDirectory, string $path, string $context = 'file mount path'): string
|
||||||
|
{
|
||||||
|
$baseDirectory = normalizeUnixPath($baseDirectory);
|
||||||
|
$mountPath = validateFileMountPath($path, $context);
|
||||||
|
$resolvedPath = normalizeUnixPath($baseDirectory.'/'.$mountPath);
|
||||||
|
|
||||||
|
if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) {
|
||||||
|
throw new Exception(
|
||||||
|
"Invalid {$context}: resolved path must stay inside the resource configuration directory."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an existing host path and assert it remains inside a base directory.
|
||||||
|
*
|
||||||
|
* Dot-relative paths are resolved against the base directory for legacy
|
||||||
|
* LocalFileVolume rows. Absolute paths must already point inside the base.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function confinePathToBase(string $baseDirectory, string $path, string $context = 'path'): string
|
||||||
|
{
|
||||||
|
$baseDirectory = normalizeUnixPath($baseDirectory);
|
||||||
|
$path = trim($path);
|
||||||
|
|
||||||
|
if (str_starts_with($path, '.')) {
|
||||||
|
$path = $baseDirectory.'/'.str($path)->after('.')->value();
|
||||||
|
} elseif (! str_starts_with($path, '/')) {
|
||||||
|
$path = $baseDirectory.'/'.$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedPath = normalizeUnixPath($path);
|
||||||
|
|
||||||
|
if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) {
|
||||||
|
throw new Exception(
|
||||||
|
"Invalid {$context}: resolved path must stay inside the resource configuration directory."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a Unix path lexically without consulting the remote filesystem.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
function normalizeUnixPath(string $path): string
|
||||||
|
{
|
||||||
|
validateShellSafePath($path, 'path');
|
||||||
|
|
||||||
|
if (str_contains($path, "\0")) {
|
||||||
|
throw new Exception('Invalid path: contains null byte.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($path, '\\')) {
|
||||||
|
throw new Exception('Invalid path: backslash directory separators are not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAbsolute = str_starts_with($path, '/');
|
||||||
|
$segments = [];
|
||||||
|
|
||||||
|
foreach (explode('/', $path) as $segment) {
|
||||||
|
if ($segment === '' || $segment === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($segment === '..') {
|
||||||
|
if ($segments === [] || end($segments) === '..') {
|
||||||
|
if ($isAbsolute) {
|
||||||
|
throw new Exception('Invalid path: resolved path escapes the base directory.');
|
||||||
|
}
|
||||||
|
$segments[] = $segment;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
array_pop($segments);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments[] = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = implode('/', $segments);
|
||||||
|
|
||||||
|
if ($isAbsolute) {
|
||||||
|
return $normalized === '' ? '/' : '/'.$normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized === '' ? '.' : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a databases_to_backup input string is safe from command injection.
|
* Validate that a databases_to_backup input string is safe from command injection.
|
||||||
*
|
*
|
||||||
|
|
@ -3819,7 +4010,7 @@ function formatBytes(?int $bytes, int $precision = 2): string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a file path is safely within the /tmp/ directory.
|
* Validates that a file path is safely within the /tmp/ directory.
|
||||||
* Protects against path traversal attacks by resolving the real path
|
* Protects against unsafe parent directory paths by resolving the real path
|
||||||
* and verifying it stays within /tmp/.
|
* and verifying it stays within /tmp/.
|
||||||
*
|
*
|
||||||
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
|
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('local_file_volumes', 'is_host_file')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('local_file_volumes', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_host_file')->default(false)->after('is_directory');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('local_file_volumes', 'is_host_file')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('local_file_volumes', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('is_host_file');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -433,6 +433,7 @@ CREATE TABLE IF NOT EXISTS "local_file_volumes" (
|
||||||
"created_at" TEXT,
|
"created_at" TEXT,
|
||||||
"updated_at" TEXT,
|
"updated_at" TEXT,
|
||||||
"is_directory" INTEGER DEFAULT false NOT NULL,
|
"is_directory" INTEGER DEFAULT false NOT NULL,
|
||||||
|
"is_host_file" INTEGER DEFAULT false NOT NULL,
|
||||||
"chown" TEXT,
|
"chown" TEXT,
|
||||||
"chmod" TEXT,
|
"chmod" TEXT,
|
||||||
"is_based_on_git" INTEGER DEFAULT false NOT NULL
|
"is_based_on_git" INTEGER DEFAULT false NOT NULL
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
<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">
|
||||||
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
|
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
|
||||||
</div>
|
</div>
|
||||||
|
@elseif ($fileStorage->is_host_file)
|
||||||
|
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||||
|
This host file mount is bind-only. Coolify will not create, edit, load, chmod, or delete the source file.
|
||||||
|
</div>
|
||||||
@elseif ($isReadOnly)
|
@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)
|
||||||
|
|
@ -32,7 +36,14 @@
|
||||||
@if (!$isReadOnly)
|
@if (!$isReadOnly)
|
||||||
@can('update', $resource)
|
@can('update', $resource)
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@if ($fileStorage->is_directory)
|
@if ($fileStorage->is_host_file)
|
||||||
|
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
|
||||||
|
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
|
||||||
|
:actions="['Only the mount configuration will be removed. The host file will not be deleted.']"
|
||||||
|
confirmationText="{{ $fs_path }}"
|
||||||
|
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||||
|
shortConfirmationLabel="Filepath" />
|
||||||
|
@elseif ($fileStorage->is_directory)
|
||||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
||||||
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
||||||
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
||||||
|
|
@ -68,7 +79,7 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
@if (!$fileStorage->is_directory)
|
@if (!$fileStorage->is_directory && !$fileStorage->is_host_file)
|
||||||
@can('update', $resource)
|
@can('update', $resource)
|
||||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||||
<div class="w-full sm:w-96">
|
<div class="w-full sm:w-96">
|
||||||
|
|
@ -99,7 +110,7 @@
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
{{-- Read-only view --}}
|
{{-- Read-only view --}}
|
||||||
@if (!$fileStorage->is_directory)
|
@if (!$fileStorage->is_directory && !$fileStorage->is_host_file)
|
||||||
@can('update', $resource)
|
@can('update', $resource)
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@
|
||||||
dropdownOpen: false,
|
dropdownOpen: false,
|
||||||
volumeModalOpen: false,
|
volumeModalOpen: false,
|
||||||
fileModalOpen: false,
|
fileModalOpen: false,
|
||||||
|
hostFileModalOpen: false,
|
||||||
directoryModalOpen: false
|
directoryModalOpen: false
|
||||||
}"
|
}"
|
||||||
@close-storage-modal.window="
|
@close-storage-modal.window="
|
||||||
if ($event.detail === 'volume') volumeModalOpen = false;
|
if ($event.detail === 'volume') volumeModalOpen = false;
|
||||||
if ($event.detail === 'file') fileModalOpen = false;
|
if ($event.detail === 'file') fileModalOpen = false;
|
||||||
|
if ($event.detail === 'host-file') hostFileModalOpen = false;
|
||||||
if ($event.detail === 'directory') directoryModalOpen = false;
|
if ($event.detail === 'directory') directoryModalOpen = false;
|
||||||
">
|
">
|
||||||
<div class="relative" @click.outside="dropdownOpen = false">
|
<div class="relative" @click.outside="dropdownOpen = false">
|
||||||
|
|
@ -62,6 +64,15 @@ class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:b
|
||||||
</svg>
|
</svg>
|
||||||
File Mount
|
File Mount
|
||||||
</a>
|
</a>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
@click="hostFileModalOpen = true; dropdownOpen = false">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Host File Mount
|
||||||
|
</a>
|
||||||
<a class="dropdown-item"
|
<a class="dropdown-item"
|
||||||
@click="directoryModalOpen = true; dropdownOpen = false">
|
@click="directoryModalOpen = true; dropdownOpen = false">
|
||||||
<svg class="size-4" fill="none" stroke="currentColor"
|
<svg class="size-4" fill="none" stroke="currentColor"
|
||||||
|
|
@ -188,14 +199,28 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
}
|
}
|
||||||
})">
|
})">
|
||||||
<form class="flex flex-col w-full gap-2 rounded-sm"
|
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||||
|
x-data="{
|
||||||
|
hostPath: @js($this->fileStorageHostPath()),
|
||||||
|
filePath: @entangle('file_storage_path'),
|
||||||
|
previewPath() {
|
||||||
|
const path = (this.filePath || '').trim();
|
||||||
|
|
||||||
|
return this.hostPath + (path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`));
|
||||||
|
},
|
||||||
|
}"
|
||||||
wire:submit='submitFileStorage'>
|
wire:submit='submitFileStorage'>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div>Actual file mounted from the host system to the container.</div>
|
<div>This file will be created on the host, then mounted into the container.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="p-2 text-xs rounded-sm bg-neutral-100 dark:bg-coolgray-200">
|
||||||
|
<div class="mb-1 font-medium">Host file path</div>
|
||||||
|
<code class="break-all" x-text="previewPath()">{{ $this->fileStoragePreviewPath() }}</code>
|
||||||
|
</div>
|
||||||
<x-forms.input canGate="update" :canResource="$resource"
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
|
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
|
||||||
label="Destination Path" required
|
label="Destination Path" required
|
||||||
|
x-on:input="filePath = $event.target.value"
|
||||||
helper="File location inside the container" />
|
helper="File location inside the container" />
|
||||||
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
||||||
id="file_storage_content"></x-forms.textarea>
|
id="file_storage_content"></x-forms.textarea>
|
||||||
|
|
@ -209,6 +234,67 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
{{-- Host File Modal --}}
|
||||||
|
<template x-teleport="body">
|
||||||
|
<div x-show="hostFileModalOpen" @keydown.window.escape="hostFileModalOpen=false"
|
||||||
|
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
|
<div x-show="hostFileModalOpen" x-transition:enter="ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0" @click="hostFileModalOpen=false"
|
||||||
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
|
<div x-show="hostFileModalOpen" x-trap.inert.noscroll="hostFileModalOpen"
|
||||||
|
x-transition:enter="ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave="ease-in duration-100"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
|
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
|
||||||
|
<div class="flex items-center justify-between pb-3">
|
||||||
|
<h3 class="text-2xl font-bold">Add Host File Mount</h3>
|
||||||
|
<button @click="hostFileModalOpen=false"
|
||||||
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex items-center justify-center w-auto"
|
||||||
|
x-init="$watch('hostFileModalOpen', value => {
|
||||||
|
if (value) {
|
||||||
|
$nextTick(() => {
|
||||||
|
const input = $el.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||||
|
wire:submit='submitHostFileStorage'>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div>Bind an existing host file into the container. Coolify will not create, edit, load, chmod, or delete the source file.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
|
placeholder="/etc/nginx/nginx.conf"
|
||||||
|
id="host_file_storage_source" label="Host File Path" required
|
||||||
|
helper="Existing file on the host system." />
|
||||||
|
<x-forms.input canGate="update" :canResource="$resource"
|
||||||
|
placeholder="/etc/nginx/nginx.conf"
|
||||||
|
id="host_file_storage_destination" label="Destination Path"
|
||||||
|
required helper="File location inside the container." />
|
||||||
|
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||||
|
Add
|
||||||
|
</x-forms.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
{{-- Directory Modal --}}
|
{{-- Directory Modal --}}
|
||||||
<template x-teleport="body">
|
<template x-teleport="body">
|
||||||
<div x-show="directoryModalOpen" @keydown.window.escape="directoryModalOpen=false"
|
<div x-show="directoryModalOpen" @keydown.window.escape="directoryModalOpen=false"
|
||||||
|
|
|
||||||
|
|
@ -2361,7 +2361,7 @@
|
||||||
"category": "automation",
|
"category": "automation",
|
||||||
"logo": "svgs/inngest.png",
|
"logo": "svgs/inngest.png",
|
||||||
"minversion": "0.0.0",
|
"minversion": "0.0.0",
|
||||||
"template_last_updated_at": "2026-06-10T13:46:21+05:30",
|
"template_last_updated_at": "2026-07-02T13:25:47+02:00",
|
||||||
"port": "8288"
|
"port": "8288"
|
||||||
},
|
},
|
||||||
"invoice-ninja": {
|
"invoice-ninja": {
|
||||||
|
|
|
||||||
|
|
@ -2361,7 +2361,7 @@
|
||||||
"category": "automation",
|
"category": "automation",
|
||||||
"logo": "svgs/inngest.png",
|
"logo": "svgs/inngest.png",
|
||||||
"minversion": "0.0.0",
|
"minversion": "0.0.0",
|
||||||
"template_last_updated_at": "2026-06-10T13:46:21+05:30",
|
"template_last_updated_at": "2026-07-02T13:25:47+02:00",
|
||||||
"port": "8288"
|
"port": "8288"
|
||||||
},
|
},
|
||||||
"invoice-ninja": {
|
"invoice-ninja": {
|
||||||
|
|
|
||||||
123
tests/Feature/FileStorageMountPathTest.php
Normal file
123
tests/Feature/FileStorageMountPathTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\ServerStorageSaveJob;
|
||||||
|
use App\Livewire\Project\Service\Storage;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\LocalFileVolume;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||||
|
Bus::fake();
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||||
|
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->admin = User::factory()->create();
|
||||||
|
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$keyId = DB::table('private_keys')->insertGetId([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'name' => 'Test Key',
|
||||||
|
'private_key' => 'test-key',
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->server = Server::factory()->create([
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'private_key_id' => $keyId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
StandaloneDocker::withoutEvents(function () {
|
||||||
|
$this->destination = StandaloneDocker::firstOrCreate(
|
||||||
|
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||||
|
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->project = Project::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'name' => 'Test Project',
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->environment = $this->project->environments()->first()
|
||||||
|
?? Environment::factory()->create(['project_id' => $this->project->id]);
|
||||||
|
|
||||||
|
$this->application = Application::factory()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'name' => 'Test App',
|
||||||
|
'environment_id' => $this->environment->id,
|
||||||
|
'destination_id' => $this->destination->id,
|
||||||
|
'destination_type' => $this->destination->getMorphClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
|
||||||
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||||
|
->set('file_storage_path', '/../../../../../../etc/example.conf')
|
||||||
|
->set('file_storage_content', 'owned')
|
||||||
|
->call('submitFileStorage')
|
||||||
|
->assertDispatched('error');
|
||||||
|
|
||||||
|
expect(LocalFileVolume::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file mount modal shows the calculated host file path above the destination input', function () {
|
||||||
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||||
|
->assertSeeText('This file will be created on the host, then mounted into the container.')
|
||||||
|
->assertSeeText('Host file path')
|
||||||
|
->assertSeeText($this->application->workdir().'/')
|
||||||
|
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
||||||
|
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
|
||||||
|
->assertDontSeeText('Actual file mounted from the host system to the container.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('livewire file storage stores safe file mounts under the application configuration root', function () {
|
||||||
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||||
|
->set('file_storage_path', '/etc/nginx/nginx.conf')
|
||||||
|
->set('file_storage_content', 'server {}')
|
||||||
|
->call('submitFileStorage')
|
||||||
|
->assertDispatched('success');
|
||||||
|
|
||||||
|
$volume = LocalFileVolume::query()->sole();
|
||||||
|
|
||||||
|
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
||||||
|
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
|
||||||
|
->and($volume->is_directory)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('livewire host file storage stores an existing host file path without managed content', function () {
|
||||||
|
Livewire::test(Storage::class, ['resource' => $this->application])
|
||||||
|
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
|
||||||
|
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
|
||||||
|
->call('submitHostFileStorage')
|
||||||
|
->assertDispatched('success');
|
||||||
|
|
||||||
|
$volume = LocalFileVolume::query()->sole();
|
||||||
|
|
||||||
|
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
|
||||||
|
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
|
||||||
|
->and($volume->content)->toBeNull()
|
||||||
|
->and($volume->is_host_file)->toBeTrue()
|
||||||
|
->and($volume->is_directory)->toBeFalse();
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\ServerStorageSaveJob;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
|
|
@ -7,6 +8,8 @@
|
||||||
use App\Models\LocalPersistentVolume;
|
use App\Models\LocalPersistentVolume;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
|
@ -18,8 +21,9 @@
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||||
|
|
||||||
$this->team = Team::factory()->create();
|
$this->team = Team::factory()->create();
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
|
|
@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTestServiceApplication($context): array
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'environment_id' => $context->environment->id,
|
||||||
|
'destination_id' => $context->destination->id,
|
||||||
|
'destination_type' => $context->destination->getMorphClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serviceApplication = ServiceApplication::create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'name' => 'test-service-app',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'image' => 'nginx:alpine',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$service, $serviceApplication];
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Application Storage Endpoints
|
// Application Storage Endpoints
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -140,6 +162,56 @@ function createTestDatabase($context): StandalonePostgresql
|
||||||
expect($vol)->not->toBeNull();
|
expect($vol)->not->toBeNull();
|
||||||
expect($vol->mount_path)->toBe('/app/config.json');
|
expect($vol->mount_path)->toBe('/app/config.json');
|
||||||
expect($vol->is_directory)->toBeFalse();
|
expect($vol->is_directory)->toBeFalse();
|
||||||
|
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates bind only host file storage for application', function () {
|
||||||
|
$app = createTestApplication($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'is_host_file' => true,
|
||||||
|
'fs_path' => '/etc/nginx/nginx.conf',
|
||||||
|
'mount_path' => '/etc/nginx/nginx.conf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
$vol = LocalFileVolume::where('resource_id', $app->id)
|
||||||
|
->where('resource_type', get_class($app))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($vol)->not->toBeNull();
|
||||||
|
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
|
||||||
|
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
|
||||||
|
expect($vol->content)->toBeNull();
|
||||||
|
expect($vol->is_host_file)->toBeTrue();
|
||||||
|
expect($vol->is_directory)->toBeFalse();
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects file storage paths with parent segments', function () {
|
||||||
|
$app = createTestApplication($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'mount_path' => '/../../../../../../etc/example.conf',
|
||||||
|
'content' => 'owned',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonPath('message', 'Validation failed.');
|
||||||
|
|
||||||
|
expect(LocalFileVolume::where('resource_id', $app->id)
|
||||||
|
->where('resource_type', get_class($app))
|
||||||
|
->exists())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects persistent storage without name', function () {
|
test('rejects persistent storage without name', function () {
|
||||||
|
|
@ -331,6 +403,92 @@ function createTestDatabase($context): StandalonePostgresql
|
||||||
expect($vol)->not->toBeNull();
|
expect($vol)->not->toBeNull();
|
||||||
expect($vol->mount_path)->toBe('/extra');
|
expect($vol->mount_path)->toBe('/extra');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('creates a file storage for a database under the database configuration root', function () {
|
||||||
|
$db = createTestDatabase($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'mount_path' => '/postgres/postgresql.conf',
|
||||||
|
'content' => 'listen_addresses = "*"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
$vol = LocalFileVolume::where('resource_id', $db->id)
|
||||||
|
->where('resource_type', get_class($db))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($vol)->not->toBeNull();
|
||||||
|
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects file storage paths with parent segments for a database', function () {
|
||||||
|
$db = createTestDatabase($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'mount_path' => '/postgres/../../../etc/shadow',
|
||||||
|
'content' => 'owned',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
|
||||||
|
expect(LocalFileVolume::where('resource_id', $db->id)
|
||||||
|
->where('resource_type', get_class($db))
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/services/{uuid}/storages', function () {
|
||||||
|
test('creates a file storage for a service resource under the service configuration root', function () {
|
||||||
|
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'resource_uuid' => $serviceApplication->uuid,
|
||||||
|
'mount_path' => '/etc/nginx/nginx.conf',
|
||||||
|
'content' => 'server {}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||||
|
->where('resource_type', get_class($serviceApplication))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($vol)->not->toBeNull();
|
||||||
|
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects file storage paths with parent segments for a service resource', function () {
|
||||||
|
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||||
|
'type' => 'file',
|
||||||
|
'resource_uuid' => $serviceApplication->uuid,
|
||||||
|
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
|
||||||
|
'content' => 'owned',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
|
||||||
|
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||||
|
->where('resource_type', get_class($serviceApplication))
|
||||||
|
->exists())->toBeFalse();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
|
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
|
||||||
|
|
|
||||||
|
|
@ -141,3 +141,77 @@
|
||||||
expect(fn () => validateShellSafePath('./data', 'storage path'))
|
expect(fn () => validateShellSafePath('./data', 'storage path'))
|
||||||
->not->toThrow(Exception::class);
|
->not->toThrow(Exception::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('file mount path validator rejects parent segments and unsafe separators', function (string $path) {
|
||||||
|
expect(fn () => validateFileMountPath($path, 'file storage path'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
})->with([
|
||||||
|
'parent segment to etc' => ['/../../etc/passwd'],
|
||||||
|
'embedded parent segment' => ['/foo/../bar'],
|
||||||
|
'parent segment' => ['/..'],
|
||||||
|
'double slash before parent segment' => ['/foo//../bar'],
|
||||||
|
'current directory segment' => ['/foo/./bar'],
|
||||||
|
'backslash parent segment' => ['\\..\\etc\\passwd'],
|
||||||
|
'null byte' => ["/app/config\0/../../etc/passwd"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('file mount path validator accepts safe absolute container file paths', function (string $path, string $expected) {
|
||||||
|
expect(validateFileMountPath($path, 'file storage path'))->toBe($expected);
|
||||||
|
})->with([
|
||||||
|
'nginx config' => ['/etc/nginx/nginx.conf', '/etc/nginx/nginx.conf'],
|
||||||
|
'app env filename' => ['/app/.env', '/app/.env'],
|
||||||
|
'relative input becomes absolute' => ['config/app.yaml', '/config/app.yaml'],
|
||||||
|
'duplicate slashes collapse' => ['/opt//app///config.json', '/opt/app/config.json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('host file mount path validator accepts absolute host file paths', function () {
|
||||||
|
expect(validateHostFileMountPath('/etc/nginx/nginx.conf', 'host file path'))
|
||||||
|
->toBe('/etc/nginx/nginx.conf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host file mount path validator rejects ambiguous or directory paths', function (string $path) {
|
||||||
|
expect(fn () => validateHostFileMountPath($path, 'host file path'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
})->with([
|
||||||
|
'relative path' => ['etc/nginx/nginx.conf'],
|
||||||
|
'root directory' => ['/'],
|
||||||
|
'trailing slash' => ['/etc/nginx/'],
|
||||||
|
'parent segment' => ['/etc/../shadow'],
|
||||||
|
'current segment' => ['/etc/./nginx.conf'],
|
||||||
|
'backslash' => ['\\etc\\nginx.conf'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('confined path resolver keeps file mounts inside their resource configuration root', function () {
|
||||||
|
expect(confineFileMountPath('/data/coolify/applications/app-uuid', '/etc/nginx/nginx.conf', 'file storage path'))
|
||||||
|
->toBe('/data/coolify/applications/app-uuid/etc/nginx/nginx.conf');
|
||||||
|
|
||||||
|
expect(confineFileMountPath('/data/coolify/databases/db-uuid/', 'postgres/postgresql.conf', 'file storage path'))
|
||||||
|
->toBe('/data/coolify/databases/db-uuid/postgres/postgresql.conf');
|
||||||
|
|
||||||
|
expect(confineFileMountPath('/data/coolify/services/service-uuid', '/config.yaml', 'file storage path'))
|
||||||
|
->toBe('/data/coolify/services/service-uuid/config.yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confined path resolver rejects paths that escape the resource configuration root', function (string $base, string $path) {
|
||||||
|
expect(fn () => confineFileMountPath($base, $path, 'file storage path'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
})->with([
|
||||||
|
'application parent segment' => ['/data/coolify/applications/app-uuid', '/../../etc/passwd'],
|
||||||
|
'database parent segment' => ['/data/coolify/databases/db-uuid', '/postgres/../../../etc/shadow'],
|
||||||
|
'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('local file volume write sink keeps saved managed file paths for compatibility', function () {
|
||||||
|
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
|
||||||
|
|
||||||
|
expect($source)->not->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
|
||||||
|
->and($source)->toContain('tee {$escapedPath}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host file mounts are bind-only and skipped by server storage writes', function () {
|
||||||
|
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
|
||||||
|
|
||||||
|
expect($source)->toContain('if ($this->is_host_file) {')
|
||||||
|
->and($source)->toContain('return;')
|
||||||
|
->and($source)->toContain('tee {$escapedPath}');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue