Ajoute un menu admin pour éditer les variables .env de l'app
Nouvelle page "Configuration (.env)" réservée au rôle Administrateur : - EnvFileManager lit/réécrit le .env ligne à ligne en préservant commentaires et mise en forme, avec écriture atomique (fichier temporaire + rename). - Variables critiques (APP_KEY, DB_*, SESSION_DRIVER) en lecture seule pour éviter de casser la connexion base de données ou de bloquer l'accès admin — protection appliquée aussi côté serveur, pas seulement dans l'affichage. - Valeurs sensibles (mots de passe, jetons, clés) masquées par défaut avec un bouton de révélation par champ. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2f576ca286
commit
da589d0627
@@ -0,0 +1,160 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,10 @@
|
||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||
Utilisateurs
|
||||
</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)
|
||||
</a>
|
||||
</div>
|
||||
@endrole
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
use App\Services\EnvFileManager;
|
||||
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();
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
foreach ($this->values as $key => $value) {
|
||||
if (str_contains($value, "\n") || str_contains($value, "\r")) {
|
||||
$this->addError('values', "La valeur de {$key} ne peut pas contenir de retour à la ligne.");
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
app(EnvFileManager::class)->update($this->values);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('status', 'Configuration enregistrée. Les nouvelles valeurs seront actives dès la prochaine requête.');
|
||||
|
||||
$this->loadLines();
|
||||
}
|
||||
|
||||
protected function loadLines(): void
|
||||
{
|
||||
$this->lines = app(EnvFileManager::class)->read();
|
||||
$this->values = [];
|
||||
|
||||
foreach ($this->lines as $line) {
|
||||
if ($line['type'] === 'var' && ! $line['protected']) {
|
||||
$this->values[$line['key']] = $line['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="mb-2 text-2xl font-semibold text-slate-900">Configuration (.env)</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.
|
||||
</p>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-4 rounded border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-700">
|
||||
{{ session('status') }}
|
||||
</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'] }}
|
||||
</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'])
|
||||
<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"
|
||||
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">
|
||||
<span x-text="show ? 'Masquer' : 'Révéler'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<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>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<div class="flex items-center gap-3 pt-5">
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span wire:loading.remove wire:target="save">Enregistrer</span>
|
||||
<span wire:loading wire:target="save">Enregistrement…</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Configuration (.env)">
|
||||
<livewire:settings.env />
|
||||
</x-layouts.app>
|
||||
@@ -116,6 +116,10 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/{type}/edit', fn (FlexibleAssetType $type) => view('asset-types.edit', ['type' => $type]))->name('edit');
|
||||
});
|
||||
|
||||
Route::get('/settings/env', fn () => view('settings.env'))
|
||||
->name('settings.env')
|
||||
->middleware('role:Administrateur');
|
||||
|
||||
Route::get('/organizations/{organization}/assets', function (Organization $organization) {
|
||||
return view('assets.picker', [
|
||||
'organization' => $organization,
|
||||
|
||||
Reference in New Issue
Block a user