mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 20:14:45 +00:00
Merge remote-tracking branch 'origin/next' into jean/port-exposes-improvement
This commit is contained in:
commit
07f381b88c
766 files changed
+43286
-12686
No files matched your search
@@ -4,6 +4,8 @@ namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -61,6 +63,9 @@ class Advanced extends Component
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $gpuOptions = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $stopGracePeriod = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isBuildServerEnabled = false;
|
||||
|
||||
@@ -145,6 +150,10 @@ class Advanced extends Component
|
||||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||
}
|
||||
|
||||
// Load stop_grace_period separately since it has its own save handler
|
||||
// Convert null to empty string to prevent dirty detection issues
|
||||
$this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
|
||||
}
|
||||
|
||||
private function resetDefaultLabels()
|
||||
@@ -210,6 +219,7 @@ class Advanced extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -228,6 +238,7 @@ class Advanced extends Component
|
||||
if (is_null($this->customInternalName)) {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -247,6 +258,32 @@ class Advanced extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Custom name saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveStopGracePeriod()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$validated = Validator::make(
|
||||
['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
|
||||
['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
|
||||
[],
|
||||
['stopGracePeriod' => 'stop grace period']
|
||||
)->validate();
|
||||
|
||||
$this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
|
||||
? null
|
||||
: (int) $validated['stopGracePeriod'];
|
||||
$this->application->settings->save();
|
||||
|
||||
$this->dispatch('success', 'Stop grace period updated.');
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -17,17 +17,10 @@ class Configuration extends Component
|
||||
|
||||
public $servers;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
||||
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
|
||||
'buildPackUpdated' => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
}
|
||||
protected $listeners = [
|
||||
'buildPackUpdated' => '$refresh',
|
||||
'refresh' => '$refresh',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
@@ -35,7 +28,7 @@ class Configuration extends Component
|
||||
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
@@ -51,8 +44,6 @@ class Configuration extends Component
|
||||
$this->environment = $environment;
|
||||
$this->application = $application;
|
||||
|
||||
|
||||
|
||||
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
|
||||
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
|
||||
}
|
||||
|
||||
@@ -108,19 +108,6 @@ class Show extends Component
|
||||
return decode_remote_command_output($this->application_deployment_queue);
|
||||
}
|
||||
|
||||
public function copyLogs(): string
|
||||
{
|
||||
$logs = decode_remote_command_output($this->application_deployment_queue)
|
||||
->map(function ($line) {
|
||||
return $line['timestamp'].' '.
|
||||
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
|
||||
trim($line['line']);
|
||||
})
|
||||
->join("\n");
|
||||
|
||||
return sanitizeLogsForExport($logs);
|
||||
}
|
||||
|
||||
public function downloadAllLogs(): string
|
||||
{
|
||||
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application;
|
||||
use App\Actions\Application\GenerateConfig;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -144,21 +145,21 @@ class General extends Component
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'fqdn' => 'nullable',
|
||||
'gitRepository' => 'required',
|
||||
'gitBranch' => 'required',
|
||||
'gitBranch' => ['required', 'string', new ValidGitBranch],
|
||||
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'installCommand' => 'nullable',
|
||||
'buildCommand' => 'nullable',
|
||||
'startCommand' => 'nullable',
|
||||
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'startCommand' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'buildPack' => 'required',
|
||||
'staticImage' => 'required',
|
||||
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
|
||||
'publishDirectory' => ValidationPatterns::directoryPathRules(),
|
||||
'portsExposes' => 'nullable',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'customNetworkAliases' => 'nullable',
|
||||
'dockerfile' => 'nullable',
|
||||
'dockerRegistryImageName' => 'nullable',
|
||||
'dockerRegistryImageTag' => 'nullable',
|
||||
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
|
||||
'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'dockerfileLocation' => ValidationPatterns::filePathRules(),
|
||||
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
|
||||
'dockerCompose' => 'nullable',
|
||||
@@ -197,9 +198,12 @@ class General extends Component
|
||||
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
|
||||
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
|
||||
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
|
||||
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
|
||||
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
|
||||
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
|
||||
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
|
||||
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
|
||||
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
|
||||
'name.required' => 'The Name field is required.',
|
||||
@@ -208,6 +212,8 @@ class General extends Component
|
||||
'buildPack.required' => 'The Build Pack field is required.',
|
||||
'staticImage.required' => 'The Static Image field is required.',
|
||||
'baseDirectory.required' => 'The Base Directory field is required.',
|
||||
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
|
||||
...ValidationPatterns::portMappingMessages(),
|
||||
'isStatic.required' => 'The Static setting is required.',
|
||||
'isStatic.boolean' => 'The Static setting must be true or false.',
|
||||
'isSpa.required' => 'The SPA setting is required.',
|
||||
@@ -600,7 +606,7 @@ class General extends Component
|
||||
// Sync property to model before checking/modifying
|
||||
$this->syncData(toModel: true);
|
||||
|
||||
if ($this->buildPack !== 'nixpacks') {
|
||||
if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
|
||||
$this->isStatic = false;
|
||||
$this->application->settings->is_static = false;
|
||||
$this->application->settings->save();
|
||||
@@ -731,6 +737,7 @@ class General extends Component
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
$this->application->redirect = $this->redirect;
|
||||
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
||||
if ($has_www === 0 && $this->application->redirect === 'www') {
|
||||
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
|
||||
@@ -751,6 +758,12 @@ class General extends Component
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
$oldPortsExposes = $this->application->ports_exposes;
|
||||
@@ -835,7 +848,7 @@ class General extends Component
|
||||
}
|
||||
if ($this->buildPack === 'dockerimage') {
|
||||
$this->validate([
|
||||
'dockerRegistryImageName' => 'required',
|
||||
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,17 @@ class Previews extends Component
|
||||
|
||||
public array $previewFqdns = [];
|
||||
|
||||
public array $previewDockerTags = [];
|
||||
|
||||
public ?int $manualPullRequestId = null;
|
||||
|
||||
public ?string $manualDockerTag = null;
|
||||
|
||||
protected $rules = [
|
||||
'previewFqdns.*' => 'string|nullable',
|
||||
'previewDockerTags.*' => 'string|nullable',
|
||||
'manualPullRequestId' => 'integer|min:1|nullable',
|
||||
'manualDockerTag' => 'string|nullable',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -53,12 +62,17 @@ class Previews extends Component
|
||||
$preview = $this->application->previews->get($key);
|
||||
if ($preview) {
|
||||
$preview->fqdn = $fqdn;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->previewFqdns = [];
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewFqdns[$key] = $preview->fqdn;
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +188,7 @@ class Previews extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function add(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||
public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
@@ -195,13 +209,18 @@ class Previews extends Component
|
||||
} else {
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found && ! is_null($pull_request_html_url)) {
|
||||
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
|
||||
$found = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_registry_image_tag' => $docker_registry_image_tag,
|
||||
]);
|
||||
}
|
||||
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
|
||||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$found->generate_preview_fqdn();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
@@ -217,37 +236,50 @@ class Previews extends Component
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
|
||||
$dockerRegistryImageTag = null;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$dockerRegistryImageTag = $this->application->previews()
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->value('docker_registry_image_tag');
|
||||
}
|
||||
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
|
||||
}
|
||||
|
||||
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$this->add($pull_request_id, $pull_request_html_url);
|
||||
$this->deploy($pull_request_id, $pull_request_html_url);
|
||||
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
|
||||
}
|
||||
|
||||
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
|
||||
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
try {
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found && ! is_null($pull_request_html_url)) {
|
||||
ApplicationPreview::create([
|
||||
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
|
||||
$found = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_registry_image_tag' => $docker_registry_image_tag,
|
||||
]);
|
||||
}
|
||||
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
|
||||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
deployment_uuid: $this->deployment_uuid,
|
||||
force_rebuild: $force_rebuild,
|
||||
pull_request_id: $pull_request_id,
|
||||
git_type: $found->git_type ?? null,
|
||||
docker_registry_image_tag: $docker_registry_image_tag,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
@@ -277,13 +309,40 @@ class Previews extends Component
|
||||
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
|
||||
}
|
||||
|
||||
public function addDockerImagePreview()
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
$this->validateOnly('manualPullRequestId');
|
||||
$this->validateOnly('manualDockerTag');
|
||||
|
||||
if ($this->application->build_pack !== 'dockerimage') {
|
||||
$this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) {
|
||||
$this->dispatch('error', 'Both pull request id and docker tag are required.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dockerTag = str($this->manualDockerTag)->trim()->value();
|
||||
|
||||
$this->add_and_deploy($this->manualPullRequestId, null, $dockerTag);
|
||||
|
||||
$this->manualPullRequestId = null;
|
||||
$this->manualDockerTag = null;
|
||||
}
|
||||
|
||||
private function stopContainers(array $containers, $server)
|
||||
{
|
||||
$containersToStop = collect($containers)->pluck('Names')->toArray();
|
||||
$timeout = $this->application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class ServerStatusBadge extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$team = $user->currentTeam();
|
||||
if (! $team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
|
||||
"echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshStatus(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.application.server-status-badge');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
@@ -21,13 +24,13 @@ class Source extends Component
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $privateKeyName = null;
|
||||
|
||||
#[Validate(['nullable', 'integer'])]
|
||||
#[Locked]
|
||||
public ?int $privateKeyId = null;
|
||||
|
||||
#[Validate(['required', 'string'])]
|
||||
public string $gitRepository;
|
||||
|
||||
#[Validate(['required', 'string'])]
|
||||
#[Validate(['required', 'string', new ValidGitBranch])]
|
||||
public string $gitBranch;
|
||||
|
||||
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||
@@ -103,12 +106,14 @@ class Source extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->privateKeyId = $privateKeyId;
|
||||
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
|
||||
$this->privateKeyId = $key->id;
|
||||
$this->syncData(true);
|
||||
$this->getPrivateKeys();
|
||||
$this->application->refresh();
|
||||
$this->privateKeyName = $this->application->private_key->name;
|
||||
$this->dispatch('success', 'Private key updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -124,6 +129,7 @@ class Source extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Application source updated!');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -134,8 +140,11 @@ class Source extends Component
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
|
||||
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
|
||||
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
|
||||
$this->application->update([
|
||||
'source_id' => $sourceId,
|
||||
'source_id' => $source->id,
|
||||
'source_type' => $sourceType,
|
||||
]);
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class CloneMe extends Component
|
||||
public function mount($project_uuid)
|
||||
{
|
||||
$this->project_uuid = $project_uuid;
|
||||
$this->project = Project::where('uuid', $project_uuid)->firstOrFail();
|
||||
$this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first();
|
||||
$this->project_id = $this->project->id;
|
||||
$this->servers = currentTeam()
|
||||
@@ -187,6 +187,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newDatabase->id,
|
||||
@@ -298,9 +299,9 @@ class CloneMe extends Component
|
||||
}
|
||||
|
||||
foreach ($newService->applications() as $application) {
|
||||
$application->update([
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -315,6 +316,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $application->id,
|
||||
@@ -352,9 +354,9 @@ class CloneMe extends Component
|
||||
}
|
||||
|
||||
foreach ($newService->databases() as $database) {
|
||||
$database->update([
|
||||
$database->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -369,6 +371,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $database->id,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -76,7 +77,7 @@ class BackupEdit extends Component
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
public int|string $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
@@ -144,7 +145,7 @@ class BackupEdit extends Component
|
||||
|
||||
try {
|
||||
$server = null;
|
||||
if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
|
||||
if ($this->backup->database instanceof ServiceDatabase) {
|
||||
$server = $this->backup->database->service->destination->server;
|
||||
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
|
||||
$server = $this->backup->database->destination->server;
|
||||
@@ -170,7 +171,7 @@ class BackupEdit extends Component
|
||||
|
||||
$this->backup->delete();
|
||||
|
||||
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$serviceDatabase = $this->backup->database;
|
||||
|
||||
return redirect()->route('project.service.database.backups', [
|
||||
@@ -182,7 +183,7 @@ class BackupEdit extends Component
|
||||
} else {
|
||||
return redirect()->route('project.database.backup.index', $this->parameters);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||
|
||||
return handleError($e, $this);
|
||||
@@ -207,6 +208,13 @@ class BackupEdit extends Component
|
||||
$this->backup->s3_storage_id = null;
|
||||
}
|
||||
|
||||
// S3 backup cannot be enabled without a valid S3 storage owned by the team
|
||||
$availableS3Ids = collect($this->s3s)->pluck('id');
|
||||
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = null;
|
||||
}
|
||||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
||||
@@ -214,7 +222,7 @@ class BackupEdit extends Component
|
||||
|
||||
$isValid = validate_cron_expression($this->backup->frequency);
|
||||
if (! $isValid) {
|
||||
throw new \Exception('Invalid Cron / Human expression');
|
||||
throw new Exception('Invalid Cron / Human expression');
|
||||
}
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
@@ -34,24 +34,27 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
||||
public ?string $dbUrlPublic = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
$team = $user->currentTeam();
|
||||
if (! $team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -76,16 +79,18 @@ class General extends Component
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'clickhouseAdminUser' => 'required|string',
|
||||
'clickhouseAdminPassword' => 'required|string',
|
||||
'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
|
||||
),
|
||||
'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
|
||||
),
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
@@ -94,14 +99,15 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'clickhouseAdminUser.required' => 'The Admin User field is required.',
|
||||
'clickhouseAdminUser.string' => 'The Admin User must be a string.',
|
||||
'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
|
||||
'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
|
||||
...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'),
|
||||
...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'),
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'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.',
|
||||
]
|
||||
@@ -119,14 +125,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->save();
|
||||
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
@@ -139,8 +142,6 @@ class General extends Component
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +190,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -197,9 +199,13 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function databaseProxyStopped()
|
||||
public function databaseProxyStopped(): void
|
||||
{
|
||||
$this->syncData();
|
||||
$this->database->refresh();
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->dispatch('databaseUpdated');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -207,11 +213,15 @@ class General extends Component
|
||||
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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Clickhouse;
|
||||
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneClickhouse $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'Clickhouse';
|
||||
}
|
||||
|
||||
protected function supportsSsl(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function showPublicUrlPlaceholder(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use Auth;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\ItemNotFoundException;
|
||||
use Livewire\Component;
|
||||
|
||||
class Configuration extends Component
|
||||
@@ -18,15 +19,6 @@ class Configuration extends Component
|
||||
|
||||
public $environment;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -34,7 +26,7 @@ class Configuration extends Component
|
||||
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
@@ -55,10 +47,10 @@ class Configuration extends Component
|
||||
$this->dispatch('configurationChanged');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
|
||||
if ($e instanceof AuthorizationException) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
|
||||
if ($e instanceof ItemNotFoundException) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -48,6 +50,20 @@ class CreateScheduledBackup extends Component
|
||||
|
||||
$this->validate();
|
||||
|
||||
if ($this->saveToS3) {
|
||||
$s3StorageExists = ! is_null($this->s3StorageId)
|
||||
&& S3Storage::where('team_id', currentTeam()->id)
|
||||
->where('is_usable', true)
|
||||
->whereKey($this->s3StorageId)
|
||||
->exists();
|
||||
|
||||
if (! $s3StorageExists) {
|
||||
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$isValid = validate_cron_expression($this->frequency);
|
||||
if (! $isValid) {
|
||||
$this->dispatch('error', 'Invalid Cron / Human expression.');
|
||||
@@ -74,7 +90,7 @@ class CreateScheduledBackup extends Component
|
||||
}
|
||||
|
||||
$databaseBackup = ScheduledDatabaseBackup::create($payload);
|
||||
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($this->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
|
||||
} else {
|
||||
$this->dispatch('refreshScheduledBackups');
|
||||
|
||||
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -34,30 +32,27 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
||||
public ?string $dbUrlPublic = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public bool $enable_ssl = false;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
$team = $user->currentTeam();
|
||||
if (! $team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -72,12 +67,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -88,17 +77,16 @@ class General extends Component
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'dragonflyPassword' => 'required|string',
|
||||
'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
|
||||
),
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'enable_ssl' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -106,12 +94,14 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'dragonflyPassword.required' => 'The Dragonfly Password field is required.',
|
||||
'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
|
||||
...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'),
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'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.',
|
||||
]
|
||||
@@ -128,15 +118,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
$this->database->save();
|
||||
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
@@ -148,9 +134,6 @@ class General extends Component
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->enable_ssl = $this->database->enable_ssl;
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +182,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -207,9 +191,13 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function databaseProxyStopped()
|
||||
public function databaseProxyStopped(): void
|
||||
{
|
||||
$this->syncData();
|
||||
$this->database->refresh();
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->dispatch('databaseUpdated');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -217,11 +205,15 @@ class General extends Component
|
||||
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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -233,64 +225,9 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSaveSSL()
|
||||
public function refresh(): void
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
$server = $this->database->destination->server;
|
||||
|
||||
$caCert = $server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$server->generateCaCertificate();
|
||||
$caCert = $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->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
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 regenerated. Restart database to apply changes.');
|
||||
} catch (Exception $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Dragonfly;
|
||||
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneDragonfly $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'Dragonfly';
|
||||
}
|
||||
|
||||
protected function showPublicUrlPlaceholder(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Health extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $database;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $healthCheckEnabled = true;
|
||||
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $healthCheckInterval = 15;
|
||||
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $healthCheckTimeout = 5;
|
||||
|
||||
#[Validate(['integer', 'min:1'])]
|
||||
public int $healthCheckRetries = 5;
|
||||
|
||||
#[Validate(['integer', 'min:0'])]
|
||||
public int $healthCheckStartPeriod = 5;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->database->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->database->health_check_interval = $this->healthCheckInterval;
|
||||
$this->database->health_check_timeout = $this->healthCheckTimeout;
|
||||
$this->database->health_check_retries = $this->healthCheckRetries;
|
||||
$this->database->health_check_start_period = $this->healthCheckStartPeriod;
|
||||
$this->database->save();
|
||||
} else {
|
||||
$this->healthCheckEnabled = $this->database->health_check_enabled;
|
||||
$this->healthCheckInterval = $this->database->health_check_interval;
|
||||
$this->healthCheckTimeout = $this->database->health_check_timeout;
|
||||
$this->healthCheckRetries = $this->database->health_check_retries;
|
||||
$this->healthCheckStartPeriod = $this->database->health_check_start_period;
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$updateSuccessful = false;
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->syncData(true);
|
||||
$updateSuccessful = true;
|
||||
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
|
||||
if (! $updateSuccessful) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markConfigurationChanged();
|
||||
}
|
||||
|
||||
public function toggleHealthcheck(): void
|
||||
{
|
||||
$updateSuccessful = false;
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
|
||||
$this->syncData(true);
|
||||
$updateSuccessful = true;
|
||||
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
|
||||
if (! $updateSuccessful) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markConfigurationChanged();
|
||||
}
|
||||
|
||||
private function markConfigurationChanged(): void
|
||||
{
|
||||
if (is_null($this->database->config_hash)) {
|
||||
$this->database->isConfigurationChanged(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.database.health');
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -17,797 +17,134 @@ class Import extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 bucket name.
|
||||
* Allows alphanumerics, dots, dashes, and underscores.
|
||||
*/
|
||||
private function validateBucketName(string $bucket): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 path.
|
||||
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||
*/
|
||||
private function validateS3Path(string $path): bool
|
||||
{
|
||||
// Must not be empty
|
||||
if (empty($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as a file path on the server.
|
||||
*/
|
||||
private function validateServerPath(string $path): bool
|
||||
{
|
||||
// Must be an absolute path
|
||||
if (! str_starts_with($path, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
public bool $unsupported = false;
|
||||
|
||||
// Store IDs instead of models for proper Livewire serialization
|
||||
#[Locked]
|
||||
public ?int $resourceId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $resourceType = null;
|
||||
|
||||
#[Locked]
|
||||
public ?int $serverId = null;
|
||||
|
||||
// View-friendly properties to avoid computed property access in Blade
|
||||
#[Locked]
|
||||
public string $resourceUuid = '';
|
||||
|
||||
public string $resourceStatus = '';
|
||||
|
||||
#[Locked]
|
||||
public string $resourceDbType = '';
|
||||
public string $resourceUuid = '';
|
||||
|
||||
public array $parameters = [];
|
||||
public bool $unsupported = false;
|
||||
|
||||
public array $containers = [];
|
||||
|
||||
public bool $scpInProgress = false;
|
||||
|
||||
public bool $importRunning = false;
|
||||
|
||||
public ?string $filename = null;
|
||||
|
||||
public ?string $filesize = null;
|
||||
|
||||
public bool $isUploading = false;
|
||||
|
||||
public int $progress = 0;
|
||||
|
||||
public bool $error = false;
|
||||
|
||||
#[Locked]
|
||||
public string $container;
|
||||
|
||||
public array $importCommands = [];
|
||||
|
||||
public bool $dumpAll = false;
|
||||
|
||||
public string $restoreCommandText = '';
|
||||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
|
||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
||||
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
|
||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
// S3 Restore properties
|
||||
public array $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
#[Computed]
|
||||
public function resource()
|
||||
public function getListeners(): array
|
||||
{
|
||||
if ($this->resourceId === null || $this->resourceType === null) {
|
||||
return null;
|
||||
$listeners = ['databaseUpdated' => 'refreshStatus'];
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
return $this->resourceType::find($this->resourceId);
|
||||
}
|
||||
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
|
||||
|
||||
#[Computed]
|
||||
public function server()
|
||||
{
|
||||
if ($this->serverId === null) {
|
||||
return null;
|
||||
$team = $user->currentTeam();
|
||||
if ($team) {
|
||||
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
|
||||
}
|
||||
|
||||
return Server::ownedByCurrentTeam()->find($this->serverId);
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
public function getListeners()
|
||||
public function mount(): void
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'slideOverClosed' => 'resetActivityId',
|
||||
];
|
||||
}
|
||||
|
||||
public function resetActivityId()
|
||||
{
|
||||
$this->activityId = null;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
{
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === \App\Models\ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
if ($value === true) {
|
||||
$this->mariadbRestoreCommand = <<<'EOD'
|
||||
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||
done && \
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
|
||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
|
||||
} else {
|
||||
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
if ($value === true) {
|
||||
$this->mysqlRestoreCommand = <<<'EOD'
|
||||
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||
done && \
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
|
||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
|
||||
} else {
|
||||
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
if ($value === true) {
|
||||
$this->postgresqlRestoreCommand = <<<'EOD'
|
||||
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
|
||||
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
|
||||
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
} else {
|
||||
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function getContainers()
|
||||
{
|
||||
$this->containers = [];
|
||||
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
||||
|
||||
// Try to find resource by route parameter
|
||||
$databaseUuid = data_get($this->parameters, 'database_uuid');
|
||||
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
|
||||
|
||||
$resource = null;
|
||||
if ($databaseUuid) {
|
||||
// Standalone database route
|
||||
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
} elseif ($stackServiceUuid) {
|
||||
// ServiceDatabase route - look up the service database
|
||||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||||
$service = Service::whereUuid($serviceUuid)->first();
|
||||
if (! $service) {
|
||||
abort(404);
|
||||
}
|
||||
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
} else {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$resource = $this->resolveResourceFromRoute();
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
// Store IDs for Livewire serialization
|
||||
$this->resourceId = $resource->id;
|
||||
$this->resourceType = get_class($resource);
|
||||
|
||||
// Store view-friendly properties
|
||||
$this->refreshStatus();
|
||||
}
|
||||
|
||||
public function refreshStatus(): void
|
||||
{
|
||||
$resource = $this->resolveStoredResource();
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$resource->refresh();
|
||||
$this->resourceUuid = $resource->uuid;
|
||||
$this->resourceStatus = $resource->status ?? '';
|
||||
$this->unsupported = $this->isUnsupportedResource($resource);
|
||||
}
|
||||
|
||||
// Handle ServiceDatabase server access differently
|
||||
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$server = $resource->service?->server;
|
||||
if (! $server) {
|
||||
abort(404, 'Server not found for this service database.');
|
||||
}
|
||||
$this->serverId = $server->id;
|
||||
$this->container = $resource->name.'-'.$resource->service->uuid;
|
||||
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.database.import');
|
||||
}
|
||||
|
||||
// Determine database type for ServiceDatabase
|
||||
$dbType = $resource->databaseType();
|
||||
if (str_contains($dbType, 'postgres')) {
|
||||
$this->resourceDbType = 'standalone-postgresql';
|
||||
} elseif (str_contains($dbType, 'mysql')) {
|
||||
$this->resourceDbType = 'standalone-mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$this->resourceDbType = 'standalone-mariadb';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$this->resourceDbType = 'standalone-mongodb';
|
||||
} else {
|
||||
$this->resourceDbType = $dbType;
|
||||
private function resolveResourceFromRoute(): object
|
||||
{
|
||||
$parameters = get_route_parameters();
|
||||
$teamId = data_get(Auth::user()?->currentTeam(), 'id');
|
||||
$databaseUuid = data_get($parameters, 'database_uuid');
|
||||
$stackServiceUuid = data_get($parameters, 'stack_service_uuid');
|
||||
|
||||
if ($databaseUuid) {
|
||||
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||||
if ($resource) {
|
||||
return $resource;
|
||||
}
|
||||
} else {
|
||||
$server = $resource->destination?->server;
|
||||
if (! $server) {
|
||||
abort(404, 'Server not found for this database.');
|
||||
}
|
||||
$this->serverId = $server->id;
|
||||
$this->container = $resource->uuid;
|
||||
$this->resourceUuid = $resource->uuid;
|
||||
$this->resourceDbType = $resource->type();
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (str($resource->status)->startsWith('running')) {
|
||||
$this->containers[] = $this->container;
|
||||
if ($stackServiceUuid) {
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->where('uuid', data_get($parameters, 'project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->where('uuid', data_get($parameters, 'environment_uuid'))
|
||||
->firstOrFail();
|
||||
$service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
|
||||
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||||
if ($resource) {
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
private function resolveStoredResource(): object
|
||||
{
|
||||
if ($this->resourceId === null || $this->resourceType === null) {
|
||||
return $this->resolveResourceFromRoute();
|
||||
}
|
||||
|
||||
$resource = $this->resourceType::find($this->resourceId);
|
||||
if ($resource) {
|
||||
return $resource;
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
private function isUnsupportedResource(object $resource): bool
|
||||
{
|
||||
if (
|
||||
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
|
||||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
|
||||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
|
||||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
|
||||
$resource instanceof StandaloneRedis ||
|
||||
$resource instanceof StandaloneKeydb ||
|
||||
$resource instanceof StandaloneDragonfly ||
|
||||
$resource instanceof StandaloneClickhouse
|
||||
) {
|
||||
$this->unsupported = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
|
||||
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($resource instanceof ServiceDatabase) {
|
||||
$dbType = $resource->databaseType();
|
||||
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
|
||||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
|
||||
$this->unsupported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkFile()
|
||||
{
|
||||
if (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$escapedPath = escapeshellarg($this->customLocation);
|
||||
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||
if (blank($result)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->filename = $this->customLocation;
|
||||
$this->dispatch('success', 'The file exists.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function runImport(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
return str_contains($dbType, 'redis') ||
|
||||
str_contains($dbType, 'keydb') ||
|
||||
str_contains($dbType, 'dragonfly') ||
|
||||
str_contains($dbType, 'clickhouse');
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->filename === '') {
|
||||
$this->dispatch('error', 'Please select a file to import.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||||
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return true;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
if (! empty($this->importCommands)) {
|
||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||||
'scriptPath' => $scriptPath,
|
||||
'tmpPath' => $tmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
$this->filename = null;
|
||||
$this->importCommands = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get()
|
||||
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3Path($value)
|
||||
{
|
||||
// Reset validation state when path changes
|
||||
$this->s3FileSize = null;
|
||||
|
||||
// Ensure path starts with a slash
|
||||
if ($value !== null && $value !== '') {
|
||||
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3StorageId()
|
||||
{
|
||||
// Reset validation state when storage changes
|
||||
$this->s3FileSize = null;
|
||||
}
|
||||
|
||||
public function checkS3File()
|
||||
{
|
||||
if (! $this->s3StorageId) {
|
||||
$this->dispatch('error', 'Please select an S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please provide an S3 path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean the path (remove leading slash if present)
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
// Validate bucket name early
|
||||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
$s3Storage->testConnection();
|
||||
|
||||
// Build S3 disk configuration
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'region' => $s3Storage->region,
|
||||
'key' => $s3Storage->key,
|
||||
'secret' => $s3Storage->secret,
|
||||
'bucket' => $s3Storage->bucket,
|
||||
'endpoint' => $s3Storage->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
// Check if file exists
|
||||
if (! $disk->exists($cleanPath)) {
|
||||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file size
|
||||
$this->s3FileSize = $disk->size($cleanPath);
|
||||
|
||||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||
} catch (\Throwable $e) {
|
||||
$this->s3FileSize = null;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function restoreFromS3(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path to prevent command injection
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||||
} else {
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
}
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resourceUuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
// Prepare all commands in sequence
|
||||
$commands = [];
|
||||
|
||||
// 1. Clean up any existing helper container and temp files from previous runs
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
|
||||
|
||||
// 2. Start helper container on the database network
|
||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||
|
||||
// 3. Configure S3 access in helper container
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||
$escapedBucket = escapeshellarg($bucket);
|
||||
$escapedCleanPath = escapeshellarg($cleanPath);
|
||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
||||
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||
|
||||
// 8. Build and execute restore command inside database container
|
||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$commands[] = "chmod +x {$scriptPath}";
|
||||
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'containerName' => $containerName,
|
||||
'serverTmpPath' => $serverTmpPath,
|
||||
'scriptPath' => $scriptPath,
|
||||
'containerTmpPath' => $containerTmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === \App\Models\ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$morphClass = 'mongodb';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
case 'mongodb':
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ImportForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 bucket name.
|
||||
* Allows alphanumerics, dots, dashes, and underscores.
|
||||
*/
|
||||
private function validateBucketName(string $bucket): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 path.
|
||||
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||
*/
|
||||
private function validateS3Path(string $path): bool
|
||||
{
|
||||
// Must not be empty
|
||||
if (empty($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as a file path on the server.
|
||||
*/
|
||||
private function validateServerPath(string $path): bool
|
||||
{
|
||||
// Must be an absolute path
|
||||
if (! str_starts_with($path, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||
$dangerousPatterns = [
|
||||
'..', // Directory traversal
|
||||
'$(', // Command substitution
|
||||
'`', // Backtick command substitution
|
||||
'|', // Pipe
|
||||
';', // Command separator
|
||||
'&', // Background/AND
|
||||
'>', // Redirect
|
||||
'<', // Redirect
|
||||
"\n", // Newline
|
||||
"\r", // Carriage return
|
||||
"\0", // Null byte
|
||||
"'", // Single quote
|
||||
'"', // Double quote
|
||||
'\\', // Backslash
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (str_contains($path, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||
}
|
||||
|
||||
public bool $unsupported = false;
|
||||
|
||||
// Store IDs instead of models for proper Livewire serialization
|
||||
#[Locked]
|
||||
public ?int $resourceId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $resourceType = null;
|
||||
|
||||
#[Locked]
|
||||
public ?int $serverId = null;
|
||||
|
||||
// View-friendly properties to avoid computed property access in Blade
|
||||
#[Locked]
|
||||
public string $resourceUuid = '';
|
||||
|
||||
public string $resourceStatus = '';
|
||||
|
||||
#[Locked]
|
||||
public string $resourceDbType = '';
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public array $containers = [];
|
||||
|
||||
public bool $scpInProgress = false;
|
||||
|
||||
public bool $importRunning = false;
|
||||
|
||||
public ?string $filename = null;
|
||||
|
||||
public ?string $filesize = null;
|
||||
|
||||
public bool $isUploading = false;
|
||||
|
||||
public int $progress = 0;
|
||||
|
||||
public bool $error = false;
|
||||
|
||||
#[Locked]
|
||||
public string $container;
|
||||
|
||||
public array $importCommands = [];
|
||||
|
||||
public bool $dumpAll = false;
|
||||
|
||||
public string $restoreCommandText = '';
|
||||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
|
||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
||||
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
|
||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
// S3 Restore properties
|
||||
public array $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
#[Computed]
|
||||
public function resource()
|
||||
{
|
||||
if ($this->resourceId === null || $this->resourceType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resourceType::find($this->resourceId);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function server()
|
||||
{
|
||||
if ($this->serverId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Server::ownedByCurrentTeam()->find($this->serverId);
|
||||
}
|
||||
|
||||
protected $listeners = [
|
||||
'slideOverClosed' => 'resetActivityId',
|
||||
];
|
||||
|
||||
public function resetActivityId()
|
||||
{
|
||||
$this->activityId = null;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
{
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
if ($value === true) {
|
||||
$this->mariadbRestoreCommand = <<<'EOD'
|
||||
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||
done && \
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
|
||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
|
||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
|
||||
} else {
|
||||
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
}
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
if ($value === true) {
|
||||
$this->mysqlRestoreCommand = <<<'EOD'
|
||||
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||
done && \
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
|
||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
|
||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
|
||||
} else {
|
||||
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
}
|
||||
break;
|
||||
case StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
if ($value === true) {
|
||||
$this->postgresqlRestoreCommand = <<<'EOD'
|
||||
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
|
||||
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
|
||||
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
} else {
|
||||
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function getContainers()
|
||||
{
|
||||
$this->containers = [];
|
||||
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
||||
|
||||
// Try to find resource by route parameter
|
||||
$databaseUuid = data_get($this->parameters, 'database_uuid');
|
||||
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
|
||||
|
||||
$resource = null;
|
||||
if ($databaseUuid) {
|
||||
// Standalone database route
|
||||
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
} elseif ($stackServiceUuid) {
|
||||
// ServiceDatabase route - look up the service database
|
||||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->where('uuid', data_get($this->parameters, 'project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->where('uuid', data_get($this->parameters, 'environment_uuid'))
|
||||
->firstOrFail();
|
||||
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
|
||||
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
} else {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
// Store IDs for Livewire serialization
|
||||
$this->resourceId = $resource->id;
|
||||
$this->resourceType = get_class($resource);
|
||||
|
||||
// Store view-friendly properties
|
||||
$this->resourceStatus = $resource->status ?? '';
|
||||
|
||||
// Handle ServiceDatabase server access differently
|
||||
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||||
$server = $resource->service?->server;
|
||||
if (! $server) {
|
||||
abort(404, 'Server not found for this service database.');
|
||||
}
|
||||
$this->serverId = $server->id;
|
||||
$this->container = $resource->name.'-'.$resource->service->uuid;
|
||||
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
|
||||
|
||||
// Determine database type for ServiceDatabase
|
||||
$dbType = $resource->databaseType();
|
||||
if (str_contains($dbType, 'postgres')) {
|
||||
$this->resourceDbType = 'standalone-postgresql';
|
||||
} elseif (str_contains($dbType, 'mysql')) {
|
||||
$this->resourceDbType = 'standalone-mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$this->resourceDbType = 'standalone-mariadb';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$this->resourceDbType = 'standalone-mongodb';
|
||||
} else {
|
||||
$this->resourceDbType = $dbType;
|
||||
}
|
||||
} else {
|
||||
$server = $resource->destination?->server;
|
||||
if (! $server) {
|
||||
abort(404, 'Server not found for this database.');
|
||||
}
|
||||
$this->serverId = $server->id;
|
||||
$this->container = $resource->uuid;
|
||||
$this->resourceUuid = $resource->uuid;
|
||||
$this->resourceDbType = $resource->type();
|
||||
}
|
||||
|
||||
if (str($resource->status)->startsWith('running')) {
|
||||
$this->containers[] = $this->container;
|
||||
}
|
||||
|
||||
if (
|
||||
$resource->getMorphClass() === StandaloneRedis::class ||
|
||||
$resource->getMorphClass() === StandaloneKeydb::class ||
|
||||
$resource->getMorphClass() === StandaloneDragonfly::class ||
|
||||
$resource->getMorphClass() === StandaloneClickhouse::class
|
||||
) {
|
||||
$this->unsupported = true;
|
||||
}
|
||||
|
||||
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
|
||||
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||||
$dbType = $resource->databaseType();
|
||||
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
|
||||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
|
||||
$this->unsupported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function checkFile()
|
||||
{
|
||||
if (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$escapedPath = escapeshellarg($this->customLocation);
|
||||
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||
if (blank($result)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->filename = $this->customLocation;
|
||||
$this->dispatch('success', 'The file exists.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function runImport(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->filename === '') {
|
||||
$this->dispatch('error', 'Please select a file to import.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||||
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return true;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
if (! empty($this->importCommands)) {
|
||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||||
'scriptPath' => $scriptPath,
|
||||
'tmpPath' => $tmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
$this->filename = null;
|
||||
$this->importCommands = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get()
|
||||
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3Path($value)
|
||||
{
|
||||
// Reset validation state when path changes
|
||||
$this->s3FileSize = null;
|
||||
|
||||
// Ensure path starts with a slash
|
||||
if ($value !== null && $value !== '') {
|
||||
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3StorageId()
|
||||
{
|
||||
// Reset validation state when storage changes
|
||||
$this->s3FileSize = null;
|
||||
}
|
||||
|
||||
public function checkS3File()
|
||||
{
|
||||
if (! $this->s3StorageId) {
|
||||
$this->dispatch('error', 'Please select an S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please provide an S3 path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean the path (remove leading slash if present)
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
// Validate bucket name early
|
||||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
$s3Storage->testConnection();
|
||||
|
||||
// Build S3 disk configuration
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'region' => $s3Storage->region,
|
||||
'key' => $s3Storage->key,
|
||||
'secret' => $s3Storage->secret,
|
||||
'bucket' => $s3Storage->bucket,
|
||||
'endpoint' => $s3Storage->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
// Check if file exists
|
||||
if (! $disk->exists($cleanPath)) {
|
||||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file size
|
||||
$this->s3FileSize = $disk->size($cleanPath);
|
||||
|
||||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||
} catch (\Throwable $e) {
|
||||
$this->s3FileSize = null;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function restoreFromS3(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path to prevent command injection
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
|
||||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||||
} else {
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
}
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resourceUuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
|
||||
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
|
||||
$escapedScriptPath = escapeshellarg($scriptPath);
|
||||
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
|
||||
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
|
||||
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
|
||||
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
|
||||
|
||||
// Prepare all commands in sequence
|
||||
$commands = [];
|
||||
|
||||
// 1. Clean up any existing helper container and temp files from previous runs
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
|
||||
|
||||
// 2. Start helper container on the database network
|
||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||
|
||||
// 3. Configure S3 access in helper container
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||||
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
|
||||
// 8. Build and execute restore command inside database container
|
||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
|
||||
$commands[] = "chmod +x {$escapedScriptPath}";
|
||||
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
|
||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'containerName' => $containerName,
|
||||
'serverTmpPath' => $serverTmpPath,
|
||||
'scriptPath' => $scriptPath,
|
||||
'containerTmpPath' => $containerTmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$morphClass = 'mongodb';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||||
} else {
|
||||
$restoreCommand .= " {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMongodb::class:
|
||||
case 'mongodb':
|
||||
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -36,30 +34,27 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
||||
public ?string $dbUrlPublic = null;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public bool $enable_ssl = false;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
$team = $user->currentTeam();
|
||||
if (! $team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -74,12 +69,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -87,36 +76,35 @@ class General extends Component
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$baseRules = [
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'keydbConf' => 'nullable|string',
|
||||
'keydbPassword' => 'required|string',
|
||||
'keydbPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
|
||||
),
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'enable_ssl' => 'boolean',
|
||||
];
|
||||
|
||||
return $baseRules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'keydbPassword.required' => 'The KeyDB Password field is required.',
|
||||
'keydbPassword.string' => 'The KeyDB Password must be a string.',
|
||||
...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'),
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'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.',
|
||||
]
|
||||
@@ -134,15 +122,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
$this->database->save();
|
||||
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
@@ -155,9 +139,6 @@ class General extends Component
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->enable_ssl = $this->database->enable_ssl;
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +187,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -214,9 +196,13 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function databaseProxyStopped()
|
||||
public function databaseProxyStopped(): void
|
||||
{
|
||||
$this->syncData();
|
||||
$this->database->refresh();
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->dispatch('databaseUpdated');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -224,11 +210,15 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('manageEnvironment', $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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -240,51 +230,9 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSaveSSL()
|
||||
public function refresh(): void
|
||||
{
|
||||
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();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
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 regenerated. Restart database to apply changes.');
|
||||
} catch (Exception $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Keydb;
|
||||
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneKeydb $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'KeyDB';
|
||||
}
|
||||
|
||||
protected function showPublicUrlPlaceholder(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mariadb;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneMariadb;
|
||||
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
|
||||
@@ -42,49 +39,39 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mariadbRootPassword' => 'required',
|
||||
'mariadbUser' => 'required',
|
||||
'mariadbPassword' => 'required',
|
||||
'mariadbDatabase' => 'required',
|
||||
'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password,
|
||||
),
|
||||
'mariadbUser' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mariadbUser !== $this->database->mariadb_user,
|
||||
),
|
||||
'mariadbPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password,
|
||||
),
|
||||
'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database,
|
||||
),
|
||||
'mariadbConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,14 +79,17 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mariadbRootPassword.required' => 'The Root Password field is required.',
|
||||
'mariadbUser.required' => 'The MariaDB User field is required.',
|
||||
'mariadbPassword.required' => 'The MariaDB Password field is required.',
|
||||
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
|
||||
...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'),
|
||||
...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'),
|
||||
...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'),
|
||||
...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB 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.',
|
||||
]
|
||||
@@ -120,7 +110,6 @@ class General extends Component
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -134,12 +123,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -159,15 +142,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$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->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;
|
||||
@@ -183,9 +162,6 @@ class General extends Component
|
||||
$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->db_url = $this->database->internal_db_url;
|
||||
$this->db_url_public = $this->database->external_db_url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,11 +189,15 @@ class General extends Component
|
||||
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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -254,6 +234,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -262,52 +243,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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 refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Mariadb;
|
||||
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneMariadb $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'MariaDB';
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mongodb;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneMongodb;
|
||||
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
|
||||
@@ -40,51 +37,36 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $sslMode = null;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mongoConf' => 'nullable',
|
||||
'mongoInitdbRootUsername' => 'required',
|
||||
'mongoInitdbRootPassword' => 'required',
|
||||
'mongoInitdbDatabase' => 'required',
|
||||
'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username,
|
||||
),
|
||||
'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password,
|
||||
),
|
||||
'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database,
|
||||
),
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'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-full',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,16 +74,18 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
|
||||
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
|
||||
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
|
||||
...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'),
|
||||
...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'),
|
||||
...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB 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-full.',
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -119,8 +103,6 @@ class General extends Component
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -134,12 +116,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -158,16 +134,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$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;
|
||||
@@ -182,10 +153,6 @@ class General extends Component
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +180,9 @@ class General extends Component
|
||||
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;
|
||||
}
|
||||
@@ -221,6 +191,7 @@ class General extends Component
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -257,6 +228,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -265,57 +237,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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 refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Mongodb;
|
||||
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneMongodb $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'Mongo';
|
||||
}
|
||||
|
||||
protected function sslModeOptions(): array
|
||||
{
|
||||
return [
|
||||
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
|
||||
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
|
||||
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
|
||||
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function sslModeHelper(): string
|
||||
{
|
||||
return 'Choose the SSL verification mode for MongoDB connections';
|
||||
}
|
||||
|
||||
protected function afterRefresh(): void
|
||||
{
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
}
|
||||
|
||||
protected function applyExtraSslAttributes(): void
|
||||
{
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
}
|
||||
|
||||
public function updatedSslMode(): void
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneMysql;
|
||||
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
|
||||
@@ -42,52 +39,39 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?string $sslMode = null;
|
||||
|
||||
public ?string $db_url = null;
|
||||
|
||||
public ?string $db_url_public = null;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'mysqlRootPassword' => 'required',
|
||||
'mysqlUser' => 'required',
|
||||
'mysqlPassword' => 'required',
|
||||
'mysqlDatabase' => 'required',
|
||||
'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password,
|
||||
),
|
||||
'mysqlUser' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mysqlUser !== $this->database->mysql_user,
|
||||
),
|
||||
'mysqlPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->mysqlPassword !== $this->database->mysql_password,
|
||||
),
|
||||
'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database,
|
||||
),
|
||||
'mysqlConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,17 +79,19 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mysqlRootPassword.required' => 'The Root Password field is required.',
|
||||
'mysqlUser.required' => 'The MySQL User field is required.',
|
||||
'mysqlPassword.required' => 'The MySQL Password field is required.',
|
||||
'mysqlDatabase.required' => 'The MySQL Database field is required.',
|
||||
...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'),
|
||||
...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'),
|
||||
...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'),
|
||||
...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL 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: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -124,8 +110,6 @@ class General extends Component
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -139,12 +123,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -164,16 +142,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$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;
|
||||
@@ -189,10 +162,6 @@ class General extends Component
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,11 +189,15 @@ class General extends Component
|
||||
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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -261,6 +234,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -269,57 +243,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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 refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Mysql;
|
||||
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneMysql $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'MySQL';
|
||||
}
|
||||
|
||||
protected function sslModeOptions(): array
|
||||
{
|
||||
return [
|
||||
'PREFERRED' => ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
|
||||
'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
|
||||
'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
|
||||
'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function sslModeHelper(): string
|
||||
{
|
||||
return 'Choose the SSL verification mode for MySQL connections';
|
||||
}
|
||||
|
||||
protected function afterRefresh(): void
|
||||
{
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
}
|
||||
|
||||
protected function applyExtraSslAttributes(): void
|
||||
{
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
}
|
||||
|
||||
public function updatedSslMode(): void
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ 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
|
||||
@@ -46,60 +43,48 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
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();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'save_init_script',
|
||||
'delete_init_script',
|
||||
];
|
||||
}
|
||||
protected $listeners = [
|
||||
'save_init_script',
|
||||
'delete_init_script',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'postgresUser' => 'required',
|
||||
'postgresPassword' => 'required',
|
||||
'postgresDb' => 'required',
|
||||
'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' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -107,16 +92,18 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'postgresUser.required' => 'The Postgres User field is required.',
|
||||
'postgresPassword.required' => 'The Postgres Password field is required.',
|
||||
'postgresDb.required' => 'The Postgres Database 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.',
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -137,8 +124,6 @@ class General extends Component
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -152,12 +137,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -179,16 +158,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$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;
|
||||
@@ -206,10 +180,6 @@ class General extends Component
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,57 +202,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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 {
|
||||
@@ -308,6 +227,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -336,9 +256,14 @@ class General extends Component
|
||||
|
||||
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']}";
|
||||
// New filename is user-supplied — must be safe before accepting the rename.
|
||||
validateFilenameSafe($script['filename'], 'init script filename');
|
||||
|
||||
// Old filename may be a legacy value written before this validation existed.
|
||||
// basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
|
||||
// any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
|
||||
$old_filename = basename($oldScript['filename']);
|
||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}";
|
||||
$escapedOldPath = escapeshellarg($old_file_path);
|
||||
$delete_command = "rm -f {$escapedOldPath}";
|
||||
instant_remote_process([$delete_command], $this->server);
|
||||
@@ -382,9 +307,11 @@ class General extends Component
|
||||
$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']}";
|
||||
// Allow deletion of legacy rows with unsafe filenames so operators can clean up.
|
||||
// basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the
|
||||
// shell invocation safe regardless of the stored value.
|
||||
$safe_filename = basename($script['filename']);
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}";
|
||||
$escapedPath = escapeshellarg($file_path);
|
||||
|
||||
$command = "rm -f {$escapedPath}";
|
||||
@@ -421,8 +348,8 @@ class General extends Component
|
||||
]);
|
||||
|
||||
try {
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($this->new_filename, 'init script filename');
|
||||
// Validate filename to prevent path traversal and command injection
|
||||
validateFilenameSafe($this->new_filename, 'init script filename');
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
@@ -456,11 +383,15 @@ class General extends Component
|
||||
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.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -471,4 +402,10 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Postgresql;
|
||||
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandalonePostgresql $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'Postgres';
|
||||
}
|
||||
|
||||
protected function sslModeOptions(): array
|
||||
{
|
||||
return [
|
||||
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
|
||||
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
|
||||
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
|
||||
'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
|
||||
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function sslModeHelper(): string
|
||||
{
|
||||
return 'Choose the SSL verification mode for PostgreSQL connections';
|
||||
}
|
||||
|
||||
protected function afterRefresh(): void
|
||||
{
|
||||
$this->sslMode = $this->database->ssl_mode;
|
||||
}
|
||||
|
||||
protected function applyExtraSslAttributes(): void
|
||||
{
|
||||
$this->database->ssl_mode = $this->sslMode;
|
||||
}
|
||||
|
||||
public function updatedSslMode(): void
|
||||
{
|
||||
$this->instantSaveSSL();
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneRedis;
|
||||
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
|
||||
@@ -34,9 +31,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -48,23 +45,9 @@ class General extends Component
|
||||
|
||||
public string $redisVersion;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
||||
public ?string $dbUrlPublic = null;
|
||||
|
||||
public bool $enableSsl = false;
|
||||
|
||||
public ?Carbon $certificateValidUntil = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'envsUpdated' => 'refresh',
|
||||
];
|
||||
}
|
||||
protected $listeners = [
|
||||
'envsUpdated' => 'refresh',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
@@ -73,15 +56,18 @@ class General extends Component
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'redisConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'redisUsername' => 'required',
|
||||
'redisPassword' => 'required',
|
||||
'enableSsl' => 'boolean',
|
||||
'redisUsername' => ValidationPatterns::databaseIdentifierRules(
|
||||
enforcePattern: $this->redisUsername !== $this->database->redis_username,
|
||||
),
|
||||
'redisPassword' => ValidationPatterns::databasePasswordRules(
|
||||
enforcePattern: $this->redisPassword !== $this->database->redis_password,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -89,14 +75,17 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'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.',
|
||||
'redisUsername.required' => 'The Redis Username field is required.',
|
||||
'redisPassword.required' => 'The Redis Password field is required.',
|
||||
...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'),
|
||||
...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -113,7 +102,6 @@ class General extends Component
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'redisUsername' => 'Redis Username',
|
||||
'redisPassword' => 'Redis Password',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -127,12 +115,6 @@ class General extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
if ($existingCert) {
|
||||
$this->certificateValidUntil = $existingCert->valid_until;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -148,15 +130,11 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$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->save();
|
||||
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
} else {
|
||||
$this->name = $this->database->name;
|
||||
$this->description = $this->database->description;
|
||||
@@ -168,9 +146,6 @@ class General extends Component
|
||||
$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->dbUrl = $this->database->internal_db_url;
|
||||
$this->dbUrlPublic = $this->database->external_db_url;
|
||||
$this->redisVersion = $this->database->getRedisVersion();
|
||||
$this->redisUsername = $this->database->redis_username;
|
||||
$this->redisPassword = $this->database->redis_password;
|
||||
@@ -201,6 +176,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('manageEnvironment', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
$this->syncData(true);
|
||||
|
||||
if (version_compare($this->redisVersion, '6.0', '>=')) {
|
||||
@@ -215,6 +193,7 @@ class General extends Component
|
||||
);
|
||||
|
||||
$this->dispatch('success', 'Database updated.');
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
@@ -247,6 +226,7 @@ class General extends Component
|
||||
StopDatabaseProxy::run($this->database);
|
||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
@@ -255,52 +235,6 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
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 regenerated. Restart database to apply changes.');
|
||||
} catch (Exception $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database\Redis;
|
||||
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Traits\HasDatabaseStatusInfo;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class StatusInfo extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasDatabaseStatusInfo;
|
||||
|
||||
public StandaloneRedis $database;
|
||||
|
||||
protected function databaseLabel(): string
|
||||
{
|
||||
return 'Redis';
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ namespace App\Livewire\Project;
|
||||
|
||||
use App\Models\Environment;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeleteEnvironment extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public int $environment_id;
|
||||
|
||||
public bool $disabled = false;
|
||||
@@ -20,12 +22,8 @@ class DeleteEnvironment extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->environmentName = Environment::findOrFail($this->environment_id)->name;
|
||||
$this->parameters = get_route_parameters();
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
@@ -33,7 +31,7 @@ class DeleteEnvironment extends Component
|
||||
$this->validate([
|
||||
'environment_id' => 'required|int',
|
||||
]);
|
||||
$environment = Environment::findOrFail($this->environment_id);
|
||||
$environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
|
||||
$this->authorize('delete', $environment);
|
||||
|
||||
if ($environment->isEmpty()) {
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeleteProject extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->projectName = Project::findOrFail($this->project_id)->name;
|
||||
$this->projectName = Project::ownedByCurrentTeam()->findOrFail($this->project_id)->name;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
@@ -29,7 +29,7 @@ class DeleteProject extends Component
|
||||
$this->validate([
|
||||
'project_id' => 'required|int',
|
||||
]);
|
||||
$project = Project::findOrFail($this->project_id);
|
||||
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
|
||||
$this->authorize('delete', $project);
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -31,7 +29,6 @@ class DockerCompose extends Component
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$server_id = $this->query['server_id'];
|
||||
try {
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
@@ -41,23 +38,20 @@ class DockerCompose extends Component
|
||||
// Validate for command injection BEFORE saving to database
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw);
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$service = Service::create([
|
||||
'docker_compose_raw' => $this->dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
]);
|
||||
|
||||
@@ -4,9 +4,8 @@ namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Services\DockerImageParser;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -83,8 +82,8 @@ class DockerImage extends Component
|
||||
public function submit()
|
||||
{
|
||||
$this->validate([
|
||||
'imageName' => ['required', 'string'],
|
||||
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
|
||||
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||
'imageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
]);
|
||||
|
||||
@@ -111,18 +110,15 @@ class DockerImage extends Component
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
$imageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
@@ -5,11 +5,11 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class GithubPrivateRepository extends Component
|
||||
@@ -30,6 +30,7 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
public int $selected_repository_id;
|
||||
|
||||
#[Locked]
|
||||
public int $selected_github_app_id;
|
||||
|
||||
public string $selected_repository_owner;
|
||||
@@ -38,8 +39,6 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
public string $selected_branch_name = 'main';
|
||||
|
||||
public string $token;
|
||||
|
||||
public $repositories;
|
||||
|
||||
public int $total_repositories_count = 0;
|
||||
@@ -72,7 +71,10 @@ class GithubPrivateRepository extends Component
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->repositories = $this->branches = collect();
|
||||
$this->github_apps = GithubApp::private();
|
||||
$this->github_apps = GithubApp::ownedByCurrentTeam()
|
||||
->where('is_public', false)
|
||||
->whereNotNull('app_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function updatedSelectedRepositoryId(): void
|
||||
@@ -82,9 +84,11 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
public function updatedBuildPack()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
|
||||
$this->show_is_static = true;
|
||||
$this->port = 3000;
|
||||
if (! $this->is_static) {
|
||||
$this->port = 3000;
|
||||
}
|
||||
} elseif ($this->build_pack === 'static') {
|
||||
$this->show_is_static = false;
|
||||
$this->is_static = false;
|
||||
@@ -95,20 +99,25 @@ class GithubPrivateRepository extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function loadRepositories($github_app_id)
|
||||
public function loadRepositories(int $github_app_id): void
|
||||
{
|
||||
$this->repositories = collect();
|
||||
$this->branches = collect();
|
||||
$this->total_branches_count = 0;
|
||||
$this->page = 1;
|
||||
$this->selected_github_app_id = $github_app_id;
|
||||
$this->github_app = GithubApp::where('id', $github_app_id)->first();
|
||||
$this->token = generateGithubInstallationToken($this->github_app);
|
||||
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
|
||||
$this->github_app = GithubApp::ownedByCurrentTeam()
|
||||
->where('is_public', false)
|
||||
->whereNotNull('app_id')
|
||||
->findOrFail($github_app_id);
|
||||
$token = generateGithubInstallationToken($this->github_app);
|
||||
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
|
||||
$this->total_repositories_count = $repositories['total_count'];
|
||||
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
|
||||
if ($this->repositories->count() < $this->total_repositories_count) {
|
||||
while ($this->repositories->count() < $this->total_repositories_count) {
|
||||
$this->page++;
|
||||
$repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
|
||||
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
|
||||
$this->total_repositories_count = $repositories['total_count'];
|
||||
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
|
||||
}
|
||||
@@ -139,7 +148,9 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
protected function loadBranchByPage()
|
||||
{
|
||||
$response = Http::GitHub($this->github_app->api_url, $this->token)
|
||||
$token = generateGithubInstallationToken($this->github_app);
|
||||
|
||||
$response = Http::GitHub($this->github_app->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
|
||||
@@ -168,25 +179,22 @@ class GithubPrivateRepository extends Component
|
||||
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
|
||||
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
|
||||
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
|
||||
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
|
||||
|
||||
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -66,7 +65,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,9 +94,11 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
|
||||
public function updatedBuildPack()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
|
||||
$this->show_is_static = true;
|
||||
$this->port = 3000;
|
||||
if (! $this->is_static) {
|
||||
$this->port = 3000;
|
||||
}
|
||||
} elseif ($this->build_pack === 'static') {
|
||||
$this->show_is_static = false;
|
||||
$this->is_static = false;
|
||||
@@ -129,13 +130,10 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
{
|
||||
$this->validate();
|
||||
try {
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
@@ -144,8 +142,8 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
// Note: git_repository has already been validated and transformed in get_git_source()
|
||||
// It may now be in SSH format (git@host:repo.git) which is valid for deploy keys
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
if ($this->git_source === 'other') {
|
||||
$application_init = [
|
||||
'name' => generate_random_name(),
|
||||
|
||||
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -33,8 +32,6 @@ class PublicGitRepository extends Component
|
||||
|
||||
public bool $isStatic = false;
|
||||
|
||||
public bool $checkCoolifyConfig = true;
|
||||
|
||||
public ?string $publish_directory = null;
|
||||
|
||||
// In case of docker compose
|
||||
@@ -72,7 +69,7 @@ class PublicGitRepository extends Component
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
'git_branch' => ['required', 'string', new ValidGitBranch],
|
||||
];
|
||||
}
|
||||
@@ -99,9 +96,11 @@ class PublicGitRepository extends Component
|
||||
|
||||
public function updatedBuildPack()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
|
||||
$this->show_is_static = true;
|
||||
$this->port = 3000;
|
||||
if (! $this->isStatic) {
|
||||
$this->port = 3000;
|
||||
}
|
||||
} elseif ($this->build_pack === 'static') {
|
||||
$this->show_is_static = false;
|
||||
$this->isStatic = false;
|
||||
@@ -207,13 +206,8 @@ class PublicGitRepository extends Component
|
||||
|
||||
if ($this->repository_url_parsed->getSegment(3) === 'tree') {
|
||||
$path = str($this->repository_url_parsed->getPath())->trim('/');
|
||||
$this->git_branch = str($path)->after('tree/')->before('/')->value();
|
||||
$this->base_directory = str($path)->after($this->git_branch)->after('/')->value();
|
||||
if (filled($this->base_directory)) {
|
||||
$this->base_directory = '/'.$this->base_directory;
|
||||
} else {
|
||||
$this->base_directory = '/';
|
||||
}
|
||||
$this->git_branch = str($path)->after('tree/')->value();
|
||||
$this->base_directory = '/';
|
||||
} else {
|
||||
$this->git_branch = 'main';
|
||||
}
|
||||
@@ -233,10 +227,33 @@ class PublicGitRepository extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->git_source->getMorphClass() === \App\Models\GithubApp::class) {
|
||||
['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$this->git_branch}");
|
||||
$this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
|
||||
$this->branchFound = true;
|
||||
if ($this->git_source->getMorphClass() === GithubApp::class) {
|
||||
$originalBranch = $this->git_branch;
|
||||
$branchToTry = $originalBranch;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$encodedBranch = urlencode($branchToTry);
|
||||
['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$encodedBranch}");
|
||||
$this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
|
||||
$this->git_branch = $branchToTry;
|
||||
|
||||
$remaining = str($originalBranch)->after($branchToTry)->trim('/')->value();
|
||||
$this->base_directory = filled($remaining) ? '/'.$remaining : '/';
|
||||
|
||||
$this->branchFound = true;
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
if (str_contains($branchToTry, '/')) {
|
||||
$branchToTry = str($branchToTry)->beforeLast('/')->value();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,21 +282,18 @@ class PublicGitRepository extends Component
|
||||
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$project_uuid = $this->parameters['project_uuid'];
|
||||
$environment_uuid = $this->parameters['environment_uuid'];
|
||||
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$project = Project::where('uuid', $project_uuid)->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
|
||||
|
||||
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
|
||||
$server = $destination->server;
|
||||
@@ -352,12 +366,6 @@ class PublicGitRepository extends Component
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
if ($this->checkCoolifyConfig) {
|
||||
// $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
|
||||
// if ($config) {
|
||||
// $application->setConfig($config);
|
||||
// }
|
||||
}
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
|
||||
@@ -65,7 +65,7 @@ class Select extends Component
|
||||
$this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432';
|
||||
}
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
$project = Project::whereUuid($projectUuid)->firstOrFail();
|
||||
$project = Project::ownedByCurrentTeam()->whereUuid($projectUuid)->firstOrFail();
|
||||
$this->environments = $project->environments;
|
||||
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
|
||||
|
||||
@@ -79,7 +79,7 @@ class Select extends Component
|
||||
$this->type = $queryType;
|
||||
$this->server_id = $queryServerId;
|
||||
$this->destination_uuid = $queryDestination;
|
||||
$this->server = Server::find($queryServerId);
|
||||
$this->server = Server::ownedByCurrentTeam()->find($queryServerId);
|
||||
$this->current_step = 'select-postgresql-type';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -35,18 +33,15 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
$this->validate([
|
||||
'dockerfile' => 'required',
|
||||
]);
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$port = get_port_from_dockerfile($this->dockerfile);
|
||||
if (! $port) {
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Resource;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
@@ -18,7 +17,6 @@ class Create extends Component
|
||||
|
||||
$type = str(request()->query('type'));
|
||||
$destination_uuid = request()->query('destination');
|
||||
$server_id = request()->query('server_id');
|
||||
$database_image = request()->query('database_image');
|
||||
|
||||
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
|
||||
@@ -30,7 +28,11 @@ class Create extends Component
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
if (isset($type) && isset($destination_uuid) && isset($server_id)) {
|
||||
if (isset($type) && isset($destination_uuid)) {
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$services = get_service_templates();
|
||||
|
||||
if (in_array($type, DATABASE_TYPES)) {
|
||||
@@ -44,23 +46,23 @@ class Create extends Component
|
||||
}
|
||||
$database = create_standalone_postgresql(
|
||||
environmentId: $environment->id,
|
||||
destinationUuid: $destination_uuid,
|
||||
destination: $destination,
|
||||
databaseImage: $database_image
|
||||
);
|
||||
} elseif ($type->value() === 'redis') {
|
||||
$database = create_standalone_redis($environment->id, $destination_uuid);
|
||||
$database = create_standalone_redis($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mongodb') {
|
||||
$database = create_standalone_mongodb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mongodb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mysql') {
|
||||
$database = create_standalone_mysql($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mysql($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mariadb') {
|
||||
$database = create_standalone_mariadb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mariadb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'keydb') {
|
||||
$database = create_standalone_keydb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_keydb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'dragonfly') {
|
||||
$database = create_standalone_dragonfly($environment->id, $destination_uuid);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination);
|
||||
} elseif ($type->value() === 'clickhouse') {
|
||||
$database = create_standalone_clickhouse($environment->id, $destination_uuid);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination);
|
||||
}
|
||||
|
||||
return redirect()->route('project.database.configuration', [
|
||||
@@ -69,7 +71,7 @@ class Create extends Component
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
|
||||
if ($type->startsWith('one-click-service-')) {
|
||||
$oneClickServiceName = $type->after('one-click-service-')->value();
|
||||
$oneClickService = data_get($services, "$oneClickServiceName.compose");
|
||||
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
|
||||
@@ -79,12 +81,11 @@ class Create extends Component
|
||||
});
|
||||
}
|
||||
if ($oneClickService) {
|
||||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first();
|
||||
$service_payload = [
|
||||
'docker_compose_raw' => base64_decode($oneClickService),
|
||||
'environment_id' => $environment->id,
|
||||
'service_type' => $oneClickServiceName,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class Configuration extends Component
|
||||
@@ -27,16 +26,10 @@ class Configuration extends Component
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
}
|
||||
protected $listeners = [
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
|
||||
public function render()
|
||||
{
|
||||
@@ -51,7 +44,7 @@ class Configuration extends Component
|
||||
$this->query = request()->query();
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->select('id', 'uuid', 'name', 'team_id')
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
@@ -105,18 +98,4 @@ class Configuration extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function serviceChecked()
|
||||
{
|
||||
try {
|
||||
$this->service->applications->each(function ($application) {
|
||||
$application->refresh();
|
||||
});
|
||||
$this->service->databases->each(function ($database) {
|
||||
$database->refresh();
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,16 @@ class DatabaseBackups extends Component
|
||||
try {
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
|
||||
if (! $this->service) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->where('uuid', $this->parameters['project_uuid'])
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->where('uuid', $this->parameters['environment_uuid'])
|
||||
->firstOrFail();
|
||||
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->service);
|
||||
|
||||
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
|
||||
|
||||
@@ -63,13 +63,16 @@ class FileStorage extends Component
|
||||
$this->fs_path = $this->fileStorage->fs_path;
|
||||
}
|
||||
|
||||
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
|
||||
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
return;
|
||||
}
|
||||
$this->validate();
|
||||
|
||||
// Sync to model
|
||||
@@ -172,6 +175,12 @@ class FileStorage extends Component
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$original = $this->fileStorage->getOriginal();
|
||||
try {
|
||||
$this->validate();
|
||||
@@ -197,6 +206,11 @@ class FileStorage extends Component
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'File updated.');
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class Heading extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public array $parameters;
|
||||
@@ -27,6 +30,8 @@ class Heading extends Component
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorizeService('view');
|
||||
|
||||
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
|
||||
$this->service->isConfigurationChanged(true);
|
||||
$this->dispatch('configurationChanged');
|
||||
@@ -47,6 +52,8 @@ class Heading extends Component
|
||||
|
||||
public function checkStatus()
|
||||
{
|
||||
$this->authorizeService('view');
|
||||
|
||||
if ($this->service->server->isFunctional()) {
|
||||
GetContainersStatus::dispatch($this->service->server);
|
||||
} else {
|
||||
@@ -61,6 +68,8 @@ class Heading extends Component
|
||||
|
||||
public function serviceChecked()
|
||||
{
|
||||
$this->authorizeService('view');
|
||||
|
||||
try {
|
||||
$this->service->applications->each(function ($application) {
|
||||
$application->refresh();
|
||||
@@ -82,6 +91,8 @@ class Heading extends Component
|
||||
|
||||
public function checkDeployments()
|
||||
{
|
||||
$this->authorizeService('view');
|
||||
|
||||
try {
|
||||
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
|
||||
$status = data_get($activity, 'properties.status');
|
||||
@@ -99,12 +110,16 @@ class Heading extends Component
|
||||
|
||||
public function start()
|
||||
{
|
||||
$this->authorizeService('deploy');
|
||||
|
||||
$activity = StartService::run($this->service, pullLatestImages: true);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
}
|
||||
|
||||
public function forceDeploy()
|
||||
{
|
||||
$this->authorizeService('deploy');
|
||||
|
||||
try {
|
||||
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
|
||||
->where(function ($q) {
|
||||
@@ -124,6 +139,8 @@ class Heading extends Component
|
||||
|
||||
public function stop()
|
||||
{
|
||||
$this->authorizeService('stop');
|
||||
|
||||
try {
|
||||
StopService::dispatch($this->service, false, $this->docker_cleanup);
|
||||
} catch (\Exception $e) {
|
||||
@@ -133,6 +150,8 @@ class Heading extends Component
|
||||
|
||||
public function restart()
|
||||
{
|
||||
$this->authorizeService('deploy');
|
||||
|
||||
$this->checkDeployments();
|
||||
if ($this->isDeploymentProgress) {
|
||||
$this->dispatch('error', 'There is a deployment in progress.');
|
||||
@@ -145,6 +164,8 @@ class Heading extends Component
|
||||
|
||||
public function pullAndRestartEvent()
|
||||
{
|
||||
$this->authorizeService('deploy');
|
||||
|
||||
$this->checkDeployments();
|
||||
if ($this->isDeploymentProgress) {
|
||||
$this->dispatch('error', 'There is a deployment in progress.');
|
||||
@@ -155,6 +176,15 @@ class Heading extends Component
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
}
|
||||
|
||||
private function authorizeService(string $ability): void
|
||||
{
|
||||
$this->service = Service::ownedByCurrentTeam()
|
||||
->whereKey($this->service->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize($ability, $this->service);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.heading', [
|
||||
|
||||
@@ -51,9 +51,9 @@ class Index extends Component
|
||||
|
||||
public bool $excludeFromStatus = false;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isPublic = false;
|
||||
|
||||
@@ -91,7 +91,7 @@ class Index extends Component
|
||||
'description' => 'nullable',
|
||||
'image' => 'required',
|
||||
'excludeFromStatus' => 'required|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isPublic' => 'required|boolean',
|
||||
'isLogDrainEnabled' => 'required|boolean',
|
||||
@@ -108,10 +108,16 @@ class Index extends Component
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
|
||||
if (! $this->service) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$project = currentTeam()
|
||||
->projects()
|
||||
->select('id', 'uuid', 'team_id')
|
||||
->where('uuid', $this->parameters['project_uuid'])
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->where('uuid', $this->parameters['environment_uuid'])
|
||||
->firstOrFail();
|
||||
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('view', $this->service);
|
||||
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
|
||||
if ($service) {
|
||||
@@ -160,8 +166,8 @@ class Index extends Component
|
||||
$this->serviceDatabase->description = $this->description;
|
||||
$this->serviceDatabase->image = $this->image;
|
||||
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
|
||||
$this->serviceDatabase->public_port = $this->publicPort;
|
||||
$this->serviceDatabase->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->serviceDatabase->public_port = $this->publicPort ?: null;
|
||||
$this->serviceDatabase->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->serviceDatabase->is_public = $this->isPublic;
|
||||
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResourceCard extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public ServiceApplication|ServiceDatabase $resource;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$team = $user->currentTeam();
|
||||
if (! $team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
"echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshResource(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
}
|
||||
|
||||
public function restart(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->service);
|
||||
$this->resource->restart();
|
||||
$message = $this->resource instanceof ServiceApplication
|
||||
? 'Service application restarted successfully.'
|
||||
: 'Service database restarted successfully.';
|
||||
$this->dispatch('success', $message);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.service.resource-card', [
|
||||
'isApplication' => $this->resource instanceof ServiceApplication,
|
||||
'isDatabase' => $this->resource instanceof ServiceDatabase,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -49,7 +52,7 @@ class Storage extends Component
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
if ($this->resource->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
}
|
||||
@@ -66,7 +69,11 @@ class Storage extends Component
|
||||
|
||||
public function refreshStorages()
|
||||
{
|
||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
|
||||
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
|
||||
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
}
|
||||
});
|
||||
$this->resource->load('persistentStorages.resource');
|
||||
}
|
||||
|
||||
@@ -101,10 +108,14 @@ class Storage extends Component
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
|
||||
]);
|
||||
'host_path' => $this->isSwarm
|
||||
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
|
||||
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
], array_merge(ValidationPatterns::volumeNameMessages(), [
|
||||
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]));
|
||||
|
||||
$name = $this->resource->uuid.'-'.$this->name;
|
||||
|
||||
@@ -138,7 +149,10 @@ class Storage extends Component
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
// Validate path to prevent command injection
|
||||
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;
|
||||
@@ -146,7 +160,7 @@ class Storage extends Component
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
@@ -183,7 +197,7 @@ class Storage extends Component
|
||||
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
||||
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
|
||||
@@ -12,15 +12,18 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class ConfigurationChecker extends Component
|
||||
{
|
||||
public bool $isConfigurationChanged = false;
|
||||
|
||||
public array $configurationDiff = [];
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
@@ -30,18 +33,71 @@ class ConfigurationChecker extends Component
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.configuration-checker');
|
||||
}
|
||||
|
||||
public function configurationChanged()
|
||||
public function refreshConfigurationChanges(): void
|
||||
{
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Members must never see environment variable values, so redact every
|
||||
* environment-section change before it is serialized to the browser.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function redactEnvironmentChanges(array $changes, bool $redact): array
|
||||
{
|
||||
if (! $redact) {
|
||||
return $changes;
|
||||
}
|
||||
|
||||
return collect($changes)
|
||||
->map(function (array $change): array {
|
||||
if (data_get($change, 'section') !== 'environment') {
|
||||
return $change;
|
||||
}
|
||||
|
||||
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['old_full_value'] = null;
|
||||
$change['new_full_value'] = null;
|
||||
$change['expandable'] = false;
|
||||
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
|
||||
|
||||
return $change;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
if ($this->resource instanceof Application) {
|
||||
$diff = $this->resource->pendingDeploymentConfigurationDiff();
|
||||
// Fail closed: only owners/admins may see unlocked env values.
|
||||
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
|
||||
|
||||
$array = $diff->toArray();
|
||||
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
|
||||
|
||||
$this->isConfigurationChanged = $diff->isChanged();
|
||||
$this->configurationDiff = $array;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
|
||||
$this->configurationDiff = [];
|
||||
}
|
||||
}
|
||||
@@ -110,15 +110,27 @@ class Destination extends Component
|
||||
|
||||
public function promote(int $network_id, int $server_id)
|
||||
{
|
||||
$main_destination = $this->resource->destination;
|
||||
$this->resource->update([
|
||||
'destination_id' => $network_id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
|
||||
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
|
||||
$this->refreshServers();
|
||||
$this->resource->refresh();
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->resource->getConnection()->transaction(function () use ($network, $server) {
|
||||
$main_destination = $this->resource->destination;
|
||||
$this->resource->update([
|
||||
'destination_id' => $network->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
$this->resource->additional_networks()
|
||||
->wherePivot('server_id', $server->id)
|
||||
->detach($network->id);
|
||||
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
|
||||
});
|
||||
$this->resource->refresh();
|
||||
$this->refreshServers();
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshServers()
|
||||
@@ -130,8 +142,16 @@ class Destination extends Component
|
||||
|
||||
public function addServer(int $network_id, int $server_id)
|
||||
{
|
||||
$this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
|
||||
$this->dispatch('refresh');
|
||||
try {
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
|
||||
$this->dispatch('refresh');
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
|
||||
@@ -148,7 +168,9 @@ class Destination extends Component
|
||||
}
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
|
||||
StopApplicationOneServer::run($this->resource, $server);
|
||||
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
|
||||
$this->resource->additional_networks()
|
||||
->wherePivot('server_id', $server_id)
|
||||
->detach($network_id);
|
||||
$this->loadData();
|
||||
$this->dispatch('refresh');
|
||||
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -37,15 +42,23 @@ class Add extends Component
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
];
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'nullable',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return ValidationPatterns::environmentVariableKeyMessages('key');
|
||||
}
|
||||
|
||||
protected $validationAttributes = [
|
||||
'key' => 'key',
|
||||
@@ -71,6 +84,7 @@ class Add extends Component
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
'server' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
@@ -84,7 +98,7 @@ class Add extends Component
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
@@ -115,22 +129,83 @@ class Add extends Component
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get server variables
|
||||
$serverUuid = data_get($this->parameters, 'server_uuid');
|
||||
if ($serverUuid) {
|
||||
// If we have a specific server_uuid, show variables for that server
|
||||
$server = Server::where('team_id', $team->id)
|
||||
->where('uuid', $serverUuid)
|
||||
->first();
|
||||
|
||||
if ($server) {
|
||||
try {
|
||||
$this->authorize('view', $server);
|
||||
$result['server'] = $server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For application environment variables, try to use the application's destination server
|
||||
$applicationUuid = data_get($this->parameters, 'application_uuid');
|
||||
if ($applicationUuid) {
|
||||
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
|
||||
->where('uuid', $applicationUuid)
|
||||
->with('destination.server')
|
||||
->first();
|
||||
|
||||
if ($application && $application->destination && $application->destination->server) {
|
||||
try {
|
||||
$this->authorize('view', $application->destination->server);
|
||||
$result['server'] = $application->destination->server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For service environment variables, try to use the service's server
|
||||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||||
if ($serviceUuid) {
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
|
||||
->where('uuid', $serviceUuid)
|
||||
->with('server')
|
||||
->first();
|
||||
|
||||
if ($service && $service->server) {
|
||||
try {
|
||||
$this->authorize('view', $service->server);
|
||||
$result['server'] = $service->server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
|
||||
$this->validate();
|
||||
$this->dispatch('saveKey', [
|
||||
'key' => $this->key,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -38,7 +40,7 @@ class All extends Component
|
||||
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
|
||||
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
|
||||
$this->resourceClass = get_class($this->resource);
|
||||
$resourceWithPreviews = [\App\Models\Application::class];
|
||||
$resourceWithPreviews = [Application::class];
|
||||
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
|
||||
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
||||
$this->showPreview = true;
|
||||
@@ -194,7 +196,7 @@ class All extends Component
|
||||
|
||||
private function updateOrder()
|
||||
{
|
||||
$variables = parseEnvFormatToArray($this->variables);
|
||||
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
||||
$order = 1;
|
||||
foreach ($variables as $key => $value) {
|
||||
$env = $this->resource->environment_variables()->where('key', $key)->first();
|
||||
@@ -206,7 +208,7 @@ class All extends Component
|
||||
}
|
||||
|
||||
if ($this->showPreview) {
|
||||
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
|
||||
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
|
||||
$order = 1;
|
||||
foreach ($previewVariables as $key => $value) {
|
||||
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
|
||||
@@ -221,7 +223,7 @@ class All extends Component
|
||||
|
||||
private function handleBulkSubmit()
|
||||
{
|
||||
$variables = parseEnvFormatToArray($this->variables);
|
||||
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
||||
$changesMade = false;
|
||||
$errorOccurred = false;
|
||||
|
||||
@@ -241,7 +243,7 @@ class All extends Component
|
||||
}
|
||||
|
||||
if ($this->showPreview) {
|
||||
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
|
||||
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
|
||||
|
||||
// Try to delete removed preview variables
|
||||
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
|
||||
@@ -267,6 +269,7 @@ class All extends Component
|
||||
|
||||
private function handleSingleSubmit($data)
|
||||
{
|
||||
$data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
|
||||
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Environment variable already exists.');
|
||||
@@ -334,6 +337,23 @@ class All extends Component
|
||||
return $variablesToDelete->count();
|
||||
}
|
||||
|
||||
private function normalizeEnvironmentVariables(array $variables): array
|
||||
{
|
||||
$normalizedVariables = [];
|
||||
|
||||
foreach ($variables as $key => $data) {
|
||||
$normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
|
||||
|
||||
if (array_key_exists($normalizedKey, $normalizedVariables)) {
|
||||
throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
|
||||
}
|
||||
|
||||
$normalizedVariables[$normalizedKey] = $data;
|
||||
}
|
||||
|
||||
return $normalizedVariables;
|
||||
}
|
||||
|
||||
private function updateOrCreateVariables($isPreview, $variables)
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -64,23 +69,31 @@ class Show extends Component
|
||||
'compose_loaded' => '$refresh',
|
||||
];
|
||||
|
||||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'real_value' => 'nullable',
|
||||
'is_required' => 'required|boolean',
|
||||
];
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'real_value' => 'nullable',
|
||||
'is_required' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return ValidationPatterns::environmentVariableKeyMessages('key');
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->syncData();
|
||||
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
|
||||
if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
|
||||
$this->isSharedVariable = true;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
@@ -108,9 +121,11 @@ class Show extends Component
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
|
||||
|
||||
if ($this->isSharedVariable) {
|
||||
$this->validate([
|
||||
'key' => 'required|string',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
@@ -219,6 +234,7 @@ class Show extends Component
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
'server' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
@@ -232,7 +248,7 @@ class Show extends Component
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
@@ -263,17 +279,77 @@ class Show extends Component
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get server variables
|
||||
$serverUuid = data_get($this->parameters, 'server_uuid');
|
||||
if ($serverUuid) {
|
||||
// If we have a specific server_uuid, show variables for that server
|
||||
$server = Server::where('team_id', $team->id)
|
||||
->where('uuid', $serverUuid)
|
||||
->first();
|
||||
|
||||
if ($server) {
|
||||
try {
|
||||
$this->authorize('view', $server);
|
||||
$result['server'] = $server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For application environment variables, try to use the application's destination server
|
||||
$applicationUuid = data_get($this->parameters, 'application_uuid');
|
||||
if ($applicationUuid) {
|
||||
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
|
||||
->where('uuid', $applicationUuid)
|
||||
->with('destination.server')
|
||||
->first();
|
||||
|
||||
if ($application && $application->destination && $application->destination->server) {
|
||||
try {
|
||||
$this->authorize('view', $application->destination->server);
|
||||
$result['server'] = $application->destination->server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For service environment variables, try to use the service's server
|
||||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||||
if ($serviceUuid) {
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
|
||||
->where('uuid', $serviceUuid)
|
||||
->with('server')
|
||||
->first();
|
||||
|
||||
if ($service && $service->server) {
|
||||
try {
|
||||
$this->authorize('view', $service->server);
|
||||
$result['server'] = $service->server->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User not authorized to view server variables
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class GetLogs extends Component
|
||||
@@ -29,12 +31,16 @@ class GetLogs extends Component
|
||||
|
||||
public string $errors = '';
|
||||
|
||||
#[Locked]
|
||||
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null;
|
||||
|
||||
#[Locked]
|
||||
public ServiceApplication|ServiceDatabase|null $servicesubtype = null;
|
||||
|
||||
#[Locked]
|
||||
public Server $server;
|
||||
|
||||
#[Locked]
|
||||
public ?string $container = null;
|
||||
|
||||
public ?string $displayName = null;
|
||||
@@ -54,7 +60,7 @@ class GetLogs extends Component
|
||||
public function mount()
|
||||
{
|
||||
if (! is_null($this->resource)) {
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$this->showTimeStamps = $this->resource->settings->is_include_timestamps;
|
||||
} else {
|
||||
if ($this->servicesubtype) {
|
||||
@@ -63,7 +69,7 @@ class GetLogs extends Component
|
||||
$this->showTimeStamps = $this->resource->is_include_timestamps;
|
||||
}
|
||||
}
|
||||
if ($this->resource?->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource?->getMorphClass() === Application::class) {
|
||||
if (str($this->container)->contains('-pr-')) {
|
||||
$this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value();
|
||||
}
|
||||
@@ -74,11 +80,11 @@ class GetLogs extends Component
|
||||
public function instantSave()
|
||||
{
|
||||
if (! is_null($this->resource)) {
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$this->resource->settings->is_include_timestamps = $this->showTimeStamps;
|
||||
$this->resource->settings->save();
|
||||
}
|
||||
if ($this->resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($this->resource->getMorphClass() === Service::class) {
|
||||
$serviceName = str($this->container)->beforeLast('-')->value();
|
||||
$subType = $this->resource->applications()->where('name', $serviceName)->first();
|
||||
if ($subType) {
|
||||
@@ -118,10 +124,20 @@ class GetLogs extends Component
|
||||
|
||||
public function getLogs($refresh = false)
|
||||
{
|
||||
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
|
||||
$this->outputs = 'Unauthorized.';
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->server->isFunctional()) {
|
||||
return;
|
||||
}
|
||||
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) {
|
||||
if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
$this->outputs = 'Invalid container name.';
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) {
|
||||
return;
|
||||
}
|
||||
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
|
||||
@@ -194,9 +210,15 @@ class GetLogs extends Component
|
||||
|
||||
public function downloadAllLogs(): string
|
||||
{
|
||||
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
|
||||
return '';
|
||||
}
|
||||
if (! $this->server->isFunctional() || ! $this->container) {
|
||||
return '';
|
||||
}
|
||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($this->showTimeStamps) {
|
||||
if ($this->server->isSwarm()) {
|
||||
|
||||
@@ -34,7 +34,7 @@ class HealthChecks extends Component
|
||||
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
|
||||
public ?string $healthCheckPort = null;
|
||||
|
||||
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
|
||||
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'])]
|
||||
public string $healthCheckPath;
|
||||
|
||||
#[Validate(['integer'])]
|
||||
@@ -62,7 +62,7 @@ class HealthChecks extends Component
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResourceDetails extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public ?string $project_uuid = null;
|
||||
|
||||
public ?string $project_name = null;
|
||||
|
||||
public ?string $environment_uuid = null;
|
||||
|
||||
public ?string $environment_name = null;
|
||||
|
||||
public ?string $server_uuid = null;
|
||||
|
||||
public ?string $server_name = null;
|
||||
|
||||
public array $stack_applications = [];
|
||||
|
||||
public array $stack_databases = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
|
||||
$environment = $this->resource->environment ?? null;
|
||||
if ($environment) {
|
||||
$this->environment_uuid = $environment->uuid;
|
||||
$this->environment_name = $environment->name;
|
||||
$project = $environment->project ?? null;
|
||||
if ($project) {
|
||||
$this->project_uuid = $project->uuid;
|
||||
$this->project_name = $project->name;
|
||||
}
|
||||
}
|
||||
|
||||
$server = $this->resolveServer();
|
||||
if ($server) {
|
||||
$this->server_uuid = $server->uuid;
|
||||
$this->server_name = $server->name;
|
||||
}
|
||||
|
||||
if ($this->resource instanceof Service) {
|
||||
$this->stack_applications = $this->resource->applications
|
||||
->map(fn ($app) => [
|
||||
'name' => $app->human_name ?: $app->name,
|
||||
'uuid' => $app->uuid,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->stack_databases = $this->resource->databases
|
||||
->map(fn ($db) => [
|
||||
'name' => $db->human_name ?: $db->name,
|
||||
'uuid' => $db->uuid,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveServer()
|
||||
{
|
||||
try {
|
||||
if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
|
||||
return $this->resource->destination->server;
|
||||
}
|
||||
if (method_exists($this->resource, 'server') && $this->resource->server) {
|
||||
return $this->resource->server;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.resource-details');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResourceLimits extends Component
|
||||
@@ -16,24 +17,24 @@ class ResourceLimits extends Component
|
||||
|
||||
public ?string $limitsCpuset = null;
|
||||
|
||||
public ?int $limitsCpuShares = null;
|
||||
public mixed $limitsCpuShares = null;
|
||||
|
||||
public string $limitsMemory;
|
||||
|
||||
public string $limitsMemorySwap;
|
||||
|
||||
public int $limitsMemorySwappiness;
|
||||
public mixed $limitsMemorySwappiness = 0;
|
||||
|
||||
public string $limitsMemoryReservation;
|
||||
|
||||
protected $rules = [
|
||||
'limitsMemory' => 'required|string',
|
||||
'limitsMemorySwap' => 'required|string',
|
||||
'limitsMemory' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
|
||||
'limitsMemorySwap' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
|
||||
'limitsMemorySwappiness' => 'required|integer|min:0|max:100',
|
||||
'limitsMemoryReservation' => 'required|string',
|
||||
'limitsCpus' => 'nullable',
|
||||
'limitsCpuset' => 'nullable',
|
||||
'limitsCpuShares' => 'nullable',
|
||||
'limitsMemoryReservation' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'],
|
||||
'limitsCpus' => ['nullable', 'regex:/^\d*\.?\d+$/'],
|
||||
'limitsCpuset' => ['nullable', 'regex:/^\d+([,-]\d+)*$/'],
|
||||
'limitsCpuShares' => 'nullable|integer|min:0',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
@@ -46,6 +47,19 @@ class ResourceLimits extends Component
|
||||
'limitsCpuShares' => 'cpu shares',
|
||||
];
|
||||
|
||||
protected $messages = [
|
||||
'limitsMemory.regex' => 'Maximum Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
|
||||
'limitsMemorySwap.regex' => 'Maximum Swap Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
|
||||
'limitsMemoryReservation.regex' => 'Soft Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.',
|
||||
'limitsCpus.regex' => 'Number of CPUs must be a number (integer or decimal). Example: 0.5, 2.',
|
||||
'limitsCpuset.regex' => 'CPU sets must be a comma-separated list of CPU numbers or ranges. Example: 0-2 or 0,1,3.',
|
||||
'limitsMemorySwappiness.integer' => 'Swappiness must be a whole number between 0 and 100.',
|
||||
'limitsMemorySwappiness.min' => 'Swappiness must be between 0 and 100.',
|
||||
'limitsMemorySwappiness.max' => 'Swappiness must be between 0 and 100.',
|
||||
'limitsCpuShares.integer' => 'CPU Weight must be a whole number.',
|
||||
'limitsCpuShares.min' => 'CPU Weight must be a positive number.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sync data between component properties and model
|
||||
*
|
||||
@@ -57,10 +71,10 @@ class ResourceLimits extends Component
|
||||
// Sync TO model (before save)
|
||||
$this->resource->limits_cpus = $this->limitsCpus;
|
||||
$this->resource->limits_cpuset = $this->limitsCpuset;
|
||||
$this->resource->limits_cpu_shares = $this->limitsCpuShares;
|
||||
$this->resource->limits_cpu_shares = (int) $this->limitsCpuShares;
|
||||
$this->resource->limits_memory = $this->limitsMemory;
|
||||
$this->resource->limits_memory_swap = $this->limitsMemorySwap;
|
||||
$this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness;
|
||||
$this->resource->limits_memory_swappiness = (int) $this->limitsMemorySwappiness;
|
||||
$this->resource->limits_memory_reservation = $this->limitsMemoryReservation;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
@@ -91,7 +105,7 @@ class ResourceLimits extends Component
|
||||
if (! $this->limitsMemorySwap) {
|
||||
$this->limitsMemorySwap = '0';
|
||||
}
|
||||
if (is_null($this->limitsMemorySwappiness)) {
|
||||
if ($this->limitsMemorySwappiness === '' || is_null($this->limitsMemorySwappiness)) {
|
||||
$this->limitsMemorySwappiness = 60;
|
||||
}
|
||||
if (! $this->limitsMemoryReservation) {
|
||||
@@ -103,7 +117,7 @@ class ResourceLimits extends Component
|
||||
if ($this->limitsCpuset === '') {
|
||||
$this->limitsCpuset = null;
|
||||
}
|
||||
if (is_null($this->limitsCpuShares)) {
|
||||
if ($this->limitsCpuShares === '' || is_null($this->limitsCpuShares)) {
|
||||
$this->limitsCpuShares = 1024;
|
||||
}
|
||||
|
||||
@@ -112,6 +126,12 @@ class ResourceLimits extends Component
|
||||
$this->syncData(true);
|
||||
$this->resource->save();
|
||||
$this->dispatch('success', 'Resource limits updated.');
|
||||
} catch (ValidationException $e) {
|
||||
foreach ($e->validator->errors()->all() as $message) {
|
||||
$this->dispatch('error', $message);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,18 @@ use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -49,10 +58,9 @@ class ResourceOperations extends Component
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
|
||||
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
$new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
|
||||
if (! $new_destination) {
|
||||
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
$new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
|
||||
}
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
@@ -60,7 +68,7 @@ class ResourceOperations extends Component
|
||||
$uuid = (string) new Cuid2;
|
||||
$server = $new_destination->server;
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
|
||||
|
||||
$route = route('project.application.configuration', [
|
||||
@@ -71,14 +79,14 @@ class ResourceOperations extends Component
|
||||
|
||||
return redirect()->to($route);
|
||||
} elseif (
|
||||
$this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneMysql::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
|
||||
$this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
|
||||
$this->resource->getMorphClass() === StandalonePostgresql::class ||
|
||||
$this->resource->getMorphClass() === StandaloneMongodb::class ||
|
||||
$this->resource->getMorphClass() === StandaloneMysql::class ||
|
||||
$this->resource->getMorphClass() === StandaloneMariadb::class ||
|
||||
$this->resource->getMorphClass() === StandaloneRedis::class ||
|
||||
$this->resource->getMorphClass() === StandaloneKeydb::class ||
|
||||
$this->resource->getMorphClass() === StandaloneDragonfly::class ||
|
||||
$this->resource->getMorphClass() === StandaloneClickhouse::class
|
||||
) {
|
||||
$uuid = (string) new Cuid2;
|
||||
$new_resource = $this->resource->replicate([
|
||||
@@ -133,6 +141,7 @@ class ResourceOperations extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $new_resource->id,
|
||||
@@ -254,9 +263,9 @@ class ResourceOperations extends Component
|
||||
}
|
||||
|
||||
foreach ($new_resource->applications() as $application) {
|
||||
$application->update([
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -271,6 +280,7 @@ class ResourceOperations extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $application->id,
|
||||
@@ -296,9 +306,9 @@ class ResourceOperations extends Component
|
||||
}
|
||||
|
||||
foreach ($new_resource->databases() as $database) {
|
||||
$database->update([
|
||||
$database->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -313,6 +323,7 @@ class ResourceOperations extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $database->id,
|
||||
@@ -354,9 +365,9 @@ class ResourceOperations extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
|
||||
$this->resource->update([
|
||||
$this->resource->fill([
|
||||
'environment_id' => $environment_id,
|
||||
]);
|
||||
])->save();
|
||||
if ($this->resource->type() === 'application') {
|
||||
$route = route('project.application.configuration', [
|
||||
'project_uuid' => $new_environment->project->uuid,
|
||||
|
||||
@@ -52,9 +52,15 @@ class Show extends Component
|
||||
#[Locked]
|
||||
public string $task_uuid;
|
||||
|
||||
public function mount(string $task_uuid, string $project_uuid, string $environment_uuid, ?string $application_uuid = null, ?string $service_uuid = null)
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$task_uuid = request()->route('task_uuid');
|
||||
$project_uuid = request()->route('project_uuid');
|
||||
$environment_uuid = request()->route('environment_uuid');
|
||||
$application_uuid = request()->route('application_uuid');
|
||||
$service_uuid = request()->route('service_uuid');
|
||||
|
||||
$this->task_uuid = $task_uuid;
|
||||
if ($application_uuid) {
|
||||
$this->type = 'application';
|
||||
@@ -105,6 +111,19 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleEnabled()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->isEnabled = ! $this->isEnabled;
|
||||
$this->task->enabled = $this->isEnabled;
|
||||
$this->task->save();
|
||||
$this->dispatch('success', $this->isEnabled ? 'Scheduled task enabled.' : 'Scheduled task disabled.');
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -31,19 +32,33 @@ class Show extends Component
|
||||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'mountPath' => 'required|string',
|
||||
'hostPath' => 'string|nullable',
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mountPath' => 'mount',
|
||||
'hostPath' => 'host',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::volumeNameMessages(),
|
||||
[
|
||||
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
|
||||
'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data between component properties and model
|
||||
*
|
||||
|
||||
@@ -12,6 +12,8 @@ class Terminal extends Component
|
||||
{
|
||||
public bool $hasShell = true;
|
||||
|
||||
public bool $isTerminalConnected = false;
|
||||
|
||||
private function checkShellAvailability(Server $server, string $container): bool
|
||||
{
|
||||
$escapedContainer = escapeshellarg($container);
|
||||
@@ -65,12 +67,20 @@ class Terminal extends Component
|
||||
$dockerCommand = "sudo {$dockerCommand}";
|
||||
}
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$dockerCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
} else {
|
||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$shellCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
}
|
||||
// ssh command is sent back to frontend then to websocket
|
||||
// this is done because the websocket connection is not available here
|
||||
@@ -84,6 +94,23 @@ class Terminal extends Component
|
||||
$this->dispatch('send-back-command', $command);
|
||||
}
|
||||
|
||||
#[On('terminalConnected')]
|
||||
public function markTerminalConnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
#[On('terminalDisconnected')]
|
||||
public function markTerminalDisconnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = false;
|
||||
}
|
||||
|
||||
public function keepTerminalPageAlive(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.terminal');
|
||||
|
||||
Reference in new issue
Block a user