From 84eb9d31bbbe3294f237f159df8e8f9b06fbea8f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Sat, 30 May 2026 13:15:10 +0530 Subject: [PATCH] feat(ui): improve configuration changes modal --- app/Casts/EncryptedArrayCast.php | 51 +++++++ .../Project/Shared/ConfigurationChecker.php | 43 +++++- app/Models/ApplicationDeploymentQueue.php | 18 ++- .../ApplicationConfigurationSnapshot.php | 134 ++++++++++++++++-- .../Concerns/SummarizesDiffText.php | 32 +++++ .../ConfigurationDiff.php | 16 --- .../ConfigurationDiffer.php | 92 +++++++++++- ...ation_deployment_configuration_columns.php | 23 +++ .../deployment/configuration-diff.blade.php | 61 +++++++- .../shared/configuration-checker.blade.php | 2 +- 10 files changed, 426 insertions(+), 46 deletions(-) create mode 100644 app/Casts/EncryptedArrayCast.php create mode 100644 app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php create mode 100644 database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php diff --git a/app/Casts/EncryptedArrayCast.php b/app/Casts/EncryptedArrayCast.php new file mode 100644 index 000000000..4f72c6286 --- /dev/null +++ b/app/Casts/EncryptedArrayCast.php @@ -0,0 +1,51 @@ +|null, array|null> + */ +class EncryptedArrayCast implements CastsAttributes +{ + /** + * @param array $attributes + * @return array|null + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + if ($value === null || $value === '') { + return null; + } + + try { + $value = Crypt::decryptString($value); + } catch (DecryptException) { + // Legacy plaintext JSON written before this column was encrypted. + } + + $decoded = json_decode((string) $value, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR)); + } +} diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index d583e74e6..43bf3140b 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -21,8 +21,6 @@ class ConfigurationChecker extends Component public array $configurationDiff = []; - public array $groupedConfigurationChanges = []; - public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public function getListeners(): array @@ -50,21 +48,56 @@ class ConfigurationChecker extends Component $this->configurationChanged(); } + /** + * Members must never see environment variable values, so redact every + * environment-section change before it is serialized to the browser. + * + * @param array> $changes + * @return array> + */ + private function redactEnvironmentChanges(array $changes, bool $redact): array + { + if (! $redact) { + return $changes; + } + + return collect($changes) + ->map(function (array $change): array { + if (data_get($change, 'section') !== 'environment') { + return $change; + } + + $change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••'; + $change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••'; + $change['old_full_value'] = null; + $change['new_full_value'] = null; + $change['expandable'] = false; + $change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null; + + return $change; + }) + ->all(); + } + public function configurationChanged(): void { $this->resource->refresh(); if ($this->resource instanceof Application) { $diff = $this->resource->pendingDeploymentConfigurationDiff(); + // Fail closed: only owners/admins may see unlocked env values. + $redactEnvironment = ! (bool) auth()->user()?->isAdmin(); + + $array = $diff->toArray(); + $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment); + $this->isConfigurationChanged = $diff->isChanged(); - $this->configurationDiff = $diff->toArray(); - $this->groupedConfigurationChanges = $diff->groupedChanges(); + $this->configurationDiff = $array; return; } $this->isConfigurationChanged = $this->resource->isConfigurationChanged(); $this->configurationDiff = []; - $this->groupedConfigurationChanges = []; } } diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index afac89fa8..53fb8337f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\EncryptedArrayCast; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model 'finished_at', ]; + /** + * The configuration snapshot/diff hold full (decrypted on read) configuration, + * including unlocked environment variable values. They are only meant for the + * in-app diff modal (which redacts per role) and must never be serialized by the + * API, so hide them globally as defense in depth. + * + * @var array + */ + protected $hidden = [ + 'configuration_snapshot', + 'configuration_diff', + ]; + protected $casts = [ 'pull_request_id' => 'integer', 'finished_at' => 'datetime', - 'configuration_snapshot' => 'array', - 'configuration_diff' => 'array', + 'configuration_snapshot' => EncryptedArrayCast::class, + 'configuration_diff' => EncryptedArrayCast::class, ]; public function application() diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 8369f9a90..365708758 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -4,10 +4,13 @@ namespace App\Services\DeploymentConfiguration; use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot { + use SummarizesDiffText; + public const SCHEMA_VERSION = 1; public function __construct(protected Application $application) {} @@ -115,12 +118,14 @@ class ApplicationConfigurationSnapshot $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), $this->item('install_command', 'Install command', $this->application->install_command, 'build'), $this->item('build_command', 'Build command', $this->application->build_command, 'build'), - $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)), + $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile), $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'), $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'), $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'), - $this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)), - $this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)), + // The generated docker_compose is intentionally excluded: it is re-rendered + // from git on every parse (resolved env, generated labels, deployment context), + // so comparing it would flag a permanent change for git-based compose apps. + $this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'), $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), @@ -162,9 +167,10 @@ class ApplicationConfigurationSnapshot { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), - $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)), - $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)), + $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration), $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), @@ -234,6 +240,7 @@ class ApplicationConfigurationSnapshot private function environmentItem(EnvironmentVariable $environmentVariable): array { $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy'; + $locked = (bool) $environmentVariable->is_shown_once; $compareValue = [ 'value_hash' => $this->sensitiveHash($environmentVariable->value), 'is_multiline' => $environmentVariable->is_multiline, @@ -242,20 +249,62 @@ class ApplicationConfigurationSnapshot 'is_runtime' => $environmentVariable->is_runtime, ]; + // Locked (is_shown_once) variables are always redacted and never store a value. + if ($locked) { + return $this->item( + key: (string) $environmentVariable->key, + label: (string) $environmentVariable->key, + value: $compareValue, + impact: $impact, + sensitive: true, + displayValue: $this->environmentDisplayValue($environmentVariable), + ); + } + + // Unlocked variables expose their value so owners/admins can see the change. + // The compare value is pre-hashed (identical formula to the locked branch) so + // change detection stays stable and never carries the raw value; members are + // redacted at render time in ConfigurationChecker; the column is encrypted at rest. + // The value and each scope flag are rendered as their own line and diffed by line, + // so a change to one or more attributes shows exactly what changed (one line each). + $value = (string) $environmentVariable->value; + return $this->item( key: (string) $environmentVariable->key, label: (string) $environmentVariable->key, - value: $compareValue, + value: $this->sensitiveHash($this->normalizeValue($compareValue)), impact: $impact, - sensitive: true, - displayValue: $this->environmentDisplayValue($environmentVariable), + sensitive: false, + displayValue: $this->summarizeText($value), + displayFull: $this->environmentLines($environmentVariable), + diffMode: 'lines', ); } + /** + * One line per attribute so the line diff surfaces exactly which value/flags changed. + */ + private function environmentLines(EnvironmentVariable $environmentVariable): string + { + $lines = collect(); + + $value = (string) $environmentVariable->value; + if (filled($value)) { + $lines->push($value); + } + + $lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled')); + $lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled')); + $lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled')); + $lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled')); + + return $lines->implode("\n"); + } + /** * @return array */ - private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array + private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array { $normalizedValue = $this->normalizeValue($value); @@ -264,21 +313,28 @@ class ApplicationConfigurationSnapshot 'label' => $label, 'impact' => $impact, 'sensitive' => $sensitive, + 'diff_mode' => $diffMode, 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue, 'display_value' => $displayValue ?? $this->displayValue($normalizedValue), + 'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)), ]; } private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string { - $flags = collect([ + $flags = $this->environmentFlags($environmentVariable); + + return $flags ? "Hidden ({$flags})" : 'Hidden'; + } + + private function environmentFlags(EnvironmentVariable $environmentVariable): string + { + return collect([ $environmentVariable->is_buildtime ? 'build-time' : null, $environmentVariable->is_runtime ? 'runtime' : null, $environmentVariable->is_multiline ? 'multiline' : null, $environmentVariable->is_literal ? 'literal' : null, ])->filter()->implode(', '); - - return $flags ? "Hidden ({$flags})" : 'Hidden'; } private function sensitiveHash(mixed $value): string @@ -320,6 +376,58 @@ class ApplicationConfigurationSnapshot return $this->summarizeText((string) $value); } + private function stringifyValue(mixed $value): ?string + { + if ($value === null || is_bool($value)) { + return null; + } + + if (is_array($value)) { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + return (string) $value; + } + + /** + * @return array|null + */ + private function decodedComposeDomains(): ?array + { + if (blank($this->application->docker_compose_domains)) { + return null; + } + + $decoded = json_decode((string) $this->application->docker_compose_domains, true); + + return is_array($decoded) ? $decoded : null; + } + + private function composeDomainsText(): ?string + { + $decoded = $this->decodedComposeDomains(); + + if (blank($decoded)) { + return null; + } + + return collect($decoded) + ->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-')) + ->sort() + ->implode("\n"); + } + + private function decodeCustomLabels(?string $value): ?string + { + if (blank($value)) { + return null; + } + + $decoded = base64_decode($value, true); + + return $decoded === false ? $value : $decoded; + } + private function summarizeText(?string $value): string { if (blank($value)) { @@ -333,6 +441,6 @@ class ApplicationConfigurationSnapshot return str($value)->limit(80)." ({$lines} lines)"; } - return str($value)->limit(120)->value(); + return str($value)->limit(self::SINGLE_LINE_LIMIT)->value(); } } diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php new file mode 100644 index 000000000..6960a8f1b --- /dev/null +++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php @@ -0,0 +1,32 @@ + self::SINGLE_LINE_LIMIT) { + return $value; + } + + return null; + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php index e8a206025..3f0477ba3 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiff.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php @@ -2,8 +2,6 @@ namespace App\Services\DeploymentConfiguration; -use Illuminate\Support\Collection; - class ConfigurationDiff { /** @@ -81,20 +79,6 @@ class ConfigurationDiff return $this->changes; } - /** - * @return array>}> - */ - public function groupedChanges(): array - { - return collect($this->changes) - ->groupBy('section') - ->map(fn (Collection $changes): array => [ - 'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()), - 'changes' => $changes->values()->all(), - ]) - ->all(); - } - /** * @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array>} */ diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index b101b9d5b..e9707edbe 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -2,8 +2,21 @@ namespace App\Services\DeploymentConfiguration; +use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; + class ConfigurationDiffer { + use SummarizesDiffText; + + /** + * Keys that must never be reported as changes. The generated docker_compose + * is re-rendered from git on every parse, so legacy snapshots that still + * contain it would otherwise flag a permanent change after it was dropped. + * + * @var array + */ + private const IGNORED_KEYS = ['build.docker_compose']; + /** * @param array $previousSnapshot * @param array $currentSnapshot @@ -16,6 +29,10 @@ class ConfigurationDiffer $changes = []; foreach ($keys as $key) { + if (in_array($key, self::IGNORED_KEYS, true)) { + continue; + } + $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; @@ -27,6 +44,37 @@ class ConfigurationDiffer $sensitive = (bool) data_get($item, 'sensitive', false); $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed'); $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null; + $diffMode = data_get($item, 'diff_mode', 'default'); + + $oldFull = null; + $newFull = null; + + if ($sensitive) { + $oldDisplay = $previous === null ? '-' : '••••••••'; + $newDisplay = $current === null ? '-' : '••••••••'; + } elseif ($diffMode === 'lines' && $type === 'changed') { + [$oldDisplay, $newDisplay] = $this->changedLines( + data_get($previous, 'display_full'), + data_get($current, 'display_full'), + ); + + // No line-level difference (e.g. only reordering) — fall back to the summary. + if ($oldDisplay === '-' && $newDisplay === '-') { + $oldDisplay = data_get($previous, 'display_value', '-'); + $newDisplay = data_get($current, 'display_value', '-'); + } + + // Expansion reveals the full changed lines, not the entire value. + $oldFull = $this->expandableText($oldDisplay); + $newFull = $this->expandableText($newDisplay); + } else { + $oldDisplay = data_get($previous, 'display_value', '-'); + $newDisplay = data_get($current, 'display_value', '-'); + $oldFull = data_get($previous, 'display_full'); + $newFull = data_get($current, 'display_full'); + } + + $expandable = ! $sensitive && (filled($oldFull) || filled($newFull)); $changes[] = [ 'key' => $key, @@ -37,14 +85,54 @@ class ConfigurationDiffer 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), - 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), + 'old_display_value' => $oldDisplay, + 'new_display_value' => $newDisplay, + 'old_full_value' => $oldFull, + 'new_full_value' => $newFull, + 'expandable' => $expandable, ]; } return ConfigurationDiff::fromChanges($changes); } + /** + * Reduce two multi-line values to only the lines that differ, so the modal + * shows just the changed container labels instead of the whole block. + * + * @return array{0: string, 1: string} + */ + private function changedLines(?string $old, ?string $new): array + { + $oldLines = $this->textLines($old); + $newLines = $this->textLines($new); + + $removed = array_values(array_diff($oldLines, $newLines)); + $added = array_values(array_diff($newLines, $oldLines)); + + return [ + $removed === [] ? '-' : implode("\n", $removed), + $added === [] ? '-' : implode("\n", $added), + ]; + } + + /** + * @return array + */ + private function textLines(?string $value): array + { + if (blank($value)) { + return []; + } + + // Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace. + return collect(preg_split('/\r\n|\r|\n/', (string) $value)) + ->map(fn (string $line): string => rtrim($line)) + ->filter(fn (string $line): bool => trim($line) !== '') + ->values() + ->all(); + } + /** * @param array $snapshot * @return array> diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php new file mode 100644 index 000000000..123fd226d --- /dev/null +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -0,0 +1,23 @@ +filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $changes = collect(data_get($diff, 'changes', []))->values()->all(); $count = count($changes); $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @@ -41,16 +41,63 @@
@foreach ($sectionChanges as $change) + @php + $changeKey = (string) data_get($change, 'key'); + $expandable = data_get($change, 'expandable', false); + $oldDisplay = (string) data_get($change, 'old_display_value'); + $newDisplay = (string) data_get($change, 'new_display_value'); + $oldFull = data_get($change, 'old_full_value') ?? $oldDisplay; + $newFull = data_get($change, 'new_full_value') ?? $newDisplay; + $label = (string) data_get($change, 'label'); + $labelTruncated = mb_strlen($label) > 20; + $rowExpandable = $expandable || $labelTruncated; + @endphp
-
- {{ data_get($change, 'label') }} +
+ @if ($rowExpandable) +
+ @else + {{ $label }} + @endif
-
- {{ data_get($change, 'old_display_value') }} +
+ @if ($expandable) +
+ @else +
{{ $oldDisplay }}
+ @endif
-
- {{ data_get($change, 'new_display_value') }} +
+
+ @if ($expandable) +
+ @else +
{{ $newDisplay }}
+ @endif +
+ @if ($rowExpandable) + + @endif
@endforeach diff --git a/resources/views/livewire/project/shared/configuration-checker.blade.php b/resources/views/livewire/project/shared/configuration-checker.blade.php index 2c4440dfb..19974c587 100644 --- a/resources/views/livewire/project/shared/configuration-checker.blade.php +++ b/resources/views/livewire/project/shared/configuration-checker.blade.php @@ -1,6 +1,6 @@
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited()) -
+
The latest configuration has not been applied