Add shared environment variable key validation and normalization for Livewire forms and models, allowing Docker-compatible keys while rejecting invalid entries such as keys containing equals signs. Also quote Railpack build environment and secret arguments safely.
421 lines
15 KiB
PHP
421 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
|
|
|
use App\Models\Application;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Support\ValidationPatterns;
|
|
use App\Traits\EnvironmentVariableProtection;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Livewire\Component;
|
|
|
|
class All extends Component
|
|
{
|
|
use AuthorizesRequests, EnvironmentVariableProtection;
|
|
|
|
public $resource;
|
|
|
|
public string $resourceClass;
|
|
|
|
public bool $showPreview = false;
|
|
|
|
public ?string $variables = null;
|
|
|
|
public ?string $variablesPreview = null;
|
|
|
|
public string $view = 'normal';
|
|
|
|
public bool $is_env_sorting_enabled = false;
|
|
|
|
public bool $use_build_secrets = false;
|
|
|
|
protected $listeners = [
|
|
'saveKey' => 'submit',
|
|
'refreshEnvs',
|
|
'environmentVariableDeleted' => 'refreshEnvs',
|
|
];
|
|
|
|
public function mount()
|
|
{
|
|
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
|
|
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
|
|
$this->resourceClass = get_class($this->resource);
|
|
$resourceWithPreviews = [Application::class];
|
|
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
|
|
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
|
$this->showPreview = true;
|
|
}
|
|
$this->getDevView();
|
|
}
|
|
|
|
public function instantSave()
|
|
{
|
|
try {
|
|
$this->authorize('manageEnvironment', $this->resource);
|
|
|
|
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
|
|
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
|
|
$this->resource->settings->save();
|
|
$this->getDevView();
|
|
$this->dispatch('success', 'Environment variable settings updated.');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function getEnvironmentVariablesProperty()
|
|
{
|
|
$query = $this->resource->environment_variables()
|
|
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
|
|
|
|
if ($this->is_env_sorting_enabled) {
|
|
$query->orderBy('key');
|
|
} else {
|
|
$query->orderBy('order');
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
public function getEnvironmentVariablesPreviewProperty()
|
|
{
|
|
$query = $this->resource->environment_variables_preview()
|
|
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
|
|
|
|
if ($this->is_env_sorting_enabled) {
|
|
$query->orderBy('key');
|
|
} else {
|
|
$query->orderBy('order');
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
public function getHardcodedEnvironmentVariablesProperty()
|
|
{
|
|
return $this->getHardcodedVariables(false);
|
|
}
|
|
|
|
public function getHardcodedEnvironmentVariablesPreviewProperty()
|
|
{
|
|
return $this->getHardcodedVariables(true);
|
|
}
|
|
|
|
protected function getHardcodedVariables(bool $isPreview)
|
|
{
|
|
// Only for services and docker-compose applications
|
|
if ($this->resource->type() !== 'service' &&
|
|
($this->resourceClass !== 'App\Models\Application' ||
|
|
($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) {
|
|
return collect([]);
|
|
}
|
|
|
|
$dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
|
|
|
|
if (blank($dockerComposeRaw)) {
|
|
return collect([]);
|
|
}
|
|
|
|
// Extract all hard-coded variables
|
|
$hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
|
|
|
|
// Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
|
|
$hardcodedVars = $hardcodedVars->filter(function ($var) {
|
|
$key = $var['key'];
|
|
|
|
return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']);
|
|
});
|
|
|
|
// Filter out variables that exist in database (user has overridden/managed them)
|
|
// For preview, check against preview variables; for production, check against production variables
|
|
if ($isPreview) {
|
|
$managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray();
|
|
} else {
|
|
$managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray();
|
|
}
|
|
|
|
$hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) {
|
|
return ! in_array($var['key'], $managedKeys);
|
|
});
|
|
|
|
// Apply sorting based on is_env_sorting_enabled
|
|
if ($this->is_env_sorting_enabled) {
|
|
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
|
|
}
|
|
// Otherwise keep order from docker-compose file
|
|
|
|
return $hardcodedVars;
|
|
}
|
|
|
|
public function getDevView()
|
|
{
|
|
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
|
if ($this->showPreview) {
|
|
$this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
|
|
}
|
|
}
|
|
|
|
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 switch()
|
|
{
|
|
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
|
$this->getDevView();
|
|
}
|
|
|
|
public function submit($data = null)
|
|
{
|
|
try {
|
|
$this->authorize('manageEnvironment', $this->resource);
|
|
if ($data === null) {
|
|
$this->handleBulkSubmit();
|
|
} else {
|
|
$this->handleSingleSubmit($data);
|
|
}
|
|
|
|
$this->updateOrder();
|
|
$this->getDevView();
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
} finally {
|
|
$this->refreshEnvs();
|
|
}
|
|
}
|
|
|
|
private function updateOrder()
|
|
{
|
|
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
|
$order = 1;
|
|
foreach ($variables as $key => $value) {
|
|
$env = $this->resource->environment_variables()->where('key', $key)->first();
|
|
if ($env) {
|
|
$env->order = $order;
|
|
$env->save();
|
|
}
|
|
$order++;
|
|
}
|
|
|
|
if ($this->showPreview) {
|
|
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
|
|
$order = 1;
|
|
foreach ($previewVariables as $key => $value) {
|
|
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
|
|
if ($env) {
|
|
$env->order = $order;
|
|
$env->save();
|
|
}
|
|
$order++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function handleBulkSubmit()
|
|
{
|
|
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
|
$changesMade = false;
|
|
$errorOccurred = false;
|
|
|
|
// Try to delete removed variables
|
|
$deletedCount = $this->deleteRemovedVariables(false, $variables);
|
|
if ($deletedCount > 0) {
|
|
$changesMade = true;
|
|
} elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) {
|
|
// If we tried to delete but couldn't (due to Docker Compose), mark as error
|
|
$errorOccurred = true;
|
|
}
|
|
|
|
// Update or create variables
|
|
$updatedCount = $this->updateOrCreateVariables(false, $variables);
|
|
if ($updatedCount > 0) {
|
|
$changesMade = true;
|
|
}
|
|
|
|
if ($this->showPreview) {
|
|
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
|
|
|
|
// Try to delete removed preview variables
|
|
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
|
|
if ($deletedPreviewCount > 0) {
|
|
$changesMade = true;
|
|
} elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) {
|
|
// If we tried to delete but couldn't (due to Docker Compose), mark as error
|
|
$errorOccurred = true;
|
|
}
|
|
|
|
// Update or create preview variables
|
|
$updatedPreviewCount = $this->updateOrCreateVariables(true, $previewVariables);
|
|
if ($updatedPreviewCount > 0) {
|
|
$changesMade = true;
|
|
}
|
|
}
|
|
|
|
// Only show success message if changes were actually made and no errors occurred
|
|
if ($changesMade && ! $errorOccurred) {
|
|
$this->dispatch('success', 'Environment variables updated.');
|
|
}
|
|
}
|
|
|
|
private function handleSingleSubmit($data)
|
|
{
|
|
$data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
|
|
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
|
|
if ($found) {
|
|
$this->dispatch('error', 'Environment variable already exists.');
|
|
|
|
return;
|
|
}
|
|
|
|
$maxOrder = $this->resource->environment_variables()->max('order') ?? 0;
|
|
$environment = $this->createEnvironmentVariable($data);
|
|
$environment->order = $maxOrder + 1;
|
|
$environment->save();
|
|
|
|
// Clear computed property cache to force refresh
|
|
unset($this->environmentVariables);
|
|
unset($this->environmentVariablesPreview);
|
|
|
|
$this->dispatch('success', 'Environment variable added.');
|
|
}
|
|
|
|
private function createEnvironmentVariable($data)
|
|
{
|
|
$environment = new EnvironmentVariable;
|
|
$environment->key = $data['key'];
|
|
$environment->value = $data['value'];
|
|
$environment->is_multiline = $data['is_multiline'] ?? false;
|
|
$environment->is_literal = $data['is_literal'] ?? false;
|
|
$environment->is_runtime = $data['is_runtime'] ?? true;
|
|
$environment->is_buildtime = $data['is_buildtime'] ?? true;
|
|
$environment->is_preview = $data['is_preview'] ?? false;
|
|
$environment->comment = $data['comment'] ?? null;
|
|
$environment->resourceable_id = $this->resource->id;
|
|
$environment->resourceable_type = $this->resource->getMorphClass();
|
|
|
|
return $environment;
|
|
}
|
|
|
|
private function deleteRemovedVariables($isPreview, $variables)
|
|
{
|
|
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
|
|
|
// Get all environment variables that will be deleted
|
|
$variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get();
|
|
|
|
// If there are no variables to delete, return 0
|
|
if ($variablesToDelete->isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
// Check if any of these variables are used in Docker Compose
|
|
if ($this->resource->type() === 'service' || $this->resource->build_pack === 'dockercompose') {
|
|
foreach ($variablesToDelete as $envVar) {
|
|
[$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($envVar->key, $this->resource->docker_compose);
|
|
|
|
if ($isUsed) {
|
|
$this->dispatch('error', "Cannot delete environment variable '{$envVar->key}' <br><br>Please remove it from the Docker Compose file first.");
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we get here, no variables are used in Docker Compose, so we can delete them
|
|
$this->resource->$method()->whereNotIn('key', array_keys($variables))->delete();
|
|
|
|
return $variablesToDelete->count();
|
|
}
|
|
|
|
private function normalizeEnvironmentVariables(array $variables): array
|
|
{
|
|
$normalizedVariables = [];
|
|
|
|
foreach ($variables as $key => $data) {
|
|
$normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
|
|
|
|
if (array_key_exists($normalizedKey, $normalizedVariables)) {
|
|
throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
|
|
}
|
|
|
|
$normalizedVariables[$normalizedKey] = $data;
|
|
}
|
|
|
|
return $normalizedVariables;
|
|
}
|
|
|
|
private function updateOrCreateVariables($isPreview, $variables)
|
|
{
|
|
$count = 0;
|
|
foreach ($variables as $key => $data) {
|
|
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
|
|
continue;
|
|
}
|
|
|
|
// Extract value and comment from parsed data
|
|
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
|
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
|
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
|
|
|
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
|
$found = $this->resource->$method()->where('key', $key)->first();
|
|
|
|
if ($found) {
|
|
if (! $found->is_shown_once && ! $found->is_multiline) {
|
|
$changed = false;
|
|
|
|
// Update value if it changed
|
|
if ($found->value !== $value) {
|
|
$found->value = $value;
|
|
$changed = true;
|
|
}
|
|
|
|
// Only update comment from inline comment if one is provided (overwrites existing)
|
|
// If $comment is null, don't touch existing comment field to preserve it
|
|
if ($comment !== null && $found->comment !== $comment) {
|
|
$found->comment = $comment;
|
|
$changed = true;
|
|
}
|
|
|
|
if ($changed) {
|
|
$found->save();
|
|
$count++;
|
|
}
|
|
}
|
|
} else {
|
|
$environment = new EnvironmentVariable;
|
|
$environment->key = $key;
|
|
$environment->value = $value;
|
|
$environment->comment = $comment; // Set comment from inline comment
|
|
$environment->is_multiline = false;
|
|
$environment->is_preview = $isPreview;
|
|
$environment->resourceable_id = $this->resource->id;
|
|
$environment->resourceable_type = $this->resource->getMorphClass();
|
|
|
|
$environment->save();
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
public function refreshEnvs()
|
|
{
|
|
$this->resource->refresh();
|
|
// Clear computed property cache to force refresh
|
|
unset($this->environmentVariables);
|
|
unset($this->environmentVariablesPreview);
|
|
$this->getDevView();
|
|
}
|
|
}
|