coolify/app/Livewire/Project/Database/Postgresql/General.php
Andras Bacsai 40a9881ef2 fix(database): skip credential pattern validation for unchanged values
Pattern enforcement now conditional on field being dirty (changed vs
saved value). Prevents false validation failures when existing records
hold legacy credential formats that pre-date the stricter regex rules.
2026-04-20 13:58:44 +02:00

505 lines
18 KiB
PHP

<?php
namespace App\Livewire\Project\Database\Postgresql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
{
use AuthorizesRequests;
public StandalonePostgresql $database;
public ?Server $server = null;
public string $name;
public ?string $description = null;
public string $postgresUser;
public string $postgresPassword;
public string $postgresDb;
public ?string $postgresInitdbArgs = null;
public ?string $postgresHostAuthMethod = null;
public ?string $postgresConf = null;
public ?array $initScripts = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public mixed $publicPort = null;
public mixed $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename;
public string $new_content;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
}
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'postgresUser' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->postgresUser !== $this->database->postgres_user,
),
'postgresPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->postgresPassword !== $this->database->postgres_password,
),
'postgresDb' => ValidationPatterns::databaseIdentifierRules(
enforcePattern: $this->postgresDb !== $this->database->postgres_db,
),
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
'initScripts' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
protected function messages(): array
{
return array_merge(
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'),
...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'),
...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
protected $validationAttributes = [
'name' => 'Name',
'description' => 'Description',
'postgresUser' => 'Postgres User',
'postgresPassword' => 'Postgres Password',
'postgresDb' => 'Postgres DB',
'postgresInitdbArgs' => 'Postgres Initdb Args',
'postgresHostAuthMethod' => 'Postgres Host Auth Method',
'postgresConf' => 'Postgres Configuration',
'initScripts' => 'Init Scripts',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->postgres_user = $this->postgresUser;
$this->database->postgres_password = $this->postgresPassword;
$this->database->postgres_db = $this->postgresDb;
$this->database->postgres_initdb_args = $this->postgresInitdbArgs;
$this->database->postgres_host_auth_method = $this->postgresHostAuthMethod;
$this->database->postgres_conf = $this->postgresConf;
$this->database->init_scripts = $this->initScripts;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort ?: null;
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgresUser = $this->database->postgres_user;
$this->postgresPassword = $this->database->postgres_password;
$this->postgresDb = $this->database->postgres_db;
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
$this->postgresHostAuthMethod = $this->database->postgres_host_auth_method;
$this->postgresConf = $this->database->postgres_conf;
$this->initScripts = $this->database->init_scripts;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
public function instantSaveAdvanced()
{
try {
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->database);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
}
public function save_init_script($script)
{
$this->authorize('update', $this->database);
$initScripts = collect($this->initScripts ?? []);
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
$oldScript = $initScripts->firstWhere('index', $script['index']);
if ($existingScript && $existingScript['index'] !== $script['index']) {
$this->dispatch('error', 'A script with this filename already exists.');
return;
}
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($oldScript['filename'], 'init script filename');
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
}
$index = $initScripts->search(function ($item) use ($script) {
return $item['index'] === $script['index'];
});
if ($index !== false) {
$initScripts[$index] = $script;
} else {
$initScripts->push($script);
}
$this->initScripts = $initScripts->values()
->map(function ($item, $index) {
$item['index'] = $index;
return $item;
})
->all();
$this->syncData(true);
$this->dispatch('success', 'Init script saved and updated.');
}
public function delete_init_script($script)
{
$this->authorize('update', $this->database);
$collection = collect($this->initScripts);
$found = $collection->firstWhere('filename', $script['filename']);
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($script['filename'], 'init script filename');
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$updatedScripts = $collection->filter(fn ($s) => $s['filename'] !== $script['filename'])
->values()
->map(function ($item, $index) {
$item['index'] = $index;
return $item;
})
->all();
$this->initScripts = $updatedScripts;
$this->syncData(true);
$this->dispatch('refresh')->self();
$this->dispatch('success', 'Init script deleted from the database and the server.');
}
}
public function save_new_init_script()
{
$this->authorize('update', $this->database);
$this->validate([
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
try {
// Validate filename to prevent command injection
validateShellSafePath($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
return;
}
if (! isset($this->initScripts)) {
$this->initScripts = [];
}
$this->initScripts = array_merge($this->initScripts, [
[
'index' => count($this->initScripts),
'filename' => $this->new_filename,
'content' => $this->new_content,
],
]);
$this->syncData(true);
$this->dispatch('success', 'Init script added.');
$this->new_content = '';
$this->new_filename = '';
}
public function submit()
{
try {
$this->authorize('update', $this->database);
if ($this->portsMappings) {
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
}
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
if (is_null($this->database->config_hash)) {
$this->database->isConfigurationChanged(true);
} else {
$this->dispatch('configurationChanged');
}
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}