CluPilotCloud/app/Services/Env/EnvFileEditor.php

147 lines
5.4 KiB
PHP

<?php
namespace App\Services\Env;
use Illuminate\Support\Facades\File;
/**
* A raw editor for the file that holds every credential this installation
* has — .env itself.
*
* Exists because "everything the console has no field for" is still real:
* MAIL_*, DB_*, APP_KEY, whichever third-party key nobody has built a form
* for yet. Without this, an operator needing one of those needs a shell
* anyway, and the rest of the console page (App\Livewire\Admin\Integrations)
* would not have removed that need — only moved most of it.
*
* The net, not just the warning — three rules, because the obvious
* alternative is a page that can brick the installation with no way back
* through that same page:
*
* 1. VALIDATE BEFORE WRITING. write() never touches the file before checking
* the new content first. A line that is neither blank, nor a comment, nor
* KEY=value is rejected outright — see isValidLine().
* 2. An EMPTY file is rejected too, on top of the syntax check. Zero lines is
* syntactically valid by that rule (there is nothing to object to) and
* would still erase every setting in the file — worth refusing even though
* nothing asked for this check by name.
* 3. BACK UP BEFORE EVERY WRITE that actually happens: a timestamped copy of
* the file about to be REPLACED, written beside it, before the new content
* lands. Nothing here prunes old backups — App\Livewire\Admin\Integrations
* says so, in the same sentence that tells an operator where they land.
*
* Deliberately NOT built on vlucas/phpdotenv's own parser (already a Laravel
* dependency): that parser is more permissive than the rule this class
* documents — multiline values, `export KEY=`, variable interpolation — and
* borrowing it here would validate against a different, looser grammar than
* the one an operator is actually told about. This class enforces exactly the
* rule in its own docblock and nothing more.
*/
final class EnvFileEditor
{
/**
* @param string $path Empty string means base_path('.env') — the
* installation's real file. Overridden in tests so
* nothing here ever has to write the one on this
* dev machine to be exercised.
*/
public function __construct(private readonly string $path = '') {}
public function path(): string
{
return $this->path !== '' ? $this->path : base_path('.env');
}
public function read(): string
{
return File::exists($this->path()) ? File::get($this->path()) : '';
}
/**
* The first line that is neither blank, nor a comment, nor KEY=value —
* or null when every line is one of those three things. 1-indexed, so it
* can be shown to an operator directly ("Zeile 42").
*/
public function firstInvalidLine(string $content): ?int
{
foreach (preg_split('/\r\n|\r|\n/', $content) as $number => $line) {
if (! self::isValidLine($line)) {
return $number + 1;
}
}
return null;
}
public function isValid(string $content): bool
{
return $this->firstInvalidLine($content) === null;
}
/**
* Validate, back up, then write — in that order, and never out of it.
* Content that fails validation is never backed up (nothing is about to
* change) and never written.
*
* @return string the backup path, so the caller can tell the operator
* exactly where the previous version landed
*
* @throws InvalidEnvContentException the content is empty, or does not parse
*/
public function write(string $content): string
{
if (trim($content) === '') {
throw InvalidEnvContentException::empty();
}
$line = $this->firstInvalidLine($content);
if ($line !== null) {
throw InvalidEnvContentException::atLine($line);
}
$backup = $this->backup();
File::put($this->path(), $content);
return $backup;
}
/**
* A timestamped copy of the CURRENT file, beside it — before whatever
* write() is about to do to the original. Microseconds in the name so two
* saves a second apart (a real thing to hit while testing this class, not
* only a hypothetical operator habit) do not collide and silently
* overwrite one another.
*
* Returns '' — never a path — when there is nothing to back up, i.e. the
* file does not exist yet. read() already treats a missing file as ''
* rather than an error (Codex review, P2); write() has to agree, or the
* very first save on a fresh installation would fail trying to copy a
* file that was never there instead of simply creating one.
*/
public function backup(): string
{
if (! File::exists($this->path())) {
return '';
}
$target = $this->path().'.bak-'.now()->format('Ymd-His-u');
if (! File::copy($this->path(), $target)) {
throw new \RuntimeException("Could not back up [{$this->path()}] before writing it.");
}
return $target;
}
private static function isValidLine(string $line): bool
{
$line = ltrim(rtrim($line, "\r\n"));
if ($line === '' || str_starts_with($line, '#')) {
return true;
}
return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_]*=.*$/', $line);
}
}