Remplace l'édition directe du .env par des réglages en base de données

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 <noreply@anthropic.com>
This commit is contained in:
mrKamoo
2026-08-20 09:21:15 +02:00
co-authored by Claude Sonnet 5
parent da589d0627
commit c35833ed0b
8 changed files with 211 additions and 226 deletions
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
/**
* A key/value override for a whitelisted config path — see SettingsRegistry.
* Absence of a row (or a null value) means "use the .env/config default".
*/
#[Fillable(['key', 'value'])]
class Setting extends Model
{
}
+33 -1
View File
@@ -2,7 +2,11 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -11,7 +15,35 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->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]);
}
}
} }
/** /**
-160
View File
@@ -1,160 +0,0 @@
<?php
namespace App\Services;
use RuntimeException;
/**
* Reads and rewrites the application's .env file line-by-line, preserving
* comments, blank lines, and formatting for anything it doesn't touch.
*/
class EnvFileManager
{
/**
* Never editable from the admin UI: getting any of these wrong can break
* the database connection, invalidate encrypted sessions/cookies, or
* otherwise lock an administrator out of the app entirely. Change these
* via direct file/shell access instead.
*/
public const PROTECTED_KEYS = [
'APP_KEY',
'DB_CONNECTION',
'DB_HOST',
'DB_PORT',
'DB_DATABASE',
'DB_USERNAME',
'DB_PASSWORD',
'SESSION_DRIVER',
];
protected string $path;
public function __construct(?string $path = null)
{
$this->path = $path ?: base_path('.env');
}
/**
* @return array<int, array{type: 'comment', text: string}|array{type: 'blank'}|array{type: 'raw', raw: string}|array{type: 'var', key: string, value: string, protected: bool, sensitive: bool}>
*/
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<string, string> $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;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Services;
/**
* Whitelist of settings administrators can override from the database instead
* of editing .env directly. Deliberately excludes anything read at the very
* start of the boot process (APP_KEY, DB_*, SESSION_DRIVER, CACHE_STORE,
* QUEUE_CONNECTION, ...): those are resolved before a database connection —
* or before this override even runs — so changing them here wouldn't
* reliably take effect, and getting DB_* wrong here could lock everyone out.
* Only settings that are safe to read lazily, at point of use, belong here.
*/
class SettingsRegistry
{
/**
* @var array<string, array<string, array{config: string, label: string}>>
*/
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<string, string>
*/
public static function configMap(): array
{
$map = [];
foreach (self::SETTINGS as $section) {
foreach ($section as $envKey => $meta) {
$map[$envKey] = $meta['config'];
}
}
return $map;
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};
@@ -133,7 +133,7 @@
</a> </a>
<a href="{{ route('settings.env') }}" <a href="{{ route('settings.env') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('settings.env') ? 'bg-slate-800 text-white' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('settings.env') ? 'bg-slate-800 text-white' : '' }}">
Configuration (.env) Configuration
</a> </a>
</div> </div>
@endrole @endrole
@@ -1,19 +1,18 @@
<?php <?php
use App\Services\EnvFileManager; use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Livewire\Component; use Livewire\Component;
new class extends Component new class extends Component
{ {
/** @var array<int, array<string, mixed>> */
public array $lines = [];
/** @var array<string, string> */ /** @var array<string, string> */
public array $values = []; public array $values = [];
public function mount(): void public function mount(): void
{ {
$this->loadLines(); $this->loadValues();
} }
public function save(): void public function save(): void
@@ -26,38 +25,51 @@ new class extends Component
} }
} }
try { foreach (SettingsRegistry::configMap() as $envKey => $path) {
app(EnvFileManager::class)->update($this->values); $value = trim($this->values[$envKey] ?? '');
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
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; 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(); $stored = Setting::query()->pluck('value', 'key');
$this->values = [];
foreach ($this->lines as $line) { foreach (SettingsRegistry::configMap() as $envKey => $path) {
if ($line['type'] === 'var' && ! $line['protected']) { $this->values[$envKey] = $stored[$envKey] ?? (string) (config($path) ?? '');
$this->values[$line['key']] = $line['value'];
}
} }
} }
}; };
?> ?>
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="mb-2 text-2xl font-semibold text-slate-900">Configuration (.env)</h1> <h1 class="mb-2 text-2xl font-semibold text-slate-900">Configuration</h1>
<p class="mb-6 text-sm text-slate-500"> <p class="mb-6 text-sm text-slate-500">
Modifie directement les variables d'environnement de l'application. Les changements Ces réglages sont stockés en base de données et prennent effet immédiatement, sans
prennent effet à la prochaine requête — aucun redémarrage n'est nécessaire. 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 <code class="rounded bg-slate-100 px-1 py-0.5">.env</code> du serveur.
</p> </p>
@if (session('status')) @if (session('status'))
@@ -66,60 +78,52 @@ new class extends Component
</div> </div>
@endif @endif
@if (session('error'))
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ session('error') }}
</div>
@endif
@error('values') @error('values')
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700"> <div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ $message }} {{ $message }}
</div> </div>
@enderror @enderror
<form wire:submit="save" class="space-y-1 rounded border border-slate-200 bg-white p-6"> <form wire:submit="save" class="space-y-8 rounded border border-slate-200 bg-white p-6">
@foreach ($lines as $line) @foreach (\App\Services\SettingsRegistry::SETTINGS as $section => $keys)
@if ($line['type'] === 'comment') <div>
@if ($line['text'] !== '') <p class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ $section }}</p>
<p class="mb-1 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-400 first:mt-0">
{{ $line['text'] }}
</p>
@endif
@elseif ($line['type'] === 'var')
<div class="flex items-center gap-3 py-1.5">
<label for="env-{{ $line['key'] }}" class="w-56 shrink-0 truncate font-mono text-xs text-slate-600" title="{{ $line['key'] }}">
{{ $line['key'] }}
</label>
@if ($line['protected']) <div class="space-y-1.5">
<div class="flex flex-1 items-center gap-2"> @foreach ($keys as $envKey => $meta)
<input type="text" value="{{ $line['sensitive'] ? '••••••••••••' : $line['value'] }}" disabled <div class="flex items-center gap-3 py-1">
class="w-full rounded border-slate-200 bg-slate-50 font-mono text-sm text-slate-400"> <label for="setting-{{ $envKey }}" class="w-48 shrink-0 text-sm text-slate-700">
<span class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500" title="Non modifiable ici — nécessite un accès direct au serveur"> {{ $meta['label'] }}
🔒 lecture seule </label>
</span>
</div> @if (\App\Services\SettingsRegistry::isSensitive($envKey))
@elseif ($line['sensitive']) <div x-data="{ show: false }" class="flex flex-1 items-center gap-2">
<div x-data="{ show: false }" class="flex flex-1 items-center gap-2"> <input :type="show ? 'text' : 'password'" id="setting-{{ $envKey }}"
<input :type="show ? 'text' : 'password'" id="env-{{ $line['key'] }}" wire:model="values.{{ $envKey }}" autocomplete="off"
wire:model="values.{{ $line['key'] }}" autocomplete="off" class="w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
class="w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"> <button type="button" x-on:click="show = !show"
<button type="button" x-on:click="show = !show" class="shrink-0 text-xs text-slate-500 hover:text-slate-900">
class="shrink-0 text-xs text-slate-500 hover:text-slate-900"> <span x-text="show ? 'Masquer' : 'Révéler'"></span>
<span x-text="show ? 'Masquer' : 'Révéler'"></span> </button>
</div>
@else
<input type="text" id="setting-{{ $envKey }}"
wire:model="values.{{ $envKey }}"
class="flex-1 rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@endif
<button type="button" wire:click="resetToDefault('{{ $envKey }}')"
wire:confirm="Revenir à la valeur par défaut de {{ $envKey }} (celle du .env) ?"
class="shrink-0 text-xs text-slate-400 hover:text-slate-700">
Réinitialiser
</button> </button>
</div> </div>
@else @endforeach
<input type="text" id="env-{{ $line['key'] }}"
wire:model="values.{{ $line['key'] }}"
class="flex-1 rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@endif
</div> </div>
@endif </div>
@endforeach @endforeach
<div class="flex items-center gap-3 pt-5"> <div class="flex items-center gap-3 border-t border-slate-100 pt-6">
<button type="submit" wire:loading.attr="disabled" wire:target="save" <button type="submit" wire:loading.attr="disabled" wire:target="save"
class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60"> class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
<svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
+1 -1
View File
@@ -1,3 +1,3 @@
<x-layouts.app title="Configuration (.env)"> <x-layouts.app title="Configuration">
<livewire:settings.env /> <livewire:settings.env />
</x-layouts.app> </x-layouts.app>