diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php
index 3fec574..c64b47d 100644
--- a/app/Livewire/Modals/FileEditor.php
+++ b/app/Livewire/Modals/FileEditor.php
@@ -5,6 +5,7 @@ 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;
@@ -12,9 +13,16 @@ 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;
@@ -31,6 +39,9 @@ class FileEditor extends ModalComponent
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;
@@ -43,7 +54,29 @@ class FileEditor extends ModalComponent
return 'lg';
}
- public function load(FleetService $fleet): void
+ /** 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) {
@@ -54,10 +87,14 @@ class FileEditor extends ModalComponent
}
try {
- $r = $fleet->readFile($server, $this->path);
- $this->content = $r['content'];
- $this->binary = $r['binary'];
- $this->tooBig = $r['tooBig'];
+ if ($this->isImage()) {
+ $this->loadImage($server, $sftp);
+ } else {
+ $r = $fleet->readFile($server, $this->path);
+ $this->content = $r['content'];
+ $this->binary = $r['binary'];
+ $this->tooBig = $r['tooBig'];
+ }
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@@ -65,10 +102,44 @@ class FileEditor extends ModalComponent
$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
{
$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');
diff --git a/lang/de/modals.php b/lang/de/modals.php
index cb55a2e..0d95f1c 100644
--- a/lang/de/modals.php
+++ b/lang/de/modals.php
@@ -106,6 +106,8 @@ return [
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.',
+ 'image_too_big' => 'Bild zu groß für die Vorschau (über 5 MB) — bitte herunterladen.',
+ 'image_read_failed' => 'Bild konnte nicht gelesen werden.',
'binary' => 'Binärdatei — kann nicht im Editor angezeigt werden.',
'notify_saved' => '„:name“ gespeichert.',
],
diff --git a/lang/en/modals.php b/lang/en/modals.php
index 90f0f3b..c681ca4 100644
--- a/lang/en/modals.php
+++ b/lang/en/modals.php
@@ -105,6 +105,8 @@ return [
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'File too large to edit (over 256 KB) — please download it.',
+ 'image_too_big' => 'Image too large to preview (over 5 MB) — please download it.',
+ 'image_read_failed' => 'Image could not be read.',
'binary' => 'Binary file — cannot be shown in the editor.',
'notify_saved' => '“:name” saved.',
],
diff --git a/resources/views/livewire/modals/add-ssh-key.blade.php b/resources/views/livewire/modals/add-ssh-key.blade.php
index b22e4d9..d9a9de6 100644
--- a/resources/views/livewire/modals/add-ssh-key.blade.php
+++ b/resources/views/livewire/modals/add-ssh-key.blade.php
@@ -21,7 +21,7 @@
wire:model="publicKey"
rows="3"
placeholder="{{ __('modals.add_ssh_key.public_key_placeholder') }}"
- class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
+ class="w-full resize-none rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
>
@if ($generatedPrivate)
@@ -30,7 +30,7 @@