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
-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;
}
}