From c35833ed0b033054c1d3a4e52d795ff2c0f446ba Mon Sep 17 00:00:00 2001 From: mrKamoo Date: Thu, 20 Aug 2026 09:21:15 +0200 Subject: [PATCH] =?UTF-8?q?Remplace=20l'=C3=A9dition=20directe=20du=20.env?= =?UTF-8?q?=20par=20des=20r=C3=A9glages=20en=20base=20de=20donn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'édition de fichier nécessitait que le process web ait un accès en écriture sur .env — problématique en prod (permissions, plusieurs instances). À la place : - Table `settings` (clé/valeur) + SettingsRegistry, une liste blanche explicite des réglages modifiables (mail, OnlyOffice, GLPI, nom/URL de l'app) mappés vers leur chemin de config Laravel. - Volontairement exclu : tout ce qui touche à l'infrastructure (APP_KEY, DB_*, SESSION_DRIVER, CACHE_STORE, QUEUE_CONNECTION...) — ces valeurs sont nécessaires avant même qu'une connexion BDD existe, donc une surcharge en base ne serait pas fiable et reste réservée au .env. - AppServiceProvider applique les surcharges au boot (cache 5 min, invalidé immédiatement à l'enregistrement) ; le reste du code continue de lire config() normalement, sans changement ailleurs. - Tant qu'aucun réglage n'est enregistré, le comportement reste identique au .env actuel — transition sans rupture. Co-Authored-By: Claude Sonnet 5 --- app/Models/Setting.php | 15 ++ app/Providers/AppServiceProvider.php | 34 +++- app/Services/EnvFileManager.php | 160 ------------------ app/Services/SettingsRegistry.php | 71 ++++++++ ...026_08_20_090000_create_settings_table.php | 23 +++ .../views/components/layouts/app.blade.php | 2 +- .../views/components/settings/⚡env.blade.php | 130 +++++++------- resources/views/settings/env.blade.php | 2 +- 8 files changed, 211 insertions(+), 226 deletions(-) create mode 100644 app/Models/Setting.php delete mode 100644 app/Services/EnvFileManager.php create mode 100644 app/Services/SettingsRegistry.php create mode 100644 database/migrations/2026_08_20_090000_create_settings_table.php diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..ca94587 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,15 @@ +applySettingOverrides(); + } + + /** + * Overlay database-stored settings (see SettingsRegistry) onto config, + * so the rest of the app keeps reading config('mail.from.address') etc. + * without knowing whether the value came from .env or the settings UI. + * Silently does nothing if the settings table doesn't exist yet (e.g. + * during the very first `migrate` on a fresh install). + */ + protected function applySettingOverrides(): void + { + try { + $overrides = Cache::remember( + SettingsRegistry::CACHE_KEY, + 300, + fn () => Setting::query()->whereNotNull('value')->pluck('value', 'key')->all(), + ); + } catch (Throwable) { + return; + } + + $map = SettingsRegistry::configMap(); + + foreach ($overrides as $key => $value) { + if (isset($map[$key]) && $value !== '') { + config([$map[$key] => $value]); + } + } } /** diff --git a/app/Services/EnvFileManager.php b/app/Services/EnvFileManager.php deleted file mode 100644 index 32fe3df..0000000 --- a/app/Services/EnvFileManager.php +++ /dev/null @@ -1,160 +0,0 @@ -path = $path ?: base_path('.env'); - } - - /** - * @return array - */ - public function read(): array - { - if (! is_file($this->path)) { - throw new RuntimeException('Le fichier .env est introuvable.'); - } - - $lines = preg_split('/\r\n|\r|\n/', file_get_contents($this->path)); - $parsed = []; - - foreach ($lines as $line) { - $trimmed = trim($line); - - if ($trimmed === '') { - $parsed[] = ['type' => 'blank']; - - continue; - } - - if (str_starts_with($trimmed, '#')) { - $parsed[] = ['type' => 'comment', 'text' => ltrim(substr($trimmed, 1))]; - - continue; - } - - if (! str_contains($trimmed, '=')) { - $parsed[] = ['type' => 'raw', 'raw' => $line]; - - continue; - } - - [$key, $rawValue] = explode('=', $trimmed, 2); - $key = trim($key); - - $parsed[] = [ - 'type' => 'var', - 'key' => $key, - 'value' => $this->unquote(trim($rawValue)), - 'protected' => in_array($key, self::PROTECTED_KEYS, true), - 'sensitive' => $this->isSensitive($key), - ]; - } - - return $parsed; - } - - public function isSensitive(string $key): bool - { - return (bool) preg_match('/PASSWORD|SECRET|TOKEN|KEY/i', $key); - } - - /** - * Rewrites only the given keys' values, leaving every other line (order, - * comments, blanks, untouched keys) exactly as-is. Protected keys are - * silently ignored even if present in $updates — never trust the form. - * - * @param array $updates - */ - public function update(array $updates): void - { - foreach ($updates as $key => $value) { - if (str_contains($value, "\n") || str_contains($value, "\r")) { - throw new RuntimeException("La valeur de {$key} ne peut pas contenir de retour à la ligne."); - } - } - - if (! is_writable($this->path)) { - throw new RuntimeException("Le fichier .env n'est pas accessible en écriture par le serveur web."); - } - - $lines = preg_split('/\r\n|\r|\n/', file_get_contents($this->path)); - $output = []; - - foreach ($lines as $line) { - $trimmed = trim($line); - $replaced = false; - - if ($trimmed !== '' && ! str_starts_with($trimmed, '#') && str_contains($trimmed, '=')) { - [$key] = explode('=', $trimmed, 2); - $key = trim($key); - - if (array_key_exists($key, $updates) && ! in_array($key, self::PROTECTED_KEYS, true)) { - $output[] = $key.'='.$this->quote($updates[$key]); - $replaced = true; - } - } - - if (! $replaced) { - $output[] = $line; - } - } - - // Write to a temp file in the same directory first, then rename — - // rename() is atomic, so a request reading .env mid-save never sees - // a half-written file. - $tmpPath = $this->path.'.tmp-'.bin2hex(random_bytes(4)); - file_put_contents($tmpPath, implode(PHP_EOL, $output)); - rename($tmpPath, $this->path); - } - - protected function unquote(string $value): string - { - if (strlen($value) >= 2 && $value[0] === '"' && str_ends_with($value, '"')) { - return stripcslashes(substr($value, 1, -1)); - } - - if (strlen($value) >= 2 && $value[0] === "'" && str_ends_with($value, "'")) { - return substr($value, 1, -1); - } - - return $value; - } - - protected function quote(string $value): string - { - if ($value === '' || preg_match('/[\s#"]/', $value)) { - return '"'.addcslashes($value, '"\\').'"'; - } - - return $value; - } -} diff --git a/app/Services/SettingsRegistry.php b/app/Services/SettingsRegistry.php new file mode 100644 index 0000000..f62b47f --- /dev/null +++ b/app/Services/SettingsRegistry.php @@ -0,0 +1,71 @@ +> + */ + public const SETTINGS = [ + 'Application' => [ + 'APP_NAME' => ['config' => 'app.name', 'label' => "Nom de l'application"], + 'APP_URL' => ['config' => 'app.url', 'label' => 'URL de base'], + ], + 'Mail' => [ + 'MAIL_MAILER' => ['config' => 'mail.default', 'label' => 'Pilote (mailer)'], + 'MAIL_HOST' => ['config' => 'mail.mailers.smtp.host', 'label' => 'Hôte SMTP'], + 'MAIL_PORT' => ['config' => 'mail.mailers.smtp.port', 'label' => 'Port SMTP'], + 'MAIL_SCHEME' => ['config' => 'mail.mailers.smtp.scheme', 'label' => 'Schéma (tls, ssl…)'], + 'MAIL_USERNAME' => ['config' => 'mail.mailers.smtp.username', 'label' => 'Utilisateur SMTP'], + 'MAIL_PASSWORD' => ['config' => 'mail.mailers.smtp.password', 'label' => 'Mot de passe SMTP'], + 'MAIL_FROM_ADDRESS' => ['config' => 'mail.from.address', 'label' => 'Adresse expéditeur'], + 'MAIL_FROM_NAME' => ['config' => 'mail.from.name', 'label' => 'Nom expéditeur'], + ], + 'OnlyOffice' => [ + 'ONLYOFFICE_DS_URL' => ['config' => 'onlyoffice.document_server_url', 'label' => 'URL du serveur de documents'], + 'ONLYOFFICE_CALLBACK_BASE_URL' => ['config' => 'onlyoffice.callback_base_url', 'label' => 'URL de rappel (callback)'], + 'ONLYOFFICE_JWT_SECRET' => ['config' => 'onlyoffice.jwt_secret', 'label' => 'Secret JWT'], + ], + 'GLPI' => [ + 'GLPI_API_URL' => ['config' => 'glpi.api_url', 'label' => "URL de l'API"], + 'GLPI_APP_TOKEN' => ['config' => 'glpi.app_token', 'label' => 'Jeton application'], + 'GLPI_USER_TOKEN' => ['config' => 'glpi.user_token', 'label' => 'Jeton utilisateur'], + 'GLPI_CACHE_TTL' => ['config' => 'glpi.cache_ttl_seconds', 'label' => 'Durée du cache (secondes)'], + ], + ]; + + public const CACHE_KEY = 'settings.overrides'; + + public static function isSensitive(string $key): bool + { + return (bool) preg_match('/PASSWORD|SECRET|TOKEN|KEY/i', $key); + } + + /** + * Flat env-style key => config path map, for applying overrides at boot. + * + * @return array + */ + public static function configMap(): array + { + $map = []; + + foreach (self::SETTINGS as $section) { + foreach ($section as $envKey => $meta) { + $map[$envKey] = $meta['config']; + } + } + + return $map; + } +} diff --git a/database/migrations/2026_08_20_090000_create_settings_table.php b/database/migrations/2026_08_20_090000_create_settings_table.php new file mode 100644 index 0000000..5cc2756 --- /dev/null +++ b/database/migrations/2026_08_20_090000_create_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 4af9964..065a29d 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -133,7 +133,7 @@ - Configuration (.env) + Configuration @endrole diff --git a/resources/views/components/settings/⚡env.blade.php b/resources/views/components/settings/⚡env.blade.php index e6e4091..5ec4f96 100644 --- a/resources/views/components/settings/⚡env.blade.php +++ b/resources/views/components/settings/⚡env.blade.php @@ -1,19 +1,18 @@ > */ - public array $lines = []; - /** @var array */ public array $values = []; public function mount(): void { - $this->loadLines(); + $this->loadValues(); } public function save(): void @@ -26,38 +25,51 @@ new class extends Component } } - try { - app(EnvFileManager::class)->update($this->values); - } catch (\Throwable $e) { - session()->flash('error', $e->getMessage()); + foreach (SettingsRegistry::configMap() as $envKey => $path) { + $value = trim($this->values[$envKey] ?? ''); + Setting::updateOrCreate(['key' => $envKey], ['value' => $value === '' ? null : $value]); + } + + Cache::forget(SettingsRegistry::CACHE_KEY); + + session()->flash('status', 'Configuration enregistrée. Les nouvelles valeurs sont actives immédiatement.'); + + $this->loadValues(); + } + + public function resetToDefault(string $envKey): void + { + if (! array_key_exists($envKey, SettingsRegistry::configMap())) { return; } - session()->flash('status', 'Configuration enregistrée. Les nouvelles valeurs seront actives dès la prochaine requête.'); + Setting::where('key', $envKey)->delete(); + Cache::forget(SettingsRegistry::CACHE_KEY); - $this->loadLines(); + session()->flash('status', "Valeur par défaut restaurée pour {$envKey}."); + + $this->loadValues(); } - protected function loadLines(): void + protected function loadValues(): void { - $this->lines = app(EnvFileManager::class)->read(); - $this->values = []; + $stored = Setting::query()->pluck('value', 'key'); - foreach ($this->lines as $line) { - if ($line['type'] === 'var' && ! $line['protected']) { - $this->values[$line['key']] = $line['value']; - } + foreach (SettingsRegistry::configMap() as $envKey => $path) { + $this->values[$envKey] = $stored[$envKey] ?? (string) (config($path) ?? ''); } } }; ?>
-

Configuration (.env)

+

Configuration

- Modifie directement les variables d'environnement de l'application. Les changements - prennent effet à la prochaine requête — aucun redémarrage n'est nécessaire. + Ces réglages sont stockés en base de données et prennent effet immédiatement, sans + redémarrage. Les paramètres d'infrastructure (base de données, clé de chiffrement, + pilote de session…) ne sont pas modifiables ici — ils doivent être changés directement + dans le fichier .env du serveur.

@if (session('status')) @@ -66,60 +78,52 @@ new class extends Component
@endif - @if (session('error')) -
- {{ session('error') }} -
- @endif - @error('values')
{{ $message }}
@enderror -
- @foreach ($lines as $line) - @if ($line['type'] === 'comment') - @if ($line['text'] !== '') -

- {{ $line['text'] }} -

- @endif - @elseif ($line['type'] === 'var') -
- + + @foreach (\App\Services\SettingsRegistry::SETTINGS as $section => $keys) +
+

{{ $section }}

- @if ($line['protected']) -
- - - 🔒 lecture seule - -
- @elseif ($line['sensitive']) -
- - +
+ @else + + @endif + +
- @else - - @endif + @endforeach
- @endif + @endforeach -
+