mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 12:16:21 +00:00
Merge branch 'next' into shadow/fix-docker-time-command
This commit is contained in:
318 files changed
+21712
-4743
No files matched your search
@@ -10,7 +10,7 @@ class ActivityMonitor extends Component
|
||||
{
|
||||
public ?string $header = null;
|
||||
|
||||
public $activityId;
|
||||
public $activityId = null;
|
||||
|
||||
public $eventToDispatch = 'activityFinished';
|
||||
|
||||
@@ -28,12 +28,20 @@ class ActivityMonitor extends Component
|
||||
|
||||
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
|
||||
|
||||
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
|
||||
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
|
||||
{
|
||||
// Reset event dispatched flag for new activity
|
||||
self::$eventDispatched = false;
|
||||
|
||||
$this->activityId = $activityId;
|
||||
$this->eventToDispatch = $eventToDispatch;
|
||||
$this->eventData = $eventData;
|
||||
|
||||
// Update header if provided
|
||||
if ($header !== null) {
|
||||
$this->header = $header;
|
||||
}
|
||||
|
||||
$this->hydrateActivity();
|
||||
|
||||
$this->isPollingActive = true;
|
||||
@@ -41,9 +49,24 @@ class ActivityMonitor extends Component
|
||||
|
||||
public function hydrateActivity()
|
||||
{
|
||||
if ($this->activityId === null) {
|
||||
$this->activity = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activity = Activity::find($this->activityId);
|
||||
}
|
||||
|
||||
public function updatedActivityId($value)
|
||||
{
|
||||
if ($value) {
|
||||
$this->hydrateActivity();
|
||||
$this->isPollingActive = true;
|
||||
self::$eventDispatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function polling()
|
||||
{
|
||||
$this->hydrateActivity();
|
||||
|
||||
@@ -14,7 +14,10 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
|
||||
protected $listeners = [
|
||||
'refreshBoardingIndex' => 'validateServer',
|
||||
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
|
||||
];
|
||||
|
||||
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
||||
public string $currentState = 'welcome';
|
||||
@@ -76,6 +79,10 @@ class Index extends Component
|
||||
|
||||
public ?string $minDockerVersion = null;
|
||||
|
||||
public int $prerequisiteInstallAttempts = 0;
|
||||
|
||||
public int $maxPrerequisiteInstallAttempts = 3;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
|
||||
@@ -320,6 +327,62 @@ class Index extends Component
|
||||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check prerequisites
|
||||
$validationResult = $this->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
// Check if we've exceeded max attempts
|
||||
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
|
||||
}
|
||||
|
||||
// Start async installation and wait for completion via ActivityMonitor
|
||||
$activity = $this->createdServer->installPrerequisites();
|
||||
$this->prerequisiteInstallAttempts++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
|
||||
// Return early - handlePrerequisitesInstalled() will be called when installation completes
|
||||
return;
|
||||
}
|
||||
|
||||
// Prerequisites are already installed, continue with validation
|
||||
$this->continueValidation();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePrerequisitesInstalled()
|
||||
{
|
||||
try {
|
||||
// Revalidate prerequisites after installation completes
|
||||
$validationResult = $this->createdServer->validatePrerequisites();
|
||||
if (! $validationResult['success']) {
|
||||
// Installation completed but prerequisites still missing - retry
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
|
||||
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
|
||||
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
|
||||
}
|
||||
|
||||
// Try again
|
||||
$activity = $this->createdServer->installPrerequisites();
|
||||
$this->prerequisiteInstallAttempts++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Prerequisites validated successfully - continue with Docker validation
|
||||
$this->continueValidation();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError(error: $e, livewire: $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function continueValidation()
|
||||
{
|
||||
try {
|
||||
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
|
||||
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
|
||||
@@ -347,6 +410,8 @@ class Index extends Component
|
||||
}
|
||||
$this->createdServer->proxy->type = $proxyType;
|
||||
$this->createdServer->proxy->status = 'exited';
|
||||
$this->createdServer->proxy->last_saved_settings = null;
|
||||
$this->createdServer->proxy->last_applied_settings = null;
|
||||
$this->createdServer->save();
|
||||
$this->getProjects();
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ class Discord extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchDiscordNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedDiscordNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $discordPingEnabled = true;
|
||||
|
||||
@@ -98,6 +101,7 @@ class Discord extends Component
|
||||
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
|
||||
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
|
||||
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
|
||||
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
|
||||
|
||||
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
|
||||
|
||||
@@ -120,6 +124,7 @@ class Discord extends Component
|
||||
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
|
||||
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
|
||||
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
|
||||
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
|
||||
|
||||
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ class Email extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchEmailNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedEmailNotifications = true;
|
||||
|
||||
#[Validate(['nullable', 'email'])]
|
||||
public ?string $testEmailAddress = null;
|
||||
|
||||
@@ -155,6 +158,7 @@ class Email extends Component
|
||||
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
|
||||
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
|
||||
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
|
||||
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
|
||||
$this->settings->save();
|
||||
|
||||
} else {
|
||||
@@ -187,6 +191,7 @@ class Email extends Component
|
||||
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
|
||||
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
|
||||
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
|
||||
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ class Pushover extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchPushoverNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedPushoverNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -104,6 +107,7 @@ class Pushover extends Component
|
||||
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
|
||||
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
|
||||
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
|
||||
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
@@ -125,6 +129,7 @@ class Pushover extends Component
|
||||
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
|
||||
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
|
||||
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
|
||||
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ class Slack extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchSlackNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedSlackNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -100,6 +103,7 @@ class Slack extends Component
|
||||
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
|
||||
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
|
||||
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
|
||||
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
@@ -120,6 +124,7 @@ class Slack extends Component
|
||||
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
|
||||
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
|
||||
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
|
||||
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ class Telegram extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchTelegramNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedTelegramNotifications = true;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
|
||||
|
||||
@@ -109,6 +112,9 @@ class Telegram extends Component
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsServerPatchThreadId = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -143,6 +149,7 @@ class Telegram extends Component
|
||||
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
|
||||
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
|
||||
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
|
||||
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
|
||||
|
||||
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
|
||||
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
||||
@@ -157,6 +164,7 @@ class Telegram extends Component
|
||||
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
|
||||
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
|
||||
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
|
||||
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
|
||||
|
||||
$this->settings->save();
|
||||
} else {
|
||||
@@ -177,6 +185,7 @@ class Telegram extends Component
|
||||
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
|
||||
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
|
||||
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
|
||||
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
|
||||
|
||||
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
|
||||
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
||||
@@ -191,6 +200,7 @@ class Telegram extends Component
|
||||
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
||||
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ class Webhook extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $serverPatchWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $traefikOutdatedWebhookNotifications = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
@@ -95,6 +98,7 @@ class Webhook extends Component
|
||||
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
|
||||
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
|
||||
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
|
||||
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
|
||||
|
||||
$this->settings->save();
|
||||
refreshSession();
|
||||
@@ -115,6 +119,7 @@ class Webhook extends Component
|
||||
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
|
||||
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
|
||||
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
|
||||
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ class Advanced extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $disableBuildCache = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $injectBuildArgsToDockerfile = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $includeSourceCommitInBuild = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -110,6 +116,8 @@ class Advanced extends Component
|
||||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
||||
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
||||
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
|
||||
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
||||
@@ -134,6 +142,8 @@ class Advanced extends Component
|
||||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -641,8 +641,6 @@ class General extends Component
|
||||
$this->application->settings->is_static = false;
|
||||
$this->application->settings->save();
|
||||
} else {
|
||||
$this->portsExposes = '3000';
|
||||
$this->application->ports_exposes = '3000';
|
||||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
if ($this->buildPack === 'dockercompose') {
|
||||
@@ -655,18 +653,6 @@ class General extends Component
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User doesn't have update permission, just continue without saving
|
||||
}
|
||||
} else {
|
||||
// Clear Docker Compose specific data when switching away from dockercompose
|
||||
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
|
||||
$this->application->docker_compose_domains = null;
|
||||
$this->application->docker_compose_raw = null;
|
||||
|
||||
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
|
||||
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
|
||||
}
|
||||
}
|
||||
if ($this->buildPack === 'static') {
|
||||
$this->portsExposes = '80';
|
||||
@@ -1000,4 +986,60 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetectedPortInfoProperty(): ?array
|
||||
{
|
||||
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||
|
||||
if (! $detectedPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$portsExposesArray = $this->application->ports_exposes_array;
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
return [
|
||||
'port' => $detectedPort,
|
||||
'matches' => $isMatch,
|
||||
'isEmpty' => $isEmpty,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDockerComposeBuildCommandPreviewProperty(): string
|
||||
{
|
||||
if (! $this->dockerComposeCustomBuildCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||
|
||||
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
|
||||
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
||||
return injectDockerComposeFlags(
|
||||
$this->dockerComposeCustomBuildCommand,
|
||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
|
||||
);
|
||||
}
|
||||
|
||||
public function getDockerComposeStartCommandPreviewProperty(): string
|
||||
{
|
||||
if (! $this->dockerComposeCustomStartCommand) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
|
||||
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
|
||||
|
||||
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||
// Placeholder {workdir}/.env shows it's the workdir .env file (runtime env, not build-time)
|
||||
return injectDockerComposeFlags(
|
||||
$this->dockerComposeCustomStartCommand,
|
||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||
'{workdir}/.env'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,11 +101,18 @@ class Heading extends Component
|
||||
force_rebuild: $force_rebuild,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on successful deployment
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
@@ -137,6 +144,7 @@ class Heading extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setDeploymentUuid();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
@@ -149,6 +157,13 @@ class Heading extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on manual restart
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'manual',
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
||||
@@ -79,7 +79,7 @@ class BackupEdit extends Component
|
||||
#[Validate(['required', 'boolean'])]
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
@@ -107,6 +107,25 @@ class BackupEdit extends Component
|
||||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||
|
||||
// Validate databases_to_backup to prevent command injection
|
||||
if (filled($this->databasesToBackup)) {
|
||||
$databases = str($this->databasesToBackup)->explode(',');
|
||||
foreach ($databases as $index => $db) {
|
||||
$dbName = trim($db);
|
||||
try {
|
||||
validateShellSafePath($dbName, 'database name');
|
||||
} catch (\Exception $e) {
|
||||
// Provide specific error message indicating which database failed validation
|
||||
$position = $index + 1;
|
||||
throw new \Exception(
|
||||
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||
$this->backup->dump_all = $this->dumpAll;
|
||||
$this->backup->timeout = $this->timeout;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -12,6 +13,92 @@ 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;
|
||||
|
||||
public $resource;
|
||||
@@ -46,6 +133,8 @@ class Import extends Component
|
||||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||
|
||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
@@ -54,22 +143,35 @@ class Import extends Component
|
||||
|
||||
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 $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'slideOverClosed' => 'resetActivityId',
|
||||
];
|
||||
}
|
||||
|
||||
public function resetActivityId()
|
||||
{
|
||||
$this->activityId = null;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
@@ -152,8 +254,16 @@ EOD;
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
|
||||
$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.');
|
||||
|
||||
@@ -179,59 +289,35 @@ EOD;
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
if (filled($this->customLocation)) {
|
||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
|
||||
$tmpPath = $backupFileName;
|
||||
} else {
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
$path = Storage::path($backupFileName);
|
||||
if (! Storage::exists($backupFileName)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
|
||||
return;
|
||||
}
|
||||
// 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->resource->uuid;
|
||||
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;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
|
||||
$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;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
}
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
@@ -248,7 +334,13 @@ EOD;
|
||||
'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) {
|
||||
return handleError($e, $this);
|
||||
@@ -257,4 +349,267 @@ EOD;
|
||||
$this->importCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = collect();
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.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;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,15 @@ class General extends Component
|
||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
|
||||
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||
$delete_command = "rm -f $old_file_path";
|
||||
try {
|
||||
// Validate and escape filename to prevent command injection
|
||||
validateShellSafePath($oldScript['filename'], 'init script filename');
|
||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||
$escapedOldPath = escapeshellarg($old_file_path);
|
||||
$delete_command = "rm -f {$escapedOldPath}";
|
||||
instant_remote_process([$delete_command], $this->server);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -370,13 +373,17 @@ class General extends Component
|
||||
if ($found) {
|
||||
$container_name = $this->database->uuid;
|
||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||
|
||||
$command = "rm -f $file_path";
|
||||
try {
|
||||
// Validate and escape filename to prevent command injection
|
||||
validateShellSafePath($script['filename'], 'init script filename');
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||
$escapedPath = escapeshellarg($file_path);
|
||||
|
||||
$command = "rm -f {$escapedPath}";
|
||||
instant_remote_process([$command], $this->server);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -405,6 +412,16 @@ class General extends Component
|
||||
'new_filename' => 'required|string',
|
||||
'new_content' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($this->new_filename, 'init script filename');
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Filename already exists.');
|
||||
|
||||
@@ -81,7 +81,7 @@ class Create extends Component
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
|
||||
if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
|
||||
data_set($service_payload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
|
||||
@@ -39,7 +39,7 @@ class EditDomain extends Component
|
||||
{
|
||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
@@ -113,8 +113,7 @@ class EditDomain extends Component
|
||||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
||||
@@ -135,7 +135,7 @@ class ServiceApplicationView extends Component
|
||||
try {
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -268,8 +268,7 @@ class ServiceApplicationView extends Component
|
||||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
|
||||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class StackForm extends Component
|
||||
@@ -22,7 +23,7 @@ class StackForm extends Component
|
||||
|
||||
public string $dockerComposeRaw;
|
||||
|
||||
public string $dockerCompose;
|
||||
public ?string $dockerCompose = null;
|
||||
|
||||
public ?bool $connectToDockerNetwork = null;
|
||||
|
||||
@@ -30,7 +31,7 @@ class StackForm extends Component
|
||||
{
|
||||
$baseRules = [
|
||||
'dockerComposeRaw' => 'required',
|
||||
'dockerCompose' => 'required',
|
||||
'dockerCompose' => 'nullable',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'connectToDockerNetwork' => 'nullable',
|
||||
@@ -140,18 +141,27 @@ class StackForm extends Component
|
||||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
// Validate for command injection BEFORE any database operations
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||
DB::transaction(function () {
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
});
|
||||
// Refresh and write files after a successful commit
|
||||
$this->service->refresh();
|
||||
$this->service->saveComposeConfigs();
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('refreshServices');
|
||||
$notify && $this->dispatch('success', 'Service saved.');
|
||||
} catch (\Throwable $e) {
|
||||
// On error, refresh from database to restore clean state
|
||||
$this->service->refresh();
|
||||
$this->syncData(false);
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
if (is_null($this->service->config_hash)) {
|
||||
|
||||
@@ -179,6 +179,10 @@ class Storage extends Component
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
// Validate paths to prevent command injection
|
||||
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
||||
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
@@ -56,6 +59,72 @@ class Add extends Component
|
||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
if (! $team) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if user can view team variables
|
||||
try {
|
||||
$this->authorize('view', $team);
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
// Get project variables if we have a project_uuid in route
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
if ($projectUuid) {
|
||||
$project = Project::where('team_id', $team->id)
|
||||
->where('uuid', $projectUuid)
|
||||
->first();
|
||||
|
||||
if ($project) {
|
||||
try {
|
||||
$this->authorize('view', $project);
|
||||
$result['project'] = $project->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
|
||||
// Get environment variables if we have an environment_uuid in route
|
||||
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||
if ($environmentUuid) {
|
||||
$environment = $project->environments()
|
||||
->where('uuid', $environmentUuid)
|
||||
->first();
|
||||
|
||||
if ($environment) {
|
||||
try {
|
||||
$this->authorize('view', $environment);
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -184,6 +187,7 @@ class Show extends Component
|
||||
|
||||
$this->serialize();
|
||||
$this->syncData(true);
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Environment variable updated.');
|
||||
$this->dispatch('envsUpdated');
|
||||
$this->dispatch('configurationChanged');
|
||||
@@ -192,6 +196,72 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableSharedVariables(): array
|
||||
{
|
||||
$team = currentTeam();
|
||||
$result = [
|
||||
'team' => [],
|
||||
'project' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
// Early return if no team
|
||||
if (! $team) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if user can view team variables
|
||||
try {
|
||||
$this->authorize('view', $team);
|
||||
$result['team'] = $team->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view team variables
|
||||
}
|
||||
|
||||
// Get project variables if we have a project_uuid in route
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
if ($projectUuid) {
|
||||
$project = Project::where('team_id', $team->id)
|
||||
->where('uuid', $projectUuid)
|
||||
->first();
|
||||
|
||||
if ($project) {
|
||||
try {
|
||||
$this->authorize('view', $project);
|
||||
$result['project'] = $project->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
|
||||
// Get environment variables if we have an environment_uuid in route
|
||||
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||
if ($environmentUuid) {
|
||||
$environment = $project->environments()
|
||||
->where('uuid', $environmentUuid)
|
||||
->first();
|
||||
|
||||
if ($environment) {
|
||||
try {
|
||||
$this->authorize('view', $environment);
|
||||
$result['environment'] = $environment->environment_variables()
|
||||
->pluck('key')
|
||||
->toArray();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view environment variables
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
// User not authorized to view project variables
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -34,11 +34,14 @@ class Add extends Component
|
||||
|
||||
public ?string $container = '';
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'nullable|string',
|
||||
'timeout' => 'required|integer|min:60|max:3600',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
@@ -46,6 +49,7 @@ class Add extends Component
|
||||
'command' => 'command',
|
||||
'frequency' => 'frequency',
|
||||
'container' => 'container',
|
||||
'timeout' => 'timeout',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -103,6 +107,7 @@ class Add extends Component
|
||||
$task->command = $this->command;
|
||||
$task->frequency = $this->frequency;
|
||||
$task->container = $this->container;
|
||||
$task->timeout = $this->timeout;
|
||||
$task->team_id = currentTeam()->id;
|
||||
|
||||
switch ($this->type) {
|
||||
@@ -130,5 +135,6 @@ class Add extends Component
|
||||
$this->command = '';
|
||||
$this->frequency = '';
|
||||
$this->container = '';
|
||||
$this->timeout = 300;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ class Show extends Component
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $container = null;
|
||||
|
||||
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
|
||||
public $timeout = 300;
|
||||
|
||||
#[Locked]
|
||||
public ?string $application_uuid;
|
||||
|
||||
@@ -99,6 +102,7 @@ class Show extends Component
|
||||
$this->task->command = str($this->command)->trim()->value();
|
||||
$this->task->frequency = str($this->frequency)->trim()->value();
|
||||
$this->task->container = str($this->container)->trim()->value();
|
||||
$this->task->timeout = (int) $this->timeout;
|
||||
$this->task->save();
|
||||
} else {
|
||||
$this->isEnabled = $this->task->enabled;
|
||||
@@ -106,6 +110,7 @@ class Show extends Component
|
||||
$this->command = $this->task->command;
|
||||
$this->frequency = $this->task->frequency;
|
||||
$this->container = $this->task->container;
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace App\Livewire\Server;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
@@ -61,7 +62,18 @@ class Navbar extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageProxy', $this->server);
|
||||
RestartProxyJob::dispatch($this->server);
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
$this->server->proxy->force_stop = false;
|
||||
$this->server->save();
|
||||
|
||||
$activity = StartProxy::run($this->server, force: true, restarting: true);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
|
||||
// Check Traefik version after restart to provide immediate feedback
|
||||
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||
CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -118,19 +130,25 @@ class Navbar extends Component
|
||||
|
||||
public function showNotification()
|
||||
{
|
||||
$previousStatus = $this->proxyStatus;
|
||||
$this->server->refresh();
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
|
||||
switch ($this->proxyStatus) {
|
||||
case 'running':
|
||||
$this->loadProxyConfiguration();
|
||||
$this->dispatch('success', 'Proxy is running.');
|
||||
break;
|
||||
case 'restarting':
|
||||
$this->dispatch('info', 'Initiating proxy restart.');
|
||||
// Only show "Proxy is running" notification when transitioning from a stopped/error state
|
||||
// Don't show during normal start/restart flows (starting, restarting, stopping)
|
||||
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
|
||||
$this->dispatch('success', 'Proxy is running.');
|
||||
}
|
||||
break;
|
||||
case 'exited':
|
||||
$this->dispatch('info', 'Proxy has exited.');
|
||||
// Only show "Proxy has exited" notification when transitioning from running state
|
||||
// Don't show during normal stop/restart flows (stopping, restarting)
|
||||
if (in_array($previousStatus, ['running'])) {
|
||||
$this->dispatch('info', 'Proxy has exited.');
|
||||
}
|
||||
break;
|
||||
case 'stopping':
|
||||
$this->dispatch('info', 'Proxy is stopping.');
|
||||
@@ -154,6 +172,22 @@ class Navbar extends Component
|
||||
$this->server->load('settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Traefik has any outdated version info (patch or minor upgrade).
|
||||
* This shows a warning indicator in the navbar.
|
||||
*/
|
||||
public function getHasTraefikOutdatedProperty(): bool
|
||||
{
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if server has outdated info stored
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
|
||||
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.navbar');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -24,6 +25,12 @@ class Proxy extends Component
|
||||
|
||||
public bool $generateExactLabels = false;
|
||||
|
||||
/**
|
||||
* Cache the versions.json file data in memory for this component instance.
|
||||
* This avoids multiple file reads during a single request/render cycle.
|
||||
*/
|
||||
protected ?array $cachedVersionsFile = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
@@ -55,9 +62,37 @@ class Proxy extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function getConfigurationFilePathProperty()
|
||||
/**
|
||||
* Get Traefik versions from cached data with in-memory optimization.
|
||||
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
|
||||
*
|
||||
* This method adds an in-memory cache layer on top of the global
|
||||
* get_traefik_versions() helper to avoid multiple calls during
|
||||
* a single component lifecycle/render.
|
||||
*/
|
||||
protected function getTraefikVersions(): ?array
|
||||
{
|
||||
return $this->server->proxyPath().'docker-compose.yml';
|
||||
// In-memory cache for this component instance (per-request)
|
||||
if ($this->cachedVersionsFile !== null) {
|
||||
return data_get($this->cachedVersionsFile, 'traefik');
|
||||
}
|
||||
|
||||
// Load from global cached helper (Redis + filesystem)
|
||||
$versionsData = get_versions_data();
|
||||
$this->cachedVersionsFile = $versionsData;
|
||||
|
||||
if (! $versionsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$traefikVersions = data_get($versionsData, 'traefik');
|
||||
|
||||
return is_array($traefikVersions) ? $traefikVersions : null;
|
||||
}
|
||||
|
||||
public function getConfigurationFilePathProperty(): string
|
||||
{
|
||||
return rtrim($this->server->proxyPath(), '/') . '/docker-compose.yml';
|
||||
}
|
||||
|
||||
public function changeProxy()
|
||||
@@ -144,4 +179,131 @@ class Proxy extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest Traefik version for this server's current branch.
|
||||
*
|
||||
* This compares the server's detected version against available versions
|
||||
* in versions.json to determine the latest patch for the current branch,
|
||||
* or the newest available version if no current version is detected.
|
||||
*/
|
||||
public function getLatestTraefikVersionProperty(): ?string
|
||||
{
|
||||
try {
|
||||
$traefikVersions = $this->getTraefikVersions();
|
||||
|
||||
if (! $traefikVersions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get this server's current version
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
|
||||
// If we have a current version, try to find matching branch
|
||||
if ($currentVersion && $currentVersion !== 'latest') {
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||
$branch = "v{$matches[1]}";
|
||||
if (isset($traefikVersions[$branch])) {
|
||||
$version = $traefikVersions[$branch];
|
||||
|
||||
return str_starts_with($version, 'v') ? $version : "v{$version}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the newest available version
|
||||
$newestVersion = collect($traefikVersions)
|
||||
->map(fn ($v) => ltrim($v, 'v'))
|
||||
->sortBy(fn ($v) => $v, SORT_NATURAL)
|
||||
->last();
|
||||
|
||||
return $newestVersion ? "v{$newestVersion}" : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getIsTraefikOutdatedProperty(): bool
|
||||
{
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
if (! $currentVersion || $currentVersion === 'latest') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestVersion = $this->latestTraefikVersion;
|
||||
if (! $latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare versions (strip 'v' prefix)
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
$latest = ltrim($latestVersion, 'v');
|
||||
|
||||
return version_compare($current, $latest, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a newer Traefik branch (minor version) is available for this server.
|
||||
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
|
||||
*/
|
||||
public function getNewerTraefikBranchAvailableProperty(): ?string
|
||||
{
|
||||
try {
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get this server's current version
|
||||
$currentVersion = $this->server->detected_traefik_version;
|
||||
if (! $currentVersion || $currentVersion === 'latest') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have outdated info stored for this server (faster than computing)
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
|
||||
// Use the upgrade_target field if available (e.g., "v3.6")
|
||||
if (isset($outdatedInfo['upgrade_target'])) {
|
||||
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
|
||||
? $outdatedInfo['upgrade_target']
|
||||
: "v{$outdatedInfo['upgrade_target']}";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: compute from cached versions data
|
||||
$traefikVersions = $this->getTraefikVersions();
|
||||
|
||||
if (! $traefikVersions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract current branch (e.g., "3.5" from "3.5.6")
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentBranch = $matches[1];
|
||||
|
||||
// Find the newest branch that's greater than current
|
||||
$newestBranch = null;
|
||||
foreach ($traefikVersions as $branch => $version) {
|
||||
$branchNum = ltrim($branch, 'v');
|
||||
if (version_compare($branchNum, $currentBranch, '>')) {
|
||||
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
|
||||
$newestBranch = $branchNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $newestBranch ? "v{$newestBranch}" : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,25 @@ class DynamicConfigurationNavbar extends Component
|
||||
$this->authorize('update', $this->server);
|
||||
$proxy_path = $this->server->proxyPath();
|
||||
$proxy_type = $this->server->proxyType();
|
||||
|
||||
// Decode filename: pipes are used to encode dots for Livewire property binding
|
||||
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
|
||||
// This must happen BEFORE validation because validateShellSafePath() correctly
|
||||
// rejects pipe characters as dangerous shell metacharacters
|
||||
$file = str_replace('|', '.', $fileName);
|
||||
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($file, 'proxy configuration filename');
|
||||
|
||||
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
|
||||
$this->dispatch('error', 'Cannot delete Caddyfile.');
|
||||
|
||||
return;
|
||||
}
|
||||
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
|
||||
|
||||
$fullPath = "{$proxy_path}/dynamic/{$file}";
|
||||
$escapedPath = escapeshellarg($fullPath);
|
||||
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
|
||||
if ($proxy_type === 'CADDY') {
|
||||
$this->server->reloadCaddy();
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ class NewDynamicConfiguration extends Component
|
||||
'fileName' => 'required',
|
||||
'value' => 'required',
|
||||
]);
|
||||
|
||||
// Validate filename to prevent command injection
|
||||
validateShellSafePath($this->fileName, 'proxy configuration filename');
|
||||
|
||||
if (data_get($this->parameters, 'server_uuid')) {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
|
||||
}
|
||||
@@ -65,8 +69,10 @@ class NewDynamicConfiguration extends Component
|
||||
}
|
||||
$proxy_path = $this->server->proxyPath();
|
||||
$file = "{$proxy_path}/dynamic/{$this->fileName}";
|
||||
$escapedFile = escapeshellarg($file);
|
||||
|
||||
if ($this->newFile) {
|
||||
$exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
|
||||
$exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
|
||||
if ($exists == 1) {
|
||||
$this->dispatch('error', 'File already exists');
|
||||
|
||||
@@ -80,7 +86,7 @@ class NewDynamicConfiguration extends Component
|
||||
}
|
||||
$base64_value = base64_encode($this->value);
|
||||
instant_remote_process([
|
||||
"echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
|
||||
"echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
|
||||
], $this->server);
|
||||
if ($proxy_type === 'CADDY') {
|
||||
$this->server->reloadCaddy();
|
||||
|
||||
@@ -25,6 +25,8 @@ class ValidateAndInstall extends Component
|
||||
|
||||
public $supported_os_type = null;
|
||||
|
||||
public $prerequisites_installed = null;
|
||||
|
||||
public $docker_installed = null;
|
||||
|
||||
public $docker_compose_installed = null;
|
||||
@@ -33,12 +35,15 @@ class ValidateAndInstall extends Component
|
||||
|
||||
public $error = null;
|
||||
|
||||
public string $installationStep = 'Prerequisites';
|
||||
|
||||
public bool $ask = false;
|
||||
|
||||
protected $listeners = [
|
||||
'init',
|
||||
'validateConnection',
|
||||
'validateOS',
|
||||
'validatePrerequisites',
|
||||
'validateDockerEngine',
|
||||
'validateDockerVersion',
|
||||
'refresh' => '$refresh',
|
||||
@@ -48,6 +53,7 @@ class ValidateAndInstall extends Component
|
||||
{
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
$this->prerequisites_installed = null;
|
||||
$this->docker_installed = null;
|
||||
$this->docker_version = null;
|
||||
$this->docker_compose_installed = null;
|
||||
@@ -69,6 +75,7 @@ class ValidateAndInstall extends Component
|
||||
$this->authorize('update', $this->server);
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
$this->prerequisites_installed = null;
|
||||
$this->docker_installed = null;
|
||||
$this->docker_compose_installed = null;
|
||||
$this->docker_version = null;
|
||||
@@ -103,6 +110,43 @@ class ValidateAndInstall extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
$this->dispatch('validatePrerequisites');
|
||||
}
|
||||
|
||||
public function validatePrerequisites()
|
||||
{
|
||||
$validationResult = $this->server->validatePrerequisites();
|
||||
$this->prerequisites_installed = $validationResult['success'];
|
||||
if (! $validationResult['success']) {
|
||||
if ($this->install) {
|
||||
if ($this->number_of_tries == $this->max_tries) {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$this->error = "Prerequisites ({$missingCommands}) could not be installed. Please install them manually before continuing.";
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
return;
|
||||
} else {
|
||||
if ($this->number_of_tries <= $this->max_tries) {
|
||||
$this->installationStep = 'Prerequisites';
|
||||
$activity = $this->server->installPrerequisites();
|
||||
$this->number_of_tries++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$missingCommands = implode(', ', $validationResult['missing']);
|
||||
$this->error = "Prerequisites ({$missingCommands}) are not installed. Please install them before continuing.";
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->dispatch('validateDockerEngine');
|
||||
}
|
||||
|
||||
@@ -121,9 +165,10 @@ class ValidateAndInstall extends Component
|
||||
return;
|
||||
} else {
|
||||
if ($this->number_of_tries <= $this->max_tries) {
|
||||
$this->installationStep = 'Docker';
|
||||
$activity = $this->server->installDocker();
|
||||
$this->number_of_tries++;
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
|
||||
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -161,6 +206,9 @@ class ValidateAndInstall extends Component
|
||||
if (! $proxyShouldRun) {
|
||||
return;
|
||||
}
|
||||
// Ensure networks exist BEFORE dispatching async proxy startup
|
||||
// This prevents race condition where proxy tries to start before networks are created
|
||||
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
|
||||
StartProxy::dispatch($this->server);
|
||||
} else {
|
||||
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
|
||||
|
||||
@@ -44,6 +44,8 @@ class Index extends Component
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
||||
public $buildActivityId = null;
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.index');
|
||||
@@ -151,4 +153,37 @@ class Index extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildHelperImage()
|
||||
{
|
||||
try {
|
||||
if (! isDev()) {
|
||||
$this->dispatch('error', 'Building helper image is only available in development mode.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
|
||||
if (empty($version)) {
|
||||
$this->dispatch('error', 'Please specify a version to build.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
|
||||
|
||||
$activity = remote_process(
|
||||
command: [$buildCommand],
|
||||
server: $this->server,
|
||||
type: 'build-helper-image'
|
||||
);
|
||||
|
||||
$this->buildActivityId = $activity->id;
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
|
||||
$this->dispatch('success', "Building coolify-helper:{$version}...");
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\SharedVariables\Environment;
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -19,7 +20,11 @@ class Show extends Component
|
||||
|
||||
public array $parameters;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey', 'environmentVariableDeleted' => '$refresh'];
|
||||
public string $view = 'normal';
|
||||
|
||||
public ?string $variables = null;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
|
||||
|
||||
public function saveKey($data)
|
||||
{
|
||||
@@ -39,6 +44,7 @@ class Show extends Component
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$this->environment->refresh();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -49,6 +55,120 @@ class Show extends Component
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail();
|
||||
$this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function switch()
|
||||
{
|
||||
$this->authorize('view', $this->environment);
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environment->environment_variables->sortBy('key'));
|
||||
}
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
return $variables->map(function ($item) {
|
||||
if ($item->is_shown_once) {
|
||||
return "$item->key=(Locked Secret, delete and add again to change)";
|
||||
}
|
||||
if ($item->is_multiline) {
|
||||
return "$item->key=(Multiline environment variable, edit in normal view)";
|
||||
}
|
||||
|
||||
return "$item->key=$item->value";
|
||||
})->join("\n");
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->environment);
|
||||
$this->handleBulkSubmit();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->refreshEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBulkSubmit()
|
||||
{
|
||||
$variables = parseEnvFormatToArray($this->variables);
|
||||
$changesMade = false;
|
||||
|
||||
DB::transaction(function () use ($variables, &$changesMade) {
|
||||
// Delete removed variables
|
||||
$deletedCount = $this->deleteRemovedVariables($variables);
|
||||
if ($deletedCount > 0) {
|
||||
$changesMade = true;
|
||||
}
|
||||
|
||||
// Update or create variables
|
||||
$updatedCount = $this->updateOrCreateVariables($variables);
|
||||
if ($updatedCount > 0) {
|
||||
$changesMade = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Only dispatch success after transaction has committed
|
||||
if ($changesMade) {
|
||||
$this->dispatch('success', 'Environment variables updated.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteRemovedVariables($variables)
|
||||
{
|
||||
$variablesToDelete = $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->get();
|
||||
|
||||
if ($variablesToDelete->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
|
||||
|
||||
return $variablesToDelete->count();
|
||||
}
|
||||
|
||||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
$found = $this->environment->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
if (! $found->is_shown_once && ! $found->is_multiline) {
|
||||
if ($found->value !== $value) {
|
||||
$found->value = $value;
|
||||
$found->save();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->environment->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'environment',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function refreshEnvs()
|
||||
{
|
||||
$this->environment->refresh();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Project;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -12,7 +13,11 @@ class Show extends Component
|
||||
|
||||
public Project $project;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
|
||||
public string $view = 'normal';
|
||||
|
||||
public ?string $variables = null;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
|
||||
|
||||
public function saveKey($data)
|
||||
{
|
||||
@@ -32,6 +37,7 @@ class Show extends Component
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$this->project->refresh();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -46,6 +52,114 @@ class Show extends Component
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$this->project = $project;
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function switch()
|
||||
{
|
||||
$this->authorize('view', $this->project);
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->project->environment_variables->sortBy('key'));
|
||||
}
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
return $variables->map(function ($item) {
|
||||
if ($item->is_shown_once) {
|
||||
return "$item->key=(Locked Secret, delete and add again to change)";
|
||||
}
|
||||
if ($item->is_multiline) {
|
||||
return "$item->key=(Multiline environment variable, edit in normal view)";
|
||||
}
|
||||
|
||||
return "$item->key=$item->value";
|
||||
})->join("\n");
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->project);
|
||||
$this->handleBulkSubmit();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->refreshEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBulkSubmit()
|
||||
{
|
||||
$variables = parseEnvFormatToArray($this->variables);
|
||||
|
||||
$changesMade = DB::transaction(function () use ($variables) {
|
||||
// Delete removed variables
|
||||
$deletedCount = $this->deleteRemovedVariables($variables);
|
||||
|
||||
// Update or create variables
|
||||
$updatedCount = $this->updateOrCreateVariables($variables);
|
||||
|
||||
return $deletedCount > 0 || $updatedCount > 0;
|
||||
});
|
||||
|
||||
if ($changesMade) {
|
||||
$this->dispatch('success', 'Environment variables updated.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteRemovedVariables($variables)
|
||||
{
|
||||
$variablesToDelete = $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->get();
|
||||
|
||||
if ($variablesToDelete->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->project->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
|
||||
|
||||
return $variablesToDelete->count();
|
||||
}
|
||||
|
||||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
$found = $this->project->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
if (! $found->is_shown_once && ! $found->is_multiline) {
|
||||
if ($found->value !== $value) {
|
||||
$found->value = $value;
|
||||
$found->save();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->project->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'project',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function refreshEnvs()
|
||||
{
|
||||
$this->project->refresh();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Team;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
@@ -12,7 +13,11 @@ class Index extends Component
|
||||
|
||||
public Team $team;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
|
||||
public string $view = 'normal';
|
||||
|
||||
public ?string $variables = null;
|
||||
|
||||
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
|
||||
|
||||
public function saveKey($data)
|
||||
{
|
||||
@@ -32,6 +37,7 @@ class Index extends Component
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$this->team->refresh();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -40,6 +46,119 @@ class Index extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->team = currentTeam();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function switch()
|
||||
{
|
||||
$this->authorize('view', $this->team);
|
||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->team->environment_variables->sortBy('key'));
|
||||
}
|
||||
|
||||
private function formatEnvironmentVariables($variables)
|
||||
{
|
||||
return $variables->map(function ($item) {
|
||||
if ($item->is_shown_once) {
|
||||
return "$item->key=(Locked Secret, delete and add again to change)";
|
||||
}
|
||||
if ($item->is_multiline) {
|
||||
return "$item->key=(Multiline environment variable, edit in normal view)";
|
||||
}
|
||||
|
||||
return "$item->key=$item->value";
|
||||
})->join("\n");
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->team);
|
||||
$this->handleBulkSubmit();
|
||||
$this->getDevView();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->refreshEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBulkSubmit()
|
||||
{
|
||||
$variables = parseEnvFormatToArray($this->variables);
|
||||
$changesMade = false;
|
||||
|
||||
DB::transaction(function () use ($variables, &$changesMade) {
|
||||
// Delete removed variables
|
||||
$deletedCount = $this->deleteRemovedVariables($variables);
|
||||
if ($deletedCount > 0) {
|
||||
$changesMade = true;
|
||||
}
|
||||
|
||||
// Update or create variables
|
||||
$updatedCount = $this->updateOrCreateVariables($variables);
|
||||
if ($updatedCount > 0) {
|
||||
$changesMade = true;
|
||||
}
|
||||
});
|
||||
|
||||
if ($changesMade) {
|
||||
$this->dispatch('success', 'Environment variables updated.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteRemovedVariables($variables)
|
||||
{
|
||||
$variablesToDelete = $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->get();
|
||||
|
||||
if ($variablesToDelete->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->team->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
|
||||
|
||||
return $variablesToDelete->count();
|
||||
}
|
||||
|
||||
private function updateOrCreateVariables($variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
$found = $this->team->environment_variables()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
if (! $found->is_shown_once && ! $found->is_multiline) {
|
||||
if ($found->value !== $value) {
|
||||
$found->value = $value;
|
||||
$found->save();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->team->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'type' => 'team',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function refreshEnvs()
|
||||
{
|
||||
$this->team->refresh();
|
||||
$this->getDevView();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -120,9 +120,16 @@ class Form extends Component
|
||||
|
||||
$this->storage->testConnection(shouldSave: true);
|
||||
|
||||
// Update component property to reflect the new validation status
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
|
||||
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
|
||||
// Refresh model and sync to get the latest state
|
||||
$this->storage->refresh();
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
|
||||
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Livewire\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $storage = null;
|
||||
|
||||
public function mount()
|
||||
@@ -15,6 +18,7 @@ class Show extends Component
|
||||
if (! $this->storage) {
|
||||
abort(404);
|
||||
}
|
||||
$this->authorize('view', $this->storage);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
Reference in new issue
Block a user