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;
use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
@@ -11,7 +15,35 @@ class AppServiceProvider extends ServiceProvider
*/
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 href="{{ route('settings.env') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('settings.env') ? 'bg-slate-800 text-white' : '' }}">
Configuration (.env)
Configuration
</a>
</div>
@endrole
@@ -1,19 +1,18 @@
<?php
use App\Services\EnvFileManager;
use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
new class extends Component
{
/** @var array<int, array<string, mixed>> */
public array $lines = [];
/** @var array<string, string> */
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) ?? '');
}
}
};
?>
<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">
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 <code class="rounded bg-slate-100 px-1 py-0.5">.env</code> du serveur.
</p>
@if (session('status'))
@@ -66,44 +78,28 @@ new class extends Component
</div>
@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')
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ $message }}
</div>
@enderror
<form wire:submit="save" class="space-y-1 rounded border border-slate-200 bg-white p-6">
@foreach ($lines as $line)
@if ($line['type'] === 'comment')
@if ($line['text'] !== '')
<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'] }}
<form wire:submit="save" class="space-y-8 rounded border border-slate-200 bg-white p-6">
@foreach (\App\Services\SettingsRegistry::SETTINGS as $section => $keys)
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ $section }}</p>
<div class="space-y-1.5">
@foreach ($keys as $envKey => $meta)
<div class="flex items-center gap-3 py-1">
<label for="setting-{{ $envKey }}" class="w-48 shrink-0 text-sm text-slate-700">
{{ $meta['label'] }}
</label>
@if ($line['protected'])
<div class="flex flex-1 items-center gap-2">
<input type="text" value="{{ $line['sensitive'] ? '••••••••••••' : $line['value'] }}" disabled
class="w-full rounded border-slate-200 bg-slate-50 font-mono text-sm text-slate-400">
<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">
🔒 lecture seule
</span>
</div>
@elseif ($line['sensitive'])
@if (\App\Services\SettingsRegistry::isSensitive($envKey))
<div x-data="{ show: false }" class="flex flex-1 items-center gap-2">
<input :type="show ? 'text' : 'password'" id="env-{{ $line['key'] }}"
wire:model="values.{{ $line['key'] }}" autocomplete="off"
<input :type="show ? 'text' : 'password'" id="setting-{{ $envKey }}"
wire:model="values.{{ $envKey }}" autocomplete="off"
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"
class="shrink-0 text-xs text-slate-500 hover:text-slate-900">
@@ -111,15 +107,23 @@ new class extends Component
</button>
</div>
@else
<input type="text" id="env-{{ $line['key'] }}"
wire:model="values.{{ $line['key'] }}"
<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>
</div>
@endforeach
</div>
</div>
@endif
@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"
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">
+1 -1
View File
@@ -1,3 +1,3 @@
<x-layouts.app title="Configuration (.env)">
<x-layouts.app title="Configuration">
<livewire:settings.env />
</x-layouts.app>