Files
coolify/app/Livewire/Project/Service/StackForm.php
T
Andras BacsaiandClaude Opus 5.5 aabd127374 fix(compose): keep content files inside the resource directory
- A Compose bind volume with a Coolify `content:` block could make
  Coolify write any host file (for example /root/.ssh/authorized_keys)
  when the file was loaded. Content sources must now be inside the
  resource directory (./ paths); other sources get a clear validation
  error for services and applications (UI, API, load, deployment),
  and every content write is confined again on the server. Bind
  mounts without content are unchanged. All 94 template content
  volumes use ./ paths and still work.
- Quote container names in scheduled tasks and in the stop actions for
  applications, previews, and services.
- Fix a development-only crash when a preview with a bind mount was
  parsed (the preview suffix returned a plain string).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:37:21 +02:00

204 lines
6.8 KiB
PHP

<?php
namespace App\Livewire\Project\Service;
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;
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;
$this->dockerComposeRaw = $this->service->docker_compose_raw;
$this->dockerCompose = $this->service->docker_compose;
$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, composeResourceDirectory($this->service));
// 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');
$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');
}
}