mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Since aabd12737 a Compose bind volume with a content block had to stay
inside the resource directory. Services and Compose applications saved
in v4.3.23 with other paths, and Git Compose files with them, no longer
started or deployed.
Only admins and owners can edit Compose files, and they can already
write any host file with a plain bind mount, so the rule added no
security.
- Write content files to their host path again, as in v4.3.23
- Keep shell escaping and the safe-character checks on volume sources,
so injection attempts are still rejected
207 lines
7.1 KiB
PHP
207 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Project\Service;
|
|
|
|
use App\Livewire\Project\Shared\EnvironmentVariable\All;
|
|
use App\Models\Service;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Livewire\Component;
|
|
|
|
class StackForm extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
public Service $service;
|
|
|
|
public Collection $fields;
|
|
|
|
public bool $isPasswordHiddenForMember = false;
|
|
|
|
protected $listeners = ['saveCompose'];
|
|
|
|
// Explicit properties
|
|
public string $name;
|
|
|
|
public ?string $description = null;
|
|
|
|
public ?string $dockerComposeRaw = null;
|
|
|
|
public ?string $dockerCompose = null;
|
|
|
|
public ?bool $connectToDockerNetwork = null;
|
|
|
|
protected function rules(): array
|
|
{
|
|
$baseRules = [
|
|
'dockerComposeRaw' => 'required',
|
|
'dockerCompose' => 'nullable',
|
|
'name' => ValidationPatterns::nameRules(),
|
|
'description' => ValidationPatterns::descriptionRules(),
|
|
'connectToDockerNetwork' => 'nullable',
|
|
];
|
|
|
|
// Add dynamic field rules
|
|
foreach ($this->fields ?? collect() as $key => $field) {
|
|
$rules = data_get($field, 'rules', 'nullable');
|
|
$baseRules["fields.$key.value"] = $rules;
|
|
}
|
|
|
|
return $baseRules;
|
|
}
|
|
|
|
protected function messages(): array
|
|
{
|
|
return array_merge(
|
|
ValidationPatterns::combinedMessages(),
|
|
[
|
|
'name.required' => 'The Name field is required.',
|
|
'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.',
|
|
'dockerCompose.required' => 'The Docker Compose field is required.',
|
|
]
|
|
);
|
|
}
|
|
|
|
public $validationAttributes = [];
|
|
|
|
/**
|
|
* Sync data between component properties and model
|
|
*
|
|
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
|
|
*/
|
|
private function syncData(bool $toModel = false): void
|
|
{
|
|
if ($toModel) {
|
|
// Sync TO model (before save)
|
|
$this->service->name = $this->name;
|
|
$this->service->description = $this->description;
|
|
$this->service->docker_compose_raw = $this->dockerComposeRaw;
|
|
$this->service->docker_compose = $this->dockerCompose;
|
|
$this->service->connect_to_docker_network = $this->connectToDockerNetwork;
|
|
} else {
|
|
// Sync FROM model (on load/refresh)
|
|
$this->name = $this->service->name;
|
|
$this->description = $this->service->description;
|
|
$canViewCompose = auth()->user()?->can('update', $this->service) ?? false;
|
|
$this->dockerComposeRaw = $canViewCompose ? $this->service->docker_compose_raw : null;
|
|
$this->dockerCompose = $canViewCompose ? $this->service->docker_compose : null;
|
|
$this->connectToDockerNetwork = $this->service->connect_to_docker_network;
|
|
}
|
|
}
|
|
|
|
public function mount()
|
|
{
|
|
$this->syncData(false);
|
|
$this->fields = collect([]);
|
|
$extraFields = $this->service->extraFields();
|
|
foreach ($extraFields as $serviceName => $fields) {
|
|
foreach ($fields as $fieldKey => $field) {
|
|
$key = data_get($field, 'key');
|
|
$value = data_get($field, 'value');
|
|
$rules = data_get($field, 'rules', 'nullable');
|
|
$isPassword = data_get($field, 'isPassword', false);
|
|
$customHelper = data_get($field, 'customHelper', false);
|
|
$sortOrder = data_get($field, 'sortOrder');
|
|
$this->fields->put($key, [
|
|
'serviceName' => $serviceName,
|
|
'key' => $key,
|
|
'name' => $fieldKey,
|
|
'value' => $value,
|
|
'isPassword' => $isPassword,
|
|
'rules' => $rules,
|
|
'customHelper' => $customHelper,
|
|
'sortOrder' => $sortOrder,
|
|
]);
|
|
|
|
$this->validationAttributes["fields.$key.value"] = $fieldKey;
|
|
}
|
|
}
|
|
$this->fields = $this->fields->groupBy('serviceName')->map(function ($group) {
|
|
return $group->sortBy(function ($field) {
|
|
return data_get($field, 'sortOrder') ?? (data_get($field, 'isPassword') ? 1 : 0);
|
|
})->mapWithKeys(function ($field) {
|
|
return [$field['key'] => $field];
|
|
});
|
|
})->flatMap(function ($group) {
|
|
return $group;
|
|
});
|
|
|
|
$this->isPasswordHiddenForMember = auth()->user()?->isMember() ?? false;
|
|
if ($this->isPasswordHiddenForMember) {
|
|
$this->fields = $this->fields->map(function ($field) {
|
|
if (data_get($field, 'isPassword')) {
|
|
$field['value'] = null;
|
|
}
|
|
|
|
return $field;
|
|
});
|
|
}
|
|
}
|
|
|
|
public function saveCompose($raw)
|
|
{
|
|
$this->dockerComposeRaw = $raw;
|
|
$this->submit(notify: true);
|
|
$this->dispatch('compose-save-finished');
|
|
}
|
|
|
|
public function instantSave()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->service);
|
|
$this->service->refresh()->update([
|
|
'connect_to_docker_network' => $this->connectToDockerNetwork,
|
|
]);
|
|
$this->dispatch('success', 'Service settings saved.');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function submit($notify = true)
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->service);
|
|
$this->validate();
|
|
$this->syncData(true);
|
|
|
|
// Validate for command injection BEFORE any database operations
|
|
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
|
|
|
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
|
DB::transaction(function () {
|
|
$this->service->save();
|
|
$this->service->saveExtraFields($this->fields);
|
|
$this->service->parse();
|
|
});
|
|
// Refresh and write files after a successful commit
|
|
$this->service->refresh();
|
|
$this->service->saveComposeConfigs();
|
|
|
|
$this->dispatch('refreshEnvs')->to(EditCompose::class);
|
|
$this->dispatch('refreshEnvs')->to(All::class);
|
|
$this->dispatch('refreshServices');
|
|
$notify && $this->dispatch('success', 'Service saved.');
|
|
} catch (\Throwable $e) {
|
|
// On error, refresh from database to restore clean state
|
|
$this->service->refresh();
|
|
$this->syncData(false);
|
|
|
|
return handleError($e, $this);
|
|
} finally {
|
|
if (is_null($this->service->config_hash)) {
|
|
$this->service->isConfigurationChanged(true);
|
|
} else {
|
|
$this->dispatch('configurationChanged');
|
|
}
|
|
}
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.project.service.stack-form');
|
|
}
|
|
}
|