147 lines
5.4 KiB
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);
|
|
}
|
|
}
|