182 lines
5.1 KiB
PHP
182 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Modals;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Server;
|
|
use App\Services\FleetService;
|
|
use App\Support\Ssh\Sftp;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use LivewireUI\Modal\ModalComponent;
|
|
use Throwable;
|
|
|
|
/**
|
|
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
|
|
* binary or oversized files, and writes back on save with an AuditEvent.
|
|
* Image files are shown as a read-only base64 preview instead of the editor.
|
|
*/
|
|
class FileEditor extends ModalComponent
|
|
{
|
|
/** Image preview is capped — larger images are refused (base64 inflates ~33%). */
|
|
private const IMAGE_MAX_BYTES = 5_242_880; // 5 MB
|
|
|
|
/** Extensions rendered as an inline image preview (read-only). */
|
|
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
|
|
|
|
public int $serverId;
|
|
|
|
public string $path;
|
|
|
|
public string $name;
|
|
|
|
public string $content = '';
|
|
|
|
public bool $binary = false;
|
|
|
|
public bool $tooBig = false;
|
|
|
|
public bool $loaded = false;
|
|
|
|
public ?string $error = null;
|
|
|
|
/** base64 `data:` URI for image previews; null for text files. */
|
|
public ?string $dataUri = null;
|
|
|
|
public function mount(int $serverId, string $path, string $name): void
|
|
{
|
|
$this->serverId = $serverId;
|
|
$this->path = $path;
|
|
$this->name = $name;
|
|
}
|
|
|
|
public static function modalMaxWidth(): string
|
|
{
|
|
return 'lg';
|
|
}
|
|
|
|
/** True when the opened file is an image (preview, not editable). */
|
|
public function isImage(): bool
|
|
{
|
|
return in_array($this->extension(), self::IMAGE_EXTENSIONS, true);
|
|
}
|
|
|
|
private function extension(): string
|
|
{
|
|
return strtolower(pathinfo($this->name, PATHINFO_EXTENSION));
|
|
}
|
|
|
|
/** MIME type for the image preview data URI, keyed off the extension. */
|
|
private function imageMime(): string
|
|
{
|
|
return match ($this->extension()) {
|
|
'svg' => 'image/svg+xml',
|
|
'jpg', 'jpeg' => 'image/jpeg',
|
|
'ico' => 'image/x-icon',
|
|
default => 'image/'.$this->extension(),
|
|
};
|
|
}
|
|
|
|
public function load(FleetService $fleet, Sftp $sftp): void
|
|
{
|
|
$server = Server::find($this->serverId);
|
|
if (! $server) {
|
|
$this->error = __('common.server_not_found');
|
|
$this->loaded = true;
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if ($this->isImage()) {
|
|
$this->loadImage($server, $sftp);
|
|
} else {
|
|
$r = $fleet->readFile($server, $this->path);
|
|
$this->binary = $r['binary'];
|
|
$this->tooBig = $r['tooBig'];
|
|
// Never bind raw non-UTF-8 bytes to a public property: Livewire JSON-encodes the
|
|
// snapshot and invalid UTF-8 makes json_encode fail, so the client receives an empty
|
|
// response ("undefined is not valid JSON"). Binary/oversize files show a notice instead.
|
|
$this->content = ($r['binary'] || $r['tooBig']) ? '' : $r['content'];
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->error = $e->getMessage();
|
|
}
|
|
|
|
$this->loaded = true;
|
|
}
|
|
|
|
/**
|
|
* Read the image bytes over SFTP and expose them as a base64 data URI.
|
|
* Refuses files larger than IMAGE_MAX_BYTES (checked before download).
|
|
*/
|
|
private function loadImage(Server $server, Sftp $sftp): void
|
|
{
|
|
$sftp->connect($server);
|
|
|
|
try {
|
|
if ($sftp->size($this->path) > self::IMAGE_MAX_BYTES) {
|
|
$this->tooBig = true;
|
|
|
|
return;
|
|
}
|
|
|
|
$bytes = $sftp->get($this->path);
|
|
|
|
if ($bytes === '') {
|
|
$this->error = __('modals.file_editor.image_read_failed');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->dataUri = 'data:'.$this->imageMime().';base64,'.base64_encode($bytes);
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
public function save(FleetService $fleet): void
|
|
{
|
|
abort_unless(auth()->user()?->can('operate'), 403);
|
|
|
|
$this->error = null;
|
|
|
|
// Images are a read-only preview — never write them back.
|
|
if ($this->isImage()) {
|
|
return;
|
|
}
|
|
|
|
$server = Server::find($this->serverId);
|
|
if (! $server) {
|
|
$this->error = __('common.server_not_found');
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$fleet->writeFile($server, $this->path, $this->content);
|
|
} catch (Throwable $e) {
|
|
$this->error = $e->getMessage();
|
|
|
|
return;
|
|
}
|
|
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'server_id' => $server->id,
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => 'file.edit',
|
|
'target' => $this->path,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
|
|
$this->dispatch('fileSaved');
|
|
$this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name]));
|
|
$this->closeModal();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.modals.file-editor');
|
|
}
|
|
}
|